Compare commits
88
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6cc72ae0f7 | ||
|
|
13b99428de | ||
|
|
e85f39ab83 | ||
|
|
09e587cb65 | ||
|
|
74876e0923 | ||
|
|
0bebce3dc1 | ||
|
|
8d8c823616 | ||
|
|
1c547a717e | ||
|
|
d7961694f9 | ||
|
|
4e69185c48 | ||
|
|
96b8fd2534 | ||
|
|
b345ceeb42 | ||
|
|
0402a1e82d | ||
|
|
c2a09a5057 | ||
|
|
c94be9bc38 | ||
|
|
bcf3bba3c7 | ||
|
|
268284cf32 | ||
|
|
5176083139 | ||
|
|
9f184d4c2a | ||
|
|
77ed3b890f | ||
|
|
5e58d9a0dc | ||
|
|
a19c9470c5 | ||
|
|
261a90bdcd | ||
|
|
ac710952b2 | ||
|
|
8be304417d | ||
|
|
b36c6133cb | ||
|
|
b2b3782aa2 | ||
|
|
244b40025e | ||
|
|
1912d62f21 | ||
|
|
abb806de5e | ||
|
|
c22595b33a | ||
|
|
ec89d1459a | ||
|
|
c9aabf23f0 | ||
|
|
c8fa427379 | ||
|
|
fc5f094f8c | ||
|
|
8114809102 | ||
|
|
26e185a804 | ||
|
|
68733acc72 | ||
|
|
b454d5d940 | ||
|
|
11b03e8e62 | ||
|
|
a0026b1da5 | ||
|
|
1fa2fbd3f3 | ||
|
|
125492f6f1 | ||
|
|
8d56fc8e9e | ||
|
|
97a8511ea2 | ||
|
|
67c080efce | ||
|
|
b12d7cdf25 | ||
|
|
d3b978938b | ||
|
|
c909b023c7 | ||
|
|
5bc31f8936 | ||
|
|
dc7da19aee | ||
|
|
a7af34d22b | ||
|
|
0e8f19934e | ||
|
|
2a6409daa9 | ||
|
|
c658aacf0a | ||
|
|
e9ef34bb7e | ||
|
|
d6e17b9944 | ||
|
|
4a714e8141 | ||
|
|
01e48eefe2 | ||
|
|
f30514c700 | ||
|
|
c19e0e0181 | ||
|
|
959ee46c44 | ||
|
|
9192e82eee | ||
|
|
95c2ded69e | ||
|
|
1e093a0688 | ||
|
|
33d1140fbf | ||
|
|
51bdf15613 | ||
|
|
be7eabdbb1 | ||
|
|
823d626440 | ||
|
|
207a248b01 | ||
|
|
c4de871977 | ||
|
|
f0dda998a8 | ||
|
|
8c96e2099c | ||
|
|
1922d0fb1e | ||
|
|
7350293d0e | ||
|
|
8cf3c9ccee | ||
|
|
1fc020ab00 | ||
|
|
4981a240fa | ||
|
|
349b7ae03b | ||
|
|
2357dcfc67 | ||
|
|
14a0fe8d4f | ||
|
|
e304c1b724 | ||
|
|
f0d97e58d1 | ||
|
|
2b58b672d5 | ||
|
|
8bedac5ab5 | ||
|
|
8e7c8f52f6 | ||
|
|
b4710b6397 | ||
|
|
daff741c65 |
@@ -14,7 +14,6 @@
|
||||
3. [团课管理接口](#团课管理接口)
|
||||
- [获取所有团课](#获取所有团课)
|
||||
- [分页获取团课](#分页获取团课)
|
||||
- [多条件查询团课](#多条件查询团课)
|
||||
- [根据ID获取团课详情](#根据ID获取团课详情)
|
||||
- [创建团课](#创建团课)
|
||||
- [更新团课](#更新团课)
|
||||
@@ -155,149 +154,6 @@
|
||||
|
||||
---
|
||||
|
||||
### 多条件查询团课
|
||||
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| **HTTP方法** | POST |
|
||||
| **接口路径** | `/api/groupCourse/search` |
|
||||
| **所属文件** | `GroupCourseHandler.java` |
|
||||
|
||||
**功能说明**: 支持团课名称模糊查询、类型筛选、日期范围、时间段、价格排序、剩余名额排序等多条件组合查询,默认不查询不可预约的团课(已取消或已结束)。
|
||||
|
||||
**请求体**:
|
||||
|
||||
```json
|
||||
{
|
||||
"courseName": "瑜伽",
|
||||
"courseType": 1,
|
||||
"startDate": "2026-06-01T00:00:00",
|
||||
"endDate": "2026-06-30T23:59:59",
|
||||
"timePeriod": "morning",
|
||||
"priceSort": "asc",
|
||||
"remainingMost": true,
|
||||
"page": 0,
|
||||
"size": 10
|
||||
}
|
||||
```
|
||||
|
||||
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|
||||
|--------|------|------|--------|------|
|
||||
| courseName | String | 否 | - | 课程名称(模糊查询,不区分大小写) |
|
||||
| courseType | Long | 否 | - | 课程类型ID |
|
||||
| startDate | LocalDateTime | 否 | - | 查询开始日期 |
|
||||
| endDate | LocalDateTime | 否 | - | 查询结束日期 |
|
||||
| timePeriod | String | 否 | - | 时间段:`morning`(6:00-12:00)、`afternoon`(12:00-18:00)、`evening`(18:00-24:00) |
|
||||
| priceSort | String | 否 | - | 价格排序:`asc`(从低到高)、`desc`(从高到低) |
|
||||
| remainingMost | Boolean | 否 | false | 是否按剩余名额最多排序 |
|
||||
| page | Integer | 否 | 0 | 页码,从0开始 |
|
||||
| size | Integer | 否 | 10 | 每页数量,最大100 |
|
||||
|
||||
**查询条件优先级说明**:
|
||||
|
||||
| 优先级 | 条件类型 | 说明 |
|
||||
|--------|----------|------|
|
||||
| 1 | 默认过滤 | 自动过滤已删除和不可预约的团课(status != 0) |
|
||||
| 2 | 基础筛选 | courseName、courseType、startDate、endDate、timePeriod |
|
||||
| 3 | 排序规则 | remainingMost优先,其次priceSort,默认按startTime升序 |
|
||||
|
||||
**成功响应** (200 OK):
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "查询成功",
|
||||
"data": {
|
||||
"content": [
|
||||
{
|
||||
"id": 1,
|
||||
"courseName": "瑜伽入门",
|
||||
"coachId": 1,
|
||||
"courseType": 1,
|
||||
"startTime": "2026-06-15T09:00:00",
|
||||
"endTime": "2026-06-15T10:00:00",
|
||||
"maxMembers": 20,
|
||||
"currentMembers": 5,
|
||||
"status": 0,
|
||||
"location": "健身房A区",
|
||||
"coverImage": "https://example.com/yoga.jpg",
|
||||
"description": "适合初学者的瑜伽课程",
|
||||
"pointCardAmount": 1,
|
||||
"storedValueAmount": 50.00,
|
||||
"createdAt": "2026-06-01T10:00:00",
|
||||
"updatedAt": "2026-06-01T10:00:00"
|
||||
}
|
||||
],
|
||||
"totalPages": 3,
|
||||
"totalElements": 25,
|
||||
"currentPage": 0,
|
||||
"pageSize": 10,
|
||||
"first": true,
|
||||
"last": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**失败响应** (400 Bad Request):
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"message": "查询失败的原因"
|
||||
}
|
||||
```
|
||||
|
||||
**使用示例**:
|
||||
|
||||
1. **查询瑜伽课程**
|
||||
```json
|
||||
{
|
||||
"courseName": "瑜伽"
|
||||
}
|
||||
```
|
||||
|
||||
2. **查询特定类型的早晨课程**
|
||||
```json
|
||||
{
|
||||
"courseType": 1,
|
||||
"timePeriod": "morning"
|
||||
}
|
||||
```
|
||||
|
||||
3. **查询下周的课程,按价格从低到高排序**
|
||||
```json
|
||||
{
|
||||
"startDate": "2026-06-16T00:00:00",
|
||||
"endDate": "2026-06-22T23:59:59",
|
||||
"priceSort": "asc"
|
||||
}
|
||||
```
|
||||
|
||||
4. **查询剩余名额最多的晚间课程**
|
||||
```json
|
||||
{
|
||||
"timePeriod": "evening",
|
||||
"remainingMost": true
|
||||
}
|
||||
```
|
||||
|
||||
5. **多条件组合查询**
|
||||
```json
|
||||
{
|
||||
"courseName": "瑜伽",
|
||||
"courseType": 1,
|
||||
"startDate": "2026-06-01T00:00:00",
|
||||
"endDate": "2026-06-30T23:59:59",
|
||||
"timePeriod": "morning",
|
||||
"priceSort": "asc",
|
||||
"remainingMost": true,
|
||||
"page": 0,
|
||||
"size": 10
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 根据ID获取团课详情
|
||||
|
||||
| 属性 | 值 |
|
||||
@@ -604,26 +460,26 @@
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| **HTTP方法** | POST |
|
||||
| **接口路径** | `/api/groupCourse/signin/{memberId}` |
|
||||
| **接口路径** | `/api/groupCourse/{courseId}/signin` |
|
||||
| **所属文件** | `GroupCourseHandler.java` |
|
||||
|
||||
**路径参数**:
|
||||
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
|--------|------|------|------|
|
||||
| memberId | Long | 是 | 会员ID |
|
||||
| courseId | Long | 是 | 团课ID |
|
||||
|
||||
**请求体**:
|
||||
|
||||
```json
|
||||
{
|
||||
"courseId": 1
|
||||
"memberId": 1001
|
||||
}
|
||||
```
|
||||
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
|--------|------|------|------|
|
||||
| courseId | Long | **是** | 团课ID |
|
||||
| memberId | Long | **是** | 会员ID |
|
||||
|
||||
**成功响应** (200 OK):
|
||||
|
||||
@@ -638,39 +494,16 @@
|
||||
}
|
||||
```
|
||||
|
||||
**失败响应** (400 Bad Request) —— 校验规则及对应错误信息:
|
||||
|
||||
| 序号 | 校验规则 | 错误信息 |
|
||||
|------|----------|----------|
|
||||
| 1 | 团课不存在或已被删除 | `团课不存在或已删除` |
|
||||
| 2 | 团课状态为"已取消" | `团课已取消,无法签到` |
|
||||
| 3 | 团课状态为"已结束" | `团课已结束,无法签到` |
|
||||
| 4 | 当前时间早于开课前2小时 | `未到签到时间,请在开课前2小时内签到` |
|
||||
| 5 | 当前时间晚于团课结束时间 | `团课已结束,无法签到` |
|
||||
| 6 | 课程当前人数已达上限 | `课程已满员,无法签到` |
|
||||
| 7 | 用户今日无到店签到记录 | `请先完成到店签到` |
|
||||
| 8 | 到店签到状态非SUCCESS | `到店签到未成功,请重新签到` |
|
||||
| 9 | 用户未预约此课程 | `您未预约此课程,无法签到` |
|
||||
| - | 请求体为空 | `请求体不能为空` |
|
||||
| - | 请求体缺少courseId | `courseId不能为空` |
|
||||
|
||||
> **说明**: 校验按上表顺序依次执行,命中第一个失败条件即返回对应错误信息。
|
||||
|
||||
**失败响应示例**:
|
||||
**失败响应** (400 Bad Request):
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"message": "未到签到时间,请在开课前2小时内签到"
|
||||
"message": "课程已满员"
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"message": "您未预约此课程,无法签到"
|
||||
}
|
||||
```
|
||||
---
|
||||
|
||||
### 删除团课
|
||||
|
||||
@@ -1660,14 +1493,7 @@
|
||||
### 团课管理
|
||||
1. **创建团课**:课程名称为必填项
|
||||
2. **取消团课**:需提前24小时通知,否则拒绝操作
|
||||
3. **团课签到**:严格按以下顺序校验,任一不通过即拒绝签到:
|
||||
- 团课是否存在且未被删除
|
||||
- 团课状态不为"已取消"
|
||||
- 团课状态不为"已结束"(含已过结束时间)
|
||||
- 当前时间在开课前2小时内(签到时间窗口)
|
||||
- 课程当前人数未达到最大人数上限
|
||||
- 用户今日已成功到店签到(查询 sign_in_record 表当日 SUCCESS 记录)
|
||||
- 用户已预约该课程(有效预约)
|
||||
3. **团课签到**:验证课程状态必须为正常,且未达最大人数限制
|
||||
4. **删除团课**:采用软删除机制,数据保留可恢复
|
||||
|
||||
### 团课预约
|
||||
|
||||
@@ -86,25 +86,6 @@
|
||||
<artifactId>gym-member</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- ZXing二维码生成库 -->
|
||||
<dependency>
|
||||
<groupId>com.google.zxing</groupId>
|
||||
<artifactId>core</artifactId>
|
||||
<version>3.5.3</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.google.zxing</groupId>
|
||||
<artifactId>javase</artifactId>
|
||||
<version>3.5.3</version>
|
||||
</dependency>
|
||||
|
||||
<!-- 阿里云OSS SDK -->
|
||||
<dependency>
|
||||
<groupId>com.aliyun.oss</groupId>
|
||||
<artifactId>aliyun-sdk-oss</artifactId>
|
||||
<version>3.17.1</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
|
||||
+1
-184
@@ -1,19 +1,16 @@
|
||||
|
||||
package cn.novalon.gym.manage.groupcourse.dao;
|
||||
|
||||
import cn.novalon.gym.manage.groupcourse.dto.GroupCourseQueryDto;
|
||||
import cn.novalon.gym.manage.groupcourse.entity.GroupCourseEntity;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.data.r2dbc.repository.Modifying;
|
||||
import org.springframework.data.r2dbc.repository.Query;
|
||||
import org.springframework.data.r2dbc.repository.R2dbcRepository;
|
||||
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;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Repository
|
||||
public interface GroupCourseDao extends R2dbcRepository<GroupCourseEntity, Long> {
|
||||
@@ -41,184 +38,4 @@ public interface GroupCourseDao extends R2dbcRepository<GroupCourseEntity, Long>
|
||||
Mono<Integer> softDelete(Long id, LocalDateTime deletedAt);
|
||||
|
||||
Flux<GroupCourseEntity> findByCourseTypeAndDeletedAtIsNull(Long courseType);
|
||||
|
||||
/**
|
||||
* 多条件查询团课(使用 DatabaseClient 构建动态 SQL)
|
||||
*/
|
||||
default Flux<GroupCourseEntity> searchGroupCourses(DatabaseClient databaseClient, GroupCourseQueryDto query) {
|
||||
StringBuilder sql = new StringBuilder("SELECT * FROM group_course WHERE deleted_at IS NULL");
|
||||
List<String> conditions = new ArrayList<>();
|
||||
|
||||
// 默认不查询不可预约的团课(仅查询 status = '0')
|
||||
conditions.add("status = '0'");
|
||||
|
||||
// 1. 团课名称模糊查询
|
||||
if (query.getCourseName() != null && !query.getCourseName().isEmpty()) {
|
||||
conditions.add("course_name ILIKE :courseName");
|
||||
}
|
||||
|
||||
// 2. 基于团课类型查询
|
||||
if (query.getCourseType() != null) {
|
||||
conditions.add("course_type = :courseType");
|
||||
}
|
||||
|
||||
// 3. 基于日期时间段查询
|
||||
if (query.getStartDate() != null) {
|
||||
conditions.add("start_time >= :startDate");
|
||||
}
|
||||
if (query.getEndDate() != null) {
|
||||
conditions.add("start_time <= :endDate");
|
||||
}
|
||||
|
||||
// 4. 基于早晨/下午/夜晚时间段查询
|
||||
if (query.getTimePeriod() != null && !query.getTimePeriod().isEmpty()) {
|
||||
switch (query.getTimePeriod().toLowerCase()) {
|
||||
case "morning":
|
||||
conditions.add("EXTRACT(HOUR FROM start_time) >= 6 AND EXTRACT(HOUR FROM start_time) < 12");
|
||||
break;
|
||||
case "afternoon":
|
||||
conditions.add("EXTRACT(HOUR FROM start_time) >= 12 AND EXTRACT(HOUR FROM start_time) < 18");
|
||||
break;
|
||||
case "evening":
|
||||
conditions.add("EXTRACT(HOUR FROM start_time) >= 18 AND EXTRACT(HOUR FROM start_time) < 24");
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
sql.append(" AND ").append(String.join(" AND ", conditions));
|
||||
|
||||
// 5. 价格排序 / 6. 剩余名额最多排序
|
||||
boolean hasPriceSort = query.getPriceSort() != null && !query.getPriceSort().isEmpty();
|
||||
boolean hasRemainingMost = query.getRemainingMost() != null && query.getRemainingMost();
|
||||
|
||||
if (hasPriceSort || hasRemainingMost) {
|
||||
sql.append(" ORDER BY");
|
||||
List<String> orderClauses = new ArrayList<>();
|
||||
|
||||
if (hasRemainingMost) {
|
||||
orderClauses.add(" (max_members - current_members) DESC");
|
||||
}
|
||||
|
||||
if (hasPriceSort) {
|
||||
if ("asc".equalsIgnoreCase(query.getPriceSort())) {
|
||||
orderClauses.add(" stored_value_amount ASC");
|
||||
} else if ("desc".equalsIgnoreCase(query.getPriceSort())) {
|
||||
orderClauses.add(" stored_value_amount DESC");
|
||||
}
|
||||
}
|
||||
|
||||
sql.append(String.join(",", orderClauses));
|
||||
} else {
|
||||
sql.append(" ORDER BY start_time ASC");
|
||||
}
|
||||
|
||||
// 分页
|
||||
int page = query.getPage() != null ? query.getPage() : 0;
|
||||
int size = query.getSize() != null ? query.getSize() : 10;
|
||||
if (size < 1) size = 10;
|
||||
if (size > 100) size = 100;
|
||||
int offset = page * size;
|
||||
sql.append(" LIMIT :limit OFFSET :offset");
|
||||
|
||||
DatabaseClient.GenericExecuteSpec spec = databaseClient.sql(sql.toString());
|
||||
|
||||
if (query.getCourseName() != null && !query.getCourseName().isEmpty()) {
|
||||
spec = spec.bind("courseName", "%" + query.getCourseName() + "%");
|
||||
}
|
||||
if (query.getCourseType() != null) {
|
||||
spec = spec.bind("courseType", query.getCourseType());
|
||||
}
|
||||
if (query.getStartDate() != null) {
|
||||
spec = spec.bind("startDate", query.getStartDate());
|
||||
}
|
||||
if (query.getEndDate() != null) {
|
||||
spec = spec.bind("endDate", query.getEndDate());
|
||||
}
|
||||
spec = spec.bind("limit", size);
|
||||
spec = spec.bind("offset", offset);
|
||||
|
||||
return spec.map((row, meta) -> {
|
||||
GroupCourseEntity entity = new GroupCourseEntity();
|
||||
entity.setId(row.get("id", Long.class));
|
||||
entity.setCourseName(row.get("course_name", String.class));
|
||||
entity.setCoachId(row.get("coach_id", Long.class));
|
||||
entity.setCourseType(row.get("course_type", Long.class));
|
||||
entity.setStartTime(row.get("start_time", LocalDateTime.class));
|
||||
entity.setEndTime(row.get("end_time", LocalDateTime.class));
|
||||
entity.setMaxMembers(row.get("max_members", Integer.class));
|
||||
entity.setCurrentMembers(row.get("current_members", Integer.class));
|
||||
String statusStr = row.get("status", String.class);
|
||||
entity.setStatus(statusStr != null ? Long.parseLong(statusStr) : null);
|
||||
entity.setLocation(row.get("location", String.class));
|
||||
entity.setCoverImage(row.get("cover_image", String.class));
|
||||
entity.setDescription(row.get("description", String.class));
|
||||
entity.setPointCardAmount(row.get("point_card_amount", Integer.class));
|
||||
entity.setStoredValueAmount(row.get("stored_value_amount", java.math.BigDecimal.class));
|
||||
entity.setCreateBy(row.get("create_by", String.class));
|
||||
entity.setUpdateBy(row.get("update_by", String.class));
|
||||
entity.setCreatedAt(row.get("created_at", LocalDateTime.class));
|
||||
entity.setUpdatedAt(row.get("updated_at", LocalDateTime.class));
|
||||
entity.setDeletedAt(row.get("deleted_at", LocalDateTime.class));
|
||||
return entity;
|
||||
}).all();
|
||||
}
|
||||
|
||||
/**
|
||||
* 多条件查询团课总数
|
||||
*/
|
||||
default Mono<Long> countSearchGroupCourses(DatabaseClient databaseClient, GroupCourseQueryDto query) {
|
||||
StringBuilder sql = new StringBuilder("SELECT COUNT(*) FROM group_course WHERE deleted_at IS NULL");
|
||||
List<String> conditions = new ArrayList<>();
|
||||
|
||||
conditions.add("status = '0'");
|
||||
|
||||
if (query.getCourseName() != null && !query.getCourseName().isEmpty()) {
|
||||
conditions.add("course_name ILIKE :courseName");
|
||||
}
|
||||
if (query.getCourseType() != null) {
|
||||
conditions.add("course_type = :courseType");
|
||||
}
|
||||
if (query.getStartDate() != null) {
|
||||
conditions.add("start_time >= :startDate");
|
||||
}
|
||||
if (query.getEndDate() != null) {
|
||||
conditions.add("start_time <= :endDate");
|
||||
}
|
||||
if (query.getTimePeriod() != null && !query.getTimePeriod().isEmpty()) {
|
||||
switch (query.getTimePeriod().toLowerCase()) {
|
||||
case "morning":
|
||||
conditions.add("EXTRACT(HOUR FROM start_time) >= 6 AND EXTRACT(HOUR FROM start_time) < 12");
|
||||
break;
|
||||
case "afternoon":
|
||||
conditions.add("EXTRACT(HOUR FROM start_time) >= 12 AND EXTRACT(HOUR FROM start_time) < 18");
|
||||
break;
|
||||
case "evening":
|
||||
conditions.add("EXTRACT(HOUR FROM start_time) >= 18 AND EXTRACT(HOUR FROM start_time) < 24");
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
sql.append(" AND ").append(String.join(" AND ", conditions));
|
||||
|
||||
DatabaseClient.GenericExecuteSpec spec = databaseClient.sql(sql.toString());
|
||||
|
||||
if (query.getCourseName() != null && !query.getCourseName().isEmpty()) {
|
||||
spec = spec.bind("courseName", "%" + query.getCourseName() + "%");
|
||||
}
|
||||
if (query.getCourseType() != null) {
|
||||
spec = spec.bind("courseType", query.getCourseType());
|
||||
}
|
||||
if (query.getStartDate() != null) {
|
||||
spec = spec.bind("startDate", query.getStartDate());
|
||||
}
|
||||
if (query.getEndDate() != null) {
|
||||
spec = spec.bind("endDate", query.getEndDate());
|
||||
}
|
||||
|
||||
return spec.map((row, meta) -> row.get(0, Long.class)).one();
|
||||
}
|
||||
}
|
||||
-42
@@ -1,42 +0,0 @@
|
||||
package cn.novalon.gym.manage.groupcourse.dao;
|
||||
|
||||
import cn.novalon.gym.manage.groupcourse.entity.GroupCourseRecommendEntity;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.data.r2dbc.repository.Modifying;
|
||||
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
|
||||
public interface GroupCourseRecommendDao extends R2dbcRepository<GroupCourseRecommendEntity, Long> {
|
||||
|
||||
Mono<GroupCourseRecommendEntity> findByIdAndDeletedAtIsNull(Long id);
|
||||
|
||||
Flux<GroupCourseRecommendEntity> findAllByDeletedAtIsNull();
|
||||
|
||||
Flux<GroupCourseRecommendEntity> findAllByDeletedAtIsNull(Sort sort);
|
||||
|
||||
Flux<GroupCourseRecommendEntity> findByCourseIdAndDeletedAtIsNull(Long courseId);
|
||||
|
||||
Mono<GroupCourseRecommendEntity> findByCourseIdAndDeletedAtIsNullAndIsActiveTrue(Long courseId);
|
||||
|
||||
Flux<GroupCourseRecommendEntity> findByIsActiveTrueAndDeletedAtIsNull();
|
||||
|
||||
Flux<GroupCourseRecommendEntity> findByIsActiveTrueAndDeletedAtIsNull(Sort sort);
|
||||
|
||||
@Modifying
|
||||
@Query("UPDATE group_course_recommend SET is_active = :isActive, updated_at = :updatedAt WHERE id = :id AND deleted_at IS NULL")
|
||||
Mono<Integer> updateActiveStatus(Long id, Boolean isActive, LocalDateTime updatedAt);
|
||||
|
||||
@Modifying
|
||||
@Query("UPDATE group_course_recommend SET deleted_at = :deletedAt WHERE id = :id")
|
||||
Mono<Integer> softDelete(Long id, LocalDateTime deletedAt);
|
||||
|
||||
@Modifying
|
||||
@Query("UPDATE group_course_recommend SET deleted_at = :deletedAt WHERE course_id = :courseId")
|
||||
Mono<Integer> softDeleteByCourseId(Long courseId, LocalDateTime deletedAt);
|
||||
}
|
||||
-12
@@ -60,10 +60,6 @@ public class GroupCourse extends BaseDomain{
|
||||
@Schema(description = "储值卡额度(消耗金额)", example = "50.00")
|
||||
private java.math.BigDecimal storedValueAmount;
|
||||
|
||||
//二维码路径
|
||||
@Schema(description = "二维码路径", example = "D:\\Games\\exmp\\image\\abc123_20260618120000.png")
|
||||
private String qrCodePath;
|
||||
|
||||
public String getCourseName() {
|
||||
return courseName;
|
||||
}
|
||||
@@ -167,12 +163,4 @@ public class GroupCourse extends BaseDomain{
|
||||
public void setStoredValueAmount(java.math.BigDecimal storedValueAmount) {
|
||||
this.storedValueAmount = storedValueAmount;
|
||||
}
|
||||
|
||||
public String getQrCodePath() {
|
||||
return qrCodePath;
|
||||
}
|
||||
|
||||
public void setQrCodePath(String qrCodePath) {
|
||||
this.qrCodePath = qrCodePath;
|
||||
}
|
||||
}
|
||||
|
||||
-11
@@ -54,9 +54,6 @@ public class GroupCourseDetail extends BaseDomain {
|
||||
@Schema(description = "储值卡额度(消耗金额)", example = "50.00")
|
||||
private BigDecimal storedValueAmount;
|
||||
|
||||
@Schema(description = "二维码路径", example = "D:\\Games\\exmp\\image\\abc123_20260618120000.png")
|
||||
private String qrCodePath;
|
||||
|
||||
// ===== 关联的类型信息 =====
|
||||
|
||||
@Schema(description = "类型信息")
|
||||
@@ -190,14 +187,6 @@ public class GroupCourseDetail extends BaseDomain {
|
||||
this.storedValueAmount = storedValueAmount;
|
||||
}
|
||||
|
||||
public String getQrCodePath() {
|
||||
return qrCodePath;
|
||||
}
|
||||
|
||||
public void setQrCodePath(String qrCodePath) {
|
||||
this.qrCodePath = qrCodePath;
|
||||
}
|
||||
|
||||
public GroupCourseType getTypeInfo() {
|
||||
return typeInfo;
|
||||
}
|
||||
|
||||
-84
@@ -1,84 +0,0 @@
|
||||
package cn.novalon.gym.manage.groupcourse.domain;
|
||||
|
||||
import cn.novalon.gym.manage.sys.core.domain.BaseDomain;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
public class GroupCourseRecommend extends BaseDomain {
|
||||
|
||||
@Schema(description = "团课ID", example = "1")
|
||||
private Long courseId;
|
||||
|
||||
@Schema(description = "推荐标题", example = "本周热门课程")
|
||||
private String recommendTitle;
|
||||
|
||||
@Schema(description = "推荐内容", example = "这是一门非常棒的课程,快来参加吧!")
|
||||
private String recommendContent;
|
||||
|
||||
@Schema(description = "推荐理由", example = "教练专业,课程内容丰富")
|
||||
private String recommendReason;
|
||||
|
||||
@Schema(description = "优先级(数字越大优先级越高)", example = "10")
|
||||
private Integer priority;
|
||||
|
||||
@Schema(description = "是否启用", example = "true")
|
||||
private Boolean isActive;
|
||||
|
||||
@Schema(description = "团课信息")
|
||||
private GroupCourse groupCourse;
|
||||
|
||||
public Long getCourseId() {
|
||||
return courseId;
|
||||
}
|
||||
|
||||
public void setCourseId(Long courseId) {
|
||||
this.courseId = courseId;
|
||||
}
|
||||
|
||||
public String getRecommendTitle() {
|
||||
return recommendTitle;
|
||||
}
|
||||
|
||||
public void setRecommendTitle(String recommendTitle) {
|
||||
this.recommendTitle = recommendTitle;
|
||||
}
|
||||
|
||||
public String getRecommendContent() {
|
||||
return recommendContent;
|
||||
}
|
||||
|
||||
public void setRecommendContent(String recommendContent) {
|
||||
this.recommendContent = recommendContent;
|
||||
}
|
||||
|
||||
public String getRecommendReason() {
|
||||
return recommendReason;
|
||||
}
|
||||
|
||||
public void setRecommendReason(String recommendReason) {
|
||||
this.recommendReason = recommendReason;
|
||||
}
|
||||
|
||||
public Integer getPriority() {
|
||||
return priority;
|
||||
}
|
||||
|
||||
public void setPriority(Integer priority) {
|
||||
this.priority = priority;
|
||||
}
|
||||
|
||||
public Boolean getIsActive() {
|
||||
return isActive;
|
||||
}
|
||||
|
||||
public void setIsActive(Boolean isActive) {
|
||||
this.isActive = isActive;
|
||||
}
|
||||
|
||||
public GroupCourse getGroupCourse() {
|
||||
return groupCourse;
|
||||
}
|
||||
|
||||
public void setGroupCourse(GroupCourse groupCourse) {
|
||||
this.groupCourse = groupCourse;
|
||||
}
|
||||
}
|
||||
-116
@@ -1,116 +0,0 @@
|
||||
package cn.novalon.gym.manage.groupcourse.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 团课多条件查询请求DTO
|
||||
*
|
||||
* @author 张翔
|
||||
* @date 2026-06-14
|
||||
*/
|
||||
@Schema(description = "团课多条件查询参数")
|
||||
public class GroupCourseQueryDto {
|
||||
|
||||
@Schema(description = "课程名称(模糊查询)", example = "瑜伽")
|
||||
private String courseName;
|
||||
|
||||
@Schema(description = "课程类型ID", example = "1")
|
||||
private Long courseType;
|
||||
|
||||
@Schema(description = "查询开始日期", example = "2026-06-01T00:00:00")
|
||||
private LocalDateTime startDate;
|
||||
|
||||
@Schema(description = "查询结束日期", example = "2026-06-30T23:59:59")
|
||||
private LocalDateTime endDate;
|
||||
|
||||
@Schema(description = "时间段:morning-早晨(6:00-12:00), afternoon-下午(12:00-18:00), evening-夜晚(18:00-24:00)", example = "morning")
|
||||
private String timePeriod;
|
||||
|
||||
@Schema(description = "价格排序:asc-从低到高, desc-从高到低", example = "asc")
|
||||
private String priceSort;
|
||||
|
||||
@Schema(description = "按剩余名额最多排序", example = "true")
|
||||
private Boolean remainingMost;
|
||||
|
||||
@Schema(description = "页码", example = "0")
|
||||
private Integer page = 0;
|
||||
|
||||
@Schema(description = "每页大小", example = "10")
|
||||
private Integer size = 10;
|
||||
|
||||
// ===== Getters and Setters =====
|
||||
|
||||
public String getCourseName() {
|
||||
return courseName;
|
||||
}
|
||||
|
||||
public void setCourseName(String courseName) {
|
||||
this.courseName = courseName;
|
||||
}
|
||||
|
||||
public Long getCourseType() {
|
||||
return courseType;
|
||||
}
|
||||
|
||||
public void setCourseType(Long courseType) {
|
||||
this.courseType = courseType;
|
||||
}
|
||||
|
||||
public LocalDateTime getStartDate() {
|
||||
return startDate;
|
||||
}
|
||||
|
||||
public void setStartDate(LocalDateTime startDate) {
|
||||
this.startDate = startDate;
|
||||
}
|
||||
|
||||
public LocalDateTime getEndDate() {
|
||||
return endDate;
|
||||
}
|
||||
|
||||
public void setEndDate(LocalDateTime endDate) {
|
||||
this.endDate = endDate;
|
||||
}
|
||||
|
||||
public String getTimePeriod() {
|
||||
return timePeriod;
|
||||
}
|
||||
|
||||
public void setTimePeriod(String timePeriod) {
|
||||
this.timePeriod = timePeriod;
|
||||
}
|
||||
|
||||
public String getPriceSort() {
|
||||
return priceSort;
|
||||
}
|
||||
|
||||
public void setPriceSort(String priceSort) {
|
||||
this.priceSort = priceSort;
|
||||
}
|
||||
|
||||
public Boolean getRemainingMost() {
|
||||
return remainingMost;
|
||||
}
|
||||
|
||||
public void setRemainingMost(Boolean remainingMost) {
|
||||
this.remainingMost = remainingMost;
|
||||
}
|
||||
|
||||
public Integer getPage() {
|
||||
return page;
|
||||
}
|
||||
|
||||
public void setPage(Integer page) {
|
||||
this.page = page;
|
||||
}
|
||||
|
||||
public Integer getSize() {
|
||||
return size;
|
||||
}
|
||||
|
||||
public void setSize(Integer size) {
|
||||
this.size = size;
|
||||
}
|
||||
}
|
||||
-12
@@ -62,10 +62,6 @@ public class GroupCourseEntity extends BaseEntity {
|
||||
@Column("stored_value_amount")
|
||||
private java.math.BigDecimal storedValueAmount;
|
||||
|
||||
//二维码路径
|
||||
@Column("qr_code_path")
|
||||
private String qrCodePath;
|
||||
|
||||
public String getCourseName() {
|
||||
return courseName;
|
||||
}
|
||||
@@ -169,12 +165,4 @@ public class GroupCourseEntity extends BaseEntity {
|
||||
public void setStoredValueAmount(java.math.BigDecimal storedValueAmount) {
|
||||
this.storedValueAmount = storedValueAmount;
|
||||
}
|
||||
|
||||
public String getQrCodePath() {
|
||||
return qrCodePath;
|
||||
}
|
||||
|
||||
public void setQrCodePath(String qrCodePath) {
|
||||
this.qrCodePath = qrCodePath;
|
||||
}
|
||||
}
|
||||
|
||||
-77
@@ -1,77 +0,0 @@
|
||||
package cn.novalon.gym.manage.groupcourse.entity;
|
||||
|
||||
import cn.novalon.gym.manage.db.entity.BaseEntity;
|
||||
import org.springframework.data.relational.core.mapping.Column;
|
||||
import org.springframework.data.relational.core.mapping.Table;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Table("group_course_recommend")
|
||||
public class GroupCourseRecommendEntity extends BaseEntity {
|
||||
|
||||
@Column("course_id")
|
||||
private Long courseId;
|
||||
|
||||
@Column("recommend_title")
|
||||
private String recommendTitle;
|
||||
|
||||
@Column("recommend_content")
|
||||
private String recommendContent;
|
||||
|
||||
@Column("recommend_reason")
|
||||
private String recommendReason;
|
||||
|
||||
@Column("priority")
|
||||
private Integer priority;
|
||||
|
||||
@Column("is_active")
|
||||
private Boolean isActive;
|
||||
|
||||
public Long getCourseId() {
|
||||
return courseId;
|
||||
}
|
||||
|
||||
public void setCourseId(Long courseId) {
|
||||
this.courseId = courseId;
|
||||
}
|
||||
|
||||
public String getRecommendTitle() {
|
||||
return recommendTitle;
|
||||
}
|
||||
|
||||
public void setRecommendTitle(String recommendTitle) {
|
||||
this.recommendTitle = recommendTitle;
|
||||
}
|
||||
|
||||
public String getRecommendContent() {
|
||||
return recommendContent;
|
||||
}
|
||||
|
||||
public void setRecommendContent(String recommendContent) {
|
||||
this.recommendContent = recommendContent;
|
||||
}
|
||||
|
||||
public String getRecommendReason() {
|
||||
return recommendReason;
|
||||
}
|
||||
|
||||
public void setRecommendReason(String recommendReason) {
|
||||
this.recommendReason = recommendReason;
|
||||
}
|
||||
|
||||
public Integer getPriority() {
|
||||
return priority;
|
||||
}
|
||||
|
||||
public void setPriority(Integer priority) {
|
||||
this.priority = priority;
|
||||
}
|
||||
|
||||
public Boolean getIsActive() {
|
||||
return isActive;
|
||||
}
|
||||
|
||||
public void setIsActive(Boolean isActive) {
|
||||
this.isActive = isActive;
|
||||
}
|
||||
}
|
||||
+5
-27
@@ -5,7 +5,6 @@ import cn.novalon.gym.manage.common.dto.PageRequest;
|
||||
import cn.novalon.gym.manage.common.util.RedisUtil;
|
||||
import cn.novalon.gym.manage.groupcourse.domain.GroupCourse;
|
||||
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseDetail;
|
||||
import cn.novalon.gym.manage.groupcourse.dto.GroupCourseQueryDto;
|
||||
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseService;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
@@ -159,7 +158,7 @@ public class GroupCourseHandler {
|
||||
|
||||
@Operation(summary = "团课签到", description = "会员签到参加团课")
|
||||
public Mono<ServerResponse> signIn(ServerRequest request) {
|
||||
Long memberId = Long.valueOf(request.pathVariable("memberId"));
|
||||
Long courseId = Long.valueOf(request.pathVariable("courseId"));
|
||||
|
||||
return request.bodyToMono(Map.class)
|
||||
.flatMap(body -> {
|
||||
@@ -170,15 +169,15 @@ public class GroupCourseHandler {
|
||||
return ServerResponse.badRequest().bodyValue(response);
|
||||
}
|
||||
|
||||
Object courseIdObj = body.get("courseId");
|
||||
if (courseIdObj == null) {
|
||||
Object memberIdObj = body.get("memberId");
|
||||
if (memberIdObj == null) {
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", false);
|
||||
response.put("message", "courseId不能为空");
|
||||
response.put("message", "memberId不能为空");
|
||||
return ServerResponse.badRequest().bodyValue(response);
|
||||
}
|
||||
|
||||
Long courseId = ((Number) courseIdObj).longValue();
|
||||
Long memberId = ((Number) memberIdObj).longValue();
|
||||
|
||||
return groupCourseService.signIn(courseId, memberId)
|
||||
.flatMap(course -> {
|
||||
@@ -216,27 +215,6 @@ public class GroupCourseHandler {
|
||||
});
|
||||
}
|
||||
|
||||
@Operation(summary = "多条件查询团课", description = "支持团课名称模糊查询、类型筛选、日期范围、时间段、价格排序、剩余名额排序等多条件组合查询")
|
||||
public Mono<ServerResponse> searchGroupCourses(ServerRequest request) {
|
||||
return request.bodyToMono(GroupCourseQueryDto.class)
|
||||
.flatMap(query -> {
|
||||
return groupCourseService.searchGroupCourses(query)
|
||||
.flatMap(response -> {
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("success", true);
|
||||
result.put("message", "查询成功");
|
||||
result.put("data", response);
|
||||
return ServerResponse.ok().bodyValue(result);
|
||||
});
|
||||
})
|
||||
.onErrorResume(error -> {
|
||||
Map<String, Object> errorResponse = new HashMap<>();
|
||||
errorResponse.put("success", false);
|
||||
errorResponse.put("message", error.getMessage());
|
||||
return ServerResponse.badRequest().bodyValue(errorResponse);
|
||||
});
|
||||
}
|
||||
|
||||
@Operation(summary = "测试-根据Key获取Redis缓存", description = "测试接口:根据传入的key值获取Redis中缓存的数据")
|
||||
public Mono<ServerResponse> getCacheByKey(ServerRequest request) {
|
||||
return request.bodyToMono(Map.class)
|
||||
|
||||
-164
@@ -1,164 +0,0 @@
|
||||
package cn.novalon.gym.manage.groupcourse.handler;
|
||||
|
||||
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseRecommend;
|
||||
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseRecommendService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
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.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@Component
|
||||
@Tag(name = "团课推荐管理", description = "团课推荐相关操作")
|
||||
public class GroupCourseRecommendHandler {
|
||||
|
||||
private final IGroupCourseRecommendService recommendService;
|
||||
|
||||
public GroupCourseRecommendHandler(IGroupCourseRecommendService recommendService) {
|
||||
this.recommendService = recommendService;
|
||||
}
|
||||
|
||||
@Operation(summary = "获取所有团课推荐", description = "获取系统中所有团课推荐列表,支持按优先级排序")
|
||||
public Mono<ServerResponse> getAllRecommendations(ServerRequest request) {
|
||||
String sortBy = request.queryParam("sortBy").orElse("priority");
|
||||
String sortOrder = request.queryParam("sortOrder").orElse("desc");
|
||||
|
||||
return ServerResponse.ok()
|
||||
.body(recommendService.findAll(sortBy, sortOrder), GroupCourseRecommend.class);
|
||||
}
|
||||
|
||||
@Operation(summary = "获取所有启用的团课推荐", description = "获取系统中所有已启用的团课推荐列表(按优先级排序)")
|
||||
public Mono<ServerResponse> getAllActiveRecommendations(ServerRequest request) {
|
||||
return ServerResponse.ok()
|
||||
.body(recommendService.findAllActive(), GroupCourseRecommend.class);
|
||||
}
|
||||
|
||||
@Operation(summary = "根据ID获取团课推荐", description = "根据ID获取团课推荐详情")
|
||||
public Mono<ServerResponse> getRecommendationById(ServerRequest request) {
|
||||
Long id = Long.valueOf(request.pathVariable("id"));
|
||||
return recommendService.findById(id)
|
||||
.flatMap(recommend -> ServerResponse.ok().bodyValue(recommend))
|
||||
.switchIfEmpty(ServerResponse.notFound().build());
|
||||
}
|
||||
|
||||
@Operation(summary = "根据团课ID获取推荐", description = "根据团课ID获取该团课的推荐信息")
|
||||
public Mono<ServerResponse> getRecommendationsByCourseId(ServerRequest request) {
|
||||
Long courseId = Long.valueOf(request.pathVariable("courseId"));
|
||||
return ServerResponse.ok()
|
||||
.body(recommendService.findByCourseId(courseId), GroupCourseRecommend.class);
|
||||
}
|
||||
|
||||
@Operation(summary = "创建团课推荐", description = "创建新的团课推荐")
|
||||
public Mono<ServerResponse> createRecommendation(ServerRequest request) {
|
||||
return request.bodyToMono(GroupCourseRecommend.class)
|
||||
.flatMap(recommend -> {
|
||||
if (recommend.getCourseId() == null) {
|
||||
Map<String, Object> error = new HashMap<>();
|
||||
error.put("success", false);
|
||||
error.put("message", "团课ID不能为空");
|
||||
return ServerResponse.badRequest().bodyValue(error);
|
||||
}
|
||||
|
||||
return recommendService.create(recommend)
|
||||
.flatMap(r -> {
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", true);
|
||||
response.put("message", "团课推荐创建成功");
|
||||
response.put("data", r);
|
||||
return ServerResponse.ok().bodyValue(response);
|
||||
})
|
||||
.onErrorResume(error -> {
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", false);
|
||||
response.put("message", error.getMessage());
|
||||
return ServerResponse.badRequest().bodyValue(response);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@Operation(summary = "更新团课推荐", description = "更新指定团课推荐信息")
|
||||
public Mono<ServerResponse> updateRecommendation(ServerRequest request) {
|
||||
Long id = Long.valueOf(request.pathVariable("id"));
|
||||
|
||||
return request.bodyToMono(GroupCourseRecommend.class)
|
||||
.flatMap(recommend -> {
|
||||
return recommendService.update(id, recommend)
|
||||
.flatMap(r -> {
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", true);
|
||||
response.put("message", "团课推荐更新成功");
|
||||
response.put("data", r);
|
||||
return ServerResponse.ok().bodyValue(response);
|
||||
})
|
||||
.onErrorResume(error -> {
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", false);
|
||||
response.put("message", error.getMessage());
|
||||
return ServerResponse.badRequest().bodyValue(response);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@Operation(summary = "删除团课推荐", description = "删除指定团课推荐(软删除)")
|
||||
public Mono<ServerResponse> deleteRecommendation(ServerRequest request) {
|
||||
Long id = Long.valueOf(request.pathVariable("id"));
|
||||
|
||||
return recommendService.delete(id)
|
||||
.then(Mono.defer(() -> {
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", true);
|
||||
response.put("message", "团课推荐删除成功");
|
||||
return ServerResponse.ok().bodyValue(response);
|
||||
}))
|
||||
.onErrorResume(error -> {
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", false);
|
||||
response.put("message", error.getMessage());
|
||||
return ServerResponse.badRequest().bodyValue(response);
|
||||
});
|
||||
}
|
||||
|
||||
@Operation(summary = "启用团课推荐", description = "启用指定团课推荐")
|
||||
public Mono<ServerResponse> enableRecommendation(ServerRequest request) {
|
||||
Long id = Long.valueOf(request.pathVariable("id"));
|
||||
|
||||
return recommendService.enable(id)
|
||||
.flatMap(r -> {
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", true);
|
||||
response.put("message", "团课推荐启用成功");
|
||||
response.put("data", r);
|
||||
return ServerResponse.ok().bodyValue(response);
|
||||
})
|
||||
.onErrorResume(error -> {
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", false);
|
||||
response.put("message", error.getMessage());
|
||||
return ServerResponse.badRequest().bodyValue(response);
|
||||
});
|
||||
}
|
||||
|
||||
@Operation(summary = "禁用团课推荐", description = "禁用指定团课推荐")
|
||||
public Mono<ServerResponse> disableRecommendation(ServerRequest request) {
|
||||
Long id = Long.valueOf(request.pathVariable("id"));
|
||||
|
||||
return recommendService.disable(id)
|
||||
.flatMap(r -> {
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", true);
|
||||
response.put("message", "团课推荐禁用成功");
|
||||
response.put("data", r);
|
||||
return ServerResponse.ok().bodyValue(response);
|
||||
})
|
||||
.onErrorResume(error -> {
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", false);
|
||||
response.put("message", error.getMessage());
|
||||
return ServerResponse.badRequest().bodyValue(response);
|
||||
});
|
||||
}
|
||||
}
|
||||
-89
@@ -1,89 +0,0 @@
|
||||
package cn.novalon.gym.manage.groupcourse.initializer;
|
||||
|
||||
import cn.novalon.gym.manage.groupcourse.domain.GroupCourse;
|
||||
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseRepository;
|
||||
import cn.novalon.gym.manage.groupcourse.util.QRCodeUtil;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.boot.CommandLineRunner;
|
||||
import org.springframework.stereotype.Component;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 项目启动时补全缺失的团课二维码
|
||||
* 遍历所有未删除的团课,对qrCodePath为空的课程生成二维码并上传至阿里云OSS
|
||||
*/
|
||||
@Component
|
||||
public class QrCodeInitializer implements CommandLineRunner {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(QrCodeInitializer.class);
|
||||
|
||||
private final IGroupCourseRepository groupCourseRepository;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public QrCodeInitializer(IGroupCourseRepository groupCourseRepository,
|
||||
ObjectMapper objectMapper) {
|
||||
this.groupCourseRepository = groupCourseRepository;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run(String... args) {
|
||||
logger.info("===== 开始检查团课二维码缺失情况 =====");
|
||||
|
||||
groupCourseRepository.findByDeletedAtIsNull()
|
||||
.filter(course -> course.getQrCodePath() == null || course.getQrCodePath().isEmpty())
|
||||
.flatMap(course -> {
|
||||
try {
|
||||
logger.info("发现缺失二维码的团课 - id={}, name={}", course.getId(), course.getCourseName());
|
||||
|
||||
// 生成二维码内容:团课基础信息JSON
|
||||
Map<String, Object> qrCodeContent = new HashMap<>();
|
||||
qrCodeContent.put("id", course.getId());
|
||||
qrCodeContent.put("courseName", course.getCourseName());
|
||||
qrCodeContent.put("coachId", course.getCoachId());
|
||||
qrCodeContent.put("courseType", course.getCourseType());
|
||||
qrCodeContent.put("startTime", course.getStartTime() != null ? course.getStartTime().toString() : null);
|
||||
qrCodeContent.put("endTime", course.getEndTime() != null ? course.getEndTime().toString() : null);
|
||||
qrCodeContent.put("maxMembers", course.getMaxMembers());
|
||||
qrCodeContent.put("location", course.getLocation());
|
||||
qrCodeContent.put("description", course.getDescription());
|
||||
|
||||
String jsonContent = objectMapper.writeValueAsString(qrCodeContent);
|
||||
|
||||
// 生成二维码并上传到阿里云OSS
|
||||
String uuid = UUID.randomUUID().toString().replace("-", "");
|
||||
String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"));
|
||||
String fileName = "qr_" + uuid + "_" + timestamp + ".png";
|
||||
String ossUrl = QRCodeUtil.generateQRCodeAndUploadToOSS(jsonContent, fileName);
|
||||
|
||||
course.setQrCodePath(ossUrl);
|
||||
|
||||
// 更新数据库
|
||||
return groupCourseRepository.update(course)
|
||||
.doOnSuccess(updated -> logger.info("团课二维码补全成功 - id={}, name={}, ossUrl={}",
|
||||
updated.getId(), updated.getCourseName(), ossUrl))
|
||||
.doOnError(error -> logger.error("团课二维码补全失败(更新DB) - id={}, name={}, error: {}",
|
||||
course.getId(), course.getCourseName(), error.getMessage()));
|
||||
} catch (Exception e) {
|
||||
logger.error("团课二维码补全失败(生成) - id={}, name={}, error: {}",
|
||||
course.getId(), course.getCourseName(), e.getMessage(), e);
|
||||
return Mono.empty();
|
||||
}
|
||||
})
|
||||
.collectList()
|
||||
.doOnSuccess(list -> logger.info("===== 团课二维码检查完毕,共补全 {} 个缺失二维码 =====", list.size()))
|
||||
.onErrorResume(error -> {
|
||||
logger.error("团课二维码初始化检查异常: {}", error.getMessage(), error);
|
||||
return Mono.empty();
|
||||
})
|
||||
.subscribe();
|
||||
}
|
||||
}
|
||||
-30
@@ -1,30 +0,0 @@
|
||||
package cn.novalon.gym.manage.groupcourse.repository;
|
||||
|
||||
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseRecommend;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
public interface IGroupCourseRecommendRepository {
|
||||
|
||||
Mono<GroupCourseRecommend> findById(Long id);
|
||||
|
||||
Flux<GroupCourseRecommend> findAll();
|
||||
|
||||
Flux<GroupCourseRecommend> findAll(String sortBy, String sortOrder);
|
||||
|
||||
Flux<GroupCourseRecommend> findAllActive();
|
||||
|
||||
Flux<GroupCourseRecommend> findByCourseId(Long courseId);
|
||||
|
||||
Mono<GroupCourseRecommend> findActiveByCourseId(Long courseId);
|
||||
|
||||
Mono<GroupCourseRecommend> save(GroupCourseRecommend recommend);
|
||||
|
||||
Mono<GroupCourseRecommend> update(GroupCourseRecommend recommend);
|
||||
|
||||
Mono<Void> deleteById(Long id);
|
||||
|
||||
Mono<Void> deleteByCourseId(Long courseId);
|
||||
|
||||
Mono<GroupCourseRecommend> updateActiveStatus(Long id, Boolean isActive);
|
||||
}
|
||||
-3
@@ -4,7 +4,6 @@ package cn.novalon.gym.manage.groupcourse.repository;
|
||||
import cn.novalon.gym.manage.common.dto.PageRequest;
|
||||
import cn.novalon.gym.manage.common.dto.PageResponse;
|
||||
import cn.novalon.gym.manage.groupcourse.domain.GroupCourse;
|
||||
import cn.novalon.gym.manage.groupcourse.dto.GroupCourseQueryDto;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
@@ -30,6 +29,4 @@ public interface IGroupCourseRepository {
|
||||
Mono<GroupCourse> updateCurrentMembers(Long id, Integer delta);
|
||||
|
||||
Flux<GroupCourse> findByCourseType(Long courseType);
|
||||
|
||||
Mono<PageResponse<GroupCourse>> searchGroupCourses(GroupCourseQueryDto query);
|
||||
}
|
||||
|
||||
-136
@@ -1,136 +0,0 @@
|
||||
package cn.novalon.gym.manage.groupcourse.repository.impl;
|
||||
|
||||
import cn.hutool.core.bean.BeanUtil;
|
||||
import cn.novalon.gym.manage.groupcourse.dao.GroupCourseRecommendDao;
|
||||
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseRecommend;
|
||||
import cn.novalon.gym.manage.groupcourse.entity.GroupCourseRecommendEntity;
|
||||
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseRecommendRepository;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.data.r2dbc.core.R2dbcEntityTemplate;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Repository
|
||||
public class GroupCourseRecommendRepository implements IGroupCourseRecommendRepository {
|
||||
|
||||
private final GroupCourseRecommendDao recommendDao;
|
||||
private final R2dbcEntityTemplate r2dbcEntityTemplate;
|
||||
|
||||
public GroupCourseRecommendRepository(GroupCourseRecommendDao recommendDao,
|
||||
R2dbcEntityTemplate r2dbcEntityTemplate) {
|
||||
this.recommendDao = recommendDao;
|
||||
this.r2dbcEntityTemplate = r2dbcEntityTemplate;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<GroupCourseRecommend> findById(Long id) {
|
||||
return recommendDao.findByIdAndDeletedAtIsNull(id)
|
||||
.map(this::toDomain);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Flux<GroupCourseRecommend> findAll() {
|
||||
return recommendDao.findAllByDeletedAtIsNull()
|
||||
.map(this::toDomain);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Flux<GroupCourseRecommend> findAll(String sortBy, String sortOrder) {
|
||||
Sort.Direction direction = "asc".equalsIgnoreCase(sortOrder)
|
||||
? Sort.Direction.ASC
|
||||
: Sort.Direction.DESC;
|
||||
Sort sort = Sort.by(direction, sortBy);
|
||||
return recommendDao.findAllByDeletedAtIsNull(sort)
|
||||
.map(this::toDomain);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Flux<GroupCourseRecommend> findAllActive() {
|
||||
return recommendDao.findByIsActiveTrueAndDeletedAtIsNull(Sort.by(Sort.Direction.DESC, "priority"))
|
||||
.map(this::toDomain);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Flux<GroupCourseRecommend> findByCourseId(Long courseId) {
|
||||
return recommendDao.findByCourseIdAndDeletedAtIsNull(courseId)
|
||||
.map(this::toDomain);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<GroupCourseRecommend> findActiveByCourseId(Long courseId) {
|
||||
return recommendDao.findByCourseIdAndDeletedAtIsNullAndIsActiveTrue(courseId)
|
||||
.map(this::toDomain);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<GroupCourseRecommend> save(GroupCourseRecommend recommend) {
|
||||
GroupCourseRecommendEntity entity = toEntity(recommend);
|
||||
entity.setCreatedAt(LocalDateTime.now());
|
||||
entity.setUpdatedAt(LocalDateTime.now());
|
||||
if (entity.getPriority() == null) {
|
||||
entity.setPriority(0);
|
||||
}
|
||||
if (entity.getIsActive() == null) {
|
||||
entity.setIsActive(true);
|
||||
}
|
||||
|
||||
return recommendDao.save(entity)
|
||||
.map(this::toDomain);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<GroupCourseRecommend> update(GroupCourseRecommend recommend) {
|
||||
GroupCourseRecommendEntity entity = toEntity(recommend);
|
||||
entity.setUpdatedAt(LocalDateTime.now());
|
||||
|
||||
return r2dbcEntityTemplate.update(entity)
|
||||
.then(findById(recommend.getId()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> deleteById(Long id) {
|
||||
return recommendDao.softDelete(id, LocalDateTime.now())
|
||||
.then();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> deleteByCourseId(Long courseId) {
|
||||
return recommendDao.softDeleteByCourseId(courseId, LocalDateTime.now())
|
||||
.then();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<GroupCourseRecommend> updateActiveStatus(Long id, Boolean isActive) {
|
||||
return recommendDao.updateActiveStatus(id, isActive, LocalDateTime.now())
|
||||
.flatMap(updated -> {
|
||||
if (updated > 0) {
|
||||
return findById(id);
|
||||
}
|
||||
return Mono.empty();
|
||||
});
|
||||
}
|
||||
|
||||
private GroupCourseRecommend toDomain(GroupCourseRecommendEntity entity) {
|
||||
if (entity == null) {
|
||||
return null;
|
||||
}
|
||||
GroupCourseRecommend recommend = new GroupCourseRecommend();
|
||||
BeanUtil.copyProperties(entity, recommend);
|
||||
return recommend;
|
||||
}
|
||||
|
||||
private GroupCourseRecommendEntity toEntity(GroupCourseRecommend domain) {
|
||||
if (domain == null) {
|
||||
return null;
|
||||
}
|
||||
GroupCourseRecommendEntity entity = new GroupCourseRecommendEntity();
|
||||
BeanUtil.copyProperties(domain, entity);
|
||||
if (domain.getId() != null) {
|
||||
entity.markNotNew();
|
||||
}
|
||||
return entity;
|
||||
}
|
||||
}
|
||||
-24
@@ -6,7 +6,6 @@ import cn.novalon.gym.manage.common.dto.PageResponse;
|
||||
import cn.novalon.gym.manage.groupcourse.converter.GroupCourseConverter;
|
||||
import cn.novalon.gym.manage.groupcourse.dao.GroupCourseDao;
|
||||
import cn.novalon.gym.manage.groupcourse.domain.GroupCourse;
|
||||
import cn.novalon.gym.manage.groupcourse.dto.GroupCourseQueryDto;
|
||||
import cn.novalon.gym.manage.groupcourse.entity.GroupCourseEntity;
|
||||
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseRepository;
|
||||
import org.springframework.data.domain.Sort;
|
||||
@@ -185,27 +184,4 @@ public class GroupCourseRepository implements IGroupCourseRepository {
|
||||
return groupCourseDao.findByCourseTypeAndDeletedAtIsNull(courseType)
|
||||
.map(groupCourseConverter::toDomain);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<PageResponse<GroupCourse>> searchGroupCourses(GroupCourseQueryDto query) {
|
||||
return groupCourseDao.countSearchGroupCourses(r2dbcEntityTemplate.getDatabaseClient(), query)
|
||||
.flatMap(total -> {
|
||||
if (total == 0) {
|
||||
return Mono.just(new PageResponse<>(
|
||||
List.of(), 0, 0L,
|
||||
query.getPage() != null ? query.getPage() : 0,
|
||||
query.getSize() != null ? query.getSize() : 10));
|
||||
}
|
||||
return groupCourseDao.searchGroupCourses(r2dbcEntityTemplate.getDatabaseClient(), query)
|
||||
.map(groupCourseConverter::toDomain)
|
||||
.collectList()
|
||||
.map(courseList -> {
|
||||
int size = query.getSize() != null ? query.getSize() : 10;
|
||||
int totalPages = (int) Math.ceil((double) total / size);
|
||||
return new PageResponse<>(
|
||||
courseList, totalPages, total,
|
||||
query.getPage() != null ? query.getPage() : 0, size);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
-28
@@ -1,28 +0,0 @@
|
||||
package cn.novalon.gym.manage.groupcourse.service;
|
||||
|
||||
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseRecommend;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
public interface IGroupCourseRecommendService {
|
||||
|
||||
Mono<GroupCourseRecommend> findById(Long id);
|
||||
|
||||
Flux<GroupCourseRecommend> findAll();
|
||||
|
||||
Flux<GroupCourseRecommend> findAll(String sortBy, String sortOrder);
|
||||
|
||||
Flux<GroupCourseRecommend> findAllActive();
|
||||
|
||||
Flux<GroupCourseRecommend> findByCourseId(Long courseId);
|
||||
|
||||
Mono<GroupCourseRecommend> create(GroupCourseRecommend recommend);
|
||||
|
||||
Mono<GroupCourseRecommend> update(Long id, GroupCourseRecommend recommend);
|
||||
|
||||
Mono<Void> delete(Long id);
|
||||
|
||||
Mono<GroupCourseRecommend> enable(Long id);
|
||||
|
||||
Mono<GroupCourseRecommend> disable(Long id);
|
||||
}
|
||||
-3
@@ -5,7 +5,6 @@ import cn.novalon.gym.manage.common.dto.PageRequest;
|
||||
import cn.novalon.gym.manage.common.dto.PageResponse;
|
||||
import cn.novalon.gym.manage.groupcourse.domain.GroupCourse;
|
||||
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseDetail;
|
||||
import cn.novalon.gym.manage.groupcourse.dto.GroupCourseQueryDto;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
@@ -26,6 +25,4 @@ public interface IGroupCourseService {
|
||||
Mono<GroupCourse> signIn(Long courseId, Long memberId);
|
||||
|
||||
Mono<Void> delete(Long id);
|
||||
|
||||
Mono<PageResponse<GroupCourse>> searchGroupCourses(GroupCourseQueryDto query);
|
||||
}
|
||||
|
||||
-142
@@ -1,142 +0,0 @@
|
||||
package cn.novalon.gym.manage.groupcourse.service.impl;
|
||||
|
||||
import cn.novalon.gym.manage.groupcourse.domain.GroupCourse;
|
||||
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseRecommend;
|
||||
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseRecommendRepository;
|
||||
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseRepository;
|
||||
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseRecommendService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
@Service
|
||||
public class GroupCourseRecommendService implements IGroupCourseRecommendService {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(GroupCourseRecommendService.class);
|
||||
|
||||
private final IGroupCourseRecommendRepository recommendRepository;
|
||||
private final IGroupCourseRepository groupCourseRepository;
|
||||
|
||||
public GroupCourseRecommendService(IGroupCourseRecommendRepository recommendRepository,
|
||||
IGroupCourseRepository groupCourseRepository) {
|
||||
this.recommendRepository = recommendRepository;
|
||||
this.groupCourseRepository = groupCourseRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<GroupCourseRecommend> findById(Long id) {
|
||||
return recommendRepository.findById(id)
|
||||
.flatMap(this::fillGroupCourseInfo);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Flux<GroupCourseRecommend> findAll() {
|
||||
return recommendRepository.findAll()
|
||||
.flatMap(this::fillGroupCourseInfo);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Flux<GroupCourseRecommend> findAll(String sortBy, String sortOrder) {
|
||||
return recommendRepository.findAll(sortBy, sortOrder)
|
||||
.flatMap(this::fillGroupCourseInfo);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Flux<GroupCourseRecommend> findAllActive() {
|
||||
return recommendRepository.findAllActive()
|
||||
.flatMap(this::fillGroupCourseInfo);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Flux<GroupCourseRecommend> findByCourseId(Long courseId) {
|
||||
return recommendRepository.findByCourseId(courseId)
|
||||
.flatMap(this::fillGroupCourseInfo);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<GroupCourseRecommend> create(GroupCourseRecommend recommend) {
|
||||
if (recommend.getCourseId() == null) {
|
||||
return Mono.error(new RuntimeException("团课ID不能为空"));
|
||||
}
|
||||
|
||||
return groupCourseRepository.findByIdAndDeletedAtIsNull(recommend.getCourseId())
|
||||
.switchIfEmpty(Mono.error(new RuntimeException("团课不存在")))
|
||||
.flatMap(course -> {
|
||||
return recommendRepository.save(recommend)
|
||||
.doOnSuccess(r -> logger.info("团课推荐创建成功 - id={}, courseId={}", r.getId(), r.getCourseId()))
|
||||
.doOnError(error -> logger.error("团课推荐创建失败 - error: {}", error.getMessage()));
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<GroupCourseRecommend> update(Long id, GroupCourseRecommend recommend) {
|
||||
return recommendRepository.findById(id)
|
||||
.switchIfEmpty(Mono.error(new RuntimeException("团课推荐不存在")))
|
||||
.flatMap(existing -> {
|
||||
if (recommend.getRecommendTitle() != null) {
|
||||
existing.setRecommendTitle(recommend.getRecommendTitle());
|
||||
}
|
||||
if (recommend.getRecommendContent() != null) {
|
||||
existing.setRecommendContent(recommend.getRecommendContent());
|
||||
}
|
||||
if (recommend.getRecommendReason() != null) {
|
||||
existing.setRecommendReason(recommend.getRecommendReason());
|
||||
}
|
||||
if (recommend.getPriority() != null) {
|
||||
existing.setPriority(recommend.getPriority());
|
||||
}
|
||||
if (recommend.getIsActive() != null) {
|
||||
existing.setIsActive(recommend.getIsActive());
|
||||
}
|
||||
if (recommend.getCourseId() != null) {
|
||||
existing.setCourseId(recommend.getCourseId());
|
||||
}
|
||||
|
||||
return recommendRepository.update(existing);
|
||||
})
|
||||
.doOnSuccess(r -> logger.info("团课推荐更新成功 - id={}", id))
|
||||
.doOnError(error -> logger.error("团课推荐更新失败 - id={}, error: {}", id, error.getMessage()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> delete(Long id) {
|
||||
return recommendRepository.findById(id)
|
||||
.switchIfEmpty(Mono.error(new RuntimeException("团课推荐不存在")))
|
||||
.flatMap(recommend -> {
|
||||
return recommendRepository.deleteById(id)
|
||||
.doOnSuccess(v -> logger.info("团课推荐删除成功 - id={}", id))
|
||||
.doOnError(error -> logger.error("团课推荐删除失败 - id={}, error: {}", id, error.getMessage()));
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<GroupCourseRecommend> enable(Long id) {
|
||||
return recommendRepository.updateActiveStatus(id, true)
|
||||
.switchIfEmpty(Mono.error(new RuntimeException("团课推荐不存在")))
|
||||
.doOnSuccess(r -> logger.info("团课推荐启用成功 - id={}", id))
|
||||
.doOnError(error -> logger.error("团课推荐启用失败 - id={}, error: {}", id, error.getMessage()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<GroupCourseRecommend> disable(Long id) {
|
||||
return recommendRepository.updateActiveStatus(id, false)
|
||||
.switchIfEmpty(Mono.error(new RuntimeException("团课推荐不存在")))
|
||||
.doOnSuccess(r -> logger.info("团课推荐禁用成功 - id={}", id))
|
||||
.doOnError(error -> logger.error("团课推荐禁用失败 - id={}, error: {}", id, error.getMessage()));
|
||||
}
|
||||
|
||||
private Mono<GroupCourseRecommend> fillGroupCourseInfo(GroupCourseRecommend recommend) {
|
||||
if (recommend.getCourseId() == null) {
|
||||
return Mono.just(recommend);
|
||||
}
|
||||
|
||||
return groupCourseRepository.findByIdAndDeletedAtIsNull(recommend.getCourseId())
|
||||
.map(course -> {
|
||||
recommend.setGroupCourse(course);
|
||||
return recommend;
|
||||
})
|
||||
.defaultIfEmpty(recommend);
|
||||
}
|
||||
}
|
||||
+16
-101
@@ -9,7 +9,6 @@ import cn.novalon.gym.manage.groupcourse.domain.GroupCourse;
|
||||
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseBooking;
|
||||
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseDetail;
|
||||
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseType;
|
||||
import cn.novalon.gym.manage.groupcourse.dto.GroupCourseQueryDto;
|
||||
import cn.novalon.gym.manage.groupcourse.enums.CourseEvent;
|
||||
import cn.novalon.gym.manage.groupcourse.enums.CourseStatus;
|
||||
import cn.novalon.gym.manage.groupcourse.handler.GroupCourseStateMachine;
|
||||
@@ -18,7 +17,6 @@ import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseBookingRepositor
|
||||
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseRepository;
|
||||
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseTypeRepository;
|
||||
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseService;
|
||||
import cn.novalon.gym.manage.groupcourse.util.QRCodeUtil;
|
||||
import cn.novalon.gym.manage.member.entity.MemberCard;
|
||||
import cn.novalon.gym.manage.member.entity.MemberCardRecord;
|
||||
import cn.novalon.gym.manage.member.enums.MemberCardType;
|
||||
@@ -28,14 +26,11 @@ import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.r2dbc.core.DatabaseClient;
|
||||
import org.springframework.stereotype.Service;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@Service
|
||||
public class GroupCourseService implements IGroupCourseService {
|
||||
@@ -50,7 +45,6 @@ public class GroupCourseService implements IGroupCourseService {
|
||||
private final RedisUtil redisUtil;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final GroupCourseStateMachine stateMachine;
|
||||
private final DatabaseClient databaseClient;
|
||||
|
||||
private static final String CACHE_KEY_PREFIX = "group_course:page:";
|
||||
private static final String CACHE_KEY_ID_PREFIX = "group_course:id:";
|
||||
@@ -67,8 +61,7 @@ public class GroupCourseService implements IGroupCourseService {
|
||||
MemberCardRepository memberCardRepository,
|
||||
RedisUtil redisUtil,
|
||||
ObjectMapper objectMapper,
|
||||
GroupCourseStateMachine stateMachine,
|
||||
DatabaseClient databaseClient){
|
||||
GroupCourseStateMachine stateMachine){
|
||||
this.groupCourseRepository = groupCourseRepository;
|
||||
this.bookingRepository = bookingRepository;
|
||||
this.groupCourseTypeRepository = groupCourseTypeRepository;
|
||||
@@ -78,7 +71,6 @@ public class GroupCourseService implements IGroupCourseService {
|
||||
this.redisUtil = redisUtil;
|
||||
this.objectMapper = objectMapper;
|
||||
this.stateMachine = stateMachine;
|
||||
this.databaseClient = databaseClient;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -157,7 +149,6 @@ public class GroupCourseService implements IGroupCourseService {
|
||||
detail.setDescription(course.getDescription());
|
||||
detail.setPointCardAmount(course.getPointCardAmount());
|
||||
detail.setStoredValueAmount(course.getStoredValueAmount());
|
||||
detail.setQrCodePath(course.getQrCodePath());
|
||||
detail.setCreatedAt(course.getCreatedAt());
|
||||
detail.setUpdatedAt(course.getUpdatedAt());
|
||||
|
||||
@@ -271,38 +262,7 @@ public class GroupCourseService implements IGroupCourseService {
|
||||
@Override
|
||||
public Mono<GroupCourse> create(GroupCourse groupCourse) {
|
||||
return groupCourseRepository.save(groupCourse)
|
||||
.flatMap(course -> {
|
||||
try {
|
||||
// 生成二维码内容:团课基础信息JSON
|
||||
Map<String, Object> qrCodeContent = new HashMap<>();
|
||||
qrCodeContent.put("id", course.getId());
|
||||
qrCodeContent.put("courseName", course.getCourseName());
|
||||
qrCodeContent.put("coachId", course.getCoachId());
|
||||
qrCodeContent.put("courseType", course.getCourseType());
|
||||
qrCodeContent.put("startTime", course.getStartTime());
|
||||
qrCodeContent.put("endTime", course.getEndTime());
|
||||
qrCodeContent.put("maxMembers", course.getMaxMembers());
|
||||
qrCodeContent.put("location", course.getLocation());
|
||||
qrCodeContent.put("description", course.getDescription());
|
||||
qrCodeContent.put("createdAt", course.getCreatedAt());
|
||||
|
||||
String jsonContent = objectMapper.writeValueAsString(qrCodeContent);
|
||||
|
||||
// 生成二维码并上传到阿里云OSS
|
||||
String qrCodeUrl = QRCodeUtil.generateQRCodeAndUploadToOSS(jsonContent);
|
||||
course.setQrCodePath(qrCodeUrl);
|
||||
|
||||
logger.info("团课二维码上传到OSS成功 - id={}, qrCodeUrl={}", course.getId(), qrCodeUrl);
|
||||
|
||||
// 更新团课信息,保存二维码路径
|
||||
return groupCourseRepository.update(course)
|
||||
.doOnSuccess(updatedCourse -> logger.info("团课创建成功 - id={}, name={}", updatedCourse.getId(), updatedCourse.getCourseName()));
|
||||
} catch (Exception e) {
|
||||
logger.error("团课二维码生成失败 - id={}, error: {}", course.getId(), e.getMessage(), e);
|
||||
// 即使二维码生成失败,也返回成功创建的团课
|
||||
return Mono.just(course);
|
||||
}
|
||||
})
|
||||
.doOnSuccess(course -> logger.info("团课创建成功 - id={}, name={}", course.getId(), course.getCourseName()))
|
||||
.flatMap(course -> clearCache().thenReturn(course))
|
||||
.doOnError(error -> logger.error("团课创建失败 - error: {}", error.getMessage()));
|
||||
}
|
||||
@@ -348,9 +308,6 @@ public class GroupCourseService implements IGroupCourseService {
|
||||
if (groupCourse.getStoredValueAmount() != null) {
|
||||
existing.setStoredValueAmount(groupCourse.getStoredValueAmount());
|
||||
}
|
||||
if (groupCourse.getQrCodePath() != null) {
|
||||
existing.setQrCodePath(groupCourse.getQrCodePath());
|
||||
}
|
||||
return groupCourseRepository.update(existing);
|
||||
})
|
||||
.doOnSuccess(course -> logger.info("团课更新成功 - id={}", id))
|
||||
@@ -483,56 +440,26 @@ public class GroupCourseService implements IGroupCourseService {
|
||||
@Override
|
||||
public Mono<GroupCourse> signIn(Long courseId, Long memberId) {
|
||||
return groupCourseRepository.findByIdAndDeletedAtIsNull(courseId)
|
||||
.switchIfEmpty(Mono.error(new RuntimeException("团课不存在或已删除")))
|
||||
.switchIfEmpty(Mono.error(new RuntimeException("团课不存在")))
|
||||
.flatMap(course -> {
|
||||
// 校验1:团课已取消
|
||||
if (course.getStatus().equals(CourseStatus.CANCELLED.getValue())) {
|
||||
return Mono.error(new RuntimeException("团课已取消,无法签到"));
|
||||
if (course.getStatus() != 0L) {
|
||||
return Mono.error(new RuntimeException("课程状态不允许签到"));
|
||||
}
|
||||
|
||||
// 校验2:团课已结束
|
||||
if (course.getStatus().equals(CourseStatus.ENDED.getValue())) {
|
||||
return Mono.error(new RuntimeException("团课已结束,无法签到"));
|
||||
}
|
||||
|
||||
// 校验3:签到时间校验(开课前2小时内才能签到)
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
LocalDateTime signInStart = course.getStartTime().minusHours(2);
|
||||
if (now.isBefore(signInStart)) {
|
||||
return Mono.error(new RuntimeException("未到签到时间,请在开课前2小时内签到"));
|
||||
}
|
||||
if (now.isAfter(course.getEndTime())) {
|
||||
return Mono.error(new RuntimeException("团课已结束,无法签到"));
|
||||
}
|
||||
|
||||
// 校验4:课程已满员
|
||||
if (course.getCurrentMembers() >= course.getMaxMembers()) {
|
||||
return Mono.error(new RuntimeException("课程已满员,无法签到"));
|
||||
return Mono.error(new RuntimeException("课程已满员"));
|
||||
}
|
||||
|
||||
// 校验5:用户今日是否已到店签到(直接查询sign_in_record表)
|
||||
LocalDateTime todayStart = LocalDateTime.now().toLocalDate().atStartOfDay();
|
||||
LocalDateTime todayEnd = todayStart.plusDays(1);
|
||||
return databaseClient.sql("SELECT sign_in_status 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 LIMIT 1")
|
||||
.bind("memberId", memberId)
|
||||
.bind("startTime", todayStart)
|
||||
.bind("endTime", todayEnd)
|
||||
.map(row -> row.get("sign_in_status", String.class))
|
||||
.one()
|
||||
.switchIfEmpty(Mono.error(new RuntimeException("请先完成到店签到")))
|
||||
.flatMap(status -> {
|
||||
if (!"SUCCESS".equals(status)) {
|
||||
return Mono.error(new RuntimeException("到店签到未成功,请重新签到"));
|
||||
}
|
||||
// 校验6:用户已预约此课程(有效预约,状态为0-已预约)
|
||||
return bookingRepository.findValidBooking(courseId, memberId)
|
||||
.switchIfEmpty(Mono.error(new RuntimeException("您未预约此课程,无法签到")))
|
||||
.flatMap(booking -> {
|
||||
return groupCourseRepository.updateCurrentMembers(courseId, 1)
|
||||
.flatMap(updatedCourse -> {
|
||||
return bookingRepository.updateStatus(booking.getId(), "2")
|
||||
.thenReturn(updatedCourse);
|
||||
});
|
||||
// 检查会员是否已预约此课程
|
||||
return bookingRepository.findValidBooking(courseId, memberId)
|
||||
.switchIfEmpty(Mono.error(new RuntimeException("会员未预约此课程")))
|
||||
.flatMap(booking -> {
|
||||
// 更新课程当前人数
|
||||
return groupCourseRepository.updateCurrentMembers(courseId, 1)
|
||||
.flatMap(updatedCourse -> {
|
||||
// 更新预约状态为已出席
|
||||
return bookingRepository.updateStatus(booking.getId(), "2")
|
||||
.thenReturn(updatedCourse);
|
||||
});
|
||||
});
|
||||
})
|
||||
@@ -561,18 +488,6 @@ public class GroupCourseService implements IGroupCourseService {
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<PageResponse<GroupCourse>> searchGroupCourses(GroupCourseQueryDto query) {
|
||||
logger.info("多条件查询团课 - courseName={}, courseType={}, startDate={}, endDate={}, timePeriod={}, priceSort={}, remainingMost={}",
|
||||
query.getCourseName(), query.getCourseType(), query.getStartDate(), query.getEndDate(),
|
||||
query.getTimePeriod(), query.getPriceSort(), query.getRemainingMost());
|
||||
|
||||
return groupCourseRepository.searchGroupCourses(query)
|
||||
.doOnSuccess(result -> logger.info("多条件查询结果 - total={}, page={}, size={}",
|
||||
result.getTotalElements(), result.getCurrentPage(), result.getPageSize()))
|
||||
.doOnError(error -> logger.error("多条件查询失败 - error: {}", error.getMessage()));
|
||||
}
|
||||
|
||||
private Mono<Void> clearCache() {
|
||||
return redisUtil.deleteByPattern(CACHE_KEY_PREFIX + "*")
|
||||
.then(redisUtil.deleteByPattern(CACHE_KEY_ID_PREFIX + "*"))
|
||||
|
||||
-109
@@ -1,109 +0,0 @@
|
||||
package cn.novalon.gym.manage.groupcourse.util;
|
||||
|
||||
import com.aliyun.oss.OSS;
|
||||
import com.aliyun.oss.OSSClientBuilder;
|
||||
import com.aliyun.oss.model.PutObjectRequest;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.File;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
|
||||
/**
|
||||
* 阿里云OSS工具类
|
||||
*/
|
||||
public class OSSUtil {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(OSSUtil.class);
|
||||
|
||||
// OSS配置信息
|
||||
private static final String ENDPOINT = "oss-cn-beijing.aliyuncs.com";
|
||||
private static final String ACCESS_KEY_ID = "LTAI5t9TFh9Vayeahz45kZjg";
|
||||
private static final String ACCESS_KEY_SECRET = "zD6NlCeH5UhjBs4vnQVqn8Ksi3CaZz";
|
||||
private static final String BUCKET_NAME = "ycc-filesaver";
|
||||
|
||||
// OSS访问地址前缀
|
||||
private static final String OSS_URL_PREFIX = "https://" + BUCKET_NAME + "." + ENDPOINT + "/";
|
||||
|
||||
// 文件存储目录
|
||||
private static final String QRCODE_DIR = "qrcode/";
|
||||
|
||||
/**
|
||||
* 上传文件到阿里云OSS
|
||||
*
|
||||
* @param localFilePath 本地文件路径
|
||||
* @param fileName 文件名(不含路径)
|
||||
* @return OSS访问地址
|
||||
*/
|
||||
public static String uploadToOSS(String localFilePath, String fileName) {
|
||||
OSS ossClient = null;
|
||||
try {
|
||||
// 创建OSS客户端
|
||||
ossClient = new OSSClientBuilder().build(ENDPOINT, ACCESS_KEY_ID, ACCESS_KEY_SECRET);
|
||||
|
||||
// 构建OSS文件路径:qrcode/2026/06/18/xxx.png
|
||||
String datePath = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd"));
|
||||
String ossFilePath = QRCODE_DIR + datePath + "/" + fileName;
|
||||
|
||||
// 创建上传请求
|
||||
PutObjectRequest putObjectRequest = new PutObjectRequest(BUCKET_NAME, ossFilePath, new File(localFilePath));
|
||||
|
||||
// 上传文件
|
||||
ossClient.putObject(putObjectRequest);
|
||||
|
||||
// 构建访问地址
|
||||
String accessUrl = OSS_URL_PREFIX + ossFilePath;
|
||||
|
||||
logger.info("文件上传到OSS成功: localPath={}, ossUrl={}", localFilePath, accessUrl);
|
||||
|
||||
return accessUrl;
|
||||
} catch (Exception e) {
|
||||
logger.error("文件上传到OSS失败 - localPath: {}, error: {}", localFilePath, e.getMessage(), e);
|
||||
throw new RuntimeException("文件上传到OSS失败: " + e.getMessage(), e);
|
||||
} finally {
|
||||
if (ossClient != null) {
|
||||
ossClient.shutdown();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文件到阿里云OSS(自定义存储路径)
|
||||
*
|
||||
* @param localFilePath 本地文件路径
|
||||
* @param ossDirectory OSS存储目录
|
||||
* @param fileName 文件名(不含路径)
|
||||
* @return OSS访问地址
|
||||
*/
|
||||
public static String uploadToOSS(String localFilePath, String ossDirectory, String fileName) {
|
||||
OSS ossClient = null;
|
||||
try {
|
||||
// 创建OSS客户端
|
||||
ossClient = new OSSClientBuilder().build(ENDPOINT, ACCESS_KEY_ID, ACCESS_KEY_SECRET);
|
||||
|
||||
// 构建OSS文件路径
|
||||
String ossFilePath = ossDirectory + fileName;
|
||||
|
||||
// 创建上传请求
|
||||
PutObjectRequest putObjectRequest = new PutObjectRequest(BUCKET_NAME, ossFilePath, new File(localFilePath));
|
||||
|
||||
// 上传文件
|
||||
ossClient.putObject(putObjectRequest);
|
||||
|
||||
// 构建访问地址
|
||||
String accessUrl = OSS_URL_PREFIX + ossFilePath;
|
||||
|
||||
logger.info("文件上传到OSS成功: localPath={}, ossUrl={}", localFilePath, accessUrl);
|
||||
|
||||
return accessUrl;
|
||||
} catch (Exception e) {
|
||||
logger.error("文件上传到OSS失败 - localPath: {}, error: {}", localFilePath, e.getMessage(), e);
|
||||
throw new RuntimeException("文件上传到OSS失败: " + e.getMessage(), e);
|
||||
} finally {
|
||||
if (ossClient != null) {
|
||||
ossClient.shutdown();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-156
@@ -1,156 +0,0 @@
|
||||
package cn.novalon.gym.manage.groupcourse.util;
|
||||
|
||||
import com.google.zxing.BarcodeFormat;
|
||||
import com.google.zxing.EncodeHintType;
|
||||
import com.google.zxing.WriterException;
|
||||
import com.google.zxing.client.j2se.MatrixToImageWriter;
|
||||
import com.google.zxing.common.BitMatrix;
|
||||
import com.google.zxing.qrcode.QRCodeWriter;
|
||||
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 二维码生成工具类
|
||||
*/
|
||||
public class QRCodeUtil {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(QRCodeUtil.class);
|
||||
|
||||
// 二维码默认保存路径(本地临时路径)
|
||||
private static final String DEFAULT_SAVE_PATH = "D:\\Games\\exmp\\image";
|
||||
|
||||
// 二维码尺寸
|
||||
private static final int QR_CODE_WIDTH = 300;
|
||||
private static final int QR_CODE_HEIGHT = 300;
|
||||
|
||||
/**
|
||||
* 生成二维码并保存到指定路径
|
||||
*
|
||||
* @param content 二维码内容
|
||||
* @param savePath 保存路径
|
||||
* @param fileName 文件名(不含扩展名)
|
||||
* @return 生成的二维码文件完整路径
|
||||
*/
|
||||
public static String generateQRCode(String content, String savePath, String fileName) {
|
||||
try {
|
||||
Path directory = Paths.get(savePath);
|
||||
if (!Files.exists(directory)) {
|
||||
Files.createDirectories(directory);
|
||||
logger.info("创建二维码保存目录: {}", savePath);
|
||||
}
|
||||
|
||||
Map<EncodeHintType, Object> hints = new HashMap<>();
|
||||
hints.put(EncodeHintType.CHARACTER_SET, "UTF-8");
|
||||
hints.put(EncodeHintType.ERROR_CORRECTION, ErrorCorrectionLevel.H);
|
||||
hints.put(EncodeHintType.MARGIN, 1);
|
||||
|
||||
QRCodeWriter qrCodeWriter = new QRCodeWriter();
|
||||
BitMatrix bitMatrix = qrCodeWriter.encode(content, BarcodeFormat.QR_CODE, QR_CODE_WIDTH, QR_CODE_HEIGHT, hints);
|
||||
|
||||
String filePath = Paths.get(savePath, fileName + ".png").toString();
|
||||
Path path = Paths.get(filePath);
|
||||
|
||||
MatrixToImageWriter.writeToPath(bitMatrix, "PNG", path);
|
||||
logger.info("二维码生成成功: {}", filePath);
|
||||
|
||||
return filePath;
|
||||
} catch (WriterException e) {
|
||||
logger.error("二维码生成失败 - WriterException: {}", e.getMessage(), e);
|
||||
throw new RuntimeException("二维码生成失败: " + e.getMessage(), e);
|
||||
} catch (IOException e) {
|
||||
logger.error("二维码保存失败 - IOException: {}", e.getMessage(), e);
|
||||
throw new RuntimeException("二维码保存失败: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成二维码并保存到默认路径
|
||||
* 文件名格式: UUID + 创建时间
|
||||
*
|
||||
* @param content 二维码内容
|
||||
* @return 生成的二维码文件完整路径
|
||||
*/
|
||||
public static String generateQRCode(String content) {
|
||||
String uuid = UUID.randomUUID().toString().replace("-", "");
|
||||
String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"));
|
||||
String fileName = uuid + "_" + timestamp;
|
||||
|
||||
return generateQRCode(content, DEFAULT_SAVE_PATH, fileName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成二维码并保存到默认路径,使用自定义文件名
|
||||
*
|
||||
* @param content 二维码内容
|
||||
* @param fileName 文件名(不含扩展名)
|
||||
* @return 生成的二维码文件完整路径
|
||||
*/
|
||||
public static String generateQRCodeWithFileName(String content, String fileName) {
|
||||
return generateQRCode(content, DEFAULT_SAVE_PATH, fileName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成二维码并上传到阿里云OSS
|
||||
* 文件名格式: UUID + 创建时间
|
||||
*
|
||||
* @param content 二维码内容
|
||||
* @return 阿里云OSS访问地址
|
||||
*/
|
||||
public static String generateQRCodeAndUploadToOSS(String content) {
|
||||
String uuid = UUID.randomUUID().toString().replace("-", "");
|
||||
String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"));
|
||||
String fileName = uuid + "_" + timestamp + ".png";
|
||||
|
||||
return generateQRCodeAndUploadToOSS(content, fileName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成二维码并上传到阿里云OSS
|
||||
*
|
||||
* @param content 二维码内容
|
||||
* @param fileName 文件名(含扩展名)
|
||||
* @return 阿里云OSS访问地址
|
||||
*/
|
||||
public static String generateQRCodeAndUploadToOSS(String content, String fileName) {
|
||||
try {
|
||||
Path tempDir = Files.createTempDirectory("qrcode_temp");
|
||||
String tempFilePath = tempDir.resolve(fileName).toString();
|
||||
|
||||
Map<EncodeHintType, Object> hints = new HashMap<>();
|
||||
hints.put(EncodeHintType.CHARACTER_SET, "UTF-8");
|
||||
hints.put(EncodeHintType.ERROR_CORRECTION, ErrorCorrectionLevel.H);
|
||||
hints.put(EncodeHintType.MARGIN, 1);
|
||||
|
||||
QRCodeWriter qrCodeWriter = new QRCodeWriter();
|
||||
BitMatrix bitMatrix = qrCodeWriter.encode(content, BarcodeFormat.QR_CODE, QR_CODE_WIDTH, QR_CODE_HEIGHT, hints);
|
||||
|
||||
MatrixToImageWriter.writeToPath(bitMatrix, "PNG", Paths.get(tempFilePath));
|
||||
logger.info("二维码临时文件生成成功: {}", tempFilePath);
|
||||
|
||||
String ossUrl = OSSUtil.uploadToOSS(tempFilePath, fileName);
|
||||
|
||||
Files.deleteIfExists(Paths.get(tempFilePath));
|
||||
Files.deleteIfExists(tempDir);
|
||||
logger.info("临时文件已删除: {}", tempFilePath);
|
||||
|
||||
return ossUrl;
|
||||
} catch (WriterException e) {
|
||||
logger.error("二维码生成失败 - WriterException: {}", e.getMessage(), e);
|
||||
throw new RuntimeException("二维码生成失败: " + e.getMessage(), e);
|
||||
} catch (IOException e) {
|
||||
logger.error("二维码处理失败 - IOException: {}", e.getMessage(), e);
|
||||
throw new RuntimeException("二维码处理失败: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
-71
@@ -1,71 +0,0 @@
|
||||
package cn.novalon.gym.manage.groupcourse.util;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* QRCodeUtil测试类
|
||||
*/
|
||||
class QRCodeUtilTest {
|
||||
|
||||
@TempDir
|
||||
Path tempDir;
|
||||
|
||||
@Test
|
||||
void testGenerateQRCode() {
|
||||
String content = "测试二维码内容";
|
||||
String qrCodePath = QRCodeUtil.generateQRCode(content);
|
||||
|
||||
assertNotNull(qrCodePath, "二维码路径不应为空");
|
||||
assertTrue(qrCodePath.endsWith(".png"), "二维码文件应为PNG格式");
|
||||
assertTrue(qrCodePath.contains("D:\\Games\\exmp\\image"), "二维码应保存到指定路径");
|
||||
|
||||
System.out.println("生成的二维码路径: " + qrCodePath);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGenerateQRCodeWithCustomPath() {
|
||||
String content = "自定义路径测试";
|
||||
String customPath = tempDir.toString();
|
||||
String fileName = "test_qrcode";
|
||||
|
||||
String qrCodePath = QRCodeUtil.generateQRCode(content, customPath, fileName);
|
||||
|
||||
assertNotNull(qrCodePath, "二维码路径不应为空");
|
||||
assertTrue(qrCodePath.endsWith(".png"), "二维码文件应为PNG格式");
|
||||
assertTrue(qrCodePath.contains(fileName), "二维码文件名应包含指定名称");
|
||||
|
||||
System.out.println("生成的二维码路径: " + qrCodePath);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGenerateQRCodeWithJsonContent() {
|
||||
String jsonContent = "{\"id\":1,\"courseName\":\"瑜伽课\",\"coachId\":100,\"startTime\":\"2026-06-18T10:00:00\"}";
|
||||
|
||||
String qrCodePath = QRCodeUtil.generateQRCode(jsonContent);
|
||||
|
||||
assertNotNull(qrCodePath, "二维码路径不应为空");
|
||||
assertTrue(qrCodePath.endsWith(".png"), "二维码文件应为PNG格式");
|
||||
|
||||
System.out.println("JSON内容二维码路径: " + qrCodePath);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGenerateQRCodeAndUploadToOSS() {
|
||||
String jsonContent = "{\"id\":1,\"courseName\":\"瑜伽课\",\"coachId\":100,\"startTime\":\"2026-06-18T10:00:00\"}";
|
||||
|
||||
String ossUrl = QRCodeUtil.generateQRCodeAndUploadToOSS(jsonContent);
|
||||
|
||||
assertNotNull(ossUrl, "OSS访问地址不应为空");
|
||||
assertTrue(ossUrl.startsWith("https://"), "OSS访问地址应为HTTPS");
|
||||
assertTrue(ossUrl.contains("ycc-filesaver.oss-cn-beijing.aliyuncs.com"), "OSS访问地址应包含正确的域名");
|
||||
assertTrue(ossUrl.contains("/qrcode/"), "OSS访问地址应包含qrcode目录");
|
||||
assertTrue(ossUrl.endsWith(".png"), "OSS访问地址应为PNG格式");
|
||||
|
||||
System.out.println("上传到OSS的二维码地址: " + ossUrl);
|
||||
}
|
||||
}
|
||||
+223
-387
@@ -1,387 +1,223 @@
|
||||
[
|
||||
{
|
||||
"id": "3",
|
||||
"createBy": "admin",
|
||||
"updateBy": null,
|
||||
"createdAt": "2026-06-15T10:00:00",
|
||||
"updatedAt": "2026-06-15T10:00:00",
|
||||
"deletedAt": null,
|
||||
"courseId": "1",
|
||||
"recommendTitle": "本周热门推荐",
|
||||
"recommendContent": "极速燃脂单车课程,跟随音乐节奏变换阻力和速度,体验爬坡与冲刺的快感,一节课消耗800大卡!",
|
||||
"recommendReason": "教练专业,课程内容丰富,深受学员喜爱,燃脂效果显著",
|
||||
"priority": 20,
|
||||
"isActive": true,
|
||||
"groupCourse": {
|
||||
"id": "1",
|
||||
"createBy": "system",
|
||||
"updateBy": "system",
|
||||
"createdAt": "2026-06-11T13:50:25.118925",
|
||||
"updatedAt": "2026-06-11T13:50:25.118925",
|
||||
"deletedAt": null,
|
||||
"courseName": "动感单车升级版aaaaa",
|
||||
"coachId": "2",
|
||||
"courseType": "2",
|
||||
"startTime": "2026-06-02T16:45:00",
|
||||
"endTime": "2026-06-15T20:20:00",
|
||||
"maxMembers": 30,
|
||||
"currentMembers": 0,
|
||||
"status": "0",
|
||||
"location": "单车房",
|
||||
"coverImage": "/images/spinning.jpg",
|
||||
"description": "升级版高强度有氧运动课程",
|
||||
"pointCardAmount": 2,
|
||||
"storedValueAmount": 80
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "7",
|
||||
"createBy": "coach_li",
|
||||
"updateBy": null,
|
||||
"createdAt": "2026-05-25T09:15:00",
|
||||
"updatedAt": "2026-05-25T09:15:00",
|
||||
"deletedAt": null,
|
||||
"courseId": "6",
|
||||
"recommendTitle": "塑形热门课程",
|
||||
"recommendContent": "蜜桃臀塑造课程,针对性训练臀部肌肉群,打造完美曲线。",
|
||||
"recommendReason": "专业私教指导,动作标准,效果显著,深受女性学员喜爱",
|
||||
"priority": 18,
|
||||
"isActive": true,
|
||||
"groupCourse": {
|
||||
"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": "/images/glute.jpg",
|
||||
"description": "针对性训练臀部肌肉群,课程已于5月30日结束,无法预约。",
|
||||
"pointCardAmount": 1,
|
||||
"storedValueAmount": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "9",
|
||||
"createBy": "coach_zhang",
|
||||
"updateBy": null,
|
||||
"createdAt": "2026-06-15T14:00:00",
|
||||
"updatedAt": "2026-06-15T14:00:00",
|
||||
"deletedAt": null,
|
||||
"courseId": "1",
|
||||
"recommendTitle": "减脂首选课程",
|
||||
"recommendContent": "想要快速减脂?极速燃脂单车是你的最佳选择!专业教练带领,科学训练计划。",
|
||||
"recommendReason": "减脂效果最佳,课程强度适中,适合想要快速瘦身的学员",
|
||||
"priority": 16,
|
||||
"isActive": true,
|
||||
"groupCourse": {
|
||||
"id": "1",
|
||||
"createBy": "system",
|
||||
"updateBy": "system",
|
||||
"createdAt": "2026-06-11T13:50:25.118925",
|
||||
"updatedAt": "2026-06-11T13:50:25.118925",
|
||||
"deletedAt": null,
|
||||
"courseName": "动感单车升级版aaaaa",
|
||||
"coachId": "2",
|
||||
"courseType": "2",
|
||||
"startTime": "2026-06-02T16:45:00",
|
||||
"endTime": "2026-06-15T20:20:00",
|
||||
"maxMembers": 30,
|
||||
"currentMembers": 0,
|
||||
"status": "0",
|
||||
"location": "单车房",
|
||||
"coverImage": "/images/spinning.jpg",
|
||||
"description": "升级版高强度有氧运动课程",
|
||||
"pointCardAmount": 2,
|
||||
"storedValueAmount": 80
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "4",
|
||||
"createBy": "admin",
|
||||
"updateBy": null,
|
||||
"createdAt": "2026-06-15T11:00:00",
|
||||
"updatedAt": "2026-06-15T11:00:00",
|
||||
"deletedAt": null,
|
||||
"courseId": "2",
|
||||
"recommendTitle": "新手友好推荐",
|
||||
"recommendContent": "清晨流瑜伽课程,适合有一定基础的学员,通过流畅的体式连接呼吸,唤醒身体能量。",
|
||||
"recommendReason": "适合新手入门,教练耐心指导,课程节奏适中",
|
||||
"priority": 15,
|
||||
"isActive": true,
|
||||
"groupCourse": {
|
||||
"id": "2",
|
||||
"createBy": "admin",
|
||||
"updateBy": null,
|
||||
"createdAt": "2026-06-01T10:00:00",
|
||||
"updatedAt": "2026-06-01T10:00:00",
|
||||
"deletedAt": null,
|
||||
"courseName": "清晨流瑜伽",
|
||||
"coachId": "101",
|
||||
"courseType": "1",
|
||||
"startTime": "2026-06-12T09:00:00",
|
||||
"endTime": "2026-06-12T10:30:00",
|
||||
"maxMembers": 15,
|
||||
"currentMembers": 5,
|
||||
"status": "0",
|
||||
"location": "A座3楼瑜伽教室",
|
||||
"coverImage": "/images/yoga_flow.jpg",
|
||||
"description": "适合有一定基础的学员,通过流畅的体式连接呼吸,唤醒身体能量。",
|
||||
"pointCardAmount": 1,
|
||||
"storedValueAmount": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "10",
|
||||
"createBy": "coach_wang",
|
||||
"updateBy": null,
|
||||
"createdAt": "2026-06-15T15:00:00",
|
||||
"updatedAt": "2026-06-15T15:00:00",
|
||||
"deletedAt": null,
|
||||
"courseId": "2",
|
||||
"recommendTitle": "晨练优选",
|
||||
"recommendContent": "清晨流瑜伽,唤醒身体能量,开启活力一天!适合晨练爱好者。",
|
||||
"recommendReason": "晨练最佳选择,提升身体活力,改善精神状态",
|
||||
"priority": 14,
|
||||
"isActive": true,
|
||||
"groupCourse": {
|
||||
"id": "2",
|
||||
"createBy": "admin",
|
||||
"updateBy": null,
|
||||
"createdAt": "2026-06-01T10:00:00",
|
||||
"updatedAt": "2026-06-01T10:00:00",
|
||||
"deletedAt": null,
|
||||
"courseName": "清晨流瑜伽",
|
||||
"coachId": "101",
|
||||
"courseType": "1",
|
||||
"startTime": "2026-06-12T09:00:00",
|
||||
"endTime": "2026-06-12T10:30:00",
|
||||
"maxMembers": 15,
|
||||
"currentMembers": 5,
|
||||
"status": "0",
|
||||
"location": "A座3楼瑜伽教室",
|
||||
"coverImage": "/images/yoga_flow.jpg",
|
||||
"description": "适合有一定基础的学员,通过流畅的体式连接呼吸,唤醒身体能量。",
|
||||
"pointCardAmount": 1,
|
||||
"storedValueAmount": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "6",
|
||||
"createBy": "coach_li",
|
||||
"updateBy": null,
|
||||
"createdAt": "2026-06-15T13:00:00",
|
||||
"updatedAt": "2026-06-15T13:00:00",
|
||||
"deletedAt": null,
|
||||
"courseId": "4",
|
||||
"recommendTitle": "基础瑜伽推荐",
|
||||
"recommendContent": "基础哈他瑜伽课程,适合所有级别学员,通过基础体式练习提升身体柔韧性和平衡能力。",
|
||||
"recommendReason": "零基础友好,适合所有健身水平,放松身心",
|
||||
"priority": 12,
|
||||
"isActive": true,
|
||||
"groupCourse": {
|
||||
"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": "/images/hatha_yoga.jpg",
|
||||
"description": "基础哈他瑜伽,适合所有级别。距开始不足30分钟,已停止预约。",
|
||||
"pointCardAmount": 1,
|
||||
"storedValueAmount": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "11",
|
||||
"createBy": "coach_li",
|
||||
"updateBy": null,
|
||||
"createdAt": "2026-06-15T16:00:00",
|
||||
"updatedAt": "2026-06-15T16:00:00",
|
||||
"deletedAt": null,
|
||||
"courseId": "4",
|
||||
"recommendTitle": "身心平衡推荐",
|
||||
"recommendContent": "哈他瑜伽课程,通过体式练习和呼吸控制,达到身心平衡,提升整体健康水平。",
|
||||
"recommendReason": "改善身体柔韧性,增强核心力量,提升身体协调性",
|
||||
"priority": 11,
|
||||
"isActive": true,
|
||||
"groupCourse": {
|
||||
"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": "/images/hatha_yoga.jpg",
|
||||
"description": "基础哈他瑜伽,适合所有级别。距开始不足30分钟,已停止预约。",
|
||||
"pointCardAmount": 1,
|
||||
"storedValueAmount": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "1",
|
||||
"createBy": "system",
|
||||
"updateBy": "system",
|
||||
"createdAt": "2026-06-15T16:21:20.865146",
|
||||
"updatedAt": "2026-06-15T16:23:05.180219",
|
||||
"deletedAt": null,
|
||||
"courseId": "1",
|
||||
"recommendTitle": "本周热门课程",
|
||||
"recommendContent": "这是一门非常棒的课程,快来参加吧!",
|
||||
"recommendReason": "教练专业,课程内容丰富",
|
||||
"priority": 10,
|
||||
"isActive": false,
|
||||
"groupCourse": {
|
||||
"id": "1",
|
||||
"createBy": "system",
|
||||
"updateBy": "system",
|
||||
"createdAt": "2026-06-11T13:50:25.118925",
|
||||
"updatedAt": "2026-06-11T13:50:25.118925",
|
||||
"deletedAt": null,
|
||||
"courseName": "动感单车升级版aaaaa",
|
||||
"coachId": "2",
|
||||
"courseType": "2",
|
||||
"startTime": "2026-06-02T16:45:00",
|
||||
"endTime": "2026-06-15T20:20:00",
|
||||
"maxMembers": 30,
|
||||
"currentMembers": 0,
|
||||
"status": "0",
|
||||
"location": "单车房",
|
||||
"coverImage": "/images/spinning.jpg",
|
||||
"description": "升级版高强度有氧运动课程",
|
||||
"pointCardAmount": 2,
|
||||
"storedValueAmount": 80
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "5",
|
||||
"createBy": "admin",
|
||||
"updateBy": null,
|
||||
"createdAt": "2026-06-15T12:00:00",
|
||||
"updatedAt": "2026-06-15T12:00:00",
|
||||
"deletedAt": null,
|
||||
"courseId": "3",
|
||||
"recommendTitle": "高强度燃脂",
|
||||
"recommendContent": "燃脂搏击课程,高强度间歇训练,配合音乐快速燃脂,释放压力。",
|
||||
"recommendReason": "高强度训练,适合进阶学员,快速燃脂塑形",
|
||||
"priority": 10,
|
||||
"isActive": false,
|
||||
"groupCourse": {
|
||||
"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": "/images/kickboxing.jpg",
|
||||
"description": "高强度间歇训练,配合音乐快速燃脂,释放压力。名额已满,无法预约。",
|
||||
"pointCardAmount": 1,
|
||||
"storedValueAmount": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "12",
|
||||
"createBy": "admin",
|
||||
"updateBy": null,
|
||||
"createdAt": "2026-05-25T10:00:00",
|
||||
"updatedAt": "2026-05-25T10:00:00",
|
||||
"deletedAt": null,
|
||||
"courseId": "7",
|
||||
"recommendTitle": "职场减压课程",
|
||||
"recommendContent": "午间冥想放松,专为职场人士设计,快速缓解工作压力,提升工作状态。",
|
||||
"recommendReason": "职场减压首选,课程时间短,效果显著",
|
||||
"priority": 9,
|
||||
"isActive": false,
|
||||
"groupCourse": {
|
||||
"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": "/images/meditation_noon.jpg",
|
||||
"description": "午间冥想课程,已于5月31日结束。",
|
||||
"pointCardAmount": 1,
|
||||
"storedValueAmount": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "8",
|
||||
"createBy": "admin",
|
||||
"updateBy": null,
|
||||
"createdAt": "2026-05-25T09:00:00",
|
||||
"updatedAt": "2026-05-25T09:00:00",
|
||||
"deletedAt": null,
|
||||
"courseId": "7",
|
||||
"recommendTitle": "午间放松推荐",
|
||||
"recommendContent": "午间冥想放松课程,通过呼吸和正念冥想,深度放松身心,缓解工作压力。",
|
||||
"recommendReason": "适合上班族,午间放松充电,提升下午工作效率",
|
||||
"priority": 8,
|
||||
"isActive": true,
|
||||
"groupCourse": {
|
||||
"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": "/images/meditation_noon.jpg",
|
||||
"description": "午间冥想课程,已于5月31日结束。",
|
||||
"pointCardAmount": 1,
|
||||
"storedValueAmount": 0
|
||||
}
|
||||
}
|
||||
]
|
||||
2026-06-11T13:43:44.371+08:00 TRACE 9324 --- [gym-manage-api] [nio-8084-exec-2] o.s.w.s.adapter.HttpWebHandlerAdapter : [27afd417] HTTP POST "/api/groupCourse/types/1/labels", headers={masked}
|
||||
2026-06-11T13:43:44.372+08:00 DEBUG 9324 --- [gym-manage-api] [ parallel-2] o.s.w.s.s.DefaultWebSessionManager : Created new WebSession.
|
||||
2026-06-11T13:43:44.372+08:00 INFO 9324 --- [gym-manage-api] [ parallel-2] c.n.g.m.sys.audit.OperationLogWebFilter : WebFilter 拦截请求: POST /api/groupCourse/types/1/labels
|
||||
2026-06-11T13:43:44.372+08:00 INFO 9324 --- [gym-manage-api] [ parallel-2] c.n.g.m.sys.audit.OperationLogWebFilter : 未匹配到操作日志配置,跳过: POST /api/groupCourse/types/1/labels
|
||||
2026-06-11T13:43:44.372+08:00 INFO 9324 --- [gym-manage-api] [ parallel-2] c.n.g.m.sys.audit.OperationLogWebFilter : WebFilter 拦截请求: POST /api/groupCourse/types/1/labels
|
||||
2026-06-11T13:43:44.372+08:00 INFO 9324 --- [gym-manage-api] [ parallel-2] c.n.g.m.sys.audit.OperationLogWebFilter : 未匹配到操作日志配置,跳过: POST /api/groupCourse/types/1/labels
|
||||
2026-06-11T13:43:44.372+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
|
||||
2026-06-11T13:43:44.372+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
|
||||
2026-06-11T13:43:44.372+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
|
||||
2026-06-11T13:43:44.372+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
|
||||
2026-06-11T13:43:44.372+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
|
||||
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "POST" matches against value "POST"
|
||||
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Pattern "/api/dictionaries" does not match against value "/api/groupCourse/types/1/labels"
|
||||
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "PUT" does not match against value "POST"
|
||||
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "DELETE" does not match against value "POST"
|
||||
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
|
||||
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
|
||||
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
|
||||
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
|
||||
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
|
||||
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
|
||||
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "POST" matches against value "POST"
|
||||
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Pattern "/api/users" does not match against value "/api/groupCourse/types/1/labels"
|
||||
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
|
||||
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "PUT" does not match against value "POST"
|
||||
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "DELETE" does not match against value "POST"
|
||||
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "POST" matches against value "POST"
|
||||
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Pattern "/api/users/{id}/action/change-password" does not match against value "/api/groupCourse/types/1/labels"
|
||||
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "POST" matches against value "POST"
|
||||
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Pattern "/api/users/{id}/action/logical-delete" does not match against value "/api/groupCourse/types/1/labels"
|
||||
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "POST" matches against value "POST"
|
||||
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Pattern "/api/users/logical-delete" does not match against value "/api/groupCourse/types/1/labels"
|
||||
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "POST" matches against value "POST"
|
||||
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Pattern "/api/users/action/restore" does not match against value "/api/groupCourse/types/1/labels"
|
||||
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "POST" matches against value "POST"
|
||||
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Pattern "/api/users/{id}/action/restore" does not match against value "/api/groupCourse/types/1/labels"
|
||||
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
|
||||
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "POST" matches against value "POST"
|
||||
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Pattern "/api/users/{id}/roles" does not match against value "/api/groupCourse/types/1/labels"
|
||||
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
|
||||
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
|
||||
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
|
||||
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "POST" matches against value "POST"
|
||||
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Pattern "/api/menus" does not match against value "/api/groupCourse/types/1/labels"
|
||||
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "PUT" does not match against value "POST"
|
||||
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "DELETE" does not match against value "POST"
|
||||
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
|
||||
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
|
||||
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
|
||||
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
|
||||
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
|
||||
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
|
||||
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "POST" matches against value "POST"
|
||||
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Pattern "/api/roles" does not match against value "/api/groupCourse/types/1/labels"
|
||||
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "PUT" does not match against value "POST"
|
||||
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "DELETE" does not match against value "POST"
|
||||
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "POST" matches against value "POST"
|
||||
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Pattern "/api/roles/{id}/restore" does not match against value "/api/groupCourse/types/1/labels"
|
||||
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
|
||||
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "POST" matches against value "POST"
|
||||
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Pattern "/api/roles/{id}/permissions" does not match against value "/api/groupCourse/types/1/labels"
|
||||
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
|
||||
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
|
||||
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
|
||||
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "POST" matches against value "POST"
|
||||
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Pattern "/api/config" does not match against value "/api/groupCourse/types/1/labels"
|
||||
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "PUT" does not match against value "POST"
|
||||
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "DELETE" does not match against value "POST"
|
||||
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
|
||||
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
|
||||
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
|
||||
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
|
||||
2026-06-11T13:43:44.373+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
|
||||
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
|
||||
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "POST" matches against value "POST"
|
||||
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Pattern "/api/logs/login" does not match against value "/api/groupCourse/types/1/labels"
|
||||
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
|
||||
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
|
||||
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
|
||||
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
|
||||
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "POST" matches against value "POST"
|
||||
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Pattern "/api/logs/exception" does not match against value "/api/groupCourse/types/1/labels"
|
||||
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
|
||||
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
|
||||
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
|
||||
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
|
||||
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
|
||||
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "POST" matches against value "POST"
|
||||
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Pattern "/api/logs/operation" does not match against value "/api/groupCourse/types/1/labels"
|
||||
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "POST" matches against value "POST"
|
||||
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Pattern "/api/auth/login" does not match against value "/api/groupCourse/types/1/labels"
|
||||
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "POST" matches against value "POST"
|
||||
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Pattern "/api/auth/register" does not match against value "/api/groupCourse/types/1/labels"
|
||||
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "POST" matches against value "POST"
|
||||
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Pattern "/api/auth/logout" does not match against value "/api/groupCourse/types/1/labels"
|
||||
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
|
||||
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
|
||||
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
|
||||
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
|
||||
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "POST" matches against value "POST"
|
||||
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Pattern "/api/dict/types" does not match against value "/api/groupCourse/types/1/labels"
|
||||
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "PUT" does not match against value "POST"
|
||||
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "DELETE" does not match against value "POST"
|
||||
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
|
||||
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
|
||||
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
|
||||
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "POST" matches against value "POST"
|
||||
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Pattern "/api/dict/data" does not match against value "/api/groupCourse/types/1/labels"
|
||||
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "PUT" does not match against value "POST"
|
||||
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "DELETE" does not match against value "POST"
|
||||
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
|
||||
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
|
||||
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
|
||||
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "POST" matches against value "POST"
|
||||
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Pattern "/api/notices" does not match against value "/api/groupCourse/types/1/labels"
|
||||
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "PUT" does not match against value "POST"
|
||||
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "DELETE" does not match against value "POST"
|
||||
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
|
||||
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
|
||||
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
|
||||
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "POST" matches against value "POST"
|
||||
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Pattern "/api/messages" does not match against value "/api/groupCourse/types/1/labels"
|
||||
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "PUT" does not match against value "POST"
|
||||
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "DELETE" does not match against value "POST"
|
||||
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
|
||||
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
|
||||
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "POST" matches against value "POST"
|
||||
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Pattern "/api/files/upload" does not match against value "/api/groupCourse/types/1/labels"
|
||||
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
|
||||
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
|
||||
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
|
||||
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
|
||||
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "DELETE" does not match against value "POST"
|
||||
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
|
||||
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
|
||||
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
|
||||
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
|
||||
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
|
||||
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "POST" matches against value "POST"
|
||||
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Pattern "/api/permissions" does not match against value "/api/groupCourse/types/1/labels"
|
||||
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "PUT" does not match against value "POST"
|
||||
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "DELETE" does not match against value "POST"
|
||||
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "POST" matches against value "POST"
|
||||
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Pattern "/api/member/auth/miniapp/login" does not match against value "/api/groupCourse/types/1/labels"
|
||||
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
|
||||
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "POST" matches against value "POST"
|
||||
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Pattern "/api/member/auth/mp/callback" does not match against value "/api/groupCourse/types/1/labels"
|
||||
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
|
||||
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "PUT" does not match against value "POST"
|
||||
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "POST" matches against value "POST"
|
||||
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Pattern "/api/member/phone/bind" does not match against value "/api/groupCourse/types/1/labels"
|
||||
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
|
||||
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "POST" matches against value "POST"
|
||||
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Pattern "/api/admin/member/{id}/phone" does not match against value "/api/groupCourse/types/1/labels"
|
||||
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
|
||||
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "PUT" does not match against value "POST"
|
||||
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
|
||||
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
|
||||
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
|
||||
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
|
||||
2026-06-11T13:43:44.375+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "POST" matches against value "POST"
|
||||
2026-06-11T13:43:44.376+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Pattern "/api/member-cards" does not match against value "/api/groupCourse/types/1/labels"
|
||||
2026-06-11T13:43:44.376+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "POST" matches against value "POST"
|
||||
2026-06-11T13:43:44.376+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Pattern "/api/member-card-records/purchase" does not match against value "/api/groupCourse/types/1/labels"
|
||||
2026-06-11T13:43:44.376+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "POST" matches against value "POST"
|
||||
2026-06-11T13:43:44.376+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Pattern "/api/member-card-records/{recordId}/renew" does not match against value "/api/groupCourse/types/1/labels"
|
||||
2026-06-11T13:43:44.376+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "POST" matches against value "POST"
|
||||
2026-06-11T13:43:44.376+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Pattern "/api/member-card-records/{recordId}/use" does not match against value "/api/groupCourse/types/1/labels"
|
||||
2026-06-11T13:43:44.376+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "POST" matches against value "POST"
|
||||
2026-06-11T13:43:44.376+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Pattern "/api/member-card-records/{recordId}/refund" does not match against value "/api/groupCourse/types/1/labels"
|
||||
2026-06-11T13:43:44.376+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
|
||||
2026-06-11T13:43:44.376+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
|
||||
2026-06-11T13:43:44.376+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "POST" matches against value "POST"
|
||||
2026-06-11T13:43:44.376+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Pattern "/api/member-card-records/process-expired" does not match against value "/api/groupCourse/types/1/labels"
|
||||
2026-06-11T13:43:44.376+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "POST" matches against value "POST"
|
||||
2026-06-11T13:43:44.376+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Pattern "/api/member-card-transactions" does not match against value "/api/groupCourse/types/1/labels"
|
||||
2026-06-11T13:43:44.376+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
|
||||
2026-06-11T13:43:44.376+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
|
||||
2026-06-11T13:43:44.376+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
|
||||
2026-06-11T13:43:44.376+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
|
||||
2026-06-11T13:43:44.376+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
|
||||
2026-06-11T13:43:44.376+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
|
||||
2026-06-11T13:43:44.376+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
|
||||
2026-06-11T13:43:44.376+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "POST" matches against value "POST"
|
||||
2026-06-11T13:43:44.376+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Pattern "/api/groupCourse/page" does not match against value "/api/groupCourse/types/1/labels"
|
||||
2026-06-11T13:43:44.376+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
|
||||
2026-06-11T13:43:44.376+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
|
||||
2026-06-11T13:43:44.376+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
|
||||
2026-06-11T13:43:44.376+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
|
||||
2026-06-11T13:43:44.376+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
|
||||
2026-06-11T13:43:44.376+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "POST" matches against value "POST"
|
||||
2026-06-11T13:43:44.376+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Pattern "/api/groupCourse/types" does not match against value "/api/groupCourse/types/1/labels"
|
||||
2026-06-11T13:43:44.376+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "PUT" does not match against value "POST"
|
||||
2026-06-11T13:43:44.376+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "DELETE" does not match against value "POST"
|
||||
2026-06-11T13:43:44.376+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
|
||||
2026-06-11T13:43:44.376+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
|
||||
2026-06-11T13:43:44.376+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
|
||||
2026-06-11T13:43:44.376+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "GET" does not match against value "POST"
|
||||
2026-06-11T13:43:44.376+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "POST" matches against value "POST"
|
||||
2026-06-11T13:43:44.376+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Pattern "/api/groupCourse/labels" does not match against value "/api/groupCourse/types/1/labels"
|
||||
2026-06-11T13:43:44.376+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "PUT" does not match against value "POST"
|
||||
2026-06-11T13:43:44.376+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "DELETE" does not match against value "POST"
|
||||
2026-06-11T13:43:44.376+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Method "POST" matches against value "POST"
|
||||
2026-06-11T13:43:44.376+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.server.RequestPredicates : Pattern "/api/groupCourse/types/{typeId}/labels" matches against value "/api/groupCourse/types/1/labels"
|
||||
2026-06-11T13:43:44.376+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.function.server.RouterFunctions : [27afd417] Matched (POST && /api/groupCourse/types/{typeId}/labels)
|
||||
2026-06-11T13:43:44.376+08:00 DEBUG 9324 --- [gym-manage-api] [ parallel-2] o.s.w.r.f.s.s.RouterFunctionMapping : [27afd417] Mapped to cn.novalon.gym.manage.app.config.SystemRouter$$Lambda/0x00000223a09d1920@3760f3e8
|
||||
2026-06-11T13:43:44.377+08:00 TRACE 9324 --- [gym-manage-api] [ parallel-2] org.springframework.web.HttpLogging : [27afd417] Decoded [{labelIds=[1, 3, 5]}]
|
||||
2026-06-11T13:43:44.377+08:00 DEBUG 9324 --- [gym-manage-api] [ parallel-2] o.s.r.c.R2dbcTransactionManager : Creating new transaction with name [cn.novalon.gym.manage.groupcourse.repository.impl.CourseLabelRepository.addLabelsToType]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
|
||||
2026-06-11T13:43:44.377+08:00 DEBUG 9324 --- [gym-manage-api] [ parallel-2] o.s.r.c.R2dbcTransactionManager : Acquired Connection [PooledConnection[PostgresqlConnection{client=io.r2dbc.postgresql.client.ReactorNettyClient@c3a5202, codecs=io.r2dbc.postgresql.codec.DefaultCodecs@922d1d4}]] for R2DBC transaction
|
||||
2026-06-11T13:43:44.377+08:00 DEBUG 9324 --- [gym-manage-api] [ parallel-2] o.s.r.c.R2dbcTransactionManager : Starting R2DBC transaction on Connection [PooledConnection[PostgresqlConnection{client=io.r2dbc.postgresql.client.ReactorNettyClient@c3a5202, codecs=io.r2dbc.postgresql.codec.DefaultCodecs@922d1d4}]] using [ExtendedTransactionDefinition [transactionName='cn.novalon.gym.manage.groupcourse.repository.impl.CourseLabelRepository.addLabelsToType', readOnly=false, isolationLevel=null, lockWaitTimeout=PT0S]]
|
||||
2026-06-11T13:43:44.379+08:00 DEBUG 9324 --- [gym-manage-api] [actor-tcp-nio-6] o.s.r2dbc.core.DefaultDatabaseClient : Executing SQL statement [SELECT course_type_label.type_id, course_type_label.label_id, course_type_label.id, course_type_label.create_by, course_type_label.update_by, course_type_label.created_at, course_type_label.updated_at, course_type_label.deleted_at FROM course_type_label WHERE course_type_label.type_id = $1 AND (course_type_label.label_id = $2) AND (course_type_label.deleted_at IS NULL)]
|
||||
2026-06-11T13:43:44.381+08:00 DEBUG 9324 --- [gym-manage-api] [actor-tcp-nio-6] o.s.r2dbc.core.DefaultDatabaseClient : Executing SQL statement [SELECT course_type_label.type_id, course_type_label.label_id, course_type_label.id, course_type_label.create_by, course_type_label.update_by, course_type_label.created_at, course_type_label.updated_at, course_type_label.deleted_at FROM course_type_label WHERE course_type_label.type_id = $1 AND (course_type_label.label_id = $2) AND (course_type_label.deleted_at IS NULL)]
|
||||
2026-06-11T13:43:44.382+08:00 DEBUG 9324 --- [gym-manage-api] [actor-tcp-nio-6] o.s.r2dbc.core.DefaultDatabaseClient : Executing SQL statement [SELECT course_type_label.type_id, course_type_label.label_id, course_type_label.id, course_type_label.create_by, course_type_label.update_by, course_type_label.created_at, course_type_label.updated_at, course_type_label.deleted_at FROM course_type_label WHERE course_type_label.type_id = $1 AND (course_type_label.label_id = $2) AND (course_type_label.deleted_at IS NULL)]
|
||||
2026-06-11T13:43:44.382+08:00 DEBUG 9324 --- [gym-manage-api] [actor-tcp-nio-6] o.s.r2dbc.core.DefaultDatabaseClient : Executing SQL statement [INSERT INTO course_type_label (type_id, label_id, create_by, update_by, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6)]
|
||||
2026-06-11T13:43:44.385+08:00 DEBUG 9324 --- [gym-manage-api] [actor-tcp-nio-6] o.s.r2dbc.core.DefaultDatabaseClient : Executing SQL statement [INSERT INTO course_type_label (type_id, label_id, create_by, update_by, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6)]
|
||||
2026-06-11T13:43:44.385+08:00 DEBUG 9324 --- [gym-manage-api] [actor-tcp-nio-6] o.s.r2dbc.core.DefaultDatabaseClient : Executing SQL statement [INSERT INTO course_type_label (type_id, label_id, create_by, update_by, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6)]
|
||||
2026-06-11T13:43:44.387+08:00 DEBUG 9324 --- [gym-manage-api] [actor-tcp-nio-6] o.s.r.c.R2dbcTransactionManager : Initiating transaction rollback
|
||||
2026-06-11T13:43:44.387+08:00 DEBUG 9324 --- [gym-manage-api] [actor-tcp-nio-6] o.s.r.c.R2dbcTransactionManager : Rolling back R2DBC transaction on Connection [PooledConnection[PostgresqlConnection{client=io.r2dbc.postgresql.client.ReactorNettyClient@c3a5202, codecs=io.r2dbc.postgresql.codec.DefaultCodecs@922d1d4}]]
|
||||
2026-06-11T13:43:44.389+08:00 DEBUG 9324 --- [gym-manage-api] [actor-tcp-nio-6] o.s.r.c.R2dbcTransactionManager : Releasing R2DBC Connection [PooledConnection[PostgresqlConnection{client=io.r2dbc.postgresql.client.ReactorNettyClient@c3a5202, codecs=io.r2dbc.postgresql.codec.DefaultCodecs@922d1d4}]] after transaction
|
||||
2026-06-11T13:43:44.389+08:00 ERROR 9324 --- [gym-manage-api] [actor-tcp-nio-6] c.n.g.m.g.s.impl.CourseLabelService : 标签添加到类型失败 - typeId=1, error: executeMany; SQL [INSERT INTO course_type_label (type_id, label_id, create_by, update_by, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6)]; 重复键违反唯一约束"course_type_label_type_id_label_id_key"
|
||||
2026-06-11T13:43:44.389+08:00 TRACE 9324 --- [gym-manage-api] [actor-tcp-nio-6] org.springframework.web.HttpLogging : [27afd417] Encoding [{success=false, message=executeMany; SQL [INSERT INTO course_type_label (type_id, label_id, create_by, update_by, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6)]; 重复键违反唯一约束"course_type_label_type_id_label_id_key"}]
|
||||
2026-06-11T13:43:44.390+08:00 TRACE 9324 --- [gym-manage-api] [actor-tcp-nio-6] o.s.w.s.adapter.HttpWebHandlerAdapter : [27afd417] Completed 400 BAD_REQUEST, headers={masked}
|
||||
2026-06-11T13:43:44.390+08:00 TRACE 9324 --- [gym-manage-api] [actor-tcp-nio-6] org.springframework.web.HttpLogging : [27afd417] onComplete
|
||||
|
||||
+1
-15
@@ -6,7 +6,6 @@ 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;
|
||||
import cn.novalon.gym.manage.groupcourse.handler.GroupCourseRecommendHandler;
|
||||
import cn.novalon.gym.manage.groupcourse.handler.GroupCourseTypeHandler;
|
||||
import cn.novalon.gym.manage.groupcourse.handler.CourseLabelHandler;
|
||||
import cn.novalon.gym.manage.member.handler.MemberCardHandler;
|
||||
@@ -72,7 +71,6 @@ public class SystemRouter {
|
||||
MemberCardTransactionHandler memberCardTransactionHandler,
|
||||
GroupCourseHandler groupCourseHandler,
|
||||
GroupCourseBookingHandler groupCourseBookingHandler,
|
||||
GroupCourseRecommendHandler groupCourseRecommendHandler,
|
||||
GroupCourseTypeHandler groupCourseTypeHandler,
|
||||
CourseLabelHandler courseLabelHandler,
|
||||
CheckInHandler checkInHandler,
|
||||
@@ -301,17 +299,6 @@ public class SystemRouter {
|
||||
.GET("/api/groupCourse/bookings/course/{courseId}", groupCourseBookingHandler::getBookingsByCourseId)
|
||||
.GET("/api/groupCourse/bookings/{bookingId}", groupCourseBookingHandler::getBookingById)
|
||||
|
||||
// ===== 团课推荐管理 =====
|
||||
.GET("/api/groupCourse/recommend/list", groupCourseRecommendHandler::getAllRecommendations)
|
||||
.GET("/api/groupCourse/recommend/active", groupCourseRecommendHandler::getAllActiveRecommendations)
|
||||
.GET("/api/groupCourse/recommend/{id}", groupCourseRecommendHandler::getRecommendationById)
|
||||
.GET("/api/groupCourse/recommend/course/{courseId}", groupCourseRecommendHandler::getRecommendationsByCourseId)
|
||||
.POST("/api/groupCourse/recommend", groupCourseRecommendHandler::createRecommendation)
|
||||
.PUT("/api/groupCourse/recommend/{id}", groupCourseRecommendHandler::updateRecommendation)
|
||||
.DELETE("/api/groupCourse/recommend/{id}", groupCourseRecommendHandler::deleteRecommendation)
|
||||
.POST("/api/groupCourse/recommend/{id}/enable", groupCourseRecommendHandler::enableRecommendation)
|
||||
.POST("/api/groupCourse/recommend/{id}/disable", groupCourseRecommendHandler::disableRecommendation)
|
||||
|
||||
// ===== 团课课程管理(需要放在具体路由之后)=====
|
||||
.GET("/api/groupCourse/{id}", groupCourseHandler::getGroupCourseById)
|
||||
.GET("/api/groupCourse/{id}/detail", groupCourseHandler::getGroupCourseDetailById)
|
||||
@@ -319,8 +306,7 @@ public class SystemRouter {
|
||||
.PUT("/api/groupCourse/{id}", groupCourseHandler::updateGroupCourse)
|
||||
.DELETE("/api/groupCourse/{id}", groupCourseHandler::deleteGroupCourse)
|
||||
.POST("/api/groupCourse/{id}/cancel", groupCourseHandler::cancelGroupCourse)
|
||||
.POST("/api/groupCourse/signin/{memberId}", groupCourseHandler::signIn)
|
||||
.POST("/api/groupCourse/search", groupCourseHandler::searchGroupCourses)
|
||||
.POST("/api/groupCourse/{courseId}/signin", groupCourseHandler::signIn)
|
||||
|
||||
// ========= 签到模块路由 ==========
|
||||
// ===== 签到核心功能 =====
|
||||
|
||||
-38
@@ -1,38 +0,0 @@
|
||||
-- ============================================
|
||||
-- 团课推荐表
|
||||
-- ============================================
|
||||
|
||||
-- 团课推荐表
|
||||
CREATE TABLE IF NOT EXISTS group_course_recommend (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
course_id BIGINT NOT NULL,
|
||||
recommend_title VARCHAR(200),
|
||||
recommend_content TEXT,
|
||||
recommend_reason VARCHAR(500),
|
||||
priority INTEGER DEFAULT 0,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
create_by VARCHAR(50),
|
||||
update_by VARCHAR(50),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
deleted_at TIMESTAMP
|
||||
);
|
||||
|
||||
-- 创建索引
|
||||
CREATE INDEX IF NOT EXISTS idx_group_course_recommend_course_id ON group_course_recommend(course_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_group_course_recommend_priority ON group_course_recommend(priority);
|
||||
CREATE INDEX IF NOT EXISTS idx_group_course_recommend_is_active ON group_course_recommend(is_active);
|
||||
|
||||
COMMENT ON TABLE group_course_recommend IS '团课推荐表';
|
||||
COMMENT ON COLUMN group_course_recommend.id IS '主键ID';
|
||||
COMMENT ON COLUMN group_course_recommend.course_id IS '团课ID(关联group_course.id)';
|
||||
COMMENT ON COLUMN group_course_recommend.recommend_title IS '推荐标题';
|
||||
COMMENT ON COLUMN group_course_recommend.recommend_content IS '推荐内容';
|
||||
COMMENT ON COLUMN group_course_recommend.recommend_reason IS '推荐理由';
|
||||
COMMENT ON COLUMN group_course_recommend.priority IS '优先级(数字越大优先级越高)';
|
||||
COMMENT ON COLUMN group_course_recommend.is_active IS '是否启用';
|
||||
COMMENT ON COLUMN group_course_recommend.create_by IS '创建人';
|
||||
COMMENT ON COLUMN group_course_recommend.update_by IS '更新人';
|
||||
COMMENT ON COLUMN group_course_recommend.created_at IS '创建时间';
|
||||
COMMENT ON COLUMN group_course_recommend.updated_at IS '更新时间';
|
||||
COMMENT ON COLUMN group_course_recommend.deleted_at IS '删除时间(软删除)';
|
||||
-43
@@ -1,43 +0,0 @@
|
||||
-- ============================================
|
||||
-- 团课推荐测试数据
|
||||
-- ============================================
|
||||
|
||||
-- 推荐数据1: 极速燃脂单车 - 高优先级推荐(热门课程)
|
||||
INSERT INTO group_course_recommend (course_id, recommend_title, recommend_content, recommend_reason, priority, is_active, create_by, created_at, updated_at) VALUES
|
||||
(1, '本周热门推荐', '极速燃脂单车课程,跟随音乐节奏变换阻力和速度,体验爬坡与冲刺的快感,一节课消耗800大卡!', '教练专业,课程内容丰富,深受学员喜爱,燃脂效果显著', 20, true, 'admin', '2026-06-15 10:00:00', '2026-06-15 10:00:00');
|
||||
|
||||
-- 推荐数据2: 清晨流瑜伽 - 中等优先级推荐(适合新手)
|
||||
INSERT INTO group_course_recommend (course_id, recommend_title, recommend_content, recommend_reason, priority, is_active, create_by, created_at, updated_at) VALUES
|
||||
(2, '新手友好推荐', '清晨流瑜伽课程,适合有一定基础的学员,通过流畅的体式连接呼吸,唤醒身体能量。', '适合新手入门,教练耐心指导,课程节奏适中', 15, true, 'admin', '2026-06-15 11:00:00', '2026-06-15 11:00:00');
|
||||
|
||||
-- 推荐数据3: 燃脂搏击 - 低优先级推荐(满员课程,已禁用)
|
||||
INSERT INTO group_course_recommend (course_id, recommend_title, recommend_content, recommend_reason, priority, is_active, create_by, created_at, updated_at) VALUES
|
||||
(3, '高强度燃脂', '燃脂搏击课程,高强度间歇训练,配合音乐快速燃脂,释放压力。', '高强度训练,适合进阶学员,快速燃脂塑形', 10, false, 'admin', '2026-06-15 12:00:00', '2026-06-15 12:00:00');
|
||||
|
||||
-- 推荐数据4: 哈他瑜伽 - 中等优先级推荐(基础课程)
|
||||
INSERT INTO group_course_recommend (course_id, recommend_title, recommend_content, recommend_reason, priority, is_active, create_by, created_at, updated_at) VALUES
|
||||
(4, '基础瑜伽推荐', '基础哈他瑜伽课程,适合所有级别学员,通过基础体式练习提升身体柔韧性和平衡能力。', '零基础友好,适合所有健身水平,放松身心', 12, true, 'coach_li', '2026-06-15 13:00:00', '2026-06-15 13:00:00');
|
||||
|
||||
-- 推荐数据5: 蜜桃臀塑造 - 高优先级推荐(热门课程,但课程已结束)
|
||||
INSERT INTO group_course_recommend (course_id, recommend_title, recommend_content, recommend_reason, priority, is_active, create_by, created_at, updated_at) VALUES
|
||||
(6, '塑形热门课程', '蜜桃臀塑造课程,针对性训练臀部肌肉群,打造完美曲线。', '专业私教指导,动作标准,效果显著,深受女性学员喜爱', 18, true, 'coach_li', '2026-05-25 09:15:00', '2026-05-25 09:15:00');
|
||||
|
||||
-- 推荐数据6: 午间冥想放松 - 低优先级推荐(放松课程)
|
||||
INSERT INTO group_course_recommend (course_id, recommend_title, recommend_content, recommend_reason, priority, is_active, create_by, created_at, updated_at) VALUES
|
||||
(7, '午间放松推荐', '午间冥想放松课程,通过呼吸和正念冥想,深度放松身心,缓解工作压力。', '适合上班族,午间放松充电,提升下午工作效率', 8, true, 'admin', '2026-05-25 09:00:00', '2026-05-25 09:00:00');
|
||||
|
||||
-- 推荐数据7: 极速燃脂单车 - 第二个推荐(不同角度推荐)
|
||||
INSERT INTO group_course_recommend (course_id, recommend_title, recommend_content, recommend_reason, priority, is_active, create_by, created_at, updated_at) VALUES
|
||||
(1, '减脂首选课程', '想要快速减脂?极速燃脂单车是你的最佳选择!专业教练带领,科学训练计划。', '减脂效果最佳,课程强度适中,适合想要快速瘦身的学员', 16, true, 'coach_zhang', '2026-06-15 14:00:00', '2026-06-15 14:00:00');
|
||||
|
||||
-- 推荐数据8: 清晨流瑜伽 - 第二个推荐(不同角度推荐)
|
||||
INSERT INTO group_course_recommend (course_id, recommend_title, recommend_content, recommend_reason, priority, is_active, create_by, created_at, updated_at) VALUES
|
||||
(2, '晨练优选', '清晨流瑜伽,唤醒身体能量,开启活力一天!适合晨练爱好者。', '晨练最佳选择,提升身体活力,改善精神状态', 14, true, 'coach_wang', '2026-06-15 15:00:00', '2026-06-15 15:00:00');
|
||||
|
||||
-- 推荐数据9: 哈他瑜伽 - 第二个推荐(不同角度推荐)
|
||||
INSERT INTO group_course_recommend (course_id, recommend_title, recommend_content, recommend_reason, priority, is_active, create_by, created_at, updated_at) VALUES
|
||||
(4, '身心平衡推荐', '哈他瑜伽课程,通过体式练习和呼吸控制,达到身心平衡,提升整体健康水平。', '改善身体柔韧性,增强核心力量,提升身体协调性', 11, true, 'coach_li', '2026-06-15 16:00:00', '2026-06-15 16:00:00');
|
||||
|
||||
-- 推荐数据10: 午间冥想放松 - 第二个推荐(不同角度推荐,已禁用)
|
||||
INSERT INTO group_course_recommend (course_id, recommend_title, recommend_content, recommend_reason, priority, is_active, create_by, created_at, updated_at) VALUES
|
||||
(7, '职场减压课程', '午间冥想放松,专为职场人士设计,快速缓解工作压力,提升工作状态。', '职场减压首选,课程时间短,效果显著', 9, false, 'admin', '2026-05-25 10:00:00', '2026-05-25 10:00:00');
|
||||
-9
@@ -1,9 +0,0 @@
|
||||
-- ============================================
|
||||
-- 为团课表添加二维码路径字段
|
||||
-- ============================================
|
||||
|
||||
-- 添加二维码路径字段
|
||||
ALTER TABLE group_course ADD COLUMN qr_code_path VARCHAR(500);
|
||||
|
||||
-- 添加字段注释
|
||||
COMMENT ON COLUMN group_course.qr_code_path IS '二维码图片路径';
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
unpackage/
|
||||
.hbuilderx/
|
||||
.DS_Store
|
||||
@@ -0,0 +1,33 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools" package="gym.manage.uniapp">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
|
||||
|
||||
<application>
|
||||
<activity
|
||||
android:name="com.mobile.auth.gatewayauth.LoginAuthActivity"
|
||||
android:configChanges="orientation|keyboardHidden|screenSize"
|
||||
android:exported="false"
|
||||
android:launchMode="singleTop"
|
||||
android:screenOrientation="behind"
|
||||
android:theme="@style/authsdk_activity_dialog" />
|
||||
|
||||
<activity
|
||||
android:name="com.mobile.auth.gatewayauth.activity.AuthWebVeiwActivity"
|
||||
android:configChanges="orientation|keyboardHidden|screenSize"
|
||||
android:exported="false"
|
||||
android:launchMode="singleTop"
|
||||
android:screenOrientation="behind" />
|
||||
|
||||
<activity
|
||||
android:name="com.mobile.auth.gatewayauth.PrivacyDialogActivity"
|
||||
android:configChanges="orientation|keyboardHidden|screenSize"
|
||||
android:exported="false"
|
||||
android:launchMode="singleTop"
|
||||
android:screenOrientation="behind"
|
||||
android:theme="@style/authsdk_activity_dialog" />
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,85 @@
|
||||
<template>
|
||||
<view>
|
||||
<GlobalLoading />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onLaunch, onShow, onHide } from '@dcloudio/uni-app'
|
||||
import GlobalLoading from '@/components/global/GlobalLoading.vue'
|
||||
|
||||
// 隐藏原生 TabBar - 这里是核心修复
|
||||
const hideNativeTabBar = () => {
|
||||
// 尝试多次执行,确保执行成功
|
||||
const tryHide = (times = 0) => {
|
||||
if (times > 10) return // 最多尝试10次
|
||||
|
||||
uni.hideTabBar({
|
||||
animation: false,
|
||||
success: () => {
|
||||
console.log('✅ 原生TabBar隐藏成功')
|
||||
// 强制 CSS 覆盖(双重保险)
|
||||
forceCSSHide()
|
||||
},
|
||||
fail: (err) => {
|
||||
console.log(`❌ 第${times+1}次隐藏失败,1秒后重试`, err)
|
||||
setTimeout(() => tryHide(times + 1), 1000)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 延迟 300ms 执行,确保页面挂载完成
|
||||
setTimeout(() => tryHide(), 300)
|
||||
}
|
||||
|
||||
// 强制 CSS 覆盖(最终保险)
|
||||
const forceCSSHide = () => {
|
||||
// #ifdef APP-PLUS
|
||||
const style = document.createElement('style')
|
||||
style.innerHTML = `
|
||||
uni-tabbar,
|
||||
uni-tabbar .uni-tabbar,
|
||||
.uni-tabbar,
|
||||
uni-tabbar > .uni-tabbar,
|
||||
[class*="uni-tabbar"] {
|
||||
display: none !important;
|
||||
height: 0 !important;
|
||||
opacity: 0 !important;
|
||||
visibility: hidden !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
`
|
||||
document.head.appendChild(style)
|
||||
console.log('✅ CSS 强制覆盖已注入')
|
||||
// #endif
|
||||
}
|
||||
|
||||
// 预加载所有 Tab 页面的核心数据
|
||||
const preloadTabData = () => {
|
||||
// 延迟执行,不阻塞首屏
|
||||
setTimeout(() => {
|
||||
// 预加载课程数据等...
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
onLaunch(() => {
|
||||
console.log('App Launch')
|
||||
preloadTabData()
|
||||
})
|
||||
|
||||
onShow(() => {
|
||||
console.log('App Show')
|
||||
// #ifdef APP-PLUS
|
||||
hideNativeTabBar()
|
||||
// #endif
|
||||
})
|
||||
|
||||
onHide(() => {
|
||||
console.log('App Hide')
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
/* 注意要写在第一行,同时给style标签加入lang="scss"属性 */
|
||||
@import "@/uni.scss";
|
||||
</style>
|
||||
@@ -0,0 +1,132 @@
|
||||
import request from "@/utils/request.js"
|
||||
|
||||
export function getGroupCourseList(params = {}, options = {}) {
|
||||
return request.get('/groupCourse/list', params, options)
|
||||
}
|
||||
|
||||
export function getGroupCoursePage(params = {}, options = { cache: true, cacheTime: 5 * 60 * 1000 }) {
|
||||
const { page = 0, size = 10, sort = 'id', order = 'asc', keyword } = params
|
||||
return request.post('/groupCourse/page', { page, size, sort, order, keyword }, options)
|
||||
}
|
||||
|
||||
export function getGroupCourseById(id, options = { cache: true, cacheTime: 15 * 60 * 1000 }) {
|
||||
return request.get(`/groupCourse/${id}`, {}, options)
|
||||
}
|
||||
|
||||
export function getGroupCourseDetail(id, options = { cache: true, cacheTime: 15 * 60 * 1000 }) {
|
||||
return request.get(`/groupCourse/${id}/detail`, {}, options)
|
||||
}
|
||||
|
||||
export function createGroupCourse(params) {
|
||||
return request.post('/groupCourse', params)
|
||||
}
|
||||
|
||||
export function updateGroupCourse(id, params) {
|
||||
return request.put(`/groupCourse/${id}`, params)
|
||||
}
|
||||
|
||||
export function cancelGroupCourse(id) {
|
||||
return request.post(`/groupCourse/${id}/cancel`)
|
||||
}
|
||||
|
||||
export function deleteGroupCourse(id) {
|
||||
return request.delete(`/groupCourse/${id}`)
|
||||
}
|
||||
|
||||
export function getGroupCourseTypes(params = {}, options = { cache: true, cacheTime: 10 * 60 * 1000 }) {
|
||||
return request.get('/groupCourse/types', params, options)
|
||||
}
|
||||
|
||||
export function getGroupCourseTypeById(id, options = { cache: true, cacheTime: 10 * 60 * 1000 }) {
|
||||
return request.get(`/groupCourse/types/${id}`, {}, options)
|
||||
}
|
||||
|
||||
export function getTypeLabels(typeId, options = { cache: true, cacheTime: 5 * 60 * 1000 }) {
|
||||
return request.get(`/groupCourse/types/${typeId}/labels`, {}, options)
|
||||
}
|
||||
|
||||
export function searchGroupCourse(params = {}, options = {}) {
|
||||
const {
|
||||
courseName,
|
||||
courseType,
|
||||
startDate,
|
||||
endDate,
|
||||
timePeriod,
|
||||
priceSort,
|
||||
remainingMost,
|
||||
isRecurring,
|
||||
page = 0,
|
||||
size = 10
|
||||
} = params
|
||||
|
||||
const requestBody = { page, size }
|
||||
|
||||
if (courseName) requestBody.courseName = courseName
|
||||
if (courseType) requestBody.courseType = courseType
|
||||
if (startDate) requestBody.startDate = formatDateTime(startDate)
|
||||
if (endDate) requestBody.endDate = formatDateTime(endDate, true)
|
||||
if (timePeriod) requestBody.timePeriod = timePeriod
|
||||
if (priceSort) requestBody.priceSort = priceSort
|
||||
if (remainingMost !== undefined) requestBody.remainingMost = remainingMost
|
||||
if (isRecurring !== undefined) requestBody.isRecurring = isRecurring
|
||||
|
||||
return request.post('/groupCourse/search', requestBody, options)
|
||||
}
|
||||
|
||||
function formatDateTime(dateStr, isEnd = false) {
|
||||
if (!dateStr) return dateStr
|
||||
if (dateStr.includes('T')) return dateStr
|
||||
return isEnd
|
||||
? `${dateStr}T23:59:59`
|
||||
: `${dateStr}T00:00:00`
|
||||
}
|
||||
|
||||
export function bookGroupCourse(params) {
|
||||
return request.post('/groupCourse/book', params)
|
||||
}
|
||||
|
||||
export function cancelBooking(bookingId, params) {
|
||||
return request.post(`/groupCourse/booking/${bookingId}/cancel`, params)
|
||||
}
|
||||
|
||||
export function getMemberBookings(memberId, options = {}) {
|
||||
return request.get(`/groupCourse/bookings/member/${memberId}`, {}, options)
|
||||
}
|
||||
|
||||
export function getActiveRecommendCourses(options = { cache: false }) {
|
||||
return request.get('/groupCourse/recommend/active', {}, options)
|
||||
}
|
||||
|
||||
export function getGroupCourseRecommendList(params = {}, options = { cache: false }) {
|
||||
return request.get('/groupCourse/recommend/list', params, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫码签到
|
||||
* 扫描团课二维码后签到,将预约状态更新为已出席
|
||||
* @param {number} courseId - 团课ID
|
||||
* @param {number} memberId - 会员ID
|
||||
*/
|
||||
export function qrSignInGroupCourse(courseId, memberId) {
|
||||
return request.post(`/groupCourse/signin/${memberId}`, { courseId })
|
||||
}
|
||||
|
||||
export default {
|
||||
getGroupCourseList,
|
||||
getGroupCoursePage,
|
||||
searchGroupCourse,
|
||||
getGroupCourseById,
|
||||
getGroupCourseDetail,
|
||||
createGroupCourse,
|
||||
updateGroupCourse,
|
||||
cancelGroupCourse,
|
||||
deleteGroupCourse,
|
||||
getGroupCourseTypes,
|
||||
getGroupCourseTypeById,
|
||||
getTypeLabels,
|
||||
bookGroupCourse,
|
||||
cancelBooking,
|
||||
getMemberBookings,
|
||||
getActiveRecommendCourses,
|
||||
getGroupCourseRecommendList
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
import request from "@/utils/request.js"
|
||||
|
||||
/**
|
||||
* 微信小程序登录
|
||||
* @param {object} params - 登录参数 { code: string }
|
||||
*/
|
||||
export function login(params) {
|
||||
return request.post('/member/auth/miniapp/login', params)
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送短信验证码
|
||||
* @param {object} params - { phone: string }
|
||||
*/
|
||||
export function sendCode(params) {
|
||||
return request.post('/auth/phone/send-code', params)
|
||||
}
|
||||
|
||||
/**
|
||||
* 手机号验证码登录
|
||||
* @param {object} params - { phone: string, code: string }
|
||||
*/
|
||||
export function loginWithPhone(params) {
|
||||
return request.post('/auth/phone/code-login', params)
|
||||
}
|
||||
|
||||
/**
|
||||
* 手机号一键登录
|
||||
* @param {object} params - { accessToken: string, openid: string, nickname?: string, avatar?: string }
|
||||
*/
|
||||
export function oneClickLogin(params) {
|
||||
return request.post('/auth/phone/one-click-login', params)
|
||||
}
|
||||
|
||||
/**
|
||||
* 退出登录
|
||||
*/
|
||||
export function logout() {
|
||||
return request.post('/member/auth/logout')
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取签到二维码
|
||||
* @param {object} options - 请求选项 { cache: boolean, cacheTime: number }
|
||||
*/
|
||||
export function getQRCode(options = { cache: true, cacheTime: 5 * 60 * 1000 }) {
|
||||
return request.get('/checkIn/qrcode', {}, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫码签到
|
||||
* @param {object} params - { qrcode: string, memberId: number }
|
||||
*/
|
||||
export function checkIn(params) {
|
||||
return request.post('/checkIn', params)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取签到记录(分页)
|
||||
* @param {object} params - { page: number, size: number }
|
||||
* @param {object} options - 请求选项
|
||||
*/
|
||||
export function getCheckInRecords(params = {}, options = {}) {
|
||||
const { page = 0, size = 5 } = params
|
||||
return request.post('/checkIn/page', { page, size }, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户未读消息数量
|
||||
* @param {number} userId - 用户ID
|
||||
* @param {object} options - 请求选项
|
||||
*/
|
||||
export function getUnreadMessageCount(userId, options = {}) {
|
||||
return request.get(`/messages/user/${userId}/unread`, {}, options)
|
||||
}
|
||||
|
||||
// ========== 消息相关API ==========
|
||||
|
||||
/**
|
||||
* 获取用户消息列表(支持分页)
|
||||
* @param {number} userId - 用户ID
|
||||
* @param {object} params - 查询参数
|
||||
* @param {number} params.page - 页码(从0开始)
|
||||
* @param {number} params.size - 每页条数
|
||||
* @param {object} options - 请求选项
|
||||
* @returns {Promise} 消息列表
|
||||
*/
|
||||
export function getUserMessages(userId, params = {}, options = {}) {
|
||||
const { page = 0, size = 10 } = params
|
||||
return request.get(`/messages/user/${userId}/page`, { page, size }, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取未读消息列表(支持分页)
|
||||
* @param {number} userId - 用户ID
|
||||
* @param {object} params - 查询参数
|
||||
* @param {number} params.page - 页码(从0开始)
|
||||
* @param {number} params.size - 每页条数
|
||||
* @param {object} options - 请求选项
|
||||
* @returns {Promise} 未读消息列表
|
||||
*/
|
||||
export function getUnreadMessages(userId, params = {}, options = {}) {
|
||||
const { page = 0, size = 10 } = params
|
||||
return request.get(`/messages/user/${userId}/unread/page`, { page, size }, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记消息为已读
|
||||
* @param {number|string} id - 消息ID
|
||||
* @param {object} options - 请求选项
|
||||
* @returns {Promise} 操作结果
|
||||
*/
|
||||
export function markMessageAsRead(id, options = {}) {
|
||||
return request.put(`/messages/${id}/read`, {}, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记所有消息为已读
|
||||
* @param {number} userId - 用户ID
|
||||
* @param {object} options - 请求选项
|
||||
* @returns {Promise} 操作结果
|
||||
*/
|
||||
export function markAllMessagesAsRead(userId, options = {}) {
|
||||
return request.put(`/messages/user/${userId}/read`, {}, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除消息
|
||||
* @param {number|string} id - 消息ID
|
||||
* @param {object} options - 请求选项
|
||||
* @returns {Promise} 操作结果
|
||||
*/
|
||||
export function deleteMessage(id, options = {}) {
|
||||
return request.delete(`/messages/${id}`, {}, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户信息(基础信息缓存)
|
||||
* @param {object} options - 请求选项 { cache: boolean, cacheTime: number }
|
||||
*/
|
||||
export function getUserInfo(options = { cache: true, cacheTime: 30 * 60 * 1000 }) {
|
||||
return request.get('/member/info', {}, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会员详细信息(包含会员卡、积分等)
|
||||
* @param {object} options - 请求选项 { cache: boolean }
|
||||
*/
|
||||
export function getMemberDetail(options = { cache: false }) {
|
||||
return request.get('/member/info', {}, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取签到统计
|
||||
* @param {object} params - 查询参数 { startDate: string, endDate: string }
|
||||
* @param {object} options - 请求选项 { cache: boolean }
|
||||
*/
|
||||
export function getCheckInStats(params = {}, options = { cache: false }) {
|
||||
return request.get('/checkIn/statistics', params, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新用户信息
|
||||
* @param {object} params - 用户信息参数
|
||||
*/
|
||||
export function updateUserInfo(params) {
|
||||
return request.put('/member/info', params)
|
||||
}
|
||||
|
||||
// ========== 系统配置相关API ==========
|
||||
|
||||
/**
|
||||
* 根据配置键获取配置值
|
||||
* @param {string} configKey - 配置键
|
||||
* @param {object} options - 请求选项
|
||||
*/
|
||||
export function getConfigByKey(configKey, options = {}) {
|
||||
return request.get(`/config/key/${configKey}`, {}, options)
|
||||
}
|
||||
|
||||
// ========== 课程相关API ==========
|
||||
|
||||
/**
|
||||
* 获取推荐课程列表
|
||||
* @param {object} options - 请求选项 { cache: boolean, cacheTime: number }
|
||||
*/
|
||||
export function getRecommendCourses(options = { cache: true, cacheTime: 10 * 60 * 1000 }) {
|
||||
return request.get('/course/recommend', {}, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取课程详情
|
||||
* @param {number} id - 课程ID
|
||||
* @param {object} options - 请求选项 { cache: boolean, cacheTime: number }
|
||||
*/
|
||||
export function getCourseDetail(id, options = { cache: true, cacheTime: 15 * 60 * 1000 }) {
|
||||
return request.get(`/course/${id}`, {}, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取团课列表(分页)
|
||||
* @param {object} params - 查询参数 { page: number, size: number, sort: string, order: string, keyword: string }
|
||||
* @param {object} options - 请求选项 { cache: boolean, cacheTime: number }
|
||||
*/
|
||||
export function getGroupCoursePage(params = {}, options = { cache: true, cacheTime: 5 * 60 * 1000 }) {
|
||||
const { page = 0, size = 10, sort = 'id', order = 'asc', keyword } = params
|
||||
return request.post('/groupCourse/page', { page, size, sort, order, keyword }, options)
|
||||
}
|
||||
|
||||
// ========== 团课预约相关API ==========
|
||||
|
||||
/**
|
||||
* 预约团课
|
||||
* @param {object} params - 预约参数
|
||||
* @param {number} params.courseId - 团课ID
|
||||
* @param {number} params.memberId - 会员ID
|
||||
*/
|
||||
export function bookGroupCourse(params) {
|
||||
return request.post('/groupCourse/book', params)
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消预约
|
||||
* @param {number} bookingId - 预约ID
|
||||
*/
|
||||
export function cancelBooking(bookingId) {
|
||||
return request.delete(`/groupCourse/book/${bookingId}`, {})
|
||||
}
|
||||
|
||||
/**
|
||||
* 团课签到
|
||||
* @param {number} memberId - 会员ID
|
||||
* @param {number} courseId - 团课ID
|
||||
*/
|
||||
export function signinGroupCourse(memberId, courseId) {
|
||||
return request.post(`/groupCourse/signin/${memberId}`, { courseId })
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询会员预约记录
|
||||
* @param {number} memberId - 会员ID
|
||||
*/
|
||||
export function getMemberBookings(memberId, options = { cache: false }) {
|
||||
return request.get(`/groupCourse/bookings/member/${memberId}`, {}, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询课程预约记录
|
||||
* @param {number} courseId - 团课ID
|
||||
*/
|
||||
export function getCourseBookings(courseId, options = { cache: false }) {
|
||||
return request.get(`/groupCourse/bookings/course/${courseId}`, {}, options)
|
||||
}
|
||||
|
||||
// ========== 轮播图相关API ==========
|
||||
|
||||
/**
|
||||
* 获取启用的轮播图列表
|
||||
* @param {object} options - 请求选项 { cache: boolean, cacheTime: number }
|
||||
*/
|
||||
export function getActiveBanners(options = { cache: true, cacheTime: 10 * 60 * 1000 }) {
|
||||
return request.get('/banner/active', {}, options)
|
||||
}
|
||||
|
||||
export default {
|
||||
login,
|
||||
sendCode,
|
||||
loginWithPhone,
|
||||
oneClickLogin,
|
||||
logout,
|
||||
getQRCode,
|
||||
checkIn,
|
||||
getCheckInRecords,
|
||||
getCheckInStats,
|
||||
getUnreadMessageCount,
|
||||
getUserMessages,
|
||||
getUnreadMessages,
|
||||
markMessageAsRead,
|
||||
markAllMessagesAsRead,
|
||||
deleteMessage,
|
||||
getUserInfo,
|
||||
getMemberDetail,
|
||||
updateUserInfo,
|
||||
getConfigByKey,
|
||||
getRecommendCourses,
|
||||
getCourseDetail,
|
||||
getGroupCoursePage,
|
||||
bookGroupCourse,
|
||||
cancelBooking,
|
||||
signinGroupCourse,
|
||||
getMemberBookings,
|
||||
getCourseBookings,
|
||||
getActiveBanners
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
// common/constants/routes.js
|
||||
|
||||
/** 与 pages.json 保持一致 */
|
||||
export const PAGE = {
|
||||
INDEX: '/pages/index/index',
|
||||
COURSE: '/pages/course/index',
|
||||
MEMBER: '/pages/memberInfo/memberInfo',
|
||||
BOOKING: '/pages/memberInfo/booking',
|
||||
USER_INFO: '/pages/memberInfo/userInfo',
|
||||
BODY_TEST_HOME: '/pages/memberInfo/bodyTestHome',
|
||||
BODY_TEST_CONNECT: '/pages/memberInfo/bodyTestConnect',
|
||||
BODY_TEST_MEASURING: '/pages/memberInfo/bodyTestMeasuring',
|
||||
BODY_TEST_REPORT: '/pages/memberInfo/bodyTestReport',
|
||||
BODY_TEST_HISTORY: '/pages/memberInfo/bodyTestHistory',
|
||||
BODY_TEST_COMPARE: '/pages/memberInfo/bodyTestCompare',
|
||||
BODY_TEST_SETTINGS: '/pages/memberInfo/bodyTestSettings',
|
||||
BODY_TEST_TREND: '/pages/memberInfo/bodyTestTrend',
|
||||
COURSE_LIST: '/pages/groupCourse/list',
|
||||
COURSE_DETAIL: '/pages/memberInfo/courseDetail',
|
||||
COUPON_DETAIL: '/pages/memberInfo/couponDetail',
|
||||
COUPON_CENTER: '/pages/memberInfo/couponCenter',
|
||||
POINTS_MALL: '/pages/memberInfo/pointsMall',
|
||||
POINTS_HISTORY: '/pages/memberInfo/pointsHistory',
|
||||
ONLINE_COURSE: '/pages/memberInfo/onlineCourseDetail',
|
||||
COURSE_EVALUATE: '/pages/memberInfo/courseEvaluate',
|
||||
TRAIN_SESSION: '/pages/memberInfo/trainSessionDetail',
|
||||
TRAIN_REPORT: '/pages/memberInfo/trainReport',
|
||||
COUPONS: '/pages/memberInfo/coupons',
|
||||
POINTS: '/pages/memberInfo/points',
|
||||
REFERRAL: '/pages/memberInfo/referral',
|
||||
MY_COURSES: '/pages/memberInfo/myCourses',
|
||||
CHECK_IN_HISTORY: '/pages/memberInfo/checkInHistory'
|
||||
}
|
||||
|
||||
/** 底部 TabBar 页面路径,顺序与 TabBar.vue 一致 */
|
||||
export const TAB_ROUTES = [
|
||||
PAGE.INDEX,
|
||||
PAGE.COURSE,
|
||||
PAGE.MEMBER
|
||||
]
|
||||
|
||||
const TAB_PAGES = new Set(TAB_ROUTES)
|
||||
|
||||
/** 防止 Tab 连点触发并发路由 */
|
||||
let tabNavigating = false
|
||||
|
||||
function normalizePath(url) {
|
||||
if (!url) return ''
|
||||
const path = url.split('?')[0]
|
||||
return path.startsWith('/') ? path : `/${path}`
|
||||
}
|
||||
|
||||
export function getTabIndexByRoute(route) {
|
||||
const path = normalizePath(route)
|
||||
const idx = TAB_ROUTES.indexOf(path)
|
||||
return idx >= 0 ? idx : 0
|
||||
}
|
||||
|
||||
export function getCurrentRoutePath() {
|
||||
const pages = getCurrentPages()
|
||||
if (!pages.length) return PAGE.INDEX
|
||||
const page = pages[pages.length - 1]
|
||||
const route = page.route || page.$page?.fullPath || ''
|
||||
return normalizePath(route ? `/${route}` : PAGE.INDEX)
|
||||
}
|
||||
|
||||
/**
|
||||
* 跳转到普通页面(非 TabBar 页面)
|
||||
* 使用 navigateTo,保留页面栈,可以正常返回
|
||||
*/
|
||||
export function navigateToPage(url) {
|
||||
uni.showLoading({ title: '加载中...', mask: true })
|
||||
const path = normalizePath(url)
|
||||
|
||||
// ✅ 如果目标是 TabBar 页面,不应该使用 navigateTo
|
||||
// 这种情况应该使用 switchToTabPage(会清空页面栈)
|
||||
if (TAB_PAGES.has(path)) {
|
||||
console.warn('[navigateToPage] 不应该用 navigateTo 跳转 TabBar 页面,请使用 switchToTabPage')
|
||||
uni.hideLoading()
|
||||
switchToTabPage(path)
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[navigateToPage] 跳转到:', url)
|
||||
|
||||
uni.navigateTo({
|
||||
url,
|
||||
fail: (err) => {
|
||||
console.error('[navigateTo]', url, err)
|
||||
uni.hideLoading()
|
||||
// 页面栈满时降级使用 redirectTo
|
||||
if (err.errMsg && err.errMsg.includes('limit')) {
|
||||
uni.redirectTo({ url })
|
||||
} else {
|
||||
uni.showToast({ title: '页面跳转失败', icon: 'none' })
|
||||
}
|
||||
},
|
||||
complete: () => {
|
||||
// 页面已发起跳转,隐藏 loading
|
||||
// 目标页面的 onLoad/onReady 也会调用 hideLoading 做兜底
|
||||
uni.hideLoading()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 跳转到 TabBar 页面(清空页面栈)
|
||||
* 用于从任何页面跳转到首页/课程/训练等 TabBar 页面
|
||||
*/
|
||||
export function switchToTabPage(url) {
|
||||
const path = normalizePath(url)
|
||||
if (!TAB_PAGES.has(path)) {
|
||||
console.warn('[switchToTabPage] 目标不是 TabBar 页面:', path)
|
||||
navigateToPage(url)
|
||||
return
|
||||
}
|
||||
|
||||
if (getCurrentRoutePath() === path || tabNavigating) return
|
||||
|
||||
console.log('[switchToTabPage] 跳转到 TabBar:', path)
|
||||
|
||||
tabNavigating = true
|
||||
uni.switchTab({
|
||||
url: path,
|
||||
complete: () => {
|
||||
uni.hideLoading()
|
||||
setTimeout(() => {
|
||||
tabNavigating = false
|
||||
}, 320)
|
||||
},
|
||||
fail: (err) => {
|
||||
console.error('[switchTab]', path, err)
|
||||
uni.hideLoading()
|
||||
uni.reLaunch({
|
||||
url: path,
|
||||
complete: () => {
|
||||
uni.hideLoading()
|
||||
setTimeout(() => {
|
||||
tabNavigating = false
|
||||
}, 320)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置到 TabBar 页面(清空所有历史)
|
||||
* 用于退出登录、强制跳转等场景
|
||||
*/
|
||||
export function reLaunchToTabPage(url) {
|
||||
const path = normalizePath(url)
|
||||
console.log('[reLaunchToTabPage] 重置到:', path)
|
||||
|
||||
uni.reLaunch({
|
||||
url: path,
|
||||
fail: (err) => {
|
||||
console.error('[reLaunch]', path, err)
|
||||
uni.switchTab({ url: path })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回上一页,如果没有上一页则跳转到指定 TabBar 页面
|
||||
*/
|
||||
export function goBackOrTab(fallbackUrl = PAGE.MEMBER) {
|
||||
const pages = getCurrentPages()
|
||||
if (pages.length > 1) {
|
||||
uni.navigateBack({ delta: 1 })
|
||||
} else {
|
||||
switchToTabPage(fallbackUrl)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 子页面返回个人中心
|
||||
*/
|
||||
export function backToMemberCenter() {
|
||||
goBackOrTab(PAGE.MEMBER)
|
||||
}
|
||||
|
||||
/**
|
||||
* 子页面返回指定 TabBar 页面
|
||||
*/
|
||||
export function backToTab(tabUrl) {
|
||||
goBackOrTab(tabUrl)
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* 登录拦截Hook
|
||||
* 用于在需要登录的操作前检查用户登录状态,未登录则弹出登录框
|
||||
*
|
||||
* 使用方式:
|
||||
* import { useLoginGuard } from '@/common/hooks/useLoginGuard.js'
|
||||
*
|
||||
* const { checkLogin, requireLogin } = useLoginGuard()
|
||||
*
|
||||
* // 方式1:检查登录状态(返回布尔值)
|
||||
* if (!checkLogin()) return
|
||||
*
|
||||
* // 方式2:需要登录才执行(推荐)
|
||||
* await requireLogin()
|
||||
* // 之后的代码只有在登录后才执行
|
||||
*
|
||||
* // 方式3:在点击事件中使用
|
||||
* async function handleClick() {
|
||||
* await requireLogin()
|
||||
* // 执行需要登录的操作
|
||||
* }
|
||||
*/
|
||||
|
||||
import { getToken } from '@/utils/request.js'
|
||||
import { loadMemberStore } from '@/common/memberInfo/store.js'
|
||||
|
||||
// 保存当前页面的登录弹窗引用
|
||||
let loginModalRef = null
|
||||
|
||||
/**
|
||||
* 注册登录弹窗组件
|
||||
* 在App.vue或页面onMounted中调用
|
||||
* @param {Object} modalRef - loginModal组件的ref
|
||||
*/
|
||||
export function registerLoginModal(modalRef) {
|
||||
loginModalRef = modalRef
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前登录状态
|
||||
* @returns {Boolean}
|
||||
*/
|
||||
export function isLoggedIn() {
|
||||
const token = getToken()
|
||||
const isLoginStorage = uni.getStorageSync('isLogin')
|
||||
return !!(token || isLoginStorage)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取登录用户信息
|
||||
* @returns {Object|null}
|
||||
*/
|
||||
export function getLoginUser() {
|
||||
const store = loadMemberStore()
|
||||
return store.profile || null
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用登录拦截Hook
|
||||
* @returns {Object}
|
||||
*/
|
||||
export function useLoginGuard() {
|
||||
|
||||
/**
|
||||
* 检查登录状态(同步)
|
||||
* @returns {Boolean} 是否已登录
|
||||
*/
|
||||
function checkLogin() {
|
||||
return isLoggedIn()
|
||||
}
|
||||
|
||||
/**
|
||||
* 需要登录才继续执行(异步)
|
||||
* 如果未登录,弹出登录框,等待用户登录或取消
|
||||
* @param {Object} options - 配置选项
|
||||
* @param {String} options.title - 弹窗标题
|
||||
* @param {String} options.subtitle - 弹窗副标题
|
||||
* @param {Function} options.onSuccess - 登录成功回调
|
||||
* @param {Function} options.onCancel - 取消登录回调
|
||||
* @returns {Promise<Boolean>} 是否已登录(用户登录成功返回true,取消返回false)
|
||||
*/
|
||||
async function requireLogin(options = {}) {
|
||||
// 如果已经登录,直接返回true
|
||||
if (isLoggedIn()) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 如果传入了onSuccess回调,先检查
|
||||
if (options.onSuccess) {
|
||||
const store = loadMemberStore()
|
||||
if (store.profile?.id) {
|
||||
options.onSuccess(store.profile)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// 显示登录弹窗
|
||||
return new Promise((resolve) => {
|
||||
if (!loginModalRef) {
|
||||
console.warn('[useLoginGuard] 登录弹窗未注册,请先调用 registerLoginModal')
|
||||
// 尝试跳转到登录页
|
||||
uni.navigateTo({
|
||||
url: '/pages/memberInfo/login',
|
||||
fail: () => {
|
||||
uni.showToast({
|
||||
title: '请先登录',
|
||||
icon: 'none'
|
||||
})
|
||||
}
|
||||
})
|
||||
resolve(false)
|
||||
return
|
||||
}
|
||||
|
||||
// 保存resolve函数
|
||||
let resolvePromise = resolve
|
||||
|
||||
// 显示登录弹窗
|
||||
loginModalRef.show()
|
||||
|
||||
// 监听登录成功
|
||||
const loginSuccessHandler = (result) => {
|
||||
if (options.onSuccess) {
|
||||
options.onSuccess(result)
|
||||
}
|
||||
resolvePromise(true)
|
||||
}
|
||||
|
||||
// 监听登录失败/取消
|
||||
const closeHandler = () => {
|
||||
if (options.onCancel) {
|
||||
options.onCancel()
|
||||
}
|
||||
resolvePromise(false)
|
||||
}
|
||||
|
||||
// 注册事件监听
|
||||
uni.$once('loginModal:success', loginSuccessHandler)
|
||||
uni.$once('loginModal:close', closeHandler)
|
||||
|
||||
// 设置超时
|
||||
setTimeout(() => {
|
||||
uni.$off('loginModal:success', loginSuccessHandler)
|
||||
uni.$off('loginModal:close', closeHandler)
|
||||
}, 60000) // 60秒超时
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全执行函数(仅登录后执行)
|
||||
* @param {Function} fn - 要执行的函数
|
||||
* @param {Object} options - 配置选项
|
||||
* @returns {Promise<any>} 函数返回值或null
|
||||
*/
|
||||
async function safeExecute(fn, options = {}) {
|
||||
const loggedIn = await requireLogin(options)
|
||||
if (loggedIn && typeof fn === 'function') {
|
||||
return await fn()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 包装需要登录的API调用
|
||||
* @param {Function} apiFn - API函数
|
||||
* @param {Array} args - API参数
|
||||
* @param {Object} options - 配置选项
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
async function callLoggedInApi(apiFn, args = [], options = {}) {
|
||||
const loggedIn = await requireLogin(options)
|
||||
if (loggedIn && typeof apiFn === 'function') {
|
||||
return await apiFn(...args)
|
||||
}
|
||||
throw new Error('用户未登录')
|
||||
}
|
||||
|
||||
return {
|
||||
checkLogin,
|
||||
requireLogin,
|
||||
safeExecute,
|
||||
callLoggedInApi,
|
||||
isLoggedIn,
|
||||
getLoginUser
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建登录拦截Mixin(用于选项式API)
|
||||
* @returns {Object}
|
||||
*/
|
||||
export function loginGuardMixin() {
|
||||
return {
|
||||
data() {
|
||||
return {
|
||||
isLogin: false
|
||||
}
|
||||
},
|
||||
onLoad() {
|
||||
this.checkLoginStatus()
|
||||
},
|
||||
onShow() {
|
||||
this.checkLoginStatus()
|
||||
},
|
||||
methods: {
|
||||
checkLoginStatus() {
|
||||
this.isLogin = isLoggedIn()
|
||||
},
|
||||
|
||||
async ensureLogin() {
|
||||
if (!this.isLogin) {
|
||||
if (!loginModalRef) {
|
||||
uni.navigateTo({
|
||||
url: '/pages/memberInfo/login'
|
||||
})
|
||||
return false
|
||||
}
|
||||
loginModalRef.show()
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 路由守卫配置
|
||||
* 配置需要登录的页面路径
|
||||
*/
|
||||
export const protectedRoutes = [
|
||||
'/pages/memberInfo/memberInfo',
|
||||
'/pages/memberInfo/checkIn',
|
||||
'/pages/memberInfo/booking',
|
||||
'/pages/memberInfo/coupons',
|
||||
'/pages/memberInfo/points',
|
||||
'/pages/memberInfo/userInfo'
|
||||
]
|
||||
|
||||
/**
|
||||
* 检查当前页面是否需要登录
|
||||
* @param {String} currentPage - 当前页面路径
|
||||
* @returns {Boolean}
|
||||
*/
|
||||
export function isProtectedRoute(currentPage) {
|
||||
return protectedRoutes.some(route => currentPage.includes(route))
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
const COLORS = {
|
||||
primary: '#0B2B4B',
|
||||
accent: '#FF6B35',
|
||||
accentLight: 'rgba(255, 107, 53, 0.25)',
|
||||
grid: '#E9EDF2',
|
||||
text: '#5E6F8D',
|
||||
fill: 'rgba(26, 74, 111, 0.35)',
|
||||
line: '#1A4A6F'
|
||||
}
|
||||
|
||||
function setupCanvas(canvas, width, height, dpr) {
|
||||
canvas.width = width * dpr
|
||||
canvas.height = height * dpr
|
||||
const ctx = canvas.getContext('2d')
|
||||
ctx.scale(dpr, dpr)
|
||||
return ctx
|
||||
}
|
||||
|
||||
/** 绘制雷达图 */
|
||||
export function drawRadarChart(canvas, opts = {}) {
|
||||
if (!canvas) return
|
||||
const {
|
||||
width = 280,
|
||||
height = 240,
|
||||
labels = [],
|
||||
values = [],
|
||||
dpr = 1
|
||||
} = opts
|
||||
const ctx = setupCanvas(canvas, width, height, dpr)
|
||||
ctx.clearRect(0, 0, width, height)
|
||||
|
||||
const cx = width / 2
|
||||
const cy = height / 2 + 8
|
||||
const radius = Math.min(width, height) * 0.32
|
||||
const count = labels.length || 6
|
||||
const angleStep = (Math.PI * 2) / count
|
||||
|
||||
for (let level = 1; level <= 4; level += 1) {
|
||||
ctx.beginPath()
|
||||
const r = (radius * level) / 4
|
||||
for (let i = 0; i <= count; i += 1) {
|
||||
const angle = -Math.PI / 2 + i * angleStep
|
||||
const x = cx + r * Math.cos(angle)
|
||||
const y = cy + r * Math.sin(angle)
|
||||
if (i === 0) ctx.moveTo(x, y)
|
||||
else ctx.lineTo(x, y)
|
||||
}
|
||||
ctx.strokeStyle = COLORS.grid
|
||||
ctx.lineWidth = 1
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
const angle = -Math.PI / 2 + i * angleStep
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(cx, cy)
|
||||
ctx.lineTo(cx + radius * Math.cos(angle), cy + radius * Math.sin(angle))
|
||||
ctx.strokeStyle = COLORS.grid
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
ctx.beginPath()
|
||||
values.forEach((val, i) => {
|
||||
const ratio = Math.min(1, Math.max(0, val / 100))
|
||||
const angle = -Math.PI / 2 + i * angleStep
|
||||
const x = cx + radius * ratio * Math.cos(angle)
|
||||
const y = cy + radius * ratio * Math.sin(angle)
|
||||
if (i === 0) ctx.moveTo(x, y)
|
||||
else ctx.lineTo(x, y)
|
||||
})
|
||||
ctx.closePath()
|
||||
ctx.fillStyle = COLORS.fill
|
||||
ctx.fill()
|
||||
ctx.strokeStyle = COLORS.accent
|
||||
ctx.lineWidth = 2
|
||||
ctx.stroke()
|
||||
|
||||
ctx.font = '11px sans-serif'
|
||||
ctx.fillStyle = COLORS.text
|
||||
ctx.textAlign = 'center'
|
||||
labels.forEach((label, i) => {
|
||||
const angle = -Math.PI / 2 + i * angleStep
|
||||
const x = cx + (radius + 18) * Math.cos(angle)
|
||||
const y = cy + (radius + 18) * Math.sin(angle) + 4
|
||||
ctx.fillText(label, x, y)
|
||||
})
|
||||
}
|
||||
|
||||
/** 绘制折线趋势图 */
|
||||
export function drawTrendChart(canvas, opts = {}) {
|
||||
if (!canvas) return
|
||||
const {
|
||||
width = 300,
|
||||
height = 160,
|
||||
points = [],
|
||||
dpr = 1,
|
||||
unit = ''
|
||||
} = opts
|
||||
const ctx = setupCanvas(canvas, width, height, dpr)
|
||||
ctx.clearRect(0, 0, width, height)
|
||||
|
||||
if (!points.length) {
|
||||
ctx.fillStyle = COLORS.text
|
||||
ctx.font = '13px sans-serif'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText('暂无趋势数据', width / 2, height / 2)
|
||||
return
|
||||
}
|
||||
|
||||
const pad = { top: 16, right: 12, bottom: 28, left: 12 }
|
||||
const chartW = width - pad.left - pad.right
|
||||
const chartH = height - pad.top - pad.bottom
|
||||
const values = points.map((p) => p.value)
|
||||
const min = Math.min(...values) * 0.95
|
||||
const max = Math.max(...values) * 1.05
|
||||
const range = max - min || 1
|
||||
|
||||
ctx.strokeStyle = COLORS.grid
|
||||
ctx.lineWidth = 1
|
||||
for (let i = 0; i <= 3; i += 1) {
|
||||
const y = pad.top + (chartH * i) / 3
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(pad.left, y)
|
||||
ctx.lineTo(width - pad.right, y)
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
const coords = points.map((p, i) => ({
|
||||
x: pad.left + (chartW * i) / Math.max(1, points.length - 1),
|
||||
y: pad.top + chartH - ((p.value - min) / range) * chartH
|
||||
}))
|
||||
|
||||
ctx.beginPath()
|
||||
coords.forEach((pt, i) => {
|
||||
if (i === 0) ctx.moveTo(pt.x, pt.y)
|
||||
else ctx.lineTo(pt.x, pt.y)
|
||||
})
|
||||
ctx.strokeStyle = COLORS.line
|
||||
ctx.lineWidth = 2
|
||||
ctx.stroke()
|
||||
|
||||
coords.forEach((pt, i) => {
|
||||
ctx.beginPath()
|
||||
ctx.arc(pt.x, pt.y, 4, 0, Math.PI * 2)
|
||||
ctx.fillStyle = COLORS.accent
|
||||
ctx.fill()
|
||||
ctx.strokeStyle = '#fff'
|
||||
ctx.lineWidth = 1.5
|
||||
ctx.stroke()
|
||||
|
||||
ctx.fillStyle = COLORS.text
|
||||
ctx.font = '10px sans-serif'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText(points[i].label, pt.x, height - 8)
|
||||
})
|
||||
|
||||
if (unit) {
|
||||
ctx.fillStyle = COLORS.text
|
||||
ctx.font = '10px sans-serif'
|
||||
ctx.textAlign = 'left'
|
||||
ctx.fillText(unit, pad.left, pad.top - 2)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
function formatRecordTime(date) {
|
||||
const y = date.getFullYear()
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const d = String(date.getDate()).padStart(2, '0')
|
||||
const h = String(date.getHours()).padStart(2, '0')
|
||||
const min = String(date.getMinutes()).padStart(2, '0')
|
||||
return `${y}-${m}-${d} ${h}:${min}`
|
||||
}
|
||||
|
||||
function formatIsoDate(date) {
|
||||
const y = date.getFullYear()
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const d = String(date.getDate()).padStart(2, '0')
|
||||
return `${y}-${m}-${d}`
|
||||
}
|
||||
|
||||
function formatTime(date) {
|
||||
const h = String(date.getHours()).padStart(2, '0')
|
||||
const min = String(date.getMinutes()).padStart(2, '0')
|
||||
return `${h}:${min}`
|
||||
}
|
||||
|
||||
export function getDefaultBodyTestState() {
|
||||
return {
|
||||
settings: {},
|
||||
device: { connected: false, battery: 80 },
|
||||
records: []
|
||||
}
|
||||
}
|
||||
|
||||
export function mergeBodyTestState(saved) {
|
||||
const defaults = getDefaultBodyTestState()
|
||||
if (!saved) return defaults
|
||||
return {
|
||||
settings: { ...defaults.settings, ...(saved.settings || {}) },
|
||||
device: { ...defaults.device, ...(saved.device || {}) },
|
||||
records: saved.records?.length ? saved.records : defaults.records
|
||||
}
|
||||
}
|
||||
|
||||
export function getLatestBodyTestRecord(store) {
|
||||
const records = store.bodyTest?.records || []
|
||||
return records.length ? { ...records[0] } : null
|
||||
}
|
||||
|
||||
export function getBodyTestRecordById(store, id) {
|
||||
const numId = Number(id)
|
||||
const record = (store.bodyTest?.records || []).find((item) => item.id === numId)
|
||||
return record ? { ...record } : null
|
||||
}
|
||||
|
||||
export function getBodyTestHistory(store, year) {
|
||||
let list = (store.bodyTest?.records || []).map((item) => ({ ...item }))
|
||||
if (year && year !== 'all') {
|
||||
list = list.filter((r) => r.date.startsWith(String(year)))
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
export function getBodyTestChangeBadge(record, previous) {
|
||||
if (!previous?.metrics || !record?.metrics) return null
|
||||
const diff = Math.round((record.metrics.bodyFat - previous.metrics.bodyFat) * 10) / 10
|
||||
if (diff === 0) return null
|
||||
const sign = diff > 0 ? '+' : ''
|
||||
return { key: 'bodyFat', text: `体脂率${sign}${diff}%`, good: diff < 0 }
|
||||
}
|
||||
|
||||
export function getBodyTestYears(store) {
|
||||
const years = new Set((store.bodyTest?.records || []).map((r) => r.date.slice(0, 4)))
|
||||
return ['all', ...Array.from(years).sort().reverse()]
|
||||
}
|
||||
|
||||
export function computeGrade(score) {
|
||||
if (score >= 90) return { grade: 'A', gradeLabel: '优秀' }
|
||||
if (score >= 80) return { grade: 'B+', gradeLabel: '良好' }
|
||||
if (score >= 70) return { grade: 'B', gradeLabel: '中等' }
|
||||
if (score >= 60) return { grade: 'C', gradeLabel: '一般' }
|
||||
return { grade: 'D', gradeLabel: '需改善' }
|
||||
}
|
||||
|
||||
export function computeScore(metrics) {
|
||||
const bmi = metrics.bmi || 22
|
||||
const bodyFat = metrics.bodyFat || 25
|
||||
const muscle = metrics.muscleMass || 22
|
||||
const bmiScore = bmi >= 18.5 && bmi <= 24 ? 90 : bmi >= 17 && bmi <= 27 ? 75 : 60
|
||||
const fatScore = bodyFat <= 22 ? 92 : bodyFat <= 26 ? 80 : bodyFat <= 30 ? 68 : 55
|
||||
const muscleScore = muscle >= 22 ? 88 : muscle >= 20 ? 76 : 62
|
||||
return Math.round((bmiScore + fatScore + muscleScore) / 3)
|
||||
}
|
||||
|
||||
export function computeChanges(current, previous) {
|
||||
if (!previous?.metrics) return {}
|
||||
const keys = ['weight', 'bmi', 'bodyFat', 'muscleMass', 'visceralFat', 'bmr', 'bodyWater', 'boneMass']
|
||||
const changes = {}
|
||||
keys.forEach((key) => {
|
||||
const cur = Number(current.metrics[key])
|
||||
const prev = Number(previous.metrics[key])
|
||||
if (Number.isFinite(cur) && Number.isFinite(prev)) {
|
||||
const diff = Math.round((cur - prev) * 10) / 10
|
||||
changes[key] = diff
|
||||
}
|
||||
})
|
||||
return changes
|
||||
}
|
||||
|
||||
export function formatChangeValue(key, diff, unitSystem = 'metric') {
|
||||
if (diff === undefined || diff === null) return '--'
|
||||
const sign = diff > 0 ? '+' : ''
|
||||
const units = {
|
||||
weight: unitSystem === 'metric' ? 'kg' : 'lb',
|
||||
bodyFat: '%',
|
||||
muscleMass: 'kg',
|
||||
bmi: '',
|
||||
visceralFat: '级',
|
||||
bmr: 'kcal',
|
||||
bodyWater: '%',
|
||||
boneMass: 'kg'
|
||||
}
|
||||
const unit = units[key] || ''
|
||||
return `${sign}${diff}${unit}`
|
||||
}
|
||||
|
||||
export function buildBodyReportSummary(record, previous) {
|
||||
if (!record) {
|
||||
return {
|
||||
date: '--',
|
||||
weight: '--',
|
||||
bmi: '--',
|
||||
bodyFat: '--',
|
||||
bmr: '--',
|
||||
status: '暂无数据',
|
||||
change: '--'
|
||||
}
|
||||
}
|
||||
const changes = computeChanges(record, previous)
|
||||
const weightChange = changes.weight
|
||||
let changeText = '--'
|
||||
if (weightChange !== undefined) {
|
||||
const sign = weightChange > 0 ? '+' : ''
|
||||
changeText = `${sign}${weightChange}kg`
|
||||
}
|
||||
return {
|
||||
date: record.date,
|
||||
weight: String(record.metrics.weight),
|
||||
bmi: String(record.metrics.bmi),
|
||||
bodyFat: `${record.metrics.bodyFat}%`,
|
||||
bmr: String(record.metrics.bmr),
|
||||
status: record.status,
|
||||
change: changeText,
|
||||
recordId: record.id
|
||||
}
|
||||
}
|
||||
|
||||
export function getBodyTestTrendData(store, metricKey, limit = 6) {
|
||||
const records = [...(store.bodyTest?.records || [])].reverse().slice(-limit)
|
||||
return records.map((item) => ({
|
||||
id: item.id,
|
||||
date: item.date,
|
||||
label: item.date.slice(5),
|
||||
value: Number(item.metrics[metricKey]) || 0
|
||||
}))
|
||||
}
|
||||
|
||||
function getMetricDefs() {
|
||||
return [
|
||||
{ key: 'weight', label: '体重' },
|
||||
{ key: 'bmi', label: 'BMI' },
|
||||
{ key: 'bodyFat', label: '体脂率' },
|
||||
{ key: 'muscleMass', label: '肌肉量' },
|
||||
{ key: 'visceralFat', label: '内脏脂肪' },
|
||||
{ key: 'bmr', label: '基础代谢' }
|
||||
]
|
||||
}
|
||||
|
||||
export function getCompareData(store, idA, idB) {
|
||||
const a = getBodyTestRecordById(store, idA)
|
||||
const b = getBodyTestRecordById(store, idB)
|
||||
if (!a || !b) return null
|
||||
const metricDefs = getMetricDefs()
|
||||
const keys = ['weight', 'bmi', 'bodyFat', 'muscleMass', 'visceralFat', 'bmr']
|
||||
const metrics = keys.map((key) => ({
|
||||
key,
|
||||
label: metricDefs.find((m) => m.key === key)?.label || key,
|
||||
valueA: a.metrics[key],
|
||||
valueB: b.metrics[key],
|
||||
diff: Math.round((a.metrics[key] - b.metrics[key]) * 10) / 10
|
||||
}))
|
||||
return { recordA: a, recordB: b, metrics }
|
||||
}
|
||||
|
||||
export function getRecommendedCourses(record) {
|
||||
return []
|
||||
}
|
||||
|
||||
export function updateBodyTestSettings(store, patch) {
|
||||
store.bodyTest.settings = { ...store.bodyTest.settings, ...patch }
|
||||
return store
|
||||
}
|
||||
|
||||
export function connectBodyTestDevice(store) {
|
||||
store.bodyTest.device = {
|
||||
...store.bodyTest.device,
|
||||
connected: true,
|
||||
battery: Math.min(100, (store.bodyTest.device.battery || 80) + Math.floor(Math.random() * 5)),
|
||||
lastConnected: formatRecordTime(new Date())
|
||||
}
|
||||
return store
|
||||
}
|
||||
|
||||
export function disconnectBodyTestDevice(store) {
|
||||
store.bodyTest.device = { ...store.bodyTest.device, connected: false }
|
||||
return store
|
||||
}
|
||||
|
||||
function nextRecordId(records) {
|
||||
return (records || []).reduce((max, item) => Math.max(max, item.id || 0), 0) + 1
|
||||
}
|
||||
|
||||
/** 模拟一次完整体测并写入记录 */
|
||||
export function saveSimulatedBodyTestRecord(store, finalMetrics) {
|
||||
const now = new Date()
|
||||
const previous = getLatestBodyTestRecord(store)
|
||||
const metrics = { ...finalMetrics }
|
||||
const heightCm = Number(store.profile?.height) || 165
|
||||
const heightM = heightCm / 100
|
||||
metrics.bmi = Math.round((metrics.weight / (heightM * heightM)) * 10) / 10
|
||||
|
||||
const score = computeScore(metrics)
|
||||
const { grade, gradeLabel } = computeGrade(score)
|
||||
const status = score >= 80 ? '比较健康' : score >= 70 ? '需关注' : '建议改善'
|
||||
|
||||
const radar = {
|
||||
weight: Math.min(95, Math.round(score * 0.9 + Math.random() * 5)),
|
||||
bodyFat: Math.min(95, Math.round(100 - metrics.bodyFat * 2.5)),
|
||||
muscle: Math.min(95, Math.round(metrics.muscleMass * 3.2)),
|
||||
bone: Math.min(95, Math.round(metrics.boneMass * 32)),
|
||||
water: Math.min(95, Math.round(metrics.bodyWater * 1.4)),
|
||||
bmr: Math.min(95, Math.round(metrics.bmr / 16))
|
||||
}
|
||||
|
||||
const record = {
|
||||
id: nextRecordId(store.bodyTest.records),
|
||||
date: formatIsoDate(now),
|
||||
time: formatTime(now),
|
||||
score,
|
||||
grade,
|
||||
gradeLabel,
|
||||
status,
|
||||
metrics,
|
||||
radar,
|
||||
bodySegments: [],
|
||||
advice: [],
|
||||
recommendedCourseIds: []
|
||||
}
|
||||
|
||||
if (previous) {
|
||||
record.changes = computeChanges(record, previous)
|
||||
}
|
||||
|
||||
store.bodyTest.records.unshift(record)
|
||||
store.bodyReport = buildBodyReportSummary(record, previous)
|
||||
return record
|
||||
}
|
||||
|
||||
/** 测量过程实时数据插值 */
|
||||
export function interpolateMeasuringMetrics(progress, profile) {
|
||||
const baseWeight = Number(profile?.weight) || 64
|
||||
const target = {
|
||||
weight: baseWeight - 0.3 + Math.random() * 0.2,
|
||||
bodyFat: 24.5 + Math.random() * 0.8,
|
||||
muscleMass: 22.4 + Math.random() * 0.3,
|
||||
visceralFat: 6,
|
||||
bmr: 1380 + Math.floor(Math.random() * 20),
|
||||
bodyWater: 52.5 + Math.random(),
|
||||
boneMass: 2.4,
|
||||
protein: 16.2
|
||||
}
|
||||
const ratio = Math.min(1, progress / 100)
|
||||
return {
|
||||
weight: Math.round((baseWeight + (target.weight - baseWeight) * ratio) * 10) / 10,
|
||||
bodyFat: Math.round((26 + (target.bodyFat - 26) * ratio) * 10) / 10,
|
||||
muscleMass: Math.round((21.5 + (target.muscleMass - 21.5) * ratio) * 10) / 10,
|
||||
bmr: Math.round(1340 + (target.bmr - 1340) * ratio),
|
||||
bodyWater: Math.round((51 + (target.bodyWater - 51) * ratio) * 10) / 10
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { courseCatalogMock } from './mockData.js'
|
||||
|
||||
function clone(value) {
|
||||
return JSON.parse(JSON.stringify(value))
|
||||
}
|
||||
|
||||
export function getDefaultCourseCatalog() {
|
||||
return clone(courseCatalogMock.courses)
|
||||
}
|
||||
|
||||
export function mergeCourseCatalog(saved) {
|
||||
const defaults = getDefaultCourseCatalog()
|
||||
if (!saved?.length) return defaults
|
||||
return saved.map((item, i) => ({ ...defaults[i], ...item }))
|
||||
}
|
||||
|
||||
function parseCourseStart(course) {
|
||||
const str = `${course.date} ${course.startTime}`.replace(/-/g, '/')
|
||||
return new Date(str)
|
||||
}
|
||||
|
||||
function getPeriod(hour) {
|
||||
if (hour < 12) return 'morning'
|
||||
if (hour < 18) return 'afternoon'
|
||||
return 'evening'
|
||||
}
|
||||
|
||||
export function filterCourses(courses, filters = {}) {
|
||||
const {
|
||||
date = '',
|
||||
weekDates = [],
|
||||
type = 'all',
|
||||
coach = '全部',
|
||||
period = 'all'
|
||||
} = filters
|
||||
|
||||
return courses.filter((c) => {
|
||||
if (type !== 'all' && c.type !== type) return false
|
||||
if (coach !== '全部' && c.coach !== coach) return false
|
||||
if (period !== 'all' && c.period !== period) return false
|
||||
if (date && c.date !== date) {
|
||||
if (!weekDates.length || !weekDates.includes(c.date)) return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
export function getCourseById(store, id) {
|
||||
const course = (store.courseCatalog || []).find((c) => c.id === Number(id))
|
||||
return course ? { ...course } : null
|
||||
}
|
||||
|
||||
export function canCancelBooking(item) {
|
||||
if (!item?.courseDate || !item?.startTime) return !!item?.canCancel
|
||||
const start = new Date(`${item.courseDate} ${item.startTime}`.replace(/-/g, '/'))
|
||||
const diff = start - Date.now()
|
||||
return diff >= 2 * 3600000
|
||||
}
|
||||
|
||||
export function canSigninCourse(item) {
|
||||
if (!item?.courseDate || !item?.startTime) return false
|
||||
if (item.status !== 'booked' && item.status !== 0) return false
|
||||
const start = new Date(`${item.courseDate} ${item.startTime}`.replace(/-/g, '/'))
|
||||
const diff = start - Date.now()
|
||||
return diff <= 2 * 3600000 && diff >= -2 * 3600000
|
||||
}
|
||||
|
||||
export function bookCourse(store, courseId) {
|
||||
const course = store.courseCatalog.find((c) => c.id === Number(courseId))
|
||||
if (!course) return { ok: false, message: '课程不存在' }
|
||||
if (course.enrolled >= course.capacity) return { ok: false, message: '课程已约满' }
|
||||
const exists = store.ongoingBookings.some((b) => b.courseId === course.id)
|
||||
if (exists) return { ok: false, message: '您已预约该课程' }
|
||||
|
||||
course.enrolled += 1
|
||||
const nextId = store.ongoingBookings.reduce((m, b) => Math.max(m, b.id || 0), 0) + 1
|
||||
const parts = course.date.split('-')
|
||||
const booking = {
|
||||
id: nextId,
|
||||
courseId: course.id,
|
||||
title: course.title,
|
||||
banner: course.banner,
|
||||
status: 'booked',
|
||||
statusLabel: '已预约',
|
||||
schedule: `${parts[1]}月${parts[2]}日 ${course.startTime}-${course.endTime}`,
|
||||
dateDay: parts[2],
|
||||
dateMonth: `月${parts[2]}日`,
|
||||
timeRange: `${course.startTime}-${course.endTime}`,
|
||||
courseDate: course.date,
|
||||
startTime: course.startTime,
|
||||
coach: course.coach,
|
||||
coachShort: course.coach.replace('教练', ''),
|
||||
location: course.location,
|
||||
footerText: `可取消(需提前2小时,截止 ${parts[1]}/${parts[2]} ${course.startTime} 前2小时)`,
|
||||
canCancel: true,
|
||||
type: course.type
|
||||
}
|
||||
store.ongoingBookings.unshift(booking)
|
||||
return { ok: true, message: '预约成功', booking }
|
||||
}
|
||||
|
||||
export function getWeekDates(baseDateStr) {
|
||||
const base = baseDateStr ? new Date(baseDateStr.replace(/-/g, '/')) : new Date()
|
||||
const day = base.getDay() || 7
|
||||
const monday = new Date(base)
|
||||
monday.setDate(base.getDate() - day + 1)
|
||||
const dates = []
|
||||
for (let i = 0; i < 7; i += 1) {
|
||||
const d = new Date(monday)
|
||||
d.setDate(monday.getDate() + i)
|
||||
dates.push(formatIso(d))
|
||||
}
|
||||
return dates
|
||||
}
|
||||
|
||||
function formatIso(d) {
|
||||
const y = d.getFullYear()
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
return `${y}-${m}-${day}`
|
||||
}
|
||||
|
||||
export function enrichCourseForDisplay(course) {
|
||||
const remaining = course.capacity - course.enrolled
|
||||
const percent = Math.round((course.enrolled / course.capacity) * 100)
|
||||
return {
|
||||
...course,
|
||||
remaining,
|
||||
percent,
|
||||
full: remaining <= 0,
|
||||
scarcityLabel: remaining > 0 && remaining <= 5 ? `仅剩${remaining}席` : ''
|
||||
}
|
||||
}
|
||||
|
||||
export { courseCatalogMock }
|
||||
@@ -0,0 +1,37 @@
|
||||
/** 手机号展示脱敏(中间四位 ****) */
|
||||
|
||||
export function maskPhone(phone) {
|
||||
if (phone == null || phone === '') return ''
|
||||
|
||||
const str = String(phone).trim()
|
||||
if (str.includes('****')) return str
|
||||
|
||||
const digits = str.replace(/\D/g, '')
|
||||
if (digits.length === 11) {
|
||||
return `${digits.slice(0, 3)}****${digits.slice(7)}`
|
||||
}
|
||||
if (digits.length > 4) {
|
||||
const hideLen = Math.min(4, digits.length - 3)
|
||||
const start = Math.floor((digits.length - hideLen) / 2)
|
||||
return `${digits.slice(0, start)}${'*'.repeat(hideLen)}${digits.slice(start + hideLen)}`
|
||||
}
|
||||
|
||||
return str
|
||||
}
|
||||
|
||||
/** 个人中心头部:138****6789 已绑定微信 */
|
||||
export function formatMemberCenterPhone(phone) {
|
||||
const masked = maskPhone(phone)
|
||||
return masked ? `${masked} 已绑定微信` : ''
|
||||
}
|
||||
|
||||
/** 保存前规范化:尽量存 11 位数字;已是脱敏串则原样保留 */
|
||||
export function normalizePhoneForStore(phone) {
|
||||
const str = String(phone || '').trim()
|
||||
if (!str) return ''
|
||||
if (str.includes('****')) return str
|
||||
|
||||
const digits = str.replace(/\D/g, '')
|
||||
if (digits.length >= 11) return digits.slice(0, 11)
|
||||
return digits || str
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
export { statusBarTimeMixin, subPageMixin } from './mixins.js'
|
||||
export {
|
||||
loadMemberStore,
|
||||
saveMemberStore,
|
||||
persistMemberStore,
|
||||
syncStats,
|
||||
computeRemainingDays,
|
||||
buildCardTip,
|
||||
formatUpcomingAlert,
|
||||
getBookingPreview,
|
||||
getCenterPageData,
|
||||
cancelOngoingBooking,
|
||||
renewMemberCard,
|
||||
parseLocalDate,
|
||||
saveUserProfile,
|
||||
getCurrentMemberId,
|
||||
getLoginMemberInfo,
|
||||
getToken
|
||||
} from './store.js'
|
||||
export {
|
||||
getLatestBodyTestRecord,
|
||||
getBodyTestRecordById,
|
||||
getBodyTestHistory,
|
||||
computeChanges,
|
||||
formatChangeValue,
|
||||
buildBodyReportSummary,
|
||||
getBodyTestTrendData,
|
||||
getCompareData,
|
||||
getRecommendedCourses,
|
||||
getBodyTestChangeBadge,
|
||||
getBodyTestYears,
|
||||
updateBodyTestSettings,
|
||||
connectBodyTestDevice,
|
||||
disconnectBodyTestDevice,
|
||||
saveSimulatedBodyTestRecord,
|
||||
interpolateMeasuringMetrics
|
||||
} from './bodyTestStore.js'
|
||||
export {
|
||||
getTrainingReportData,
|
||||
getTrainingSessionById,
|
||||
filterTrainingSessions,
|
||||
getCouponsByStatus,
|
||||
getCouponById,
|
||||
useCoupon,
|
||||
deleteExpiredCoupon,
|
||||
getCouponCenterList,
|
||||
claimCouponFromCenter,
|
||||
getPointsPageData,
|
||||
redeemPointsReward,
|
||||
filterPointsHistory,
|
||||
getReferralPageData,
|
||||
getMyCoursesData,
|
||||
getMyCoursesByTab,
|
||||
getOnlineCourseById,
|
||||
updateOnlineProgress,
|
||||
getCheckInHistory
|
||||
} from './moduleStore.js'
|
||||
export {
|
||||
filterCourses,
|
||||
getCourseById,
|
||||
bookCourse,
|
||||
canCancelBooking,
|
||||
enrichCourseForDisplay,
|
||||
getWeekDates
|
||||
} from './bookingStore.js'
|
||||
export { previewImage, persistChosenImage, isLocalFilePath } from './media.js'
|
||||
export { maskPhone, formatMemberCenterPhone, normalizePhoneForStore } from './format.js'
|
||||
export {
|
||||
validateName,
|
||||
validatePhone,
|
||||
validatePhoneForRebind,
|
||||
validateHeight,
|
||||
validateWeight,
|
||||
validateBirthday,
|
||||
validateFitnessGoals,
|
||||
validateUserProfile,
|
||||
showValidationError
|
||||
} from './validate.js'
|
||||
@@ -0,0 +1,159 @@
|
||||
/** 头像等媒体:真机选图后须 saveFile,/static/ 须 getImageInfo */
|
||||
|
||||
function buildStaticPathCandidates(url) {
|
||||
const list = [url]
|
||||
if (url.startsWith('/')) {
|
||||
list.push(url.slice(1))
|
||||
} else {
|
||||
list.push(`/${url}`)
|
||||
}
|
||||
return [...new Set(list.filter(Boolean))]
|
||||
}
|
||||
|
||||
function isPackageStaticPath(url) {
|
||||
return /^(\/)?static\//i.test(url)
|
||||
}
|
||||
|
||||
/** chooseImage / saveFile 产生的本地路径(含真机 temp、usr、store) */
|
||||
export function isLocalFilePath(url) {
|
||||
if (!url) return false
|
||||
if (/^(wxfile:|file:|blob:|data:)/i.test(url)) return true
|
||||
if (/^https?:\/\/(tmp|usr|store)\//i.test(url)) return true
|
||||
if (/^https?:\/\//i.test(url) && !isPackageStaticPath(url)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
function showPreviewFail() {
|
||||
uni.showToast({ title: '无法预览头像', icon: 'none' })
|
||||
}
|
||||
|
||||
function openPreview(path, onFail) {
|
||||
if (!path) {
|
||||
;(onFail || showPreviewFail)()
|
||||
return
|
||||
}
|
||||
uni.previewImage({
|
||||
urls: [path],
|
||||
current: path,
|
||||
fail: () => (onFail ? onFail() : showPreviewFail())
|
||||
})
|
||||
}
|
||||
|
||||
function previewLocalFile(url) {
|
||||
openPreview(url, () => {
|
||||
uni.getImageInfo({
|
||||
src: url,
|
||||
success: (res) => {
|
||||
openPreview(res.path || url, showPreviewFail)
|
||||
},
|
||||
fail: showPreviewFail
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function tryGetImageInfo(candidates, index, onSuccess, onFail) {
|
||||
if (index >= candidates.length) {
|
||||
onFail()
|
||||
return
|
||||
}
|
||||
uni.getImageInfo({
|
||||
src: candidates[index],
|
||||
success: (res) => onSuccess(res.path || candidates[index]),
|
||||
fail: () => tryGetImageInfo(candidates, index + 1, onSuccess, onFail)
|
||||
})
|
||||
}
|
||||
|
||||
function getMpUserDataPath() {
|
||||
// #ifdef MP-WEIXIN
|
||||
return wx.env.USER_DATA_PATH
|
||||
// #endif
|
||||
return ''
|
||||
}
|
||||
|
||||
function tryCopyFile(candidates, index, onSuccess, onFail) {
|
||||
// #ifdef MP-WEIXIN
|
||||
const userPath = getMpUserDataPath()
|
||||
if (!userPath) {
|
||||
onFail()
|
||||
return
|
||||
}
|
||||
const fs = uni.getFileSystemManager()
|
||||
const extMatch = candidates[0]?.match(/\.(\w+)(?:\?|$)/)
|
||||
const ext = extMatch ? extMatch[1] : 'png'
|
||||
const dest = `${userPath}/avatar_preview_${Date.now()}.${ext}`
|
||||
|
||||
if (index >= candidates.length) {
|
||||
onFail()
|
||||
return
|
||||
}
|
||||
|
||||
fs.copyFile({
|
||||
srcPath: candidates[index],
|
||||
destPath: dest,
|
||||
success: () => onSuccess(dest),
|
||||
fail: () => tryCopyFile(candidates, index + 1, onSuccess, onFail)
|
||||
})
|
||||
// #endif
|
||||
// #ifndef MP-WEIXIN
|
||||
onFail()
|
||||
// #endif
|
||||
}
|
||||
|
||||
function previewPackageStatic(url) {
|
||||
const candidates = buildStaticPathCandidates(url)
|
||||
tryGetImageInfo(
|
||||
candidates,
|
||||
0,
|
||||
(path) => openPreview(path, showPreviewFail),
|
||||
() => {
|
||||
tryCopyFile(
|
||||
candidates,
|
||||
0,
|
||||
(path) => openPreview(path, showPreviewFail),
|
||||
showPreviewFail
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/** 选图后将临时文件转为真机可预览、可持久化的本地路径 */
|
||||
export function persistChosenImage(tempPath) {
|
||||
return new Promise((resolve) => {
|
||||
const path = String(tempPath || '').trim()
|
||||
if (!path) {
|
||||
resolve('')
|
||||
return
|
||||
}
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
uni.saveFile({
|
||||
tempFilePath: path,
|
||||
success: (res) => resolve(res.savedFilePath || path),
|
||||
fail: () => resolve(path)
|
||||
})
|
||||
// #endif
|
||||
// #ifndef MP-WEIXIN
|
||||
resolve(path)
|
||||
// #endif
|
||||
})
|
||||
}
|
||||
|
||||
export function previewImage(src, fallback = '') {
|
||||
const url = String(src || fallback || '').trim()
|
||||
if (!url) {
|
||||
uni.showToast({ title: '暂无头像', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
if (isLocalFilePath(url)) {
|
||||
previewLocalFile(url)
|
||||
return
|
||||
}
|
||||
|
||||
if (isPackageStaticPath(url)) {
|
||||
previewPackageStatic(url)
|
||||
return
|
||||
}
|
||||
|
||||
previewLocalFile(url)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { backToMemberCenter } from '../constants/routes.js'
|
||||
|
||||
/** 状态栏时间(Pixso 顶栏占位) */
|
||||
export const statusBarTimeMixin = {
|
||||
data() {
|
||||
return {
|
||||
statusBarTime: '9:41'
|
||||
}
|
||||
},
|
||||
onLoad() {
|
||||
this.updateStatusBarTime()
|
||||
},
|
||||
methods: {
|
||||
updateStatusBarTime() {
|
||||
const now = new Date()
|
||||
this.statusBarTime = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 子页面返回个人中心 tab */
|
||||
export const subPageMixin = {
|
||||
methods: {
|
||||
goBack() {
|
||||
backToMemberCenter()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,887 @@
|
||||
/** 个人中心模块 mock 数据(后续可替换为 API) */
|
||||
|
||||
// Mock 数据开关 - 设为 false 可关闭所有 mock 数据
|
||||
export const MOCK_ENABLED = false
|
||||
|
||||
export const memberCenterMock = MOCK_ENABLED ? {
|
||||
userInfo: {
|
||||
name: '张小芳',
|
||||
phone: '13812345678 已绑定微信',
|
||||
memberLevel: '黄金会员',
|
||||
avatar: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/AvatarEditWrap.png'
|
||||
},
|
||||
stats: {
|
||||
checkInCount: 128,
|
||||
trainingHours: 23,
|
||||
pointsBalance: 1250
|
||||
},
|
||||
cardInfo: {
|
||||
name: '健身时长卡',
|
||||
detailTag: '详情',
|
||||
expireDate: '有效期至 2025年12月31日',
|
||||
remainingDays: 187,
|
||||
tip: '距离下次到期还有187天,请及时续费'
|
||||
},
|
||||
checkIns: [
|
||||
{
|
||||
id: 1,
|
||||
title: '今日签到 · 瑜伽初级班',
|
||||
time: '2024-07-12 09:05',
|
||||
tag: '团课',
|
||||
tagTheme: 'group'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: '自由训练 · 进馆记录',
|
||||
time: '2024-07-11 18:30',
|
||||
tag: '自由',
|
||||
tagTheme: 'free'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: '私教课 · 力量训练',
|
||||
time: '2024-07-10 14:00',
|
||||
tag: '私教',
|
||||
tagTheme: 'private'
|
||||
}
|
||||
],
|
||||
bodyReport: {
|
||||
date: '2024-07-01',
|
||||
weight: '63.5',
|
||||
bmi: '22.1',
|
||||
bodyFat: '24.8%',
|
||||
bmr: '165',
|
||||
status: '比较健康',
|
||||
change: '-1.2kg'
|
||||
},
|
||||
couponPoints: {
|
||||
amount: '¥50',
|
||||
couponDesc: '满500可用 · 1张',
|
||||
couponAction: '去使用',
|
||||
points: 1250,
|
||||
pointsLabel: '我的积分',
|
||||
pointsAction: '去兑换'
|
||||
},
|
||||
referral: {
|
||||
code: 'FIT-ZXF-2024',
|
||||
invited: 5,
|
||||
registered: 3,
|
||||
purchased: 2
|
||||
}
|
||||
} : null
|
||||
|
||||
export const userInfoMock = {
|
||||
name: '张小芳',
|
||||
phone: '13812345678',
|
||||
gender: 'female',
|
||||
birthday: '1995年06月15日',
|
||||
height: '165',
|
||||
weight: '63.5',
|
||||
fitnessGoals: ['减脂', '塑形'],
|
||||
avatar: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/AvatarEditWrap.png'
|
||||
}
|
||||
|
||||
export const fitnessGoalOptions = ['减脂', '塑形', '增肌', '提升耐力', '改善体态']
|
||||
|
||||
export const memberCardMock = {
|
||||
card: {
|
||||
name: '黄金健身时长卡',
|
||||
status: '生效中',
|
||||
validityStart: '2024年01月01日',
|
||||
validity: '2024年01月01日 - 2025年12月31日',
|
||||
validityEnd: '2025-12-31',
|
||||
remainingDays: 187
|
||||
},
|
||||
recordTabs: [
|
||||
{ key: 'all', label: '全部' },
|
||||
{ key: 'consume', label: '消费' },
|
||||
{ key: 'checkin', label: '签到' }
|
||||
],
|
||||
records: [
|
||||
{
|
||||
id: 1,
|
||||
type: 'checkin',
|
||||
title: '瑜伽初级班 · 团课签到',
|
||||
time: '2024-07-12 09:05',
|
||||
value: '-1次',
|
||||
valueType: 'negative',
|
||||
icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/dumbbell.png',
|
||||
iconTheme: 'orange'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
type: 'checkin',
|
||||
title: '自由进馆',
|
||||
time: '2024-07-11 18:30',
|
||||
value: '-1天',
|
||||
valueType: 'negative',
|
||||
icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/mappin.png',
|
||||
iconTheme: 'green'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
type: 'consume',
|
||||
title: '会员卡充值',
|
||||
time: '2024-07-01 10:00',
|
||||
value: '+90天',
|
||||
valueType: 'positive',
|
||||
icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/pluscircle.png',
|
||||
iconTheme: 'orange'
|
||||
}
|
||||
],
|
||||
rules: [
|
||||
'时长卡有效期内不限入场次数,但需提前预约团课',
|
||||
'卡到期后不退余额,请合理安排使用',
|
||||
'一卡仅限本人使用,不可转让'
|
||||
]
|
||||
}
|
||||
|
||||
/** 智能体测模块 mock 数据 */
|
||||
export const bodyTestMock = {
|
||||
settings: {
|
||||
autoSync: true,
|
||||
bluetoothEnabled: true,
|
||||
notifyOnComplete: true,
|
||||
shareAnonymous: false,
|
||||
unitSystem: 'metric'
|
||||
},
|
||||
device: {
|
||||
connected: false,
|
||||
name: 'InBody 270',
|
||||
model: 'IB-270',
|
||||
battery: 86,
|
||||
signal: 'strong',
|
||||
lastConnected: '2024-07-10 18:20'
|
||||
},
|
||||
connectSteps: [
|
||||
{ step: 1, title: '开启体测仪', desc: '长按电源键 3 秒,等待蓝牙指示灯闪烁' },
|
||||
{ step: 2, title: '靠近设备', desc: '将手机靠近体测仪 1 米范围内' },
|
||||
{ step: 3, title: '确认连接', desc: '点击下方按钮搜索并配对设备' }
|
||||
],
|
||||
metricDefs: [
|
||||
{ key: 'weight', label: '体重', unit: 'kg', icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/target.png' },
|
||||
{ key: 'bmi', label: 'BMI', unit: '', icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/activity.png' },
|
||||
{ key: 'bodyFat', label: '体脂率', unit: '%', icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/trendingdown.png' },
|
||||
{ key: 'muscleMass', label: '肌肉量', unit: 'kg', icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/dumbbell.png' },
|
||||
{ key: 'visceralFat', label: '内脏脂肪', unit: '级', icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/alertcircle.png' },
|
||||
{ key: 'bmr', label: '基础代谢', unit: 'kcal', icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/clock.png' },
|
||||
{ key: 'bodyWater', label: '体水分', unit: '%', icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/shield.png' },
|
||||
{ key: 'boneMass', label: '骨量', unit: 'kg', icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/user.png' }
|
||||
],
|
||||
radarLabels: [
|
||||
{ key: 'weight', label: '体重控制' },
|
||||
{ key: 'bodyFat', label: '体脂肪' },
|
||||
{ key: 'muscle', label: '肌肉量' },
|
||||
{ key: 'bone', label: '骨量' },
|
||||
{ key: 'water', label: '体水分' },
|
||||
{ key: 'bmr', label: '基础代谢' }
|
||||
],
|
||||
trendMetrics: [
|
||||
{ key: 'weight', label: '体重' },
|
||||
{ key: 'bodyFat', label: '体脂率' },
|
||||
{ key: 'muscleMass', label: '肌肉量' },
|
||||
{ key: 'bmi', label: 'BMI' }
|
||||
],
|
||||
recommendedCourses: [
|
||||
{
|
||||
id: 1,
|
||||
title: '燃脂 HIIT 团课',
|
||||
coach: '李明教练',
|
||||
schedule: '每周二、四 19:00',
|
||||
banner: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/AC1Banner.png',
|
||||
tag: '减脂推荐'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: '核心力量塑形',
|
||||
coach: '王强教练',
|
||||
schedule: '每周一、三 18:30',
|
||||
banner: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/AC2Banner.png',
|
||||
tag: '塑形推荐'
|
||||
}
|
||||
],
|
||||
records: [
|
||||
{
|
||||
id: 4,
|
||||
date: '2024-07-12',
|
||||
time: '09:05',
|
||||
score: 85,
|
||||
grade: 'B+',
|
||||
gradeLabel: '良好',
|
||||
status: '比较健康',
|
||||
bodyAge: 27,
|
||||
realAge: 29,
|
||||
metrics: {
|
||||
weight: 63.5,
|
||||
bmi: 22.1,
|
||||
bodyFat: 24.8,
|
||||
muscleMass: 22.6,
|
||||
visceralFat: 6,
|
||||
bmr: 1385,
|
||||
bodyWater: 52.8,
|
||||
boneMass: 2.42,
|
||||
protein: 16.4
|
||||
},
|
||||
radar: { weight: 78, bodyFat: 72, muscle: 74, bone: 81, water: 79, bmr: 73 },
|
||||
bodySegments: [
|
||||
{ part: '左臂', level: 'normal', value: '2.1kg' },
|
||||
{ part: '右臂', level: 'normal', value: '2.2kg' },
|
||||
{ part: '躯干', level: 'high', value: '28.5kg' },
|
||||
{ part: '左腿', level: 'normal', value: '8.6kg' },
|
||||
{ part: '右腿', level: 'normal', value: '8.7kg' }
|
||||
],
|
||||
advice: [
|
||||
'体脂率略高,建议增加有氧训练频率至每周 3-4 次',
|
||||
'核心肌群表现良好,可尝试进阶力量课程',
|
||||
'保持当前蛋白质摄入,有助于维持肌肉量'
|
||||
],
|
||||
recommendedCourseIds: [1, 2]
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
date: '2024-06-28',
|
||||
time: '18:40',
|
||||
score: 82,
|
||||
grade: 'B+',
|
||||
gradeLabel: '良好',
|
||||
status: '比较健康',
|
||||
bodyAge: 28,
|
||||
realAge: 29,
|
||||
metrics: {
|
||||
weight: 64.7,
|
||||
bmi: 22.5,
|
||||
bodyFat: 25.3,
|
||||
muscleMass: 22.2,
|
||||
visceralFat: 7,
|
||||
bmr: 1370,
|
||||
bodyWater: 52.1,
|
||||
boneMass: 2.4,
|
||||
protein: 16.1
|
||||
},
|
||||
radar: { weight: 74, bodyFat: 68, muscle: 70, bone: 80, water: 76, bmr: 70 },
|
||||
bodySegments: [
|
||||
{ part: '左臂', level: 'normal', value: '2.0kg' },
|
||||
{ part: '右臂', level: 'normal', value: '2.1kg' },
|
||||
{ part: '躯干', level: 'high', value: '28.2kg' },
|
||||
{ part: '左腿', level: 'normal', value: '8.5kg' },
|
||||
{ part: '右腿', level: 'normal', value: '8.6kg' }
|
||||
],
|
||||
advice: [
|
||||
'体重较上次下降 0.8kg,减脂方向正确',
|
||||
'建议配合拉伸课程改善体态'
|
||||
],
|
||||
recommendedCourseIds: [1]
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
date: '2024-06-10',
|
||||
time: '10:15',
|
||||
score: 79,
|
||||
grade: 'B',
|
||||
gradeLabel: '中等',
|
||||
status: '需关注',
|
||||
bodyAge: 30,
|
||||
realAge: 29,
|
||||
metrics: {
|
||||
weight: 65.5,
|
||||
bmi: 22.8,
|
||||
bodyFat: 26.1,
|
||||
muscleMass: 21.8,
|
||||
visceralFat: 8,
|
||||
bmr: 1355,
|
||||
bodyWater: 51.5,
|
||||
boneMass: 2.38,
|
||||
protein: 15.8
|
||||
},
|
||||
radar: { weight: 70, bodyFat: 62, muscle: 66, bone: 78, water: 72, bmr: 66 },
|
||||
bodySegments: [
|
||||
{ part: '左臂', level: 'low', value: '1.9kg' },
|
||||
{ part: '右臂', level: 'normal', value: '2.0kg' },
|
||||
{ part: '躯干', level: 'high', value: '28.0kg' },
|
||||
{ part: '左腿', level: 'normal', value: '8.4kg' },
|
||||
{ part: '右腿', level: 'normal', value: '8.5kg' }
|
||||
],
|
||||
advice: [
|
||||
'内脏脂肪偏高,建议减少高糖饮食',
|
||||
'增加抗阻训练提升肌肉量'
|
||||
],
|
||||
recommendedCourseIds: [2]
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
date: '2024-05-20',
|
||||
time: '14:30',
|
||||
score: 76,
|
||||
grade: 'B',
|
||||
gradeLabel: '中等',
|
||||
status: '需关注',
|
||||
bodyAge: 31,
|
||||
realAge: 29,
|
||||
metrics: {
|
||||
weight: 66.2,
|
||||
bmi: 23.1,
|
||||
bodyFat: 26.8,
|
||||
muscleMass: 21.5,
|
||||
visceralFat: 9,
|
||||
bmr: 1340,
|
||||
bodyWater: 51.0,
|
||||
boneMass: 2.35,
|
||||
protein: 15.5
|
||||
},
|
||||
radar: { weight: 66, bodyFat: 58, muscle: 62, bone: 76, water: 68, bmr: 62 },
|
||||
bodySegments: [
|
||||
{ part: '左臂', level: 'low', value: '1.8kg' },
|
||||
{ part: '右臂', level: 'low', value: '1.9kg' },
|
||||
{ part: '躯干', level: 'high', value: '27.8kg' },
|
||||
{ part: '左腿', level: 'normal', value: '8.3kg' },
|
||||
{ part: '右腿', level: 'normal', value: '8.4kg' }
|
||||
],
|
||||
advice: [
|
||||
'建议制定 8 周减脂计划并定期复测',
|
||||
'每日饮水量建议达到 2000ml'
|
||||
],
|
||||
recommendedCourseIds: [1, 2]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
export const bookingMock = {
|
||||
upcomingAlert: '明天 09:00 有一堂瑜伽课,请提前 30 分钟到场',
|
||||
tabs: [
|
||||
{ key: 'ongoing', label: '进行中' },
|
||||
{ key: 'history', label: '历史预约' }
|
||||
],
|
||||
ongoing: [
|
||||
{
|
||||
id: 1,
|
||||
title: '瑜伽基础班',
|
||||
banner: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/AC1Banner.png',
|
||||
status: 'booked',
|
||||
statusLabel: '已预约',
|
||||
schedule: '07月15日 09:00-10:00',
|
||||
dateDay: '07',
|
||||
dateMonth: '月15日',
|
||||
timeRange: '09:00-10:00',
|
||||
coach: '李明教练',
|
||||
coachShort: '李明',
|
||||
location: '一楼 大厅',
|
||||
footerText: '可取消(截止 07/15 07:00)',
|
||||
canCancel: true
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: '私教健身课',
|
||||
banner: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/AC2Banner.png',
|
||||
status: 'pending',
|
||||
statusLabel: '待上课',
|
||||
schedule: '07月18日 14:00-15:00',
|
||||
dateDay: '07',
|
||||
dateMonth: '月18日',
|
||||
timeRange: '14:00-15:00',
|
||||
coach: '王强教练',
|
||||
coachShort: '王强',
|
||||
location: 'B区私教室',
|
||||
footerText: '地点:B区私教室',
|
||||
canCancel: true
|
||||
}
|
||||
],
|
||||
history: [
|
||||
{
|
||||
id: 3,
|
||||
title: '动感单车',
|
||||
banner: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/AC1Banner.png',
|
||||
status: 'completed',
|
||||
statusLabel: '已完成',
|
||||
schedule: '07月10日 19:00-20:00',
|
||||
coach: '赵敏教练',
|
||||
footerText: '已签到',
|
||||
canCancel: false
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: '普拉提进阶',
|
||||
banner: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/AC2Banner.png',
|
||||
status: 'cancelled',
|
||||
statusLabel: '已取消',
|
||||
schedule: '07月05日 10:00-11:00',
|
||||
coach: '李明教练',
|
||||
footerText: '用户主动取消',
|
||||
canCancel: false
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
/** 可预约课程 catalog */
|
||||
export const courseCatalogMock = {
|
||||
coaches: ['全部', '李明教练', '王强教练', '赵敏教练'],
|
||||
periodOptions: [
|
||||
{ key: 'all', label: '全部时段' },
|
||||
{ key: 'morning', label: '上午' },
|
||||
{ key: 'afternoon', label: '下午' },
|
||||
{ key: 'evening', label: '晚上' }
|
||||
],
|
||||
typeOptions: [
|
||||
{ key: 'all', label: '全部' },
|
||||
{ key: 'group', label: '团课' },
|
||||
{ key: 'private', label: '私教' }
|
||||
],
|
||||
courses: [
|
||||
{
|
||||
id: 101,
|
||||
title: '瑜伽基础班',
|
||||
type: 'group',
|
||||
coach: '李明教练',
|
||||
coachAvatar: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/user0.png',
|
||||
date: '2024-07-15',
|
||||
startTime: '09:00',
|
||||
endTime: '10:00',
|
||||
location: '一楼大厅',
|
||||
enrolled: 12,
|
||||
capacity: 20,
|
||||
price: '次卡扣 1 次',
|
||||
payType: 'session',
|
||||
period: 'morning',
|
||||
banner: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/AC1Banner.png',
|
||||
intro: '适合零基础学员,重点提升柔韧性与呼吸控制。',
|
||||
suitable: '久坐办公族、初学者、想改善体态者',
|
||||
coachBio: '国家一级瑜伽指导员,5年教学经验',
|
||||
coachRating: 4.9,
|
||||
reviews: [
|
||||
{ user: '会员 A', score: 5, text: '教练讲解很细致,氛围很好' },
|
||||
{ user: '会员 B', score: 5, text: '适合新手,推荐' }
|
||||
],
|
||||
cancelRule: '至少提前 2 小时取消,否则视为爽约'
|
||||
},
|
||||
{
|
||||
id: 102,
|
||||
title: 'HIIT 燃脂团课',
|
||||
type: 'group',
|
||||
coach: '赵敏教练',
|
||||
coachAvatar: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/user1.png',
|
||||
date: '2024-07-15',
|
||||
startTime: '19:00',
|
||||
endTime: '20:00',
|
||||
location: '有氧区',
|
||||
enrolled: 18,
|
||||
capacity: 20,
|
||||
price: '时长卡',
|
||||
payType: 'duration',
|
||||
period: 'evening',
|
||||
banner: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/AC1Banner.png',
|
||||
intro: '高强度间歇训练,快速燃脂提升心肺。',
|
||||
suitable: '有一定运动基础、目标减脂者',
|
||||
coachBio: 'ACE 认证教练,擅长 HIIT 与动感单车',
|
||||
coachRating: 4.8,
|
||||
reviews: [{ user: '会员 C', score: 5, text: '强度够,出汗很多' }],
|
||||
cancelRule: '至少提前 2 小时取消'
|
||||
},
|
||||
{
|
||||
id: 103,
|
||||
title: '私教 · 力量训练',
|
||||
type: 'private',
|
||||
coach: '王强教练',
|
||||
coachAvatar: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/user2.png',
|
||||
date: '2024-07-16',
|
||||
startTime: '14:00',
|
||||
endTime: '15:00',
|
||||
location: 'B区私教室',
|
||||
enrolled: 1,
|
||||
capacity: 1,
|
||||
price: '私教课时卡',
|
||||
payType: 'private',
|
||||
period: 'afternoon',
|
||||
banner: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/AC2Banner.png',
|
||||
intro: '一对一力量训练,定制训练计划。',
|
||||
suitable: '增肌塑形、康复训练',
|
||||
coachBio: 'NSCA 认证私教,8年从业经验',
|
||||
coachRating: 5.0,
|
||||
reviews: [{ user: '会员 D', score: 5, text: '非常专业' }],
|
||||
cancelRule: '至少提前 2 小时取消'
|
||||
},
|
||||
{
|
||||
id: 104,
|
||||
title: '普拉提进阶',
|
||||
type: 'group',
|
||||
coach: '李明教练',
|
||||
coachAvatar: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/user0.png',
|
||||
date: '2024-07-17',
|
||||
startTime: '10:30',
|
||||
endTime: '11:30',
|
||||
location: '二楼瑜伽室',
|
||||
enrolled: 8,
|
||||
capacity: 15,
|
||||
price: '次卡扣 1 次',
|
||||
payType: 'session',
|
||||
period: 'morning',
|
||||
banner: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/AC2Banner.png',
|
||||
intro: '核心稳定与体态矫正进阶课程。',
|
||||
suitable: '有普拉提基础者',
|
||||
coachBio: '国家一级瑜伽指导员',
|
||||
coachRating: 4.9,
|
||||
reviews: [],
|
||||
cancelRule: '至少提前 2 小时取消'
|
||||
},
|
||||
{
|
||||
id: 105,
|
||||
title: '动感单车',
|
||||
type: 'group',
|
||||
coach: '赵敏教练',
|
||||
coachAvatar: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/user1.png',
|
||||
date: '2024-07-18',
|
||||
startTime: '18:30',
|
||||
endTime: '19:30',
|
||||
location: '单车房',
|
||||
enrolled: 20,
|
||||
capacity: 20,
|
||||
price: '储值卡 ¥39',
|
||||
payType: 'stored',
|
||||
period: 'evening',
|
||||
banner: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/AC1Banner.png',
|
||||
intro: '音乐骑行,团队氛围燃脂。',
|
||||
suitable: '所有级别,可调节阻力',
|
||||
coachBio: 'ACE 认证教练',
|
||||
coachRating: 4.7,
|
||||
reviews: [{ user: '会员 E', score: 4, text: '音乐很带感' }],
|
||||
cancelRule: '至少提前 2 小时取消'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
/** 个人中心其它模块 mock 数据 */
|
||||
export const moduleMock = {
|
||||
trainingReport: {
|
||||
periodLabel: '本周训练',
|
||||
summary: {
|
||||
sessions: 4,
|
||||
hours: 6.5,
|
||||
calories: 2180,
|
||||
streak: 3,
|
||||
visits: 5
|
||||
},
|
||||
monthlyHours: [
|
||||
{ label: '第1周', value: 4.2 },
|
||||
{ label: '第2周', value: 5.8 },
|
||||
{ label: '第3周', value: 6.5 },
|
||||
{ label: '第4周', value: 5.0 }
|
||||
],
|
||||
monthlyCalories: [
|
||||
{ label: '第1周', value: 1200 },
|
||||
{ label: '第2周', value: 1680 },
|
||||
{ label: '第3周', value: 2180 },
|
||||
{ label: '第4周', value: 1850 }
|
||||
],
|
||||
weeklyHours: [
|
||||
{ label: '一', value: 1.2 },
|
||||
{ label: '二', value: 0 },
|
||||
{ label: '三', value: 1.5 },
|
||||
{ label: '四', value: 0.8 },
|
||||
{ label: '五', value: 1.0 },
|
||||
{ label: '六', value: 2.0 },
|
||||
{ label: '日', value: 0 }
|
||||
],
|
||||
sessions: [
|
||||
{
|
||||
id: 1,
|
||||
title: '瑜伽基础班',
|
||||
coach: '李明教练',
|
||||
date: '2024-07-12',
|
||||
time: '09:00-10:00',
|
||||
duration: '60分钟',
|
||||
calories: 320,
|
||||
type: 'group',
|
||||
typeLabel: '团课'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: '自由训练 · 力量',
|
||||
coach: '自主训练',
|
||||
date: '2024-07-11',
|
||||
time: '18:30-19:45',
|
||||
duration: '75分钟',
|
||||
calories: 480,
|
||||
type: 'free',
|
||||
typeLabel: '自由'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: '私教 · 核心塑形',
|
||||
coach: '王强教练',
|
||||
date: '2024-07-10',
|
||||
time: '14:00-15:00',
|
||||
duration: '60分钟',
|
||||
calories: 410,
|
||||
type: 'private',
|
||||
typeLabel: '私教'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: '动感单车',
|
||||
coach: '赵敏教练',
|
||||
date: '2024-07-08',
|
||||
time: '19:00-20:00',
|
||||
duration: '60分钟',
|
||||
calories: 520,
|
||||
type: 'group',
|
||||
typeLabel: '团课'
|
||||
}
|
||||
]
|
||||
},
|
||||
couponTabs: [
|
||||
{ key: 'available', label: '可用' },
|
||||
{ key: 'used', label: '已使用' },
|
||||
{ key: 'expired', label: '已过期' }
|
||||
],
|
||||
coupons: [
|
||||
{
|
||||
id: 1,
|
||||
status: 'available',
|
||||
amount: 50,
|
||||
title: '满500减50',
|
||||
desc: '全场团课/私教可用',
|
||||
expire: '2024-12-31',
|
||||
minSpend: 500,
|
||||
tag: '通用券',
|
||||
rules: '1. 满500元可用\n2. 适用于团课/私教\n3. 不可与其他优惠叠加\n4. 有效期至2024-12-31',
|
||||
scope: '全门店 · 团课/私教',
|
||||
flow: '选择课程 → 确认订单 → 选择优惠券 → 完成支付'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
status: 'available',
|
||||
amount: 30,
|
||||
title: '新人专享',
|
||||
desc: '首次购课立减',
|
||||
expire: '2024-08-31',
|
||||
minSpend: 200,
|
||||
tag: '新人券',
|
||||
rules: '1. 限新注册用户首次购课\n2. 满200可用',
|
||||
scope: '全门店 · 首次购课',
|
||||
flow: '首次预约课程时自动提示使用'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
status: 'used',
|
||||
amount: 20,
|
||||
title: '签到奖励券',
|
||||
desc: '连续签到7天获得',
|
||||
expire: '2024-07-01',
|
||||
minSpend: 100,
|
||||
tag: '奖励券',
|
||||
usedAt: '2024-06-28',
|
||||
rules: '满100可用',
|
||||
scope: '团课',
|
||||
flow: '预约时使用'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
status: 'expired',
|
||||
amount: 100,
|
||||
title: '周年庆特惠',
|
||||
desc: '满1000可用',
|
||||
expire: '2024-06-01',
|
||||
minSpend: 1000,
|
||||
tag: '活动券',
|
||||
rules: '满1000可用,已过期',
|
||||
scope: '全门店',
|
||||
flow: '—'
|
||||
}
|
||||
],
|
||||
couponCenter: [
|
||||
{
|
||||
id: 11,
|
||||
amount: 20,
|
||||
title: '周末团课券',
|
||||
desc: '周末团课满200减20',
|
||||
expireDays: 30,
|
||||
minSpend: 200,
|
||||
tag: '可领取',
|
||||
claimed: false
|
||||
},
|
||||
{
|
||||
id: 12,
|
||||
amount: 50,
|
||||
title: '私教体验券',
|
||||
desc: '私教课满500减50',
|
||||
expireDays: 15,
|
||||
minSpend: 500,
|
||||
tag: '限时',
|
||||
claimed: false
|
||||
},
|
||||
{
|
||||
id: 13,
|
||||
amount: 10,
|
||||
title: '签到加油券',
|
||||
desc: '无门槛10元券',
|
||||
expireDays: 7,
|
||||
minSpend: 0,
|
||||
tag: '每日',
|
||||
claimed: true
|
||||
}
|
||||
],
|
||||
pointsConfig: {
|
||||
rate: '100积分 = 1元',
|
||||
rule: '签到、训练、邀请好友、购课均可获得积分;积分可用于商城兑换。'
|
||||
},
|
||||
pointsRewards: [
|
||||
{ id: 1, name: '团课体验券', cost: 500, stock: 12, icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/ticket.png' },
|
||||
{ id: 2, name: '运动毛巾', cost: 800, stock: 5, icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/dumbbell.png' },
|
||||
{ id: 3, name: '私教体验30分钟', cost: 2000, stock: 3, icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/usercheck.png' },
|
||||
{ id: 4, name: '蛋白粉小样', cost: 350, stock: 20, icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/star.png' }
|
||||
],
|
||||
pointsHistory: [
|
||||
{ id: 1, type: 'earn', title: '团课签到', amount: 50, time: '2024-07-12 09:10', balance: 1250 },
|
||||
{ id: 2, type: 'earn', title: '邀请好友注册', amount: 200, time: '2024-07-08 15:30', balance: 1200 },
|
||||
{ id: 3, type: 'spend', title: '兑换团课体验券', amount: -500, time: '2024-07-01 11:00', balance: 1000 },
|
||||
{ id: 4, type: 'earn', title: '会员卡续费奖励', amount: 100, time: '2024-07-01 10:05', balance: 1500 },
|
||||
{ id: 5, type: 'earn', title: '体测完成奖励', amount: 30, time: '2024-06-28 18:45', balance: 1400 }
|
||||
],
|
||||
referralRules: [
|
||||
'好友通过您的邀请码注册,双方各得 100 积分',
|
||||
'好友首次购课成功后,您额外获得 300 积分',
|
||||
'每月邀请奖励上限 10 人,超出不再计奖',
|
||||
'积分可用于兑换课程体验券及周边礼品'
|
||||
],
|
||||
referralRecords: [
|
||||
{ id: 1, name: '李**', avatar: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/user0.png', status: 'purchased', statusLabel: '已购课', time: '2024-07-05', reward: '+300积分', rewardStatus: '已发放' },
|
||||
{ id: 2, name: '王**', avatar: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/user1.png', status: 'registered', statusLabel: '已注册', time: '2024-06-20', reward: '+100积分', rewardStatus: '已发放' },
|
||||
{ id: 3, name: '陈**', avatar: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/user2.png', status: 'invited', statusLabel: '已邀请', time: '2024-06-15', reward: '待注册', rewardStatus: '待发放' },
|
||||
{ id: 4, name: '赵**', avatar: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/user3.png', status: 'purchased', statusLabel: '已购课', time: '2024-06-01', reward: '+300积分', rewardStatus: '已发放' },
|
||||
{ id: 5, name: '刘**', avatar: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/user0.png', status: 'registered', statusLabel: '已注册', time: '2024-05-28', reward: '+100积分', rewardStatus: '已发放' }
|
||||
],
|
||||
referralRewardSummary: {
|
||||
totalPoints: 800,
|
||||
totalCoupons: 2,
|
||||
pendingCount: 1
|
||||
},
|
||||
myCourseTabs: [
|
||||
{ key: 'group', label: '团课' },
|
||||
{ key: 'private', label: '私教' },
|
||||
{ key: 'online', label: '线上课' },
|
||||
{ key: 'package', label: '训练营' }
|
||||
],
|
||||
myCourses: {
|
||||
group: {
|
||||
ongoing: [
|
||||
{
|
||||
id: 1,
|
||||
title: '瑜伽基础班',
|
||||
coach: '李明教练',
|
||||
banner: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/AC1Banner.png',
|
||||
progress: 6,
|
||||
total: 12,
|
||||
schedule: '每周二、四 09:00',
|
||||
location: '一楼大厅',
|
||||
nextClass: '07月16日 09:00',
|
||||
canCancel: true,
|
||||
bookingId: 1
|
||||
}
|
||||
],
|
||||
completed: [
|
||||
{
|
||||
id: 3,
|
||||
title: '动感单车入门',
|
||||
coach: '赵敏教练',
|
||||
banner: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/AC1Banner.png',
|
||||
progress: 8,
|
||||
total: 8,
|
||||
schedule: '已结课',
|
||||
location: '单车房',
|
||||
completedAt: '2024-06-30',
|
||||
canEvaluate: true
|
||||
}
|
||||
]
|
||||
},
|
||||
private: {
|
||||
remaining: 7,
|
||||
coach: '王强教练',
|
||||
coachAvatar: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/user2.png',
|
||||
nextClass: '07月15日 14:00',
|
||||
bookings: [
|
||||
{ id: 2, title: '私教 · 力量训练', time: '07月18日 14:00', status: '已预约', location: 'B区私教室' }
|
||||
],
|
||||
completed: [
|
||||
{ id: 5, title: '私教 · 核心塑形', time: '2024-07-10 14:00', coach: '王强教练' }
|
||||
]
|
||||
},
|
||||
online: [
|
||||
{
|
||||
id: 201,
|
||||
title: '居家核心训练',
|
||||
cover: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/AC2Banner.png',
|
||||
duration: '45分钟',
|
||||
progress: 60,
|
||||
chapters: 6,
|
||||
watched: 4,
|
||||
type: 'video'
|
||||
},
|
||||
{
|
||||
id: 202,
|
||||
title: '直播 · 晨间拉伸',
|
||||
cover: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/AC1Banner.png',
|
||||
duration: '30分钟',
|
||||
progress: 0,
|
||||
liveTime: '07月20日 07:00',
|
||||
type: 'live'
|
||||
}
|
||||
],
|
||||
package: [
|
||||
{
|
||||
id: 301,
|
||||
title: '28天减脂训练营',
|
||||
banner: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/AC1Banner.png',
|
||||
progress: 3,
|
||||
total: 10,
|
||||
coach: '李明教练',
|
||||
schedule: '每周5练'
|
||||
}
|
||||
]
|
||||
},
|
||||
checkInTabs: [
|
||||
{ key: 'all', label: '全部' },
|
||||
{ key: 'group', label: '团课' },
|
||||
{ key: 'private', label: '私教' },
|
||||
{ key: 'free', label: '自由' }
|
||||
],
|
||||
checkInHistory: [
|
||||
{
|
||||
id: 1,
|
||||
title: '今日签到 · 瑜伽初级班',
|
||||
time: '2024-07-12 09:05',
|
||||
tag: '团课',
|
||||
tagTheme: 'group',
|
||||
location: '一楼大厅'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: '自由训练 · 进馆记录',
|
||||
time: '2024-07-11 18:30',
|
||||
tag: '自由',
|
||||
tagTheme: 'free',
|
||||
location: '器械区'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: '私教课 · 力量训练',
|
||||
time: '2024-07-10 14:00',
|
||||
tag: '私教',
|
||||
tagTheme: 'private',
|
||||
location: 'B区私教室'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: '团课签到 · 动感单车',
|
||||
time: '2024-07-08 19:02',
|
||||
tag: '团课',
|
||||
tagTheme: 'group',
|
||||
location: '单车房'
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
title: '自由训练 · 进馆记录',
|
||||
time: '2024-07-06 17:45',
|
||||
tag: '自由',
|
||||
tagTheme: 'free',
|
||||
location: '有氧区'
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
import { moduleMock } from './mockData.js'
|
||||
|
||||
|
||||
|
||||
function clone(value) {
|
||||
|
||||
return JSON.parse(JSON.stringify(value))
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function getDefaultModuleState() {
|
||||
|
||||
return {
|
||||
|
||||
trainingReport: clone(moduleMock.trainingReport),
|
||||
|
||||
coupons: clone(moduleMock.coupons),
|
||||
|
||||
couponCenter: clone(moduleMock.couponCenter),
|
||||
|
||||
pointsHistory: clone(moduleMock.pointsHistory),
|
||||
|
||||
pointsRewards: clone(moduleMock.pointsRewards),
|
||||
|
||||
redeemRecords: [],
|
||||
|
||||
referralRecords: clone(moduleMock.referralRecords),
|
||||
|
||||
myCourses: clone(moduleMock.myCourses),
|
||||
|
||||
checkInHistory: clone(moduleMock.checkInHistory)
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function mergeModuleState(saved) {
|
||||
|
||||
const defaults = getDefaultModuleState()
|
||||
|
||||
if (!saved) return defaults
|
||||
|
||||
return {
|
||||
|
||||
trainingReport: { ...defaults.trainingReport, ...(saved.trainingReport || {}) },
|
||||
|
||||
coupons: saved.coupons?.length ? saved.coupons : defaults.coupons,
|
||||
|
||||
couponCenter: saved.couponCenter?.length ? saved.couponCenter : defaults.couponCenter,
|
||||
|
||||
pointsHistory: saved.pointsHistory?.length ? saved.pointsHistory : defaults.pointsHistory,
|
||||
|
||||
pointsRewards: saved.pointsRewards?.length ? saved.pointsRewards : defaults.pointsRewards,
|
||||
|
||||
redeemRecords: saved.redeemRecords || defaults.redeemRecords,
|
||||
|
||||
referralRecords: saved.referralRecords?.length ? saved.referralRecords : defaults.referralRecords,
|
||||
|
||||
myCourses: saved.myCourses ? mergeMyCourses(defaults.myCourses, saved.myCourses) : defaults.myCourses,
|
||||
|
||||
checkInHistory: saved.checkInHistory?.length ? saved.checkInHistory : defaults.checkInHistory
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
function mergeMyCourses(defaults, saved) {
|
||||
|
||||
return {
|
||||
|
||||
group: saved.group || defaults.group,
|
||||
|
||||
private: saved.private || defaults.private,
|
||||
|
||||
online: saved.online?.length ? saved.online : defaults.online,
|
||||
|
||||
package: saved.package?.length ? saved.package : defaults.package
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
function syncCouponSummary(store) {
|
||||
|
||||
const available = store.modules.coupons.filter((c) => c.status === 'available')
|
||||
|
||||
const top = available[0]
|
||||
|
||||
store.couponPoints = {
|
||||
|
||||
...store.couponPoints,
|
||||
|
||||
amount: top ? `¥${top.amount}` : '暂无',
|
||||
|
||||
couponDesc: top
|
||||
|
||||
? `满${top.minSpend}可用 · ${available.length}张`
|
||||
|
||||
: '暂无可用优惠券',
|
||||
|
||||
couponAction: available.length ? '去使用' : '去领取',
|
||||
|
||||
points: store.stats.pointsBalance,
|
||||
|
||||
pointsLabel: '我的积分',
|
||||
|
||||
pointsAction: '去兑换'
|
||||
|
||||
}
|
||||
|
||||
return store
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function finalizeModules(store) {
|
||||
|
||||
syncCouponSummary(store)
|
||||
|
||||
store.checkIns = store.modules.checkInHistory.slice(0, 3).map((item) => ({ ...item }))
|
||||
|
||||
return store
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function getTrainingReportData(store, period = 'week') {
|
||||
|
||||
const report = store.modules.trainingReport
|
||||
|
||||
const trend = period === 'month' ? report.monthlyHours : report.weeklyHours
|
||||
|
||||
const calTrend = period === 'month' ? report.monthlyCalories : report.weeklyHours.map((w, i) => ({
|
||||
|
||||
label: w.label,
|
||||
|
||||
value: Math.round((report.summary.calories / 7) * (w.value || 0.5))
|
||||
|
||||
}))
|
||||
|
||||
return {
|
||||
|
||||
...report,
|
||||
|
||||
period,
|
||||
|
||||
summary: {
|
||||
|
||||
...report.summary,
|
||||
|
||||
hours: store.stats.trainingHours ?? report.summary.hours,
|
||||
|
||||
visits: report.summary.visits ?? store.stats.checkInCount ?? 5
|
||||
|
||||
},
|
||||
|
||||
trendHours: trend.map((t) => ({ ...t, id: t.label })),
|
||||
|
||||
trendCalories: calTrend.map((t) => ({ ...t, id: t.label })),
|
||||
|
||||
sessions: report.sessions.map((s) => ({ ...s }))
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function getTrainingSessionById(store, id) {
|
||||
|
||||
const session = store.modules.trainingReport.sessions.find((s) => s.id === Number(id))
|
||||
|
||||
if (!session) return null
|
||||
|
||||
return {
|
||||
|
||||
...session,
|
||||
|
||||
heartRate: '128 bpm',
|
||||
|
||||
comment: '动作标准,核心发力良好,下次可增加负重。',
|
||||
|
||||
checkInTime: `${session.date} ${session.time.split('-')[0]}`
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function filterTrainingSessions(store, filters = {}) {
|
||||
|
||||
let list = store.modules.trainingReport.sessions.map((s) => ({ ...s }))
|
||||
|
||||
if (filters.type && filters.type !== 'all') {
|
||||
|
||||
list = list.filter((s) => s.type === filters.type)
|
||||
|
||||
}
|
||||
|
||||
return list
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function getCouponsByStatus(store, status) {
|
||||
|
||||
return store.modules.coupons.filter((c) => c.status === status).map((c) => ({ ...c }))
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function getCouponById(store, id) {
|
||||
|
||||
const c = store.modules.coupons.find((item) => item.id === Number(id))
|
||||
|
||||
return c ? { ...c } : null
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function useCoupon(store, id) {
|
||||
|
||||
const coupon = store.modules.coupons.find((c) => c.id === id)
|
||||
|
||||
if (!coupon || coupon.status !== 'available') return null
|
||||
|
||||
coupon.status = 'used'
|
||||
|
||||
coupon.usedAt = new Date().toISOString().slice(0, 10)
|
||||
|
||||
syncCouponSummary(store)
|
||||
|
||||
return coupon
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function deleteExpiredCoupon(store, id) {
|
||||
|
||||
const idx = store.modules.coupons.findIndex((c) => c.id === id && c.status === 'expired')
|
||||
|
||||
if (idx >= 0) store.modules.coupons.splice(idx, 1)
|
||||
|
||||
syncCouponSummary(store)
|
||||
|
||||
return store
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function getCouponCenterList(store) {
|
||||
|
||||
return store.modules.couponCenter.map((c) => ({ ...c }))
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
import {
|
||||
getDefaultBodyTestState,
|
||||
mergeBodyTestState,
|
||||
getLatestBodyTestRecord,
|
||||
buildBodyReportSummary
|
||||
} from './bodyTestStore.js'
|
||||
import {
|
||||
getDefaultModuleState,
|
||||
mergeModuleState,
|
||||
finalizeModules
|
||||
} from './moduleStore.js'
|
||||
import { getDefaultCourseCatalog,
|
||||
mergeCourseCatalog,
|
||||
canCancelBooking
|
||||
} from './bookingStore.js'
|
||||
import { getMemberId as requestGetMemberId } from '@/utils/request.js'
|
||||
|
||||
const STORAGE_KEY = 'gym_member_info_v1'
|
||||
|
||||
/**
|
||||
* 获取当前登录会员ID
|
||||
* @deprecated 请使用 @/utils/request.js 中的 getMemberId
|
||||
*/
|
||||
export function getCurrentMemberId() {
|
||||
return requestGetMemberId()
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取登录信息
|
||||
*/
|
||||
export function getLoginMemberInfo() {
|
||||
return uni.getStorageSync('loginMemberInfo') || null
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取token
|
||||
*/
|
||||
export function getToken() {
|
||||
return uni.getStorageSync('token') || null
|
||||
}
|
||||
|
||||
export function buildCardTip(remainingDays) {
|
||||
return `距离下次到期还有${remainingDays}天,请及时续费`
|
||||
}
|
||||
|
||||
function applyCardInfo(store) {
|
||||
const days = computeRemainingDays(store.card.validityEnd)
|
||||
store.card.remainingDays = days
|
||||
store.cardInfo.remainingDays = days
|
||||
store.cardInfo.tip = buildCardTip(days)
|
||||
return store
|
||||
}
|
||||
|
||||
export function syncStats(store) {
|
||||
store.stats = {
|
||||
...store.stats,
|
||||
checkInCount: store.stats.checkInCount ?? 0,
|
||||
courseCount: store.stats.courseCount ?? 0,
|
||||
pointsBalance: store.stats.pointsBalance ?? 0
|
||||
}
|
||||
return store
|
||||
}
|
||||
|
||||
function finalizeStore(store) {
|
||||
syncStats(store)
|
||||
applyCardInfo(store)
|
||||
const latestBodyTest = getLatestBodyTestRecord(store)
|
||||
if (latestBodyTest) {
|
||||
const previous = store.bodyTest.records[1]
|
||||
store.bodyReport = buildBodyReportSummary(latestBodyTest, previous)
|
||||
}
|
||||
if (store.modules) {
|
||||
finalizeModules(store)
|
||||
}
|
||||
return store
|
||||
}
|
||||
|
||||
function getDefaultStore() {
|
||||
return finalizeStore({
|
||||
stats: {},
|
||||
cardInfo: {},
|
||||
card: {},
|
||||
cards: [],
|
||||
records: [],
|
||||
ongoingBookings: [],
|
||||
historyBookings: [],
|
||||
checkIns: [],
|
||||
bodyReport: {},
|
||||
bodyTest: getDefaultBodyTestState(),
|
||||
modules: getDefaultModuleState(),
|
||||
courseCatalog: getDefaultCourseCatalog(),
|
||||
couponPoints: {},
|
||||
referral: {},
|
||||
storedCard: {
|
||||
balance: 0,
|
||||
zeroVerified: false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export { getDefaultStore }
|
||||
|
||||
function mergeDefaults(saved) {
|
||||
const defaults = getDefaultStore()
|
||||
return finalizeStore({
|
||||
stats: { ...defaults.stats, ...(saved.stats || {}) },
|
||||
cardInfo: { ...defaults.cardInfo, ...(saved.cardInfo || {}) },
|
||||
card: { ...defaults.card, ...(saved.card || {}) },
|
||||
cards: saved.cards?.length ? saved.cards : defaults.cards,
|
||||
records: saved.records?.length ? saved.records : defaults.records,
|
||||
ongoingBookings: saved.ongoingBookings ?? defaults.ongoingBookings,
|
||||
historyBookings: saved.historyBookings ?? defaults.historyBookings,
|
||||
checkIns: saved.checkIns?.length ? saved.checkIns : defaults.checkIns,
|
||||
bodyReport: { ...defaults.bodyReport, ...(saved.bodyReport || {}) },
|
||||
bodyTest: mergeBodyTestState(saved.bodyTest),
|
||||
modules: mergeModuleState(saved.modules),
|
||||
courseCatalog: mergeCourseCatalog(saved.courseCatalog),
|
||||
couponPoints: { ...defaults.couponPoints, ...(saved.couponPoints || {}) },
|
||||
referral: { ...defaults.referral, ...(saved.referral || {}) },
|
||||
storedCard: { ...defaults.storedCard, ...(saved.storedCard || {}) }
|
||||
})
|
||||
}
|
||||
|
||||
export function loadMemberStore() {
|
||||
try {
|
||||
const saved = uni.getStorageSync(STORAGE_KEY)
|
||||
if (saved && typeof saved === 'object') {
|
||||
return mergeDefaults(saved)
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[memberStore] load failed', e)
|
||||
}
|
||||
return getDefaultStore()
|
||||
}
|
||||
|
||||
export function saveMemberStore(store) {
|
||||
uni.setStorageSync(STORAGE_KEY, store)
|
||||
}
|
||||
|
||||
/** 解析为本地 0 点,避免 ISO 字符串时区偏差 */
|
||||
export function parseLocalDate(dateStr) {
|
||||
if (!dateStr) return null
|
||||
const str = String(dateStr).trim()
|
||||
const iso = str.match(/^(\d{4})-(\d{2})-(\d{2})$/)
|
||||
if (iso) {
|
||||
return new Date(Number(iso[1]), Number(iso[2]) - 1, Number(iso[3]))
|
||||
}
|
||||
const cn = str.match(/(\d{4})年(\d{2})月(\d{2})日/)
|
||||
if (cn) {
|
||||
return new Date(Number(cn[1]), Number(cn[2]) - 1, Number(cn[3]))
|
||||
}
|
||||
const parsed = new Date(str.replace(/-/g, '/'))
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed
|
||||
}
|
||||
|
||||
function formatIsoDate(date) {
|
||||
const y = date.getFullYear()
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const d = String(date.getDate()).padStart(2, '0')
|
||||
return `${y}-${m}-${d}`
|
||||
}
|
||||
|
||||
function formatChineseDate(date) {
|
||||
const y = date.getFullYear()
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
return `${y}年${m}月${day}日`
|
||||
}
|
||||
|
||||
function formatRecordTime(date) {
|
||||
const y = date.getFullYear()
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const d = String(date.getDate()).padStart(2, '0')
|
||||
const h = String(date.getHours()).padStart(2, '0')
|
||||
const min = String(date.getMinutes()).padStart(2, '0')
|
||||
return `${y}-${m}-${d} ${h}:${min}`
|
||||
}
|
||||
|
||||
function nextRecordId(records) {
|
||||
return (records || []).reduce((max, item) => Math.max(max, item.id || 0), 0) + 1
|
||||
}
|
||||
|
||||
export function computeRemainingDays(endDateStr) {
|
||||
const end = parseLocalDate(endDateStr)
|
||||
if (!end) return 0
|
||||
const now = new Date()
|
||||
now.setHours(0, 0, 0, 0)
|
||||
end.setHours(0, 0, 0, 0)
|
||||
const diff = Math.ceil((end - now) / 86400000)
|
||||
return Math.max(0, diff)
|
||||
}
|
||||
|
||||
export function formatUpcomingAlert(booking) {
|
||||
if (!booking) return ''
|
||||
const timePart = booking.timeRange || booking.schedule?.split(' ')[1] || ''
|
||||
return `明天 ${timePart} 有一堂${booking.title},请提前 30 分钟到场`
|
||||
}
|
||||
|
||||
export function toBookingPreviewItem(item) {
|
||||
return {
|
||||
id: item.id,
|
||||
dateDay: item.dateDay,
|
||||
dateMonth: item.dateMonth,
|
||||
desc: `${item.title} · ${item.timeRange}`,
|
||||
coach: item.coachShort || item.coach.replace('教练', ''),
|
||||
location: item.location ? `地点:${item.location}` : '',
|
||||
status: item.status,
|
||||
statusLabel: item.statusLabel
|
||||
}
|
||||
}
|
||||
|
||||
export function getBookingPreview(store, limit = 2) {
|
||||
return store.ongoingBookings.slice(0, limit).map(toBookingPreviewItem)
|
||||
}
|
||||
|
||||
export function getExpiringCards(store, daysThreshold = 30) {
|
||||
if (!store.cards || store.cards.length === 0) {
|
||||
return []
|
||||
}
|
||||
return store.cards.filter(card => {
|
||||
const remainingDays = computeRemainingDays(card.validityEnd || card.expireTime)
|
||||
return remainingDays > 0 && remainingDays <= daysThreshold
|
||||
}).sort((a, b) => {
|
||||
const daysA = computeRemainingDays(a.validityEnd || a.expireTime)
|
||||
const daysB = computeRemainingDays(b.validityEnd || b.expireTime)
|
||||
return daysA - daysB
|
||||
})
|
||||
}
|
||||
|
||||
export function getCenterPageData(store) {
|
||||
const expiringCards = getExpiringCards(store, 30)
|
||||
return {
|
||||
stats: {
|
||||
checkInCount: store.stats?.checkInCount ?? 0,
|
||||
courseCount: store.stats?.courseCount ?? 0,
|
||||
pointsBalance: store.stats?.pointsBalance ?? 0
|
||||
},
|
||||
cardInfo: { ...store.cardInfo },
|
||||
expiringCards: expiringCards,
|
||||
bookingPreview: getBookingPreview(store),
|
||||
checkIns: store.checkIns.map((item) => ({ ...item })),
|
||||
bodyReport: { ...store.bodyReport },
|
||||
couponPoints: {
|
||||
...store.couponPoints,
|
||||
points: store.stats.pointsBalance
|
||||
},
|
||||
referral: { ...store.referral }
|
||||
}
|
||||
}
|
||||
|
||||
export function cancelOngoingBooking(store, id) {
|
||||
const index = store.ongoingBookings.findIndex((b) => b.id === id)
|
||||
if (index < 0) return { ok: false, message: '预约不存在' }
|
||||
|
||||
const item = store.ongoingBookings[index]
|
||||
if (!canCancelBooking(item)) {
|
||||
return { ok: false, message: '距开课不足2小时,无法取消' }
|
||||
}
|
||||
|
||||
const [removed] = store.ongoingBookings.splice(index, 1)
|
||||
if (removed.courseId) {
|
||||
const course = store.courseCatalog.find((c) => c.id === removed.courseId)
|
||||
if (course && course.enrolled > 0) course.enrolled -= 1
|
||||
}
|
||||
|
||||
// 同步从 myCourses.group.ongoing 中移除
|
||||
if (store.modules?.myCourses?.group?.ongoing) {
|
||||
const myCoursesIndex = store.modules.myCourses.group.ongoing.findIndex((c) => c.id === id)
|
||||
if (myCoursesIndex >= 0) {
|
||||
store.modules.myCourses.group.ongoing.splice(myCoursesIndex, 1)
|
||||
}
|
||||
}
|
||||
|
||||
store.historyBookings.unshift({
|
||||
...removed,
|
||||
status: 'cancelled',
|
||||
statusLabel: '已取消',
|
||||
footerText: '用户主动取消',
|
||||
canCancel: false
|
||||
})
|
||||
finalizeStore(store)
|
||||
saveMemberStore(store)
|
||||
return { ok: true, message: '已取消' }
|
||||
}
|
||||
|
||||
export function renewMemberCard(store, addDays = 90) {
|
||||
const now = new Date()
|
||||
now.setHours(0, 0, 0, 0)
|
||||
|
||||
let base = parseLocalDate(store.card.validityEnd) || new Date(now)
|
||||
base.setHours(0, 0, 0, 0)
|
||||
// 已过期:从今天起续费;未过期:从当前到期日起顺延
|
||||
if (base < now) {
|
||||
base = new Date(now)
|
||||
}
|
||||
|
||||
const end = new Date(base)
|
||||
end.setDate(end.getDate() + addDays)
|
||||
|
||||
const validityEnd = formatIsoDate(end)
|
||||
const validityEndCn = formatChineseDate(end)
|
||||
store.card.validityEnd = validityEnd
|
||||
store.card.validity = store.card.validityStart
|
||||
? `${store.card.validityStart} - ${validityEndCn}`
|
||||
: `2024年01月01日 - ${validityEndCn}`
|
||||
store.cardInfo.expireDate = `有效期至 ${validityEndCn}`
|
||||
|
||||
store.records.unshift({
|
||||
id: nextRecordId(store.records),
|
||||
type: 'consume',
|
||||
title: '会员卡续费',
|
||||
time: formatRecordTime(new Date()),
|
||||
value: `+${addDays}天`,
|
||||
valueType: 'positive',
|
||||
icon: 'https://gymfuture.oss-cn-chengdu.aliyuncs.com/static/images/pluscircle.png',
|
||||
iconTheme: 'orange'
|
||||
})
|
||||
|
||||
finalizeStore(store)
|
||||
saveMemberStore(store)
|
||||
return store
|
||||
}
|
||||
|
||||
export function saveUserProfile(store, profile) {
|
||||
// 不再保存profile到store,用户信息从loginMemberInfo获取
|
||||
if (profile.weight) {
|
||||
store.bodyReport.weight = profile.weight
|
||||
}
|
||||
finalizeStore(store)
|
||||
saveMemberStore(store)
|
||||
return store
|
||||
}
|
||||
|
||||
export function persistMemberStore(store) {
|
||||
finalizeStore(store)
|
||||
saveMemberStore(store)
|
||||
return store
|
||||
}
|
||||
|
||||
export function getStoredCardBalance() {
|
||||
console.log('[storedCard] 余额不使用缓存,每次从后端读取')
|
||||
return null
|
||||
}
|
||||
|
||||
export function saveStoredCardBalance(balance) {
|
||||
console.log('[storedCard] 余额不使用缓存,跳过保存')
|
||||
return Number(balance) || 0
|
||||
}
|
||||
|
||||
export function clearStoredCardCache() {
|
||||
console.log('[storedCard] 余额不使用缓存,无需清除')
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
/** 个人信息页前端校验(与后端手机号规则对齐:^1[3-9]\\d{9}$) */
|
||||
|
||||
const PHONE_REG = /^1[3-9]\d{9}$/
|
||||
const MIN_NAME_LEN = 2
|
||||
const MAX_NAME_LEN = 8
|
||||
const NAME_REG = new RegExp(
|
||||
`^[\\u4e00-\\u9fa5a-zA-Z·\\s]{${MIN_NAME_LEN},${MAX_NAME_LEN}}$`
|
||||
)
|
||||
const MEASURE_REG = /^\d+(\.\d)?$/
|
||||
|
||||
const MIN_HEIGHT = 50
|
||||
const MAX_HEIGHT = 250
|
||||
const MIN_WEIGHT = 20
|
||||
const MAX_WEIGHT = 300
|
||||
const MIN_BIRTH_YEAR = 1900
|
||||
const MIN_AGE = 14
|
||||
const MAX_FITNESS_GOALS = 5
|
||||
|
||||
export function isMaskedPhone(phone) {
|
||||
return String(phone || '').includes('****')
|
||||
}
|
||||
|
||||
export function validateName(name) {
|
||||
const value = String(name ?? '').trim()
|
||||
if (!value) {
|
||||
return { ok: false, message: '请输入姓名' }
|
||||
}
|
||||
if (!NAME_REG.test(value)) {
|
||||
return { ok: false, message: `姓名为${MIN_NAME_LEN}-${MAX_NAME_LEN}个汉字或字母` }
|
||||
}
|
||||
return { ok: true, value }
|
||||
}
|
||||
|
||||
/** 保存时使用:允许保留已脱敏的旧手机号 */
|
||||
export function validatePhone(phone, options = {}) {
|
||||
const { allowMasked = true } = options
|
||||
const raw = String(phone ?? '').trim()
|
||||
if (!raw) {
|
||||
return { ok: false, message: '请绑定手机号' }
|
||||
}
|
||||
if (allowMasked && isMaskedPhone(raw)) {
|
||||
const digits = raw.replace(/\D/g, '')
|
||||
if (digits.length >= 7) {
|
||||
return { ok: true, value: raw }
|
||||
}
|
||||
return { ok: false, message: '手机号格式不正确' }
|
||||
}
|
||||
|
||||
const digits = raw.replace(/\D/g, '')
|
||||
if (!PHONE_REG.test(digits)) {
|
||||
return { ok: false, message: '请输入11位有效手机号' }
|
||||
}
|
||||
return { ok: true, value: digits }
|
||||
}
|
||||
|
||||
/** 换绑时必须输入完整新号 */
|
||||
export function validatePhoneForRebind(phone) {
|
||||
return validatePhone(phone, { allowMasked: false })
|
||||
}
|
||||
|
||||
function parseMeasure(value) {
|
||||
const str = String(value ?? '').trim()
|
||||
if (!str || !MEASURE_REG.test(str)) {
|
||||
return null
|
||||
}
|
||||
const num = Number(str)
|
||||
return Number.isFinite(num) ? num : null
|
||||
}
|
||||
|
||||
function formatMeasure(num) {
|
||||
return Number.isInteger(num) ? String(num) : String(Number(num.toFixed(1)))
|
||||
}
|
||||
|
||||
export function validateHeight(height) {
|
||||
const num = parseMeasure(height)
|
||||
if (num == null) {
|
||||
return { ok: false, message: '请输入有效身高(单位 cm)' }
|
||||
}
|
||||
if (num < MIN_HEIGHT || num > MAX_HEIGHT) {
|
||||
return { ok: false, message: `身高请在 ${MIN_HEIGHT}-${MAX_HEIGHT} cm 之间` }
|
||||
}
|
||||
return { ok: true, value: formatMeasure(num) }
|
||||
}
|
||||
|
||||
export function validateWeight(weight) {
|
||||
const num = parseMeasure(weight)
|
||||
if (num == null) {
|
||||
return { ok: false, message: '请输入有效体重(单位 kg)' }
|
||||
}
|
||||
if (num < MIN_WEIGHT || num > MAX_WEIGHT) {
|
||||
return { ok: false, message: `体重请在 ${MIN_WEIGHT}-${MAX_WEIGHT} kg 之间` }
|
||||
}
|
||||
return { ok: true, value: formatMeasure(num) }
|
||||
}
|
||||
|
||||
export function parseBirthdayChinese(birthday) {
|
||||
const match = String(birthday ?? '').match(/(\d{4})年(\d{2})月(\d{2})日/)
|
||||
if (!match) return null
|
||||
return {
|
||||
year: Number(match[1]),
|
||||
month: Number(match[2]),
|
||||
day: Number(match[3])
|
||||
}
|
||||
}
|
||||
|
||||
export function validateBirthday(birthday) {
|
||||
const parts = parseBirthdayChinese(birthday)
|
||||
if (!parts) {
|
||||
return { ok: false, message: '请选择生日' }
|
||||
}
|
||||
const { year, month, day } = parts
|
||||
if (year < MIN_BIRTH_YEAR) {
|
||||
return { ok: false, message: '生日年份不合理' }
|
||||
}
|
||||
|
||||
const date = new Date(year, month - 1, day)
|
||||
if (
|
||||
date.getFullYear() !== year ||
|
||||
date.getMonth() !== month - 1 ||
|
||||
date.getDate() !== day
|
||||
) {
|
||||
return { ok: false, message: '生日日期无效' }
|
||||
}
|
||||
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
if (date > today) {
|
||||
return { ok: false, message: '生日不能晚于今天' }
|
||||
}
|
||||
|
||||
const minBirth = new Date(
|
||||
today.getFullYear() - MIN_AGE,
|
||||
today.getMonth(),
|
||||
today.getDate()
|
||||
)
|
||||
if (date > minBirth) {
|
||||
return { ok: false, message: `需年满 ${MIN_AGE} 周岁` }
|
||||
}
|
||||
|
||||
return { ok: true, value: `${year}年${String(month).padStart(2, '0')}月${String(day).padStart(2, '0')}日` }
|
||||
}
|
||||
|
||||
export function validateGender(gender) {
|
||||
if (gender === 'male' || gender === 'female') {
|
||||
return { ok: true, value: gender }
|
||||
}
|
||||
return { ok: false, message: '请选择性别' }
|
||||
}
|
||||
|
||||
export function validateFitnessGoals(goals, options = []) {
|
||||
const list = Array.isArray(goals) ? goals : []
|
||||
const allowed = new Set(options)
|
||||
const invalid = list.filter((g) => !allowed.has(g))
|
||||
if (invalid.length) {
|
||||
return { ok: false, message: '健身目标选项无效' }
|
||||
}
|
||||
if (list.length > MAX_FITNESS_GOALS) {
|
||||
return { ok: false, message: `最多选择 ${MAX_FITNESS_GOALS} 个健身目标` }
|
||||
}
|
||||
return { ok: true, value: [...list] }
|
||||
}
|
||||
|
||||
export function validateUserProfile(profile, goalOptions = []) {
|
||||
const nameResult = validateName(profile.name)
|
||||
if (!nameResult.ok) return nameResult
|
||||
|
||||
const phoneResult = validatePhone(profile.phone)
|
||||
if (!phoneResult.ok) return phoneResult
|
||||
|
||||
const genderResult = validateGender(profile.gender)
|
||||
if (!genderResult.ok) return genderResult
|
||||
|
||||
const birthdayResult = validateBirthday(profile.birthday)
|
||||
if (!birthdayResult.ok) return birthdayResult
|
||||
|
||||
const heightResult = validateHeight(profile.height)
|
||||
if (!heightResult.ok) return heightResult
|
||||
|
||||
const weightResult = validateWeight(profile.weight)
|
||||
if (!weightResult.ok) return weightResult
|
||||
|
||||
const goalsResult = validateFitnessGoals(profile.fitnessGoals, goalOptions)
|
||||
if (!goalsResult.ok) return goalsResult
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
...profile,
|
||||
name: nameResult.value,
|
||||
phone: phoneResult.value,
|
||||
gender: genderResult.value,
|
||||
birthday: birthdayResult.value,
|
||||
height: heightResult.value,
|
||||
weight: weightResult.value,
|
||||
fitnessGoals: goalsResult.value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function showValidationError(message) {
|
||||
uni.showToast({ title: message, icon: 'none' })
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
/**
|
||||
* 路由权限拦截器
|
||||
* 自动拦截配置好的路径,未登录时自动弹出登录弹窗
|
||||
*
|
||||
* 使用方式:在 App.vue 中 import 并调用 init() 即可
|
||||
* 无需在每个页面单独配置
|
||||
*/
|
||||
|
||||
import { getToken } from '@/utils/request.js'
|
||||
|
||||
/**
|
||||
* 白名单 - 不需要登录即可访问的页面
|
||||
*/
|
||||
const WHITE_LIST = [
|
||||
'/pages/index/index',
|
||||
'/pages/course/course',
|
||||
'/pages/memberInfo/login',
|
||||
'/pages/memberInfo/register',
|
||||
'/pages/memberInfo/forgetPassword',
|
||||
'/pages/about/about',
|
||||
'/pages/help/help',
|
||||
'/pages/notice/notice',
|
||||
'/pages/product/product',
|
||||
'/pages/store/store',
|
||||
'/pages/article/article',
|
||||
'/pages/search/search'
|
||||
]
|
||||
|
||||
/**
|
||||
* 需要登录的页面路径前缀
|
||||
* 匹配以下前缀的页面都需要登录
|
||||
*/
|
||||
const LOGIN_REQUIRED_PREFIXES = [
|
||||
'/pages/memberInfo/checkIn',
|
||||
'/pages/memberInfo/booking',
|
||||
'/pages/memberInfo/coupons',
|
||||
'/pages/memberInfo/points',
|
||||
'/pages/memberInfo/userInfo',
|
||||
'/pages/memberInfo/order',
|
||||
'/pages/memberInfo/settings',
|
||||
'/pages/memberInfo/feedback',
|
||||
'/pages/memberInfo/invite',
|
||||
'/pages/memberInfo/recharge'
|
||||
]
|
||||
|
||||
/**
|
||||
* 需要登录的具体页面路径
|
||||
*/
|
||||
const LOGIN_REQUIRED_PATHS = [
|
||||
'/pages/memberInfo/memberInfo'
|
||||
]
|
||||
|
||||
/**
|
||||
* 检查当前路径是否需要登录
|
||||
* @param {String} url - 页面路径(可能包含query参数)
|
||||
* @returns {Boolean}
|
||||
*/
|
||||
function isNeedLogin(url) {
|
||||
// 去掉query参数
|
||||
const path = url.split('?')[0]
|
||||
|
||||
// 检查白名单
|
||||
if (WHITE_LIST.includes(path)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查具体路径
|
||||
if (LOGIN_REQUIRED_PATHS.includes(path)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 检查路径前缀
|
||||
if (LOGIN_REQUIRED_PREFIXES.some(prefix => path.startsWith(prefix))) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查用户是否已登录
|
||||
* @returns {Boolean}
|
||||
*/
|
||||
function isLoggedIn() {
|
||||
const token = getToken()
|
||||
const isLoginStorage = uni.getStorageSync('isLogin')
|
||||
return !!(token || isLoginStorage)
|
||||
}
|
||||
|
||||
/**
|
||||
* 登录弹窗组件引用(需要在App.vue中注册)
|
||||
*/
|
||||
let loginModalRef = null
|
||||
|
||||
/**
|
||||
* 注册登录弹窗组件
|
||||
* @param {Object} modalRef - LoginModal组件的ref
|
||||
*/
|
||||
export function registerLoginModal(modalRef) {
|
||||
loginModalRef = modalRef
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示登录弹窗
|
||||
*/
|
||||
function showLoginModal() {
|
||||
if (loginModalRef) {
|
||||
loginModalRef.show()
|
||||
} else {
|
||||
// 如果没有注册弹窗,跳转到登录页
|
||||
uni.navigateTo({
|
||||
url: '/pages/memberInfo/login'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录用户原本要访问的页面
|
||||
* @param {String} url - 页面路径
|
||||
*/
|
||||
function saveRedirectUrl(url) {
|
||||
uni.setStorageSync('redirectUrl', url)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户原本要访问的页面
|
||||
* @returns {String|null}
|
||||
*/
|
||||
export function getRedirectUrl() {
|
||||
const url = uni.getStorageSync('redirectUrl')
|
||||
uni.removeStorageSync('redirectUrl')
|
||||
return url
|
||||
}
|
||||
|
||||
/**
|
||||
* 跳转到用户原本要访问的页面
|
||||
*/
|
||||
export function redirectToSavedUrl() {
|
||||
const url = getRedirectUrl()
|
||||
if (url) {
|
||||
uni.navigateTo({
|
||||
url: url,
|
||||
fail: () => {
|
||||
uni.switchTab({
|
||||
url: '/pages/index/index'
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化路由拦截器
|
||||
*/
|
||||
export function initPermissionInterceptor() {
|
||||
console.log('[PermissionInterceptor] 初始化路由拦截器')
|
||||
|
||||
// 拦截 navigateTo
|
||||
uni.addInterceptor('navigateTo', {
|
||||
invoke(args) {
|
||||
const url = args.url
|
||||
if (isNeedLogin(url) && !isLoggedIn()) {
|
||||
console.log('[PermissionInterceptor] 拦截 navigateTo:', url)
|
||||
saveRedirectUrl(url)
|
||||
showLoginModal()
|
||||
return false
|
||||
}
|
||||
return true
|
||||
},
|
||||
success() {
|
||||
// 跳转成功
|
||||
},
|
||||
fail(err) {
|
||||
console.error('[PermissionInterceptor] navigateTo 失败:', err)
|
||||
}
|
||||
})
|
||||
|
||||
// 拦截 redirectTo
|
||||
uni.addInterceptor('redirectTo', {
|
||||
invoke(args) {
|
||||
const url = args.url
|
||||
if (isNeedLogin(url) && !isLoggedIn()) {
|
||||
console.log('[PermissionInterceptor] 拦截 redirectTo:', url)
|
||||
saveRedirectUrl(url)
|
||||
showLoginModal()
|
||||
return false
|
||||
}
|
||||
return true
|
||||
},
|
||||
success() {},
|
||||
fail(err) {
|
||||
console.error('[PermissionInterceptor] redirectTo 失败:', err)
|
||||
}
|
||||
})
|
||||
|
||||
// 拦截 reLaunch
|
||||
uni.addInterceptor('reLaunch', {
|
||||
invoke(args) {
|
||||
const url = args.url
|
||||
if (isNeedLogin(url) && !isLoggedIn()) {
|
||||
console.log('[PermissionInterceptor] 拦截 reLaunch:', url)
|
||||
saveRedirectUrl(url)
|
||||
showLoginModal()
|
||||
return false
|
||||
}
|
||||
return true
|
||||
},
|
||||
success() {},
|
||||
fail(err) {
|
||||
console.error('[PermissionInterceptor] reLaunch 失败:', err)
|
||||
}
|
||||
})
|
||||
|
||||
// 拦截 switchTab
|
||||
uni.addInterceptor('switchTab', {
|
||||
invoke(args) {
|
||||
const url = args.url
|
||||
if (isNeedLogin(url) && !isLoggedIn()) {
|
||||
console.log('[PermissionInterceptor] 拦截 switchTab:', url)
|
||||
saveRedirectUrl(url)
|
||||
showLoginModal()
|
||||
return false
|
||||
}
|
||||
return true
|
||||
},
|
||||
success() {},
|
||||
fail(err) {
|
||||
console.error('[PermissionInterceptor] switchTab 失败:', err)
|
||||
}
|
||||
})
|
||||
|
||||
// 拦截 navigateBack
|
||||
uni.addInterceptor('navigateBack', {
|
||||
invoke() {
|
||||
return true
|
||||
},
|
||||
success() {},
|
||||
fail() {}
|
||||
})
|
||||
|
||||
console.log('[PermissionInterceptor] 路由拦截器初始化完成')
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加需要登录的页面路径
|
||||
* @param {String|Array} paths - 页面路径或路径数组
|
||||
*/
|
||||
export function addLoginRequiredPaths(paths) {
|
||||
if (Array.isArray(paths)) {
|
||||
LOGIN_REQUIRED_PATHS.push(...paths)
|
||||
} else {
|
||||
LOGIN_REQUIRED_PATHS.push(paths)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加需要登录的页面路径前缀
|
||||
* @param {String|Array} prefixes - 路径前缀或前缀数组
|
||||
*/
|
||||
export function addLoginRequiredPrefixes(prefixes) {
|
||||
if (Array.isArray(prefixes)) {
|
||||
LOGIN_REQUIRED_PREFIXES.push(...prefixes)
|
||||
} else {
|
||||
LOGIN_REQUIRED_PREFIXES.push(prefixes)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加到白名单
|
||||
* @param {String|Array} paths - 页面路径或路径数组
|
||||
*/
|
||||
export function addWhiteList(paths) {
|
||||
if (Array.isArray(paths)) {
|
||||
WHITE_LIST.push(...paths)
|
||||
} else {
|
||||
WHITE_LIST.push(paths)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前配置的白名单
|
||||
* @returns {Array}
|
||||
*/
|
||||
export function getWhiteList() {
|
||||
return [...WHITE_LIST]
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前配置的需要登录的路径
|
||||
* @returns {Array}
|
||||
*/
|
||||
export function getLoginRequiredPaths() {
|
||||
return [...LOGIN_REQUIRED_PATHS]
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前配置的需要登录的路径前缀
|
||||
* @returns {Array}
|
||||
*/
|
||||
export function getLoginRequiredPrefixes() {
|
||||
return [...LOGIN_REQUIRED_PREFIXES]
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* ============================================
|
||||
* 健身房管理系统小程序 - 全局配色变量
|
||||
* 主题:清新健康运动风格
|
||||
* 主色调:浅蓝渐变 + 活力橙点缀
|
||||
* 兼容暗色/浅色模式基础,保证可访问性
|
||||
* ============================================
|
||||
*/
|
||||
|
||||
:root {
|
||||
/* ========== 主品牌色(首页主题蓝绿色系)========== */
|
||||
--primary-dark: #2D4A5A; /* 深蓝绿主色 - 用于重要文字、品牌标识,体现专业信赖感 */
|
||||
--primary-deep: #7AB5CC; /* 蓝绿主色 - 用于按钮、图标、标签背景,首页核心主题色 */
|
||||
--primary-light: #9CCFDF; /* 浅蓝绿 - 用于hover状态、渐变辅助,增加层次感 */
|
||||
|
||||
/* 主页主题蓝绿渐变色系 */
|
||||
--primary-sky-100: #D6EEF8; /* 最浅蓝 - 渐变起始色,清新自然 */
|
||||
--primary-sky-200: #E4F2FA; /* 浅蓝 - 渐变第二层 */
|
||||
--primary-sky-300: #EEF6FB; /* 淡蓝 - 渐变第三层 */
|
||||
--primary-sky-400: #F5FAFD; /* 微蓝 - 渐变第四层 */
|
||||
--primary-sky-500: #FAFCFE; /* 极浅蓝 - 渐变第五层,接近白色 */
|
||||
|
||||
/* 光晕效果色 */
|
||||
--glow-blue-1: rgba(160, 210, 235, 0.35); /* 蓝绿色光晕 */
|
||||
--glow-blue-2: rgba(180, 220, 240, 0.3); /* 浅蓝色光晕 */
|
||||
--glow-blue-3: rgba(170, 215, 238, 0.25); /* 浅蓝绿色光晕 */
|
||||
|
||||
/* ========== 强调/行动色(活力绿)========== */
|
||||
--accent-green: rgba(130, 220, 130, 0.9); /* 活力绿 - 主要CTA按钮、图标背景,首页核心行动色 */
|
||||
--accent-green-light: rgba(150, 230, 150, 0.8); /* 浅绿 - hover轻量背景 */
|
||||
--accent-green-dark: rgba(100, 200, 100, 1); /* 深绿 - 按压状态 */
|
||||
--accent-orange: #FF6B35; /* 活力橙 - 辅助CTA按钮、会员标识、高亮徽章、关键数据 */
|
||||
--accent-orange-light: #FF8C5A; /* 浅橙色 - hover轻量背景、渐变辅助 */
|
||||
--accent-orange-dark: #E55A2B; /* 深橙色 - 按压状态或重要警告 */
|
||||
|
||||
/* ========== 背景色系(主页主题)========== */
|
||||
--bg-gradient-primary: linear-gradient(180deg, #D6EEF8 0%, #E4F2FA 15%, #EEF6FB 30%, #F5FAFD 50%, #FAFCFE 70%, #FFFFFF 100%); /* 主页主渐变背景 */
|
||||
--bg-light: #F5FAFD; /* 全局浅蓝背景 - 柔和且提升蓝色/橙色的视觉舒适度 */
|
||||
--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-green: linear-gradient(135deg, rgba(130, 220, 130, 0.9) 0%, rgba(150, 230, 150, 0.8) 100%); /* 绿色渐变 - 主要CTA按钮、图标背景 */
|
||||
--gradient-orange: linear-gradient(135deg, #FF6B35 0%, #FF8C5A 100%); /* 橙色渐变 - 辅助按钮、重要徽章 */
|
||||
--gradient-blue: linear-gradient(135deg, #7AB5CC 0%, #9CCFDF 100%); /* 蓝绿渐变 - 头部banner或特别卡片,首页核心渐变 */
|
||||
--gradient-sky: linear-gradient(180deg, #D6EEF8 0%, #E4F2FA 15%, #EEF6FB 30%, #F5FAFD 50%, #FAFCFE 70%, #FFFFFF 100%); /* 主页天空渐变 - 全局背景 */
|
||||
--gradient-subtle: linear-gradient(120deg, #F9FAFE 0%, #FFFFFF 100%); /* 微弱渐变 - 增加细节精致度 */
|
||||
|
||||
/* ========== TabBar 配色(清新蓝绿风格)========== */
|
||||
/* 引用位置:components/TabBar.vue */
|
||||
--tabbar-bg: rgba(200, 225, 238, 0.8); /* TabBar背景色 - 半透明浅蓝色毛玻璃效果 */
|
||||
--tabbar-shadow: rgba(120, 185, 215, 0.2); /* TabBar阴影色 - 蓝色系柔和阴影 */
|
||||
--tabbar-icon-inactive: gray; /* 未选中图标颜色 - 灰色 */
|
||||
--tabbar-icon-active: #7AB5CC; /* 选中图标颜色 - 蓝绿色(首页主题色) */
|
||||
--tabbar-text-inactive: #8AABBB; /* 未选中文字颜色 - 浅灰蓝 */
|
||||
--tabbar-text-active: #7AB5CC; /* 选中文字颜色 - 蓝绿色(与图标一致,首页主题色) */
|
||||
|
||||
/* ========== 通用蓝色系阴影(用于卡片、按钮等)========== */
|
||||
/* 引用位置:components/index/RecommendCourses.vue, QuickEntry.vue, TodayRecommend.vue */
|
||||
--shadow-blue-light: rgba(120, 185, 215, 0.18); /* 浅蓝色阴影 - 卡片悬浮效果 */
|
||||
|
||||
/* ========== 阴影层级 ========== */
|
||||
--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吸引力 */
|
||||
--shadow-sky-glow: 0 4px 12px rgba(160, 210, 235, 0.2); /* 蓝色光晕 - 主页效果增强 */
|
||||
|
||||
/* ========== 圆角规范 (柔和运动风) ========== */
|
||||
--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);
|
||||
}
|
||||
.bg-gradient-sky {
|
||||
background: var(--gradient-sky);
|
||||
}
|
||||
.bg-gradient-primary {
|
||||
background: var(--bg-gradient-primary);
|
||||
}
|
||||
.bg-sky-100 {
|
||||
background-color: var(--primary-sky-100);
|
||||
}
|
||||
.bg-sky-200 {
|
||||
background-color: var(--primary-sky-200);
|
||||
}
|
||||
.bg-sky-300 {
|
||||
background-color: var(--primary-sky-300);
|
||||
}
|
||||
.bg-sky-400 {
|
||||
background-color: var(--primary-sky-400);
|
||||
}
|
||||
.bg-sky-500 {
|
||||
background-color: var(--primary-sky-500);
|
||||
}
|
||||
|
||||
/* 文字色类 */
|
||||
.text-primary {
|
||||
color: var(--primary-dark);
|
||||
}
|
||||
.text-accent {
|
||||
color: var(--accent-orange);
|
||||
}
|
||||
.text-sky-100 {
|
||||
color: var(--primary-sky-100);
|
||||
}
|
||||
.text-sky-200 {
|
||||
color: var(--primary-sky-200);
|
||||
}
|
||||
|
||||
/* 光晕效果类 */
|
||||
.glow-blue-1 {
|
||||
background: radial-gradient(circle, var(--glow-blue-1) 0%, transparent 70%);
|
||||
}
|
||||
.glow-blue-2 {
|
||||
background: radial-gradient(circle, var(--glow-blue-2) 0%, transparent 70%);
|
||||
}
|
||||
.glow-blue-3 {
|
||||
background: radial-gradient(circle, var(--glow-blue-3) 0%, transparent 70%);
|
||||
}
|
||||
|
||||
/* 按钮类 */
|
||||
.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);
|
||||
}
|
||||
.card-sky {
|
||||
background: var(--gradient-sky);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
/* 阴影类 */
|
||||
.shadow-sky {
|
||||
box-shadow: var(--shadow-sky-glow);
|
||||
}
|
||||
|
||||
/* 通用页面容器 */
|
||||
.page-container-sky {
|
||||
min-height: 100vh;
|
||||
background: var(--gradient-sky);
|
||||
}
|
||||
|
||||
/* 滚动容器 */
|
||||
.scroll-container-sky {
|
||||
height: 100vh;
|
||||
width: 100%;
|
||||
background: var(--gradient-sky);
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,25 @@
|
||||
@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";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
@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";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
@import './member-info-page.css';
|
||||
@import './member-info-status-bar.css';
|
||||
@import './member-info-header.css';
|
||||
@import './member-info-member-card.css';
|
||||
@import './member-info-quick-actions.css';
|
||||
@import './member-info-booking-list.css';
|
||||
@import './member-info-check-in-list.css';
|
||||
@import './member-info-body-report.css';
|
||||
@import './member-info-coupon-points.css';
|
||||
@import './member-info-referral.css';
|
||||
@import './member-info-settings.css';
|
||||
@import './member-info-logout.css';
|
||||
@@ -0,0 +1,245 @@
|
||||
.body-report-section {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.body-report-section__inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.body-report-section__header {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.body-report-section__header-inner {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.body-report-section__title {
|
||||
font-size: var(--font-size-md);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 700;
|
||||
color: var(--text-dark);
|
||||
}
|
||||
|
||||
.body-report-section__card {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 14px;
|
||||
box-shadow: 0px 2px 10px 0px rgba(26, 25, 24, 0.03137254901960784);
|
||||
background-color: var(--bg-white, #ffffff);
|
||||
}
|
||||
|
||||
.body-report-section__card-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: var(--spacing-md, 16px);
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.body-report-section__card-head {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.body-report-section__card-head-inner {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.body-report-section__desc {
|
||||
font-size: var(--font-size-base);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 400;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.body-report-section__view-btn {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.body-report-section__view-icon {width: 14px;
|
||||
height: 14px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.body-report-section__view-report {
|
||||
font-size: var(--font-size-base);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 500;
|
||||
color: var(--accent-orange);
|
||||
}
|
||||
|
||||
.body-report-section__metrics {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.body-report-section__metrics-inner {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.body-report-section__metric {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.body-report-section__metric-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.body-report-section__text {
|
||||
font-size: var(--font-size-xl);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 700;
|
||||
color: var(--accent-orange);
|
||||
}
|
||||
|
||||
.body-report-section__text-2 {
|
||||
font-size: var(--font-size-xl);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 700;
|
||||
color: var(--success-green);
|
||||
}
|
||||
|
||||
.body-report-section__text-4 {
|
||||
font-size: var(--font-size-xl);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 700;
|
||||
color: rgba(243, 156, 18, 1);
|
||||
}
|
||||
|
||||
.body-report-section__num {
|
||||
font-size: var(--font-size-xl);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 700;
|
||||
color: var(--primary-deep);
|
||||
}
|
||||
|
||||
.body-report-section__metric-value,
|
||||
.body-report-section__text-3,
|
||||
.body-report-section__metric-label,
|
||||
.body-report-section__text-5 {
|
||||
font-size: var(--font-size-xs);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 400;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.body-report-section__metric-divider {
|
||||
width: 1px;
|
||||
height: 30px;
|
||||
flex-shrink: 0;
|
||||
background-color: var(--border-light, #e9edf2);
|
||||
}
|
||||
|
||||
.body-report-section__summary {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
background-color: var(--bg-light, #f9fafe);
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.body-report-section__summary-inner {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.body-report-section__goal {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
max-width: 58%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: var(--spacing-xs);
|
||||
align-items: center;
|
||||
padding: 5px 10px;
|
||||
border-radius: 100px;
|
||||
background-color: rgba(240, 250, 245, 1);
|
||||
}
|
||||
|
||||
.body-report-section__goal-icon {width: 12px;
|
||||
height: 12px;
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.body-report-section__goal-text {
|
||||
font-size: var(--font-size-sm);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 500;
|
||||
color: var(--success-green);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.body-report-section__change {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: var(--spacing-xs);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.body-report-section__change-icon {width: 14px;
|
||||
height: 14px;
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.body-report-section__metric-value-2 {
|
||||
font-size: var(--font-size-base);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 500;
|
||||
color: var(--success-green);
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
.booking-section {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.booking-section__inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.booking-section__header {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.booking-section__header-inner {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.booking-section__title {
|
||||
font-size: var(--font-size-md);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 700;
|
||||
color: var(--text-dark);
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.booking-section__link {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.booking-section__item {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
border-radius: 14px;
|
||||
box-shadow: 0px 2px 10px 0px rgba(26, 25, 24, 0.03137254901960784);
|
||||
background-color: var(--bg-white, #ffffff);
|
||||
}
|
||||
|
||||
.booking-section__item-inner {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 14px;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.booking-section__date {
|
||||
width: 48px;
|
||||
height: 56px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 10px;
|
||||
background-color: rgba(255, 243, 238, 1);
|
||||
}
|
||||
|
||||
.booking-section__date-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.booking-section__num {
|
||||
font-size: var(--font-size-2xl);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 700;
|
||||
color: var(--accent-orange);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.booking-section__date-sub {
|
||||
font-size: var(--font-size-xs);
|
||||
font-family: var(--font-family);
|
||||
font-weight: var(--font-weight-regular);
|
||||
color: var(--accent-orange-light);
|
||||
}
|
||||
|
||||
.booking-section__content {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
flex-shrink: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
flex-grow: 1;
|
||||
flex-basis: 0;
|
||||
}
|
||||
|
||||
.booking-section__content-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.booking-section__desc {
|
||||
font-size: var(--font-size-base);
|
||||
font-family: var(--font-family);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--text-dark);
|
||||
}
|
||||
|
||||
.booking-section__meta {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.booking-section__meta-inner {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.booking-section__icon-coach {width: 12px;
|
||||
height: 12px;
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.booking-section__coach {
|
||||
font-size: var(--font-size-sm);
|
||||
font-family: var(--font-family);
|
||||
font-weight: var(--font-weight-regular);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.booking-section__icon-location {width: 12px;
|
||||
height: 12px;
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.booking-section__text {
|
||||
font-size: var(--font-size-sm);
|
||||
font-family: var(--font-family);
|
||||
font-weight: var(--font-weight-regular);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.booking-section__status-wrap {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.booking-section__status-badge {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 3px 8px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.booking-section__status-badge--booked {
|
||||
background-color: var(--success-green);
|
||||
}
|
||||
|
||||
.booking-section__status-badge--pending {
|
||||
background-color: rgba(255, 243, 238, 1);
|
||||
border: 1px solid rgba(212, 166, 74, 1);
|
||||
}
|
||||
|
||||
.booking-section__status-text {
|
||||
font-size: var(--font-size-xs);
|
||||
font-family: var(--font-family);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--text-inverse);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.booking-section__status-text--pending {
|
||||
color: var(--accent-orange);
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
.checkin-section {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.checkin-section__inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.checkin-section__header {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.checkin-section__header-inner {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.checkin-section__title {
|
||||
font-size: var(--font-size-md);
|
||||
font-family: var(--font-family);
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--text-dark);
|
||||
}
|
||||
|
||||
.checkin-section__list {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
border-radius: 14px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
background-color: var(--bg-white);
|
||||
border: 1px solid var(--border-light);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.checkin-section__list-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.checkin-section__row {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.checkin-section__item {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.checkin-section__item-inner {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 14px var(--spacing-md);
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.checkin-section__dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
flex-shrink: 0;
|
||||
border-radius: var(--radius-full);
|
||||
background-color: var(--success-green);
|
||||
}
|
||||
|
||||
.checkin-section__dot--group {
|
||||
background-color: var(--success-green);
|
||||
}
|
||||
|
||||
.checkin-section__dot--free {
|
||||
background-color: var(--accent-orange);
|
||||
}
|
||||
|
||||
.checkin-section__dot--private {
|
||||
background-color: var(--primary-deep);
|
||||
}
|
||||
|
||||
.checkin-section__content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.checkin-section__content-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.checkin-section__desc {
|
||||
font-size: var(--font-size-base);
|
||||
font-family: var(--font-family);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--text-dark);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.checkin-section__text {
|
||||
font-size: var(--font-size-sm);
|
||||
font-family: var(--font-family);
|
||||
font-weight: var(--font-weight-regular);
|
||||
color: var(--text-light);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.checkin-section__tag-badge {
|
||||
flex-shrink: 0;
|
||||
margin-left: var(--spacing-sm);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 3px var(--spacing-sm);
|
||||
border-radius: 6px;
|
||||
background-color: rgba(240, 250, 245, 1);
|
||||
}
|
||||
|
||||
.checkin-section__tag-badge--group {
|
||||
background-color: rgba(240, 250, 245, 1);
|
||||
}
|
||||
|
||||
.checkin-section__tag-badge--free {
|
||||
background-color: rgba(255, 243, 238, 1);
|
||||
}
|
||||
|
||||
.checkin-section__tag-badge--private {
|
||||
background-color: rgba(235, 243, 250, 1);
|
||||
}
|
||||
|
||||
.checkin-section__tag-text {
|
||||
font-size: var(--font-size-xs);
|
||||
font-family: var(--font-family);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--success-green);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.checkin-section__tag-text--group {
|
||||
color: var(--success-green);
|
||||
}
|
||||
|
||||
.checkin-section__tag-text--free {
|
||||
color: var(--accent-orange);
|
||||
}
|
||||
|
||||
.checkin-section__tag-text--private {
|
||||
color: var(--primary-deep);
|
||||
}
|
||||
|
||||
.checkin-section__divider {
|
||||
height: 1px;
|
||||
background-color: var(--border-light);
|
||||
margin-left: calc(var(--spacing-md) + 8px + 12px);
|
||||
margin-right: var(--spacing-md);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/* 组件根节点:锁定浅色变量 + box-sizing(小程序组件内继承 page 的 theme-light 不稳定) */
|
||||
.status-bar,
|
||||
.profile-header,
|
||||
.member-card-section,
|
||||
.quick-actions,
|
||||
.booking-section,
|
||||
.checkin-section,
|
||||
.body-report-section,
|
||||
.coupon-section,
|
||||
.referral-section,
|
||||
.settings-section,
|
||||
.logout-btn__border-wrap,
|
||||
.logout-section {
|
||||
box-sizing: border-box;
|
||||
--primary-dark: #0B2B4B;
|
||||
--primary-deep: #1A4A6F;
|
||||
--primary-light: #2C6288;
|
||||
--accent-orange: #FF6B35;
|
||||
--accent-orange-light: #FF8C5A;
|
||||
--accent-orange-dark: #E55A2B;
|
||||
--bg-light: #F9FAFE;
|
||||
--bg-white: #FFFFFF;
|
||||
--bg-gray: #F2F5F9;
|
||||
--text-dark: #1E2A3A;
|
||||
--text-muted: #5E6F8D;
|
||||
--text-light: #8A99B4;
|
||||
--text-inverse: #FFFFFF;
|
||||
--border-light: #E9EDF2;
|
||||
--border-focus: #FF6B35;
|
||||
--success-green: #2ECC71;
|
||||
--warning-amber: #F39C12;
|
||||
--error-red: #E74C3C;
|
||||
--info-blue: #3498DB;
|
||||
--spacing-xs: 4px;
|
||||
--spacing-sm: 8px;
|
||||
--spacing-md: 16px;
|
||||
--spacing-lg: 24px;
|
||||
--spacing-xl: 32px;
|
||||
--radius-sm: 12px;
|
||||
--radius-md: 20px;
|
||||
--radius-lg: 28px;
|
||||
--radius-full: 999px;
|
||||
--font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||
--font-size-xs: 11px;
|
||||
--font-size-sm: 12px;
|
||||
--font-size-base: 14px;
|
||||
--font-size-md: 16px;
|
||||
--font-size-lg: 18px;
|
||||
--font-size-xl: 20px;
|
||||
--font-size-2xl: 22px;
|
||||
--font-size-3xl: 24px;
|
||||
--font-size-4xl: 28px;
|
||||
--font-size-5xl: 32px;
|
||||
--font-weight-regular: 400;
|
||||
--font-weight-medium: 500;
|
||||
--font-weight-bold: 700;
|
||||
--shadow-sm: 0 8px 20px rgba(0, 0, 0, 0.03), 0 2px 6px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
/* 区块标题右侧操作链接:查看全部 / 全部记录 / 历史数据 / 推荐记录 */
|
||||
.member-card-section__link-text,
|
||||
.booking-section__view-all,
|
||||
.checkin-section__view-all,
|
||||
.coupon-section__view-all,
|
||||
.body-report-section__history-link,
|
||||
.referral-section__records-link {
|
||||
font-size: var(--font-size-base, 14px);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 400;
|
||||
color: var(--accent-orange);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.member-card-section__link,
|
||||
.booking-section__link,
|
||||
.checkin-section__link,
|
||||
.coupon-section__link,
|
||||
.body-report-section__link,
|
||||
.referral-section__link {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.member-card-section__link-arrow,
|
||||
.booking-section__link-arrow,
|
||||
.checkin-section__link-arrow,
|
||||
.coupon-section__link-arrow,
|
||||
.body-report-section__link-arrow,
|
||||
.referral-section__link-arrow {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
@import '@/common/style/memberInfo/member-info-gradient-cards.css';
|
||||
|
||||
.coupon-section {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.coupon-section__inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.coupon-section__header {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.coupon-section__header-inner {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.coupon-section__title {
|
||||
font-size: var(--font-size-md);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 700;
|
||||
color: var(--text-dark);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
|
||||
.coupon-section__link {
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.coupon-section__view-all {
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-grow: 0;
|
||||
}
|
||||
|
||||
.coupon-section__link-arrow {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.coupon-section__cards {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.coupon-section__cards-inner {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.coupon-section__coupon {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
flex-grow: 1;
|
||||
flex-basis: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.coupon-section__coupon-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: 14px 14px 14px 14px;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.coupon-section__amount {
|
||||
font-size: var(--font-size-3xl);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 700;
|
||||
color: var(--text-inverse);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
|
||||
.coupon-section__desc {
|
||||
font-size: var(--font-size-xs);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 400;
|
||||
color: rgba(255, 212, 184, 1);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
|
||||
.coupon-section__coupon-status {
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
padding: 2px 6px 2px 6px;
|
||||
border-radius: 6px 6px 6px 6px;
|
||||
background-color: rgba(255, 255, 255, 0.1882352977991104);
|
||||
}
|
||||
|
||||
.coupon-section__status {
|
||||
font-size: var(--font-size-xs);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 500;
|
||||
color: var(--text-inverse);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
|
||||
.coupon-section__points {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
flex-grow: 1;
|
||||
flex-basis: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.coupon-section__points-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: 14px 14px 14px 14px;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.coupon-section__num {
|
||||
font-size: var(--font-size-3xl);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 700;
|
||||
color: var(--text-inverse);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
|
||||
.coupon-section__points-label {
|
||||
font-size: var(--font-size-xs);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 400;
|
||||
color: rgba(255, 212, 184, 1);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
|
||||
.coupon-section__points-action {
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
padding: 2px 6px 2px 6px;
|
||||
border-radius: 6px 6px 6px 6px;
|
||||
background-color: rgba(255, 255, 255, 0.1882352977991104);
|
||||
}
|
||||
|
||||
.coupon-section__text {
|
||||
font-size: var(--font-size-xs);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 500;
|
||||
color: var(--text-inverse);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* 与个人中心首页 coupon-section 一致的渐变卡片背景
|
||||
* 使用 SVG 背景,兼容微信小程序(CSS 变量渐变在部分端不生效)
|
||||
*/
|
||||
|
||||
.mi-gradient-blue,
|
||||
.coupon-section__points,
|
||||
.mi-mod-points-hero,
|
||||
.bt-hero,
|
||||
.bt-score-card,
|
||||
.mi-mod-referral-hero {
|
||||
background-color: #0B2B4B;
|
||||
background-position: center;
|
||||
background-size: cover;
|
||||
background-image: url("data:image/svg+xml;utf8,%3Csvg%20viewBox%3D'0%200%20174%20106'%20preserveAspectRatio%3D'none'%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%3E%0A%20%20%20%20%20%20%3Cdefs%3E%0A%20%20%20%20%20%20%20%20%3ClinearGradient%20id%3D'grad'%20gradientUnits%3D'objectBoundingBox'%20x1%3D'0'%20y1%3D'0.5'%20x2%3D'1'%20y2%3D'0.5'%20gradientTransform%3D'matrix(-0.7071%2C%200.7071%2C%20-0.7071%2C%20-0.7071%2C%201.2071%2C%200.5000)'%3E%0A%20%20%20%20%20%20%20%20%20%20%3Cstop%20stop-color%3D'rgba(11%2C43%2C75%2C1)'%20offset%3D'0'%2F%3E%3Cstop%20stop-color%3D'rgba(26%2C74%2C111%2C1)'%20offset%3D'1'%2F%3E%0A%20%20%20%20%20%20%20%20%3C%2FlinearGradient%3E%0A%20%20%20%20%20%20%3C%2Fdefs%3E%0A%20%20%20%20%20%20%3Crect%20width%3D'100%25'%20height%3D'100%25'%20fill%3D'url(%23grad)'%2F%3E%0A%20%20%20%20%3C%2Fsvg%3E");
|
||||
box-shadow: 0px 4px 8px 0px rgba(11, 43, 75, 0.25);
|
||||
}
|
||||
|
||||
.mi-gradient-orange,
|
||||
.coupon-section__coupon,
|
||||
.mi-mod-coupon__left,
|
||||
.mi-mod-coupon__use,
|
||||
.mi-center-coupon__btn:not(.mi-center-coupon__btn--done),
|
||||
.bt-page__action-link--primary,
|
||||
.bt-btn--primary {
|
||||
background-color: #FF6B35;
|
||||
background-position: center;
|
||||
background-size: cover;
|
||||
background-image: url("data:image/svg+xml;utf8,%3Csvg%20viewBox%3D'0%200%20174%20106'%20preserveAspectRatio%3D'none'%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%3E%0A%20%20%20%20%20%20%3Cdefs%3E%0A%20%20%20%20%20%20%20%20%3ClinearGradient%20id%3D'grad'%20gradientUnits%3D'objectBoundingBox'%20x1%3D'0'%20y1%3D'0.5'%20x2%3D'1'%20y2%3D'0.5'%20gradientTransform%3D'matrix(-0.7071%2C%200.7071%2C%20-0.7071%2C%20-0.7071%2C%201.2071%2C%200.5000)'%3E%0A%20%20%20%20%20%20%20%20%20%20%3Cstop%20stop-color%3D'rgba(255%2C107%2C53%2C1)'%20offset%3D'0'%2F%3E%3Cstop%20stop-color%3D'rgba(255%2C140%2C90%2C1)'%20offset%3D'1'%2F%3E%0A%20%20%20%20%20%20%20%20%3C%2FlinearGradient%3E%0A%20%20%20%20%20%20%3C%2Fdefs%3E%0A%20%20%20%20%20%20%3Crect%20width%3D'100%25'%20height%3D'100%25'%20fill%3D'url(%23grad)'%2F%3E%0A%20%20%20%20%3C%2Fsvg%3E");
|
||||
box-shadow: 0px 4px 8px 0px rgba(255, 107, 53, 0.25);
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
.profile-header {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 顶栏固定:仅白底导航栏吸顶,下方用户信息可滚动 */
|
||||
.profile-header__toolbar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 100;
|
||||
width: 100%;
|
||||
background-color: var(--bg-white, #ffffff);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.profile-header__toolbar-spacer {
|
||||
width: 100%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.profile-header__nav {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
height: 44px;
|
||||
padding-left: 16px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.profile-header__nav-left {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 56px;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.profile-header__nav-right {
|
||||
min-width: 72px;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.profile-header__title {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
max-width: 42%;
|
||||
text-align: center;
|
||||
font-size: var(--font-size-md, 16px);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 600;
|
||||
color: var(--text-dark);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.profile-header__icon-bell,
|
||||
.profile-header__icon-settings {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* settings.png 为白色线稿,白底顶栏需着色后才可见 */
|
||||
.profile-header__icon-settings {
|
||||
filter: brightness(0) saturate(100%) invert(52%) sepia(98%) saturate(1800%) hue-rotate(346deg) brightness(102%) contrast(101%);
|
||||
}
|
||||
|
||||
/* 用户信息渐变区 */
|
||||
.profile-header__hero {
|
||||
width: 100%;
|
||||
background-position: center;
|
||||
background-size: 100% 100%;
|
||||
background-repeat: no-repeat;
|
||||
background-image: url("data:image/svg+xml;utf8,%3Csvg%20viewBox%3D'0%200%20390%20239'%20preserveAspectRatio%3D'none'%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%3E%0A%20%20%20%20%20%20%3Cdefs%3E%0A%20%20%20%20%20%20%20%20%3ClinearGradient%20id%3D'grad'%20gradientUnits%3D'objectBoundingBox'%20x1%3D'0'%20y1%3D'0.5'%20x2%3D'1'%20y2%3D'0.5'%20gradientTransform%3D'matrix(-0.0000%2C%201.0000%2C%20-1.0000%2C%20-0.0000%2C%201.0000%2C%200.0000)'%3E%0A%20%20%20%20%20%20%20%20%20%20%3Cstop%20stop-color%3D'rgba(11%2C43%2C75%2C1)'%20offset%3D'0'%2F%3E%3Cstop%20stop-color%3D'rgba(26%2C74%2C111%2C1)'%20offset%3D'1'%2F%3E%0A%20%20%20%20%20%20%20%20%3C%2FlinearGradient%3E%0A%20%20%20%20%20%20%3C%2Fdefs%3E%0A%20%20%20%20%20%20%3Crect%20width%3D'100%25'%20height%3D'100%25'%20fill%3D'url(%23grad)'%2F%3E%0A%20%20%20%20%3C%2Fsvg%3E");
|
||||
}
|
||||
|
||||
.profile-header__inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
padding: 16px 20px 28px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.profile-header__user {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.profile-header__user-inner {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.profile-header__avatar-wrap {
|
||||
position: relative;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
flex-shrink: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.profile-header__avatar-ring {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 50%;
|
||||
border: 3px solid #ffffff;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
background-color: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.profile-header__avatar {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.profile-header__avatar-badge {
|
||||
position: absolute;
|
||||
right: -1px;
|
||||
bottom: -1px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #ffffff;
|
||||
background-color: #2ecc71;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.profile-header__avatar-badge-icon {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
filter: brightness(0) invert(1);
|
||||
}
|
||||
|
||||
.profile-header__user-meta {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.profile-header__user-meta-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs, 4px);
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.profile-header__name {
|
||||
font-size: var(--font-size-xl, 20px);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 700;
|
||||
color: var(--text-inverse);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.profile-header__phone {
|
||||
font-size: var(--font-size-base, 14px);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 400;
|
||||
color: rgba(255, 212, 184, 1);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.profile-header__badge {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: var(--spacing-xs, 4px);
|
||||
align-items: center;
|
||||
padding: 3px 10px;
|
||||
border-radius: 10px;
|
||||
background-color: var(--accent-orange);
|
||||
}
|
||||
|
||||
.profile-header__badge-icon {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.profile-header__level {
|
||||
font-size: var(--font-size-sm, 12px);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 600;
|
||||
color: var(--text-inverse);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.profile-header__stats {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.profile-header__stats-inner {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
padding-top: 16px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.profile-header__stat {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.profile-header__stat-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.profile-header__stat-value {
|
||||
font-size: var(--font-size-2xl, 22px);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 700;
|
||||
color: var(--text-inverse);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.profile-header__stat-label {
|
||||
font-size: var(--font-size-sm, 12px);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 400;
|
||||
color: rgba(255, 212, 184, 1);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.profile-header__stat-divider {
|
||||
width: 1px;
|
||||
height: 32px;
|
||||
flex-shrink: 0;
|
||||
background-color: rgba(255, 255, 255, 0.31);
|
||||
}
|
||||
|
||||
.profile-header__user-inner--guest {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.profile-header__avatar-wrap--guest {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(255, 255, 255, 0.5);
|
||||
overflow: hidden;
|
||||
background-color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.profile-header__avatar--guest {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.profile-header__user-meta--guest {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.profile-header__name--guest {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: black;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.profile-header__phone--guest {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: #999999;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.profile-header__user-inner--guest {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
line-height: inherit;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
.logout-section {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.logout-section__btn {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
min-height: 50px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 2px 10px rgba(26, 25, 24, 0.03);
|
||||
background-color: #ffffff;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.logout-section__icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.logout-section__text {
|
||||
font-size: 14px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||
font-weight: 500;
|
||||
color: #e74c3c;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mi-tap-btn--hover {
|
||||
opacity: 0.85;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
@@ -0,0 +1,443 @@
|
||||
.member-card-section {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.member-card-section__inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.member-card-section__head {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.member-card-section__head-inner {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.member-card-section__title {
|
||||
font-size: var(--font-size-md);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 700;
|
||||
color: var(--text-dark);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
|
||||
.member-card-section__link {
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.member-card-section__link-text {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.member-card-section__link-arrow {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.member-card-preview {
|
||||
width: 100%;
|
||||
min-height: 140px;
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0px 8px 16px 0px rgba(255, 107, 53, 0.25);
|
||||
background-position: center;
|
||||
background-size: 100% 100%;
|
||||
box-sizing: border-box;
|
||||
background-image: url("data:image/svg+xml;utf8,%3Csvg%20viewBox%3D'0%200%20358%20140'%20preserveAspectRatio%3D'none'%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%3E%0A%20%20%20%20%20%20%3Cdefs%3E%0A%20%20%20%20%20%20%20%20%3ClinearGradient%20id%3D'grad'%20gradientUnits%3D'objectBoundingBox'%20x1%3D'0'%20y1%3D'0.5'%20x2%3D'1'%20y2%3D'0.5'%20gradientTransform%3D'matrix(-0.7071%2C%200.7071%2C%20-0.7071%2C%20-0.7071%2C%201.2071%2C%200.5000)'%3E%0A%20%20%20%20%20%20%20%20%20%20%3Cstop%20stop-color%3D'rgba(255%2C107%2C53%2C1)'%20offset%3D'0'%2F%3E%3Cstop%20stop-color%3D'rgba(255%2C140%2C90%2C1)'%20offset%3D'0.6000000238418579'%2F%3E%3Cstop%20stop-color%3D'rgba(229%2C90%2C43%2C1)'%20offset%3D'1'%2F%3E%0A%20%20%20%20%20%20%20%20%3C%2FlinearGradient%3E%0A%20%20%20%20%20%20%3C%2Fdefs%3E%0A%20%20%20%20%20%20%3Crect%20width%3D'100%25'%20height%3D'100%25'%20fill%3D'url(%23grad)'%2F%3E%0A%20%20%20%20%3C%2Fsvg%3E");
|
||||
}
|
||||
|
||||
.member-card-preview__inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
align-items: stretch;
|
||||
padding: 20px 16px 16px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.member-card-preview__head {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.member-card-preview__head-inner {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.member-card-preview__type-row {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.member-card-preview__icon-wrap {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.member-card-preview__icon-bg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
border-radius: 1.5px 1.5px 1.5px 1.5px;
|
||||
}
|
||||
|
||||
.member-card-preview__icon-border {
|
||||
position: absolute;
|
||||
left: 8.33%;
|
||||
top: 20.83%;
|
||||
right: 8.33%;
|
||||
bottom: 20.83%;
|
||||
width: 83.34%;
|
||||
height: 58.34%;
|
||||
}
|
||||
|
||||
.member-card-preview__icon-stroke {
|
||||
position: absolute;
|
||||
inset: -0.75px -0.75px -0.75px -0.75px;
|
||||
border-radius: 1.5px 1.5px 1.5px 1.5px;
|
||||
pointer-events: none;
|
||||
border-width: 1.5px 1.5px 1.5px 1.5px;
|
||||
border-style: solid;
|
||||
box-sizing: border-box;
|
||||
border-color: var(--text-inverse);
|
||||
}
|
||||
|
||||
.member-card-preview__icon-line {width: 83.34%;
|
||||
height: 8.33%;
|
||||
position: absolute;
|
||||
left: 8.33%;
|
||||
right: 8.33%;
|
||||
top: 41.67%;
|
||||
bottom: 50%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.member-card-preview__name {
|
||||
font-size: var(--font-size-md);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 700;
|
||||
color: var(--text-inverse);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.member-card-preview__tag {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 3px 10px;
|
||||
border-radius: 8px;
|
||||
background-color: rgba(255, 255, 255, 0.19);
|
||||
}
|
||||
|
||||
.member-card-preview__tag-text {
|
||||
font-size: var(--font-size-xs);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 500;
|
||||
color: var(--text-inverse);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
|
||||
.member-card-preview__expire {
|
||||
font-size: var(--font-size-base);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 400;
|
||||
color: rgba(255, 212, 184, 1);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
|
||||
.member-card-preview__footer {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.member-card-preview__footer-inner {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.member-card-preview__days {
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: var(--spacing-xs);
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.member-card-preview__days-num {
|
||||
font-size: var(--font-size-4xl);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 700;
|
||||
color: var(--text-inverse);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
|
||||
.member-card-preview__days-unit {
|
||||
font-size: var(--font-size-base);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 400;
|
||||
color: rgba(255, 212, 184, 1);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
|
||||
.member-card-preview__renew {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 6px 14px;
|
||||
border-radius: 14px;
|
||||
background-color: var(--bg-white);
|
||||
}
|
||||
|
||||
.member-card-preview__renew-text {
|
||||
font-size: var(--font-size-base);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 600;
|
||||
color: var(--accent-orange);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
|
||||
.member-card-tip__inner {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
border-radius: 10px 10px 10px 10px;
|
||||
background-color: rgba(255, 243, 238, 1);
|
||||
}
|
||||
|
||||
.member-card-tip__content {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
padding: 8px 12px 8px 12px;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.member-card-tip {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.member-card-tip__border {
|
||||
position: absolute;
|
||||
inset: 0px;
|
||||
border-radius: 10px 10px 10px 10px;
|
||||
pointer-events: none;
|
||||
border-width: 1px 1px 1px 1px;
|
||||
border-style: solid;
|
||||
box-sizing: border-box;
|
||||
border-color: rgba(255, 204, 170, 1);
|
||||
}
|
||||
|
||||
.member-card-tip__icon {width: 14px;
|
||||
height: 14px;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.member-card-tip__text {
|
||||
font-size: var(--font-size-base);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 400;
|
||||
color: var(--accent-orange);
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 1;
|
||||
flex-grow: 1;
|
||||
flex-basis: 0;
|
||||
}
|
||||
|
||||
.member-card-section__count {
|
||||
display: inline-flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 4rpx;
|
||||
padding: 4rpx 14rpx;
|
||||
border-radius: 20rpx;
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
backdrop-filter: blur(4px);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.member-card-section__count-num {
|
||||
font-size: 26rpx;
|
||||
font-weight: 700;
|
||||
color: #333;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.member-card-section__count-text {
|
||||
font-size: 22rpx;
|
||||
font-weight: 400;
|
||||
color: #333;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.member-card-preview__tag--expiring {
|
||||
background-color: #FFE4E4;
|
||||
}
|
||||
|
||||
.member-card-preview__tag--expiring .member-card-preview__tag-text {
|
||||
color: #E74C3C;
|
||||
}
|
||||
|
||||
.member-card-preview__tag--expired {
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.member-card-preview__tag--expired .member-card-preview__tag-text {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.member-card-preview__tag--active {
|
||||
background-color: rgba(255, 255, 255, 0.19);
|
||||
}
|
||||
|
||||
.member-card-preview__tag--active .member-card-preview__tag-text {
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
.member-card-preview__days-num--expiring {
|
||||
color: #FFE4E4;
|
||||
}
|
||||
|
||||
.member-card-preview__days-num--expired {
|
||||
color: #FF6B6B;
|
||||
}
|
||||
|
||||
.member-card-preview__purchase {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 8px 20px;
|
||||
border-radius: 20px;
|
||||
background-color: var(--bg-white);
|
||||
}
|
||||
|
||||
.member-card-preview__purchase-text {
|
||||
font-size: var(--font-size-base);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 600;
|
||||
color: var(--accent-orange);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
.scroll-container {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
box-sizing: border-box;
|
||||
background: var(--gradient-sky);
|
||||
--spacing-xs: 4px;
|
||||
--spacing-sm: 8px;
|
||||
--spacing-md: 16px;
|
||||
--spacing-lg: 24px;
|
||||
--spacing-xl: 32px;
|
||||
--font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||
--font-size-xs: 11px;
|
||||
--font-size-sm: 12px;
|
||||
--font-size-base: 14px;
|
||||
--font-size-md: 16px;
|
||||
--font-size-lg: 18px;
|
||||
--font-size-xl: 20px;
|
||||
--font-size-2xl: 22px;
|
||||
--font-size-3xl: 24px;
|
||||
--font-size-4xl: 28px;
|
||||
--font-size-5xl: 32px;
|
||||
}
|
||||
|
||||
.scroll-container > view {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.member-page {
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
background: var(--gradient-sky);
|
||||
box-sizing: border-box;
|
||||
padding-bottom: calc(120rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.member-page__body {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
flex: 1;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.member-page__sections {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
align-items: stretch;
|
||||
padding: var(--spacing-md, 16px);
|
||||
padding-bottom: calc(var(--spacing-md, 16px) + env(safe-area-inset-bottom));
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
font-family: var(--font-family);
|
||||
}
|
||||
|
||||
.member-page__sections text {
|
||||
font-family: var(--font-family);
|
||||
}
|
||||
|
||||
/* ????????? */
|
||||
.status-bar,
|
||||
.profile-header,
|
||||
.member-card-section,
|
||||
.quick-actions,
|
||||
.booking-section,
|
||||
.checkin-section,
|
||||
.body-report-section,
|
||||
.coupon-section,
|
||||
.referral-section,
|
||||
.settings-section,
|
||||
.logout-btn__border-wrap,
|
||||
.logout-section {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
.quick-actions {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0px 2px 12px 0px rgba(26, 25, 24, 0.03137254901960784);
|
||||
background-color: var(--bg-white, #ffffff);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.quick-actions__inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.quick-actions__grid {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.quick-actions__grid-inner {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.quick-actions__item {
|
||||
flex: 1;
|
||||
height: 80px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.quick-actions__item-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.quick-actions__icon-wrap {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.quick-actions__icon-wrap-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.quick-actions__icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.quick-actions__icon-part {
|
||||
position: absolute;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.quick-actions__icon-part:nth-child(1) {
|
||||
width: 7.69%;
|
||||
height: 16.67%;
|
||||
left: 30.77%;
|
||||
top: 8.33%;
|
||||
|
||||
}
|
||||
|
||||
.quick-actions__icon-part:nth-child(2) {
|
||||
width: 7.69%;
|
||||
height: 16.67%;
|
||||
left: 61.54%;
|
||||
top: 8.33%;
|
||||
|
||||
}
|
||||
|
||||
.quick-actions__icon-part:nth-child(4) {
|
||||
width: 7.69%;
|
||||
height: 16.67%;
|
||||
left: 30.77%;
|
||||
top: 58.33%;
|
||||
|
||||
}
|
||||
|
||||
.quick-actions__icon-part:nth-child(5) {
|
||||
width: 7.69%;
|
||||
height: 16.67%;
|
||||
left: 61.54%;
|
||||
top: 58.33%;
|
||||
|
||||
}
|
||||
|
||||
.quick-actions__border-wrap {
|
||||
position: absolute;
|
||||
left: 12.5%;
|
||||
top: 25%;
|
||||
width: 75%;
|
||||
height: 50%;
|
||||
}
|
||||
|
||||
.quick-actions__rect {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.quick-actions__border {
|
||||
position: absolute;
|
||||
inset: -1px;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--accent-orange);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.quick-actions__icon-img {width: 20px;
|
||||
height: 20px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.quick-actions__title,
|
||||
.quick-actions__title-2,
|
||||
.quick-actions__title-3,
|
||||
.quick-actions__title-4,
|
||||
.quick-actions__coach,
|
||||
.quick-actions__text,
|
||||
.quick-actions__text-2,
|
||||
.quick-actions__points-desc {
|
||||
font-size: var(--font-size-sm);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 500;
|
||||
color: var(--text-dark);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.quick-actions__divider {
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background-color: var(--border-light, #e9edf2);
|
||||
}
|
||||
|
||||
/* 第�?�?�??�?*/
|
||||
.quick-actions__grid:nth-of-type(1) .quick-actions__item:nth-child(1),
|
||||
.quick-actions__grid:nth-of-type(1) .quick-actions__item:nth-child(1) .quick-actions__icon-wrap {
|
||||
background-color: rgba(255, 243, 238, 1);
|
||||
}
|
||||
|
||||
.quick-actions__grid:nth-of-type(1) .quick-actions__item:nth-child(2),
|
||||
.quick-actions__grid:nth-of-type(1) .quick-actions__item:nth-child(2) .quick-actions__icon-wrap {
|
||||
background-color: rgba(240, 250, 245, 1);
|
||||
}
|
||||
|
||||
.quick-actions__grid:nth-of-type(1) .quick-actions__item:nth-child(2) .quick-actions__icon-img {
|
||||
|
||||
}
|
||||
|
||||
.quick-actions__grid:nth-of-type(1) .quick-actions__item:nth-child(3),
|
||||
.quick-actions__grid:nth-of-type(1) .quick-actions__item:nth-child(3) .quick-actions__icon-wrap {
|
||||
background-color: rgba(235, 243, 250, 1);
|
||||
}
|
||||
|
||||
.quick-actions__grid:nth-of-type(1) .quick-actions__item:nth-child(3) .quick-actions__icon-img {
|
||||
|
||||
}
|
||||
|
||||
.quick-actions__grid:nth-of-type(1) .quick-actions__item:nth-child(4),
|
||||
.quick-actions__grid:nth-of-type(1) .quick-actions__item:nth-child(4) .quick-actions__icon-wrap {
|
||||
background-color: rgba(255, 243, 238, 1);
|
||||
}
|
||||
|
||||
.quick-actions__grid:nth-of-type(1) .quick-actions__item:nth-child(4) .quick-actions__icon-img {
|
||||
|
||||
}
|
||||
|
||||
/* 第�?�?�??�?*/
|
||||
.quick-actions__grid:nth-of-type(3) .quick-actions__item:nth-child(1),
|
||||
.quick-actions__grid:nth-of-type(3) .quick-actions__item:nth-child(1) .quick-actions__icon-wrap {
|
||||
background-color: rgba(255, 236, 236, 1);
|
||||
}
|
||||
|
||||
.quick-actions__grid:nth-of-type(3) .quick-actions__item:nth-child(1) .quick-actions__icon-img {
|
||||
|
||||
}
|
||||
|
||||
.quick-actions__grid:nth-of-type(3) .quick-actions__item:nth-child(2),
|
||||
.quick-actions__grid:nth-of-type(3) .quick-actions__item:nth-child(2) .quick-actions__icon-wrap {
|
||||
background-color: rgba(255, 243, 238, 1);
|
||||
}
|
||||
|
||||
.quick-actions__grid:nth-of-type(3) .quick-actions__item:nth-child(2) .quick-actions__icon-img {
|
||||
|
||||
}
|
||||
|
||||
.quick-actions__grid:nth-of-type(3) .quick-actions__item:nth-child(3),
|
||||
.quick-actions__grid:nth-of-type(3) .quick-actions__item:nth-child(3) .quick-actions__icon-wrap {
|
||||
background-color: rgba(240, 250, 245, 1);
|
||||
}
|
||||
|
||||
.quick-actions__grid:nth-of-type(3) .quick-actions__item:nth-child(3) .quick-actions__icon-img {
|
||||
|
||||
}
|
||||
|
||||
.quick-actions__grid:nth-of-type(3) .quick-actions__item:nth-child(4),
|
||||
.quick-actions__grid:nth-of-type(3) .quick-actions__item:nth-child(4) .quick-actions__icon-wrap {
|
||||
background-color: rgba(235, 243, 250, 1);
|
||||
}
|
||||
|
||||
.quick-actions__grid:nth-of-type(3) .quick-actions__item:nth-child(4) .quick-actions__icon-img {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
.referral-section {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 2px 10px rgba(26, 25, 24, 0.03);
|
||||
background-color: var(--bg-white, #ffffff);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.referral-section__inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.referral-section__header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.referral-section__title {
|
||||
font-size: var(--font-size-md, 16px);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 700;
|
||||
color: var(--text-dark);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.referral-section__link {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.referral-section__records-link {
|
||||
font-size: var(--font-size-base, 14px);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 400;
|
||||
color: var(--accent-orange);
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.referral-section__link-arrow {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 推荐码行:grid 避免小程序 flex 宽度计算异常 */
|
||||
.referral-section__code-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
column-gap: 10px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.referral-section__code-box {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
background-color: #f2f5f9;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.referral-section__code-label {
|
||||
display: block;
|
||||
font-size: var(--font-size-xs, 11px);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 400;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.referral-section__code-value {
|
||||
display: block;
|
||||
width: 100%;
|
||||
font-size: var(--font-size-base, 14px);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 700;
|
||||
color: var(--primary-dark);
|
||||
line-height: 1.3;
|
||||
letter-spacing: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.referral-section__copy-btn {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
height: 100%;
|
||||
min-height: 52px;
|
||||
padding: 0 14px;
|
||||
border-radius: 10px;
|
||||
background-color: var(--accent-orange);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* 例图:双矩形复制图标 */
|
||||
.referral-section__copy-icon {
|
||||
position: relative;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.referral-section__copy-sheet {
|
||||
position: absolute;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border: 1.5px solid #ffffff;
|
||||
border-radius: 2px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.referral-section__copy-sheet--back {
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.referral-section__copy-sheet--front {
|
||||
left: 0;
|
||||
top: 0;
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.referral-section__copy-text {
|
||||
display: block;
|
||||
font-size: var(--font-size-base, 14px);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 600;
|
||||
color: var(--text-inverse);
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.referral-section__stats {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.referral-section__stat {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.referral-section__stat-num {
|
||||
display: block;
|
||||
font-size: var(--font-size-lg, 18px);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.referral-section__stat-num--orange {
|
||||
color: var(--accent-orange);
|
||||
}
|
||||
|
||||
.referral-section__stat-num--green {
|
||||
color: var(--success-green);
|
||||
}
|
||||
|
||||
.referral-section__stat-num--amber {
|
||||
color: #f39c12;
|
||||
}
|
||||
|
||||
.referral-section__stat-label {
|
||||
display: block;
|
||||
font-size: var(--font-size-xs, 11px);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 400;
|
||||
color: var(--text-light);
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.referral-section__stat-divider {
|
||||
width: 1px;
|
||||
height: 28px;
|
||||
flex-shrink: 0;
|
||||
background-color: var(--border-light);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
.settings-section {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
--bg-white: #ffffff;
|
||||
--text-dark: #1e2a3a;
|
||||
--text-light: #8a99b4;
|
||||
--error-red: #e74c3c;
|
||||
--success-green: #2ecc71;
|
||||
--border-light: #e9edf2;
|
||||
--font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||
--font-size-xs: 11px;
|
||||
--font-size-base: 14px;
|
||||
--font-size-md: 16px;
|
||||
--font-weight-medium: 500;
|
||||
--font-weight-bold: 700;
|
||||
}
|
||||
|
||||
.settings-section__inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.settings-section__title {
|
||||
display: block;
|
||||
font-size: 16px;
|
||||
font-family: var(--font-family);
|
||||
font-weight: 700;
|
||||
color: #1e2a3a;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.settings-section__list {
|
||||
width: 100%;
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 2px 10px rgba(26, 25, 24, 0.03);
|
||||
background-color: #ffffff;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.settings-section__list-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.settings-section__item {
|
||||
width: 100%;
|
||||
min-height: 52px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.settings-section__item--tall {
|
||||
min-height: 60px;
|
||||
}
|
||||
|
||||
.settings-section__item-inner {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.settings-section__item-icon-wrap {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
background-color: rgba(255, 243, 238, 1);
|
||||
}
|
||||
|
||||
.settings-section__item-icon-wrap--blue {
|
||||
background-color: rgba(235, 243, 250, 1);
|
||||
}
|
||||
|
||||
.settings-section__item-icon-wrap--green {
|
||||
background-color: rgba(240, 250, 245, 1);
|
||||
}
|
||||
|
||||
.settings-section__item-icon-wrap--red {
|
||||
background-color: rgba(255, 236, 236, 1);
|
||||
}
|
||||
|
||||
.settings-section__item-icon-wrap-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.settings-section__item-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.settings-section__item-label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
font-family: var(--font-family);
|
||||
font-weight: 500;
|
||||
color: #1e2a3a;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.settings-section__item-label--danger {
|
||||
color: #e74c3c;
|
||||
}
|
||||
|
||||
.settings-section__item-texts {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.settings-section__item-title {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
font-family: var(--font-family);
|
||||
font-weight: 500;
|
||||
color: #1e2a3a;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.settings-section__item-desc {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
font-family: var(--font-family);
|
||||
font-weight: 400;
|
||||
color: #2ecc71;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.settings-section__item-arrow {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.settings-section__item-divider {
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background-color: #e9edf2;
|
||||
}
|
||||
|
||||
.mi-tap-row--hover {
|
||||
opacity: 0.72;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
.status-bar {
|
||||
width: 100%;
|
||||
height: 62px;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background-color: var(--primary-dark);
|
||||
}
|
||||
|
||||
.status-bar__inner {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0px 20px 0px 20px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.status-bar__time {
|
||||
font-size: var(--font-size-md);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 600;
|
||||
color: var(--text-inverse);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
|
||||
.status-bar__icons {
|
||||
font-size: var(--font-size-sm);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 400;
|
||||
color: var(--text-inverse);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
.sub-nav {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.sub-nav__toolbar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 100;
|
||||
width: 100%;
|
||||
background-color: #ffffff;
|
||||
box-sizing: border-box;
|
||||
border-bottom: 1px solid #e9edf2;
|
||||
}
|
||||
|
||||
.sub-nav__spacer {
|
||||
width: 100%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sub-nav__nav {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
height: 44px;
|
||||
padding-left: 12px;
|
||||
padding-right: 12px;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sub-nav__back {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
min-width: 32px;
|
||||
border-radius: 8px;
|
||||
background-color: #f9fafe;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.sub-nav__back-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
max-width: 20px;
|
||||
max-height: 20px;
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sub-nav__title {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
max-width: 50%;
|
||||
text-align: center;
|
||||
font-size: 16px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||
font-weight: 600;
|
||||
color: #1e2a3a;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.sub-nav__right {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.sub-nav__action {
|
||||
flex-shrink: 0;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.sub-nav__action-text {
|
||||
font-size: 14px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||
font-weight: 500;
|
||||
color: #ff6b35;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sub-nav__action--button {
|
||||
padding: 6px 14px;
|
||||
border-radius: 8px;
|
||||
background-color: #ff6b35;
|
||||
}
|
||||
|
||||
.sub-nav__action--button .sub-nav__action-text {
|
||||
color: #ffffff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sub-nav__capsule {
|
||||
flex-shrink: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sub-nav__capsule--h5 {
|
||||
width: 0 !important;
|
||||
min-width: 0 !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/* 小程序点击反馈(配合 hover-class 使用) */
|
||||
.mi-tap--hover,
|
||||
.mi-tap--scale,
|
||||
.mi-tap-card--hover,
|
||||
.mi-tap-btn--hover,
|
||||
.mi-tap-tab--hover,
|
||||
.mi-tap-save--hover {
|
||||
transition: opacity 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
|
||||
.mi-tap--scale {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.mi-tap-card--hover {
|
||||
opacity: 0.92;
|
||||
transform: scale(0.995);
|
||||
}
|
||||
|
||||
.mi-tap-btn--hover {
|
||||
opacity: 0.85;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.mi-tap-tab--hover {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.mi-tap-row--hover {
|
||||
background-color: rgba(249, 250, 254, 0.95);
|
||||
}
|
||||
|
||||
.mi-tap-save--hover {
|
||||
opacity: 0.88;
|
||||
transform: scale(0.99);
|
||||
}
|
||||
@@ -0,0 +1,860 @@
|
||||
/* 智能体测模块 - 公共样式(基于 base.css 变量) */
|
||||
@import '@/common/style/memberInfo/member-info-gradient-cards.css';
|
||||
|
||||
.bt-page {
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
box-sizing: border-box;
|
||||
overflow-x: hidden;
|
||||
background-color: var(--bg-light, #F9FAFE);
|
||||
}
|
||||
|
||||
.bt-page__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm, 12px);
|
||||
padding: var(--spacing-md, 16px) var(--spacing-md, 16px) 40px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.bt-card {
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
border-radius: var(--radius-md, 20px);
|
||||
background-color: var(--bg-white, #fff);
|
||||
box-shadow: var(--shadow-sm, 0 8px 20px rgba(0, 0, 0, 0.03));
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.bt-card__title {
|
||||
font-size: var(--font-size-md, 16px);
|
||||
font-weight: 700;
|
||||
color: var(--text-dark, #1E2A3A);
|
||||
margin-bottom: 12px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.bt-card__desc {
|
||||
font-size: var(--font-size-sm, 12px);
|
||||
color: var(--text-muted, #5E6F8D);
|
||||
line-height: 1.5;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.bt-hero {
|
||||
padding: 20px;
|
||||
border-radius: var(--radius-sm, 12px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.bt-hero__top {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.bt-hero__label {
|
||||
font-size: var(--font-size-sm, 12px);
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
|
||||
.bt-hero__badge {
|
||||
padding: 4px 10px;
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
|
||||
.bt-hero__badge-text {
|
||||
font-size: var(--font-size-sm, 12px);
|
||||
color: var(--text-inverse, #fff);
|
||||
}
|
||||
|
||||
.bt-hero__score-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.bt-hero__score {
|
||||
font-size: 48px;
|
||||
font-weight: 800;
|
||||
color: var(--text-inverse, #fff);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.bt-hero__grade {
|
||||
font-size: var(--font-size-xl, 20px);
|
||||
font-weight: 700;
|
||||
color: var(--accent-orange-light, #FF8C5A);
|
||||
}
|
||||
|
||||
.bt-hero__meta {
|
||||
font-size: var(--font-size-sm, 12px);
|
||||
color: rgba(255, 212, 184, 1);
|
||||
}
|
||||
|
||||
.bt-hero__actions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 10px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.bt-btn {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 12px 16px;
|
||||
border-radius: var(--radius-full, 999px);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.bt-btn--primary {
|
||||
box-shadow: var(--shadow-orange-glow, 0 4px 12px rgba(255, 107, 53, 0.25));
|
||||
}
|
||||
|
||||
.bt-btn--ghost {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.bt-btn--outline {
|
||||
background: var(--bg-white, #fff);
|
||||
border: 1px solid var(--border-light, #E9EDF2);
|
||||
}
|
||||
|
||||
.bt-btn__text {
|
||||
font-size: var(--font-size-base, 14px);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bt-btn--primary .bt-btn__text,
|
||||
.bt-btn--ghost .bt-btn__text {
|
||||
color: var(--text-inverse, #fff);
|
||||
}
|
||||
|
||||
.bt-btn--outline .bt-btn__text {
|
||||
color: var(--primary-dark, #0B2B4B);
|
||||
}
|
||||
|
||||
.bt-btn__icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.bt-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.bt-grid__item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 12px 4px;
|
||||
border-radius: var(--radius-sm, 12px);
|
||||
background: var(--bg-gray, #F2F5F9);
|
||||
}
|
||||
|
||||
.bt-grid__icon {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.bt-grid__label {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted, #5E6F8D);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.bt-device {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.bt-device__icon-wrap {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 12px;
|
||||
background: var(--bg-gray, #F2F5F9);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bt-device__icon {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.bt-device__info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.bt-device__name {
|
||||
font-size: var(--font-size-base, 14px);
|
||||
font-weight: 600;
|
||||
color: var(--text-dark, #1E2A3A);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.bt-device__status {
|
||||
font-size: var(--font-size-sm, 12px);
|
||||
color: var(--text-muted, #5E6F8D);
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.bt-device__status--on {
|
||||
color: var(--success-green, #2ECC71);
|
||||
}
|
||||
|
||||
.bt-device__dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-light, #8A99B4);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bt-device__dot--on {
|
||||
background: var(--success-green, #2ECC71);
|
||||
}
|
||||
|
||||
.bt-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.bt-metric {
|
||||
padding: 14px;
|
||||
border-radius: var(--radius-sm, 12px);
|
||||
background: var(--bg-gray, #F2F5F9);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.bt-metric__value {
|
||||
font-size: var(--font-size-xl, 20px);
|
||||
font-weight: 700;
|
||||
color: var(--text-dark, #1E2A3A);
|
||||
}
|
||||
|
||||
.bt-metric__label {
|
||||
font-size: var(--font-size-sm, 12px);
|
||||
color: var(--text-muted, #5E6F8D);
|
||||
}
|
||||
|
||||
.bt-metric__change {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bt-metric__change--down {
|
||||
color: var(--success-green, #2ECC71);
|
||||
}
|
||||
|
||||
.bt-metric__change--up {
|
||||
color: var(--warning-amber, #F39C12);
|
||||
}
|
||||
|
||||
.bt-steps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.bt-step {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 12px;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.bt-step__num {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary-dark, #0B2B4B);
|
||||
color: var(--text-inverse, #fff);
|
||||
font-size: var(--font-size-sm, 12px);
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bt-step__content {
|
||||
flex: 1;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--border-light, #E9EDF2);
|
||||
}
|
||||
|
||||
.bt-step:last-child .bt-step__content {
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.bt-step__title {
|
||||
font-size: var(--font-size-base, 14px);
|
||||
font-weight: 600;
|
||||
color: var(--text-dark, #1E2A3A);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.bt-step__desc {
|
||||
font-size: var(--font-size-sm, 12px);
|
||||
color: var(--text-muted, #5E6F8D);
|
||||
margin-top: 4px;
|
||||
display: block;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.bt-measure {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 24px 16px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.bt-measure__ring-wrap {
|
||||
position: relative;
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
}
|
||||
|
||||
.bt-measure__ring-bg {
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
border-radius: 50%;
|
||||
border: 10px solid var(--border-light, #E9EDF2);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.bt-measure__ring-fill {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
border-radius: 50%;
|
||||
border: 10px solid transparent;
|
||||
border-top-color: var(--accent-orange, #FF6B35);
|
||||
border-right-color: var(--accent-orange, #FF6B35);
|
||||
box-sizing: border-box;
|
||||
transform: rotate(-90deg);
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.bt-measure__center {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.bt-measure__percent {
|
||||
font-size: 36px;
|
||||
font-weight: 800;
|
||||
color: var(--primary-dark, #0B2B4B);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.bt-measure__hint {
|
||||
font-size: var(--font-size-sm, 12px);
|
||||
color: var(--text-muted, #5E6F8D);
|
||||
}
|
||||
|
||||
.bt-measure__live {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.bt-measure__live-item {
|
||||
padding: 12px;
|
||||
border-radius: var(--radius-sm, 12px);
|
||||
background: var(--bg-gray, #F2F5F9);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.bt-measure__live-value {
|
||||
font-size: var(--font-size-lg, 18px);
|
||||
font-weight: 700;
|
||||
color: var(--text-dark, #1E2A3A);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.bt-measure__live-label {
|
||||
font-size: var(--font-size-sm, 12px);
|
||||
color: var(--text-muted, #5E6F8D);
|
||||
margin-top: 2px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.bt-score-card {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 20px;
|
||||
border-radius: var(--radius-sm, 12px);
|
||||
}
|
||||
|
||||
.bt-score-card__circle {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bt-score-card__num {
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
color: var(--text-inverse, #fff);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.bt-score-card__grade {
|
||||
font-size: 11px;
|
||||
color: var(--accent-orange-light, #FF8C5A);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bt-score-card__info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.bt-score-card__title {
|
||||
font-size: var(--font-size-md, 16px);
|
||||
font-weight: 700;
|
||||
color: var(--text-inverse, #fff);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.bt-score-card__date {
|
||||
font-size: var(--font-size-sm, 12px);
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
margin-top: 4px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.bt-body-map {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.bt-body-map__figure {
|
||||
width: 120px;
|
||||
height: 200px;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.bt-body-map__head {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary-light, #2C6288);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.bt-body-map__torso {
|
||||
width: 56px;
|
||||
height: 70px;
|
||||
border-radius: 12px;
|
||||
background: var(--primary-deep, #1A4A6F);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.bt-body-map__limbs {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 48px;
|
||||
margin-top: -60px;
|
||||
}
|
||||
|
||||
.bt-body-map__arm {
|
||||
width: 16px;
|
||||
height: 50px;
|
||||
border-radius: 8px;
|
||||
background: var(--primary-light, #2C6288);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.bt-body-map__legs {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.bt-body-map__leg {
|
||||
width: 22px;
|
||||
height: 60px;
|
||||
border-radius: 8px;
|
||||
background: var(--primary-light, #2C6288);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.bt-body-map__segments {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.bt-body-map__seg {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
background: var(--bg-gray, #F2F5F9);
|
||||
}
|
||||
|
||||
.bt-body-map__seg--high {
|
||||
border-left: 3px solid var(--accent-orange, #FF6B35);
|
||||
}
|
||||
|
||||
.bt-body-map__seg--low {
|
||||
border-left: 3px solid var(--info-blue, #3498DB);
|
||||
}
|
||||
|
||||
.bt-body-map__seg-name {
|
||||
font-size: var(--font-size-sm, 12px);
|
||||
color: var(--text-dark, #1E2A3A);
|
||||
}
|
||||
|
||||
.bt-body-map__seg-val {
|
||||
font-size: var(--font-size-sm, 12px);
|
||||
font-weight: 600;
|
||||
color: var(--text-muted, #5E6F8D);
|
||||
}
|
||||
|
||||
.bt-advice-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.bt-advice-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.bt-advice-item__dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-orange, #FF6B35);
|
||||
margin-top: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bt-advice-item__text {
|
||||
font-size: var(--font-size-sm, 12px);
|
||||
color: var(--text-muted, #5E6F8D);
|
||||
line-height: 1.5;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.bt-course {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 12px;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid var(--border-light, #E9EDF2);
|
||||
}
|
||||
|
||||
.bt-course:last-child {
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.bt-course__banner {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bt-course__info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.bt-course__tag {
|
||||
font-size: 10px;
|
||||
color: var(--accent-orange, #FF6B35);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bt-course__title {
|
||||
font-size: var(--font-size-base, 14px);
|
||||
font-weight: 600;
|
||||
color: var(--text-dark, #1E2A3A);
|
||||
}
|
||||
|
||||
.bt-course__meta {
|
||||
font-size: var(--font-size-sm, 12px);
|
||||
color: var(--text-muted, #5E6F8D);
|
||||
}
|
||||
|
||||
.bt-history-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 0;
|
||||
border-bottom: 1px solid var(--border-light, #E9EDF2);
|
||||
}
|
||||
|
||||
.bt-history-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.bt-history-item__date {
|
||||
width: 52px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bt-history-item__day {
|
||||
font-size: var(--font-size-xl, 20px);
|
||||
font-weight: 700;
|
||||
color: var(--primary-dark, #0B2B4B);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.bt-history-item__month {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted, #5E6F8D);
|
||||
}
|
||||
|
||||
.bt-history-item__info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.bt-history-item__score-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.bt-history-item__grade {
|
||||
font-size: var(--font-size-base, 14px);
|
||||
font-weight: 600;
|
||||
color: var(--text-dark, #1E2A3A);
|
||||
}
|
||||
|
||||
.bt-history-item__status {
|
||||
font-size: var(--font-size-sm, 12px);
|
||||
color: var(--text-muted, #5E6F8D);
|
||||
}
|
||||
|
||||
.bt-history-item__metrics {
|
||||
font-size: 11px;
|
||||
color: var(--text-light, #8A99B4);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.bt-history-item__arrow {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bt-compare-header {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.bt-compare-picker {
|
||||
padding: 12px;
|
||||
border-radius: var(--radius-sm, 12px);
|
||||
background: var(--bg-gray, #F2F5F9);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.bt-compare-picker__label {
|
||||
font-size: 10px;
|
||||
color: var(--text-light, #8A99B4);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.bt-compare-picker__date {
|
||||
font-size: var(--font-size-base, 14px);
|
||||
font-weight: 600;
|
||||
color: var(--text-dark, #1E2A3A);
|
||||
margin-top: 4px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.bt-compare-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid var(--border-light, #E9EDF2);
|
||||
}
|
||||
|
||||
.bt-compare-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.bt-compare-row__label {
|
||||
flex: 1;
|
||||
font-size: var(--font-size-sm, 12px);
|
||||
color: var(--text-muted, #5E6F8D);
|
||||
}
|
||||
|
||||
.bt-compare-row__val {
|
||||
width: 60px;
|
||||
text-align: center;
|
||||
font-size: var(--font-size-sm, 12px);
|
||||
font-weight: 600;
|
||||
color: var(--text-dark, #1E2A3A);
|
||||
}
|
||||
|
||||
.bt-compare-row__diff {
|
||||
width: 56px;
|
||||
text-align: right;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bt-setting {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 0;
|
||||
border-bottom: 1px solid var(--border-light, #E9EDF2);
|
||||
}
|
||||
|
||||
.bt-setting:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.bt-setting__label {
|
||||
font-size: var(--font-size-base, 14px);
|
||||
color: var(--text-dark, #1E2A3A);
|
||||
}
|
||||
|
||||
.bt-setting__desc {
|
||||
font-size: var(--font-size-sm, 12px);
|
||||
color: var(--text-light, #8A99B4);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.bt-footer-actions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 10px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.bt-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 16px;
|
||||
}
|
||||
|
||||
.bt-empty__text {
|
||||
font-size: var(--font-size-base, 14px);
|
||||
color: var(--text-light, #8A99B4);
|
||||
}
|
||||
|
||||
.bt-tabs {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: var(--spacing-sm, 8px);
|
||||
padding: 0 var(--spacing-md, 16px) var(--spacing-sm, 8px);
|
||||
overflow-x: auto;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.bt-tab {
|
||||
padding: 8px 14px;
|
||||
border-radius: var(--radius-full, 999px);
|
||||
background: var(--bg-white, #fff);
|
||||
border: 1px solid var(--border-light, #E9EDF2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bt-tab--active {
|
||||
background: var(--primary-dark, #0B2B4B);
|
||||
border-color: var(--primary-dark, #0B2B4B);
|
||||
}
|
||||
|
||||
.bt-tab__text {
|
||||
font-size: var(--font-size-sm, 12px);
|
||||
color: var(--text-muted, #5E6F8D);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bt-tab--active .bt-tab__text {
|
||||
color: var(--text-inverse, #fff);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bt-trend-link {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 0 0;
|
||||
margin-top: 8px;
|
||||
border-top: 1px solid var(--border-light, #E9EDF2);
|
||||
}
|
||||
|
||||
.bt-trend-link__text {
|
||||
font-size: var(--font-size-sm, 12px);
|
||||
color: var(--primary-deep, #1A4A6F);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bt-trend-link__arrow {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
.booking-page {
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
box-sizing: border-box;
|
||||
overflow-x: hidden;
|
||||
background-color: var(--bg-light, #F9FAFE);
|
||||
}
|
||||
|
||||
/* Tab 栏 */
|
||||
.booking-page__tabs {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: stretch;
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
background-color: var(--bg-white, #ffffff);
|
||||
border-bottom: 1px solid var(--border-light, #E9EDF2);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.booking-page__tab {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 44px;
|
||||
padding: 0 16px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.booking-page__tab-text {
|
||||
font-size: var(--font-size-base, 14px);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 400;
|
||||
color: var(--text-light, #8A99B4);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.booking-page__tab-text--active {
|
||||
font-weight: 700;
|
||||
color: var(--accent-orange, #FF6B35);
|
||||
}
|
||||
|
||||
.booking-page__tab-indicator {
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
right: 16px;
|
||||
bottom: 0;
|
||||
height: 2px;
|
||||
border-radius: 2px;
|
||||
background-color: var(--accent-orange, #FF6B35);
|
||||
}
|
||||
|
||||
/* 内容区 */
|
||||
.booking-page__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 16px 16px 40px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* 提醒横幅 */
|
||||
.booking-page__alert {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255, 204, 170, 1);
|
||||
background-color: rgba(255, 243, 238, 1);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.booking-page__alert-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.booking-page__alert-text {
|
||||
flex: 1;
|
||||
font-size: var(--font-size-base, 14px);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 500;
|
||||
color: var(--accent-orange, #FF6B35);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* 预约卡片 */
|
||||
.bk-card {
|
||||
width: 100%;
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 2px 10px rgba(26, 25, 24, 0.03);
|
||||
background-color: var(--bg-white, #ffffff);
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
transition: opacity 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
|
||||
.bk-card__banner {
|
||||
width: 100%;
|
||||
height: 80px;
|
||||
display: block;
|
||||
border-radius: 14px 14px 0 0;
|
||||
}
|
||||
|
||||
.bk-card__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 12px 14px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.bk-card__header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.bk-card__title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: var(--font-size-md, 16px);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 700;
|
||||
color: var(--text-dark, #1E2A3A);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.bk-card__status {
|
||||
flex-shrink: 0;
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.bk-card__status--booked {
|
||||
background-color: var(--success-green, #2ECC71);
|
||||
}
|
||||
|
||||
.bk-card__status--pending {
|
||||
background-color: rgba(255, 243, 238, 1);
|
||||
border: 1px solid rgba(212, 166, 74, 1);
|
||||
}
|
||||
|
||||
.bk-card__status--completed {
|
||||
background-color: var(--bg-light, #F9FAFE);
|
||||
border: 1px solid var(--border-light, #E9EDF2);
|
||||
}
|
||||
|
||||
.bk-card__status--cancelled {
|
||||
background-color: var(--bg-light, #F9FAFE);
|
||||
border: 1px solid var(--border-light, #E9EDF2);
|
||||
}
|
||||
|
||||
.bk-card__status-text {
|
||||
font-size: var(--font-size-sm, 12px);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bk-card__status-text--booked {
|
||||
color: var(--text-inverse, #ffffff);
|
||||
}
|
||||
|
||||
.bk-card__status-text--pending {
|
||||
color: var(--accent-orange, #FF6B35);
|
||||
}
|
||||
|
||||
.bk-card__status-text--completed {
|
||||
color: var(--text-muted, #5E6F8D);
|
||||
}
|
||||
|
||||
.bk-card__status-text--cancelled {
|
||||
color: var(--text-light, #8A99B4);
|
||||
}
|
||||
|
||||
/* 时间与教练信息(分两行) */
|
||||
.bk-card__meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.bk-card__meta-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.bk-card__meta-icon {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.bk-card__meta-text {
|
||||
font-size: var(--font-size-base, 14px);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 400;
|
||||
color: var(--text-muted, #5E6F8D);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 底部操作行 */
|
||||
.bk-card__footer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.bk-card__footer-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: var(--font-size-sm, 12px);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 400;
|
||||
color: var(--text-light, #8A99B4);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.bk-card__actions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.bk-card__signin {
|
||||
flex-shrink: 0;
|
||||
padding: 6px 14px;
|
||||
border-radius: 8px;
|
||||
background-color: var(--accent-orange, #FF6B35);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.bk-card__signin-text {
|
||||
font-size: var(--font-size-base, 14px);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 500;
|
||||
color: var(--text-inverse, #ffffff);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bk-card__cancel {
|
||||
flex-shrink: 0;
|
||||
padding: 6px 14px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-light, #E9EDF2);
|
||||
background-color: var(--bg-white, #ffffff);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.bk-card__cancel-text {
|
||||
font-size: var(--font-size-base, 14px);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 500;
|
||||
color: var(--error-red, #E74C3C);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.booking-page__empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px 16px;
|
||||
}
|
||||
|
||||
.booking-page__empty-text {
|
||||
font-size: var(--font-size-base, 14px);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 400;
|
||||
color: var(--text-light, #8A99B4);
|
||||
}
|
||||
@@ -0,0 +1,774 @@
|
||||
.Pixso-frame-2_965 {
|
||||
width: 100%;
|
||||
height: 62px;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background-color: var(--bg-white);
|
||||
}
|
||||
.frame-content-2_965 {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0px 20px 0px 20px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.Pixso-paragraph-2_966 {
|
||||
font-size: var(--font-size-md);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 600;
|
||||
color: var(--text-dark);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
.Pixso-paragraph-2_967 {
|
||||
font-size: var(--font-size-sm);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 400;
|
||||
color: var(--text-dark);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
.Pixso-frame-2_968 {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
background-color: var(--bg-white);
|
||||
}
|
||||
.frame-content-2_968 {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 0px 16px 0px 16px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.stroke-wrapper-2_968 {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 52px;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.stroke-2_968 {
|
||||
position: absolute;
|
||||
inset: 0px;
|
||||
border-radius: 0px 0px 0px 0px;
|
||||
pointer-events: none;
|
||||
border-width: 0px 0px 1px 0px;
|
||||
border-style: solid;
|
||||
box-sizing: border-box;
|
||||
border-color: var(--border-light);
|
||||
}
|
||||
.Pixso-frame-2_969 {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 8px 8px 8px 8px;
|
||||
background-color: var(--bg-light);
|
||||
}
|
||||
.frame-content-2_969 {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.Pixso-vector-2_970 {width: 20px;
|
||||
height: 20px;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
}
|
||||
.Pixso-paragraph-2_972 {
|
||||
font-size: var(--font-size-md);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
color: var(--primary-dark);
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 1;
|
||||
flex-grow: 1;
|
||||
flex-basis: 0;
|
||||
}
|
||||
.Pixso-frame-2_973 {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
background-color: rgba(249, 250, 254, 0);
|
||||
}
|
||||
.Pixso-frame-2_974 {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
background-color: var(--bg-white);
|
||||
}
|
||||
.frame-content-2_974 {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 0px 16px 0px 16px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.stroke-wrapper-2_974 {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.stroke-2_974 {
|
||||
position: absolute;
|
||||
inset: 0px;
|
||||
border-radius: 0px 0px 0px 0px;
|
||||
pointer-events: none;
|
||||
border-width: 0px 0px 1px 0px;
|
||||
border-style: solid;
|
||||
box-sizing: border-box;
|
||||
border-color: var(--border-light);
|
||||
}
|
||||
.Pixso-frame-2_975 {
|
||||
width: 74px;
|
||||
height: 44px;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
align-self: stretch;
|
||||
}
|
||||
.frame-content-2_975 {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 0px 16px 0px 16px;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.Pixso-paragraph-2_976 {
|
||||
font-size: var(--font-size-base);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 700;
|
||||
color: var(--accent-orange);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
.Pixso-frame-2_977 {
|
||||
width: 74px;
|
||||
height: 2px;
|
||||
position: absolute;
|
||||
left: 0px;
|
||||
top: 42px;
|
||||
border-radius: 2px 2px 2px 2px;
|
||||
background-color: var(--accent-orange);
|
||||
}
|
||||
.Pixso-frame-2_978 {
|
||||
width: auto;
|
||||
height: 44px;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
align-self: stretch;
|
||||
}
|
||||
.frame-content-2_978 {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 0px 16px 0px 16px;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.Pixso-paragraph-2_979 {
|
||||
font-size: var(--font-size-base);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 400;
|
||||
color: var(--text-light);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
.Pixso-frame-2_980 {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.frame-content-2_980 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
padding: 16px 16px 40px 16px;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.Pixso-frame-2_981 {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
border-radius: 10px 10px 10px 10px;
|
||||
background-color: rgba(255, 243, 238, 1);
|
||||
}
|
||||
.frame-content-2_981 {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 10px 12px 10px 12px;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.stroke-wrapper-2_981 {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.stroke-2_981 {
|
||||
position: absolute;
|
||||
inset: 0px;
|
||||
border-radius: 10px 10px 10px 10px;
|
||||
pointer-events: none;
|
||||
border-width: 1px 1px 1px 1px;
|
||||
border-style: solid;
|
||||
box-sizing: border-box;
|
||||
border-color: rgba(255, 204, 170, 1);
|
||||
}
|
||||
.Pixso-vector-2_982 {width: 14px;
|
||||
height: 14px;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
}
|
||||
.Pixso-paragraph-2_985 {
|
||||
font-size: var(--font-size-base);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 500;
|
||||
color: var(--accent-orange);
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 1;
|
||||
flex-grow: 1;
|
||||
flex-basis: 0;
|
||||
}
|
||||
.Pixso-frame-2_986 {
|
||||
width: 100%;
|
||||
height: 195px;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
border-radius: 14px 14px 14px 14px;
|
||||
box-shadow: 0px 2px 10px 0px rgba(26, 25, 24, 0.03137254901960784);
|
||||
background-color: var(--bg-white);
|
||||
}
|
||||
.frame-content-2_986 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.Pixso-vector-2_987 {width: 100%;
|
||||
height: 80px;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
}
|
||||
.Pixso-frame-2_988 {
|
||||
width: 358px;
|
||||
height: 80px;
|
||||
position: absolute;
|
||||
left: 0px;
|
||||
top: 0px;
|
||||
border-radius: 14px 14px 0px 0px;
|
||||
background-color: rgba(0, 0, 0, 0.1882352977991104);
|
||||
}
|
||||
.Pixso-frame-2_989 {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.frame-content-2_989 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
padding: 12px 14px 12px 14px;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.Pixso-frame-2_990 {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.frame-content-2_990 {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.Pixso-paragraph-2_991 {
|
||||
font-size: var(--font-size-md);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 700;
|
||||
color: var(--text-dark);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
.Pixso-frame-2_992 {
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
padding: 4px 10px 4px 10px;
|
||||
border-radius: 6px 6px 6px 6px;
|
||||
background-color: var(--success-green);
|
||||
}
|
||||
.Pixso-paragraph-2_993 {
|
||||
font-size: var(--font-size-sm);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 500;
|
||||
color: var(--text-inverse);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
.Pixso-frame-2_994 {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
.frame-content-2_994 {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.Pixso-vector-2_995 {width: 13px;
|
||||
height: 13px;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
}
|
||||
.Pixso-paragraph-2_998 {
|
||||
font-size: var(--font-size-base);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 400;
|
||||
color: var(--text-muted);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
.Pixso-vector-2_999 {width: 13px;
|
||||
height: 13px;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
}
|
||||
.Pixso-paragraph-2_1002 {
|
||||
font-size: var(--font-size-base);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 400;
|
||||
color: var(--text-muted);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
.Pixso-frame-2_1003 {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.frame-content-2_1003 {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.Pixso-paragraph-2_1004 {
|
||||
font-size: var(--font-size-sm);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 400;
|
||||
color: var(--text-light);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
.Pixso-frame-2_1005 {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
padding: 6px 14px 6px 14px;
|
||||
border-radius: 8px 8px 8px 8px;
|
||||
background-color: var(--bg-white);
|
||||
}
|
||||
.stroke-wrapper-2_1005 {
|
||||
position: relative;
|
||||
width: auto;
|
||||
height: auto;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.stroke-2_1005 {
|
||||
position: absolute;
|
||||
inset: -1px -1px -1px -1px;
|
||||
border-radius: 9px 9px 9px 9px;
|
||||
pointer-events: none;
|
||||
border-width: 1px 1px 1px 1px;
|
||||
border-style: solid;
|
||||
box-sizing: border-box;
|
||||
border-color: var(--border-light);
|
||||
}
|
||||
.Pixso-paragraph-2_1006 {
|
||||
font-size: var(--font-size-base);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 500;
|
||||
color: var(--error-red);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
.Pixso-frame-2_1007 {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
border-radius: 14px 14px 14px 14px;
|
||||
box-shadow: 0px 2px 10px 0px rgba(26, 25, 24, 0.03137254901960784);
|
||||
background-color: var(--bg-white);
|
||||
}
|
||||
.frame-content-2_1007 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.Pixso-vector-2_1008 {width: 100%;
|
||||
height: 80px;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
}
|
||||
.Pixso-frame-2_1009 {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.frame-content-2_1009 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
padding: 12px 14px 12px 14px;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.Pixso-frame-2_1010 {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.frame-content-2_1010 {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.Pixso-paragraph-2_1011 {
|
||||
font-size: var(--font-size-md);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 700;
|
||||
color: var(--text-dark);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
.Pixso-frame-2_1012 {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
padding: 4px 10px 4px 10px;
|
||||
border-radius: 6px 6px 6px 6px;
|
||||
background-color: rgba(255, 243, 238, 1);
|
||||
}
|
||||
.stroke-wrapper-2_1012 {
|
||||
position: relative;
|
||||
width: auto;
|
||||
height: auto;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.stroke-2_1012 {
|
||||
position: absolute;
|
||||
inset: 0px;
|
||||
border-radius: 6px 6px 6px 6px;
|
||||
pointer-events: none;
|
||||
border-width: 1px 1px 1px 1px;
|
||||
border-style: solid;
|
||||
box-sizing: border-box;
|
||||
border-color: rgba(212, 166, 74, 1);
|
||||
}
|
||||
.Pixso-paragraph-2_1013 {
|
||||
font-size: var(--font-size-sm);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 500;
|
||||
color: var(--accent-orange);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
.Pixso-frame-2_1014 {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
.frame-content-2_1014 {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.Pixso-vector-2_1015 {width: 13px;
|
||||
height: 13px;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
}
|
||||
.Pixso-paragraph-2_1018 {
|
||||
font-size: var(--font-size-base);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 400;
|
||||
color: var(--text-muted);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
.Pixso-vector-2_1019 {width: 13px;
|
||||
height: 13px;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
}
|
||||
.Pixso-paragraph-2_1022 {
|
||||
font-size: var(--font-size-base);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 400;
|
||||
color: var(--text-muted);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
.Pixso-frame-2_1023 {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.frame-content-2_1023 {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.Pixso-paragraph-2_1024 {
|
||||
font-size: var(--font-size-sm);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 400;
|
||||
color: var(--text-light);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
.Pixso-frame-2_1025 {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
padding: 6px 14px 6px 14px;
|
||||
border-radius: 8px 8px 8px 8px;
|
||||
background-color: var(--bg-white);
|
||||
}
|
||||
.stroke-wrapper-2_1025 {
|
||||
position: relative;
|
||||
width: auto;
|
||||
height: auto;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.stroke-2_1025 {
|
||||
position: absolute;
|
||||
inset: -1px -1px -1px -1px;
|
||||
border-radius: 9px 9px 9px 9px;
|
||||
pointer-events: none;
|
||||
border-width: 1px 1px 1px 1px;
|
||||
border-style: solid;
|
||||
box-sizing: border-box;
|
||||
border-color: var(--border-light);
|
||||
}
|
||||
.Pixso-paragraph-2_1026 {
|
||||
font-size: var(--font-size-base);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 500;
|
||||
color: var(--error-red);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
.mi-course-list__filters {
|
||||
padding: 0 var(--spacing-md, 16px) var(--spacing-sm, 8px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm, 10px);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.mi-course-list__date-bar {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mi-course-list__mode {
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
background: var(--primary-dark, #0B2B4B);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mi-course-list__mode-text {
|
||||
font-size: 12px;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mi-course-list__dates {
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mi-course-list__date {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
margin-right: 6px;
|
||||
border-radius: 12px;
|
||||
background: var(--bg-white, #fff);
|
||||
border: 1px solid var(--border-light, #E9EDF2);
|
||||
}
|
||||
|
||||
.mi-course-list__date--active {
|
||||
background: rgba(255, 107, 53, 0.12);
|
||||
border-color: var(--accent-orange, #FF6B35);
|
||||
}
|
||||
|
||||
.mi-course-list__date-week {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted, #5E6F8D);
|
||||
}
|
||||
|
||||
.mi-course-list__date-day {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-dark, #1E2A3A);
|
||||
}
|
||||
|
||||
.mi-course-list__chips {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mi-course-list__row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mi-course-list__picker {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
background: var(--bg-white, #fff);
|
||||
border: 1px solid var(--border-light, #E9EDF2);
|
||||
font-size: 13px;
|
||||
color: var(--text-dark, #1E2A3A);
|
||||
}
|
||||
|
||||
.mi-course-list__arrow {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.mi-course-card {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border-radius: 18px;
|
||||
background: var(--bg-white, #fff);
|
||||
box-shadow: var(--shadow-sm);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.mi-course-card__banner {
|
||||
width: 96px;
|
||||
height: 120px;
|
||||
border-radius: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mi-course-card__body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.mi-course-card__head {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.mi-course-card__title {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--text-dark, #1E2A3A);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.mi-course-card__type {
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mi-course-card__type--group {
|
||||
background: rgba(255, 107, 53, 0.12);
|
||||
}
|
||||
|
||||
.mi-course-card__type--private {
|
||||
background: rgba(11, 43, 75, 0.1);
|
||||
}
|
||||
|
||||
.mi-course-card__type text {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted, #5E6F8D);
|
||||
}
|
||||
|
||||
.mi-course-card__coach {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted, #5E6F8D);
|
||||
}
|
||||
|
||||
.mi-course-card__avatar {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.mi-course-card__meta {
|
||||
font-size: 11px;
|
||||
color: var(--text-light, #8A99B4);
|
||||
}
|
||||
|
||||
.mi-course-card__capacity {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.mi-course-card__bar {
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background: var(--bg-gray, #F2F5F9);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mi-course-card__bar-fill {
|
||||
height: 100%;
|
||||
background: var(--gradient-orange);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.mi-course-card__cap-text {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted, #5E6F8D);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mi-course-card__scarcity {
|
||||
font-size: 10px;
|
||||
color: var(--accent-orange, #FF6B35);
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mi-course-card__footer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.mi-course-card__price {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--primary-deep, #1A4A6F);
|
||||
}
|
||||
|
||||
.mi-course-card__btn {
|
||||
padding: 6px 16px;
|
||||
border-radius: 999px;
|
||||
background: var(--gradient-orange);
|
||||
}
|
||||
|
||||
.mi-course-card__btn text {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.mi-course-card__btn--disabled {
|
||||
background: var(--bg-gray, #F2F5F9);
|
||||
}
|
||||
|
||||
.mi-course-card__btn--disabled text {
|
||||
color: var(--text-light, #8A99B4);
|
||||
}
|
||||
|
||||
.mi-course-list__fab {
|
||||
position: fixed;
|
||||
right: 16px;
|
||||
bottom: 32px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 999px;
|
||||
background: var(--primary-dark, #0B2B4B);
|
||||
box-shadow: var(--shadow-md);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.mi-course-list__fab-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.mi-course-list__fab-text {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
.member-card-page {
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
box-sizing: border-box;
|
||||
overflow-x: hidden;
|
||||
background-color: var(--bg-light, #F9FAFE);
|
||||
}
|
||||
|
||||
.member-card-page__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 16px 16px 40px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* 会员卡 */
|
||||
.mc-hero {
|
||||
width: 100%;
|
||||
min-height: 160px;
|
||||
padding: 20px 20px 16px;
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 10px 20px rgba(11, 43, 75, 0.31);
|
||||
background: linear-gradient(135deg, #0B2B4B 0%, #1A4A6F 100%);
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mc-hero__top {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mc-hero__title-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mc-hero__crown {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mc-hero__name {
|
||||
font-size: var(--font-size-md, 16px);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 700;
|
||||
color: var(--text-inverse, #ffffff);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mc-hero__badge {
|
||||
flex-shrink: 0;
|
||||
padding: 4px 10px;
|
||||
border-radius: 10px;
|
||||
background-color: rgba(255, 255, 255, 0.19);
|
||||
}
|
||||
|
||||
.mc-hero__badge-text {
|
||||
font-size: var(--font-size-sm, 12px);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 500;
|
||||
color: var(--text-inverse, #ffffff);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mc-hero__validity {
|
||||
font-size: var(--font-size-sm, 12px);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 400;
|
||||
color: rgba(255, 212, 184, 1);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mc-hero__bottom {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.mc-hero__days {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.mc-hero__days-num {
|
||||
font-size: var(--font-size-5xl, 32px);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 700;
|
||||
color: var(--text-inverse, #ffffff);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.mc-hero__days-unit {
|
||||
font-size: var(--font-size-base, 14px);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 400;
|
||||
color: rgba(255, 212, 184, 1);
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.mc-hero__renew {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 8px 18px;
|
||||
border-radius: 16px;
|
||||
background-color: var(--bg-white, #ffffff);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mc-hero__renew-icon {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mc-hero__renew-text {
|
||||
font-size: var(--font-size-base, 14px);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 700;
|
||||
color: var(--accent-orange, #FF6B35);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 使用记录 */
|
||||
.mc-records {
|
||||
width: 100%;
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 2px 10px rgba(26, 25, 24, 0.03);
|
||||
background-color: var(--bg-white, #ffffff);
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.mc-records__header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 16px;
|
||||
height: 48px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.mc-records__title {
|
||||
font-size: var(--font-size-base, 14px);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 600;
|
||||
color: var(--text-dark, #1E2A3A);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mc-records__tabs {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 2px;
|
||||
border-radius: 8px;
|
||||
background-color: var(--bg-light, #F9FAFE);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mc-records__tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 24px;
|
||||
padding: 0 10px;
|
||||
border-radius: 6px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.mc-records__tab--active {
|
||||
background-color: var(--bg-white, #ffffff);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.mc-records__tab-text {
|
||||
font-size: var(--font-size-sm, 12px);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 400;
|
||||
color: var(--text-light, #8A99B4);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mc-records__tab-text--active {
|
||||
font-weight: 600;
|
||||
color: var(--text-dark, #1E2A3A);
|
||||
}
|
||||
|
||||
.mc-records__divider {
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background-color: var(--bg-light, #F9FAFE);
|
||||
}
|
||||
|
||||
.mc-records__item-inner {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 14px 16px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.mc-records__icon-wrap {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mc-records__icon-wrap--orange {
|
||||
background-color: rgba(255, 243, 238, 1);
|
||||
}
|
||||
|
||||
.mc-records__icon-wrap--green {
|
||||
background-color: rgba(240, 250, 245, 1);
|
||||
}
|
||||
|
||||
.mc-records__icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mc-records__info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.mc-records__item-title {
|
||||
font-size: var(--font-size-base, 14px);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 500;
|
||||
color: var(--text-dark, #1E2A3A);
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mc-records__item-time {
|
||||
font-size: var(--font-size-sm, 12px);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 400;
|
||||
color: var(--text-light, #8A99B4);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mc-records__value {
|
||||
font-size: var(--font-size-base, 14px);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mc-records__value--negative {
|
||||
color: var(--error-red, #E74C3C);
|
||||
}
|
||||
|
||||
.mc-records__value--positive {
|
||||
color: var(--success-green, #2ECC71);
|
||||
}
|
||||
|
||||
/* 使用规则 */
|
||||
.mc-rules {
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 2px 10px rgba(26, 25, 24, 0.03);
|
||||
background-color: var(--bg-white, #ffffff);
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.mc-rules__title {
|
||||
font-size: var(--font-size-base, 14px);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 600;
|
||||
color: var(--text-dark, #1E2A3A);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mc-rules__item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mc-rules__bullet {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
margin-top: 6px;
|
||||
border-radius: 1px;
|
||||
background-color: var(--accent-orange, #FF6B35);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mc-rules__text {
|
||||
flex: 1;
|
||||
font-size: var(--font-size-sm, 12px);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 400;
|
||||
color: var(--text-muted, #5E6F8D);
|
||||
line-height: 1.5;
|
||||
}
|
||||
@@ -0,0 +1,982 @@
|
||||
.Pixso-frame-2_878 {
|
||||
width: 100%;
|
||||
height: 62px;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background-color: var(--bg-white);
|
||||
}
|
||||
.frame-content-2_878 {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0px 20px 0px 20px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.Pixso-paragraph-2_879 {
|
||||
font-size: var(--font-size-md);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 600;
|
||||
color: var(--text-dark);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
.Pixso-paragraph-2_880 {
|
||||
font-size: var(--font-size-sm);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 400;
|
||||
color: var(--text-dark);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
.Pixso-frame-2_881 {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
background-color: var(--bg-white);
|
||||
}
|
||||
.frame-content-2_881 {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 0px 16px 0px 16px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.stroke-wrapper-2_881 {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 52px;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.stroke-2_881 {
|
||||
position: absolute;
|
||||
inset: 0px;
|
||||
border-radius: 0px 0px 0px 0px;
|
||||
pointer-events: none;
|
||||
border-width: 0px 0px 1px 0px;
|
||||
border-style: solid;
|
||||
box-sizing: border-box;
|
||||
border-color: var(--border-light);
|
||||
}
|
||||
.Pixso-frame-2_882 {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 8px 8px 8px 8px;
|
||||
background-color: var(--bg-light);
|
||||
}
|
||||
.frame-content-2_882 {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.Pixso-vector-2_883 {width: 20px;
|
||||
height: 20px;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
}
|
||||
.Pixso-paragraph-2_885 {
|
||||
font-size: var(--font-size-md);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
color: var(--primary-dark);
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 1;
|
||||
flex-grow: 1;
|
||||
flex-basis: 0;
|
||||
}
|
||||
.Pixso-frame-2_886 {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
background-color: rgba(249, 250, 254, 0);
|
||||
}
|
||||
.Pixso-frame-2_887 {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.frame-content-2_887 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
padding: 16px 16px 40px 16px;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.Pixso-frame-2_888 {
|
||||
width: 100%;
|
||||
height: 160px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
border-radius: 18px 18px 18px 18px;
|
||||
box-shadow: 0px 10px 20px 0px rgba(11, 43, 75, 0.3137254901960784);
|
||||
background-position: center;
|
||||
background-image: url("data:image/svg+xml;utf8,%3Csvg%20viewBox%3D'0%200%20358%20160'%20preserveAspectRatio%3D'none'%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%3E%0A%20%20%20%20%20%20%3Cdefs%3E%0A%20%20%20%20%20%20%20%20%3ClinearGradient%20id%3D'grad'%20gradientUnits%3D'objectBoundingBox'%20x1%3D'0'%20y1%3D'0.5'%20x2%3D'1'%20y2%3D'0.5'%20gradientTransform%3D'matrix(-0.7071%2C%200.7071%2C%20-0.7071%2C%20-0.7071%2C%201.2071%2C%200.5000)'%3E%0A%20%20%20%20%20%20%20%20%20%20%3Cstop%20stop-color%3D'rgba(11%2C43%2C75%2C1)'%20offset%3D'0'%2F%3E%3Cstop%20stop-color%3D'rgba(26%2C74%2C111%2C1)'%20offset%3D'1'%2F%3E%0A%20%20%20%20%20%20%20%20%3C%2FlinearGradient%3E%0A%20%20%20%20%20%20%3C%2Fdefs%3E%0A%20%20%20%20%20%20%3Crect%20width%3D'100%25'%20height%3D'100%25'%20fill%3D'url(%23grad)'%2F%3E%0A%20%20%20%20%3C%2Fsvg%3E");
|
||||
}
|
||||
.frame-content-2_888 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
padding: 20px 20px 16px 20px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.Pixso-frame-2_889 {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.frame-content-2_889 {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.Pixso-frame-2_890 {
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
.Pixso-vector-2_891 {width: 18px;
|
||||
height: 18px;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
}
|
||||
.Pixso-paragraph-2_894 {
|
||||
font-size: var(--font-size-md);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 700;
|
||||
color: var(--text-inverse);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
.Pixso-frame-2_895 {
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
padding: 4px 10px 4px 10px;
|
||||
border-radius: 10px 10px 10px 10px;
|
||||
background-color: rgba(255, 255, 255, 0.1882352977991104);
|
||||
}
|
||||
.Pixso-paragraph-2_896 {
|
||||
font-size: var(--font-size-sm);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 500;
|
||||
color: var(--text-inverse);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
.Pixso-paragraph-2_897 {
|
||||
font-size: var(--font-size-sm);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 400;
|
||||
color: rgba(255, 212, 184, 1);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
.Pixso-frame-2_898 {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.frame-content-2_898 {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.Pixso-frame-2_899 {
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 4px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.Pixso-paragraph-2_900 {
|
||||
font-size: var(--font-size-5xl);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 700;
|
||||
color: var(--text-inverse);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
.Pixso-paragraph-2_901 {
|
||||
font-size: var(--font-size-base);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 400;
|
||||
color: rgba(255, 212, 184, 1);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
.Pixso-frame-2_902 {
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
padding: 8px 18px 8px 18px;
|
||||
border-radius: 16px 16px 16px 16px;
|
||||
background-color: var(--bg-white);
|
||||
}
|
||||
.Pixso-vector-2_903 {width: 13px;
|
||||
height: 13px;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
}
|
||||
.Pixso-paragraph-2_908 {
|
||||
font-size: var(--font-size-base);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 700;
|
||||
color: var(--accent-orange);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
.Pixso-frame-2_909 {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
border-radius: 14px 14px 14px 14px;
|
||||
box-shadow: 0px 2px 10px 0px rgba(26, 25, 24, 0.03137254901960784);
|
||||
background-color: var(--bg-white);
|
||||
}
|
||||
.frame-content-2_909 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.Pixso-frame-2_910 {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.frame-content-2_910 {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0px 16px 0px 16px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.Pixso-paragraph-2_911 {
|
||||
font-size: var(--font-size-base);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 600;
|
||||
color: var(--text-dark);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
.Pixso-frame-2_912 {
|
||||
width: auto;
|
||||
height: 28px;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
border-radius: 8px 8px 8px 8px;
|
||||
background-color: var(--bg-light);
|
||||
}
|
||||
.frame-content-2_912 {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 2px;
|
||||
align-items: center;
|
||||
padding: 2px 2px 2px 2px;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.Pixso-frame-2_913 {
|
||||
width: auto;
|
||||
height: 24px;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
align-self: stretch;
|
||||
border-radius: 6px 6px 6px 6px;
|
||||
box-shadow: 0px 1px 2px 0px rgba(0, 0, 0, 0.06274509803921569);
|
||||
background-color: var(--bg-white);
|
||||
}
|
||||
.frame-content-2_913 {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 0px 10px 0px 10px;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.Pixso-paragraph-2_914 {
|
||||
font-size: var(--font-size-sm);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 600;
|
||||
color: var(--text-dark);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
.Pixso-frame-2_915 {
|
||||
width: auto;
|
||||
height: 24px;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
align-self: stretch;
|
||||
border-radius: 6px 6px 6px 6px;
|
||||
background-color: rgba(249, 250, 254, 0);
|
||||
}
|
||||
.frame-content-2_915 {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 0px 10px 0px 10px;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.Pixso-paragraph-2_916 {
|
||||
font-size: var(--font-size-sm);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 400;
|
||||
color: var(--text-light);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
.Pixso-frame-2_917 {
|
||||
width: auto;
|
||||
height: 24px;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
align-self: stretch;
|
||||
border-radius: 6px 6px 6px 6px;
|
||||
background-color: rgba(249, 250, 254, 0);
|
||||
}
|
||||
.frame-content-2_917 {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 0px 10px 0px 10px;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.Pixso-paragraph-2_918 {
|
||||
font-size: var(--font-size-sm);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 400;
|
||||
color: var(--text-light);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
.Pixso-frame-2_919 {
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
background-color: var(--bg-light);
|
||||
}
|
||||
.Pixso-frame-2_920 {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
.frame-content-2_920 {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 14px 16px 14px 16px;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.Pixso-frame-2_921 {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 10px 10px 10px 10px;
|
||||
background-color: rgba(255, 243, 238, 1);
|
||||
}
|
||||
.frame-content-2_921 {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.Pixso-vector-2_922 {width: 18px;
|
||||
height: 18px;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
}
|
||||
.Pixso-frame-2_928 {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
flex-grow: 1;
|
||||
flex-basis: 0;
|
||||
}
|
||||
.frame-content-2_928 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.Pixso-paragraph-2_929 {
|
||||
font-size: var(--font-size-base);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 500;
|
||||
color: var(--text-dark);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
.Pixso-paragraph-2_930 {
|
||||
font-size: var(--font-size-sm);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 400;
|
||||
color: var(--text-light);
|
||||
white-space: pre-wrap;
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
.Pixso-paragraph-2_931 {
|
||||
font-size: var(--font-size-base);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 700;
|
||||
color: var(--error-red);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
.Pixso-frame-2_932 {
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
background-color: var(--bg-light);
|
||||
}
|
||||
.Pixso-frame-2_933 {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
.frame-content-2_933 {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 14px 16px 14px 16px;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.Pixso-frame-2_934 {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 10px 10px 10px 10px;
|
||||
background-color: rgba(240, 250, 245, 1);
|
||||
}
|
||||
.frame-content-2_934 {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.Pixso-vector-2_935 {width: 18px;
|
||||
height: 18px;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
}
|
||||
.Pixso-frame-2_938 {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
flex-grow: 1;
|
||||
flex-basis: 0;
|
||||
}
|
||||
.frame-content-2_938 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.Pixso-paragraph-2_939 {
|
||||
font-size: var(--font-size-base);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 500;
|
||||
color: var(--text-dark);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
.Pixso-paragraph-2_940 {
|
||||
font-size: var(--font-size-sm);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 400;
|
||||
color: var(--text-light);
|
||||
white-space: pre-wrap;
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
.Pixso-paragraph-2_941 {
|
||||
font-size: var(--font-size-base);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 700;
|
||||
color: var(--error-red);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
.Pixso-frame-2_942 {
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
background-color: var(--bg-light);
|
||||
}
|
||||
.Pixso-frame-2_943 {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
.frame-content-2_943 {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 14px 16px 14px 16px;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.Pixso-frame-2_944 {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 10px 10px 10px 10px;
|
||||
background-color: rgba(255, 243, 238, 1);
|
||||
}
|
||||
.frame-content-2_944 {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.Pixso-vector-2_945 {width: 18px;
|
||||
height: 18px;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
}
|
||||
.Pixso-frame-2_949 {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
flex-grow: 1;
|
||||
flex-basis: 0;
|
||||
}
|
||||
.frame-content-2_949 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.Pixso-paragraph-2_950 {
|
||||
font-size: var(--font-size-base);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 500;
|
||||
color: var(--text-dark);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
.Pixso-paragraph-2_951 {
|
||||
font-size: var(--font-size-sm);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 400;
|
||||
color: var(--text-light);
|
||||
white-space: pre-wrap;
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
.Pixso-paragraph-2_952 {
|
||||
font-size: var(--font-size-base);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 700;
|
||||
color: var(--success-green);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
.Pixso-frame-2_953 {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
border-radius: 14px 14px 14px 14px;
|
||||
box-shadow: 0px 2px 10px 0px rgba(26, 25, 24, 0.03137254901960784);
|
||||
background-color: var(--bg-white);
|
||||
}
|
||||
.frame-content-2_953 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
padding: 16px 16px 16px 16px;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.Pixso-paragraph-2_954 {
|
||||
font-size: var(--font-size-base);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 600;
|
||||
color: var(--text-dark);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
.Pixso-frame-2_955 {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.frame-content-2_955 {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.Pixso-paragraph-2_956 {
|
||||
font-size: var(--font-size-base);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 400;
|
||||
color: var(--accent-orange);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
.Pixso-paragraph-2_957 {
|
||||
font-size: var(--font-size-base);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 400;
|
||||
color: var(--text-muted);
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 1;
|
||||
flex-grow: 1;
|
||||
flex-basis: 0;
|
||||
}
|
||||
.Pixso-frame-2_958 {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.frame-content-2_958 {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.Pixso-paragraph-2_959 {
|
||||
font-size: var(--font-size-base);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 400;
|
||||
color: var(--accent-orange);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
.Pixso-paragraph-2_960 {
|
||||
font-size: var(--font-size-base);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 400;
|
||||
color: var(--text-muted);
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 1;
|
||||
flex-grow: 1;
|
||||
flex-basis: 0;
|
||||
}
|
||||
.Pixso-frame-2_961 {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.frame-content-2_961 {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.Pixso-paragraph-2_962 {
|
||||
font-size: var(--font-size-base);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 400;
|
||||
color: var(--accent-orange);
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
flex-grow: 0;
|
||||
}
|
||||
.Pixso-paragraph-2_963 {
|
||||
font-size: var(--font-size-base);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 400;
|
||||
color: var(--text-muted);
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: relative;
|
||||
flex-shrink: 1;
|
||||
flex-grow: 1;
|
||||
flex-basis: 0;
|
||||
}
|
||||
@@ -0,0 +1,584 @@
|
||||
/* 个人中心其它模块页面样式 */
|
||||
@import '@/common/style/memberInfo/member-info-gradient-cards.css';
|
||||
|
||||
.mi-mod-tabs {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: var(--spacing-sm, 8px);
|
||||
padding: 0 var(--spacing-md, 16px) var(--spacing-sm, 8px);
|
||||
overflow-x: auto;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* 训练明细 */
|
||||
.mi-mod-session {
|
||||
padding: 14px 0;
|
||||
border-bottom: 1px solid var(--border-light, #E9EDF2);
|
||||
}
|
||||
|
||||
.mi-mod-session:last-child {
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.mi-mod-session__head {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mi-mod-session__title {
|
||||
font-size: var(--font-size-base, 14px);
|
||||
font-weight: 600;
|
||||
color: var(--text-dark, #1E2A3A);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.mi-mod-session__tag {
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mi-mod-session__tag--group {
|
||||
background: rgba(255, 107, 53, 0.12);
|
||||
}
|
||||
|
||||
.mi-mod-session__tag--private {
|
||||
background: rgba(11, 43, 75, 0.1);
|
||||
}
|
||||
|
||||
.mi-mod-session__tag--free {
|
||||
background: rgba(46, 204, 113, 0.12);
|
||||
}
|
||||
|
||||
.mi-mod-session__tag-text {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted, #5E6F8D);
|
||||
}
|
||||
|
||||
.mi-mod-session__meta {
|
||||
font-size: var(--font-size-sm, 12px);
|
||||
color: var(--text-muted, #5E6F8D);
|
||||
margin-top: 4px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mi-mod-session__footer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 16px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.mi-mod-session__stat {
|
||||
font-size: var(--font-size-sm, 12px);
|
||||
font-weight: 600;
|
||||
color: var(--primary-deep, #1A4A6F);
|
||||
}
|
||||
|
||||
/* 优惠券 */
|
||||
.mi-mod-coupon {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
border-radius: var(--radius-md, 20px);
|
||||
overflow: hidden;
|
||||
margin-bottom: 12px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.mi-mod-coupon--expired,
|
||||
.mi-mod-coupon--used {
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.mi-mod-coupon__left {
|
||||
width: 100px;
|
||||
padding: 16px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mi-mod-coupon--used .mi-mod-coupon__left,
|
||||
.mi-mod-coupon--expired .mi-mod-coupon__left {
|
||||
background: var(--bg-gray, #F2F5F9);
|
||||
}
|
||||
|
||||
.mi-mod-coupon__amount {
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
color: var(--text-inverse, #fff);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.mi-mod-coupon--used .mi-mod-coupon__amount,
|
||||
.mi-mod-coupon--expired .mi-mod-coupon__amount {
|
||||
color: var(--text-muted, #5E6F8D);
|
||||
}
|
||||
|
||||
.mi-mod-coupon__min {
|
||||
font-size: 10px;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.mi-mod-coupon--used .mi-mod-coupon__min,
|
||||
.mi-mod-coupon--expired .mi-mod-coupon__min {
|
||||
color: var(--text-light, #8A99B4);
|
||||
}
|
||||
|
||||
.mi-mod-coupon__right {
|
||||
flex: 1;
|
||||
padding: 14px 16px;
|
||||
background: var(--bg-white, #fff);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.mi-mod-coupon__title {
|
||||
font-size: var(--font-size-base, 14px);
|
||||
font-weight: 600;
|
||||
color: var(--text-dark, #1E2A3A);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mi-mod-coupon__desc {
|
||||
font-size: var(--font-size-sm, 12px);
|
||||
color: var(--text-muted, #5E6F8D);
|
||||
margin-top: 4px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mi-mod-coupon__expire {
|
||||
font-size: 11px;
|
||||
color: var(--text-light, #8A99B4);
|
||||
margin-top: 8px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mi-mod-coupon__tag {
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
right: 14px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
background: var(--bg-gray, #F2F5F9);
|
||||
}
|
||||
|
||||
.mi-mod-coupon__tag-text {
|
||||
font-size: 10px;
|
||||
color: var(--accent-orange, #FF6B35);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 积分 */
|
||||
.mi-mod-points-hero {
|
||||
padding: 24px 20px;
|
||||
border-radius: var(--radius-sm, 12px);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mi-mod-points-hero__label {
|
||||
font-size: var(--font-size-xs, 12px);
|
||||
color: rgba(255, 212, 184, 1);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mi-mod-points-hero__value {
|
||||
font-size: var(--font-size-3xl, 2rem);
|
||||
font-weight: 700;
|
||||
color: var(--text-inverse, #fff);
|
||||
line-height: 1.2;
|
||||
display: block;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.mi-mod-points-hero__tip {
|
||||
font-size: var(--font-size-xs, 12px);
|
||||
color: rgba(255, 212, 184, 1);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.mi-mod-rewards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.mi-mod-reward {
|
||||
padding: 14px;
|
||||
border-radius: var(--radius-sm, 12px);
|
||||
background: var(--bg-gray, #F2F5F9);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mi-mod-reward__icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.mi-mod-reward__name {
|
||||
font-size: var(--font-size-sm, 12px);
|
||||
font-weight: 600;
|
||||
color: var(--text-dark, #1E2A3A);
|
||||
}
|
||||
|
||||
.mi-mod-reward__cost {
|
||||
font-size: 11px;
|
||||
color: var(--accent-orange, #FF6B35);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mi-mod-reward__stock {
|
||||
font-size: 10px;
|
||||
color: var(--text-light, #8A99B4);
|
||||
}
|
||||
|
||||
.mi-mod-points-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid var(--border-light, #E9EDF2);
|
||||
}
|
||||
|
||||
.mi-mod-points-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.mi-mod-points-row__info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.mi-mod-points-row__title {
|
||||
font-size: var(--font-size-base, 14px);
|
||||
color: var(--text-dark, #1E2A3A);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mi-mod-points-row__time {
|
||||
font-size: var(--font-size-sm, 12px);
|
||||
color: var(--text-light, #8A99B4);
|
||||
margin-top: 2px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mi-mod-points-row__right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.mi-mod-points-row__amount {
|
||||
font-size: var(--font-size-md, 16px);
|
||||
font-weight: 700;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mi-mod-points-row__amount--earn {
|
||||
color: var(--success-green, #2ECC71);
|
||||
}
|
||||
|
||||
.mi-mod-points-row__amount--spend {
|
||||
color: var(--warning-amber, #F39C12);
|
||||
}
|
||||
|
||||
.mi-mod-points-row__balance {
|
||||
font-size: 10px;
|
||||
color: var(--text-light, #8A99B4);
|
||||
}
|
||||
|
||||
/* 邀请 */
|
||||
.mi-mod-referral-hero {
|
||||
padding: 20px;
|
||||
border-radius: var(--radius-sm, 12px);
|
||||
}
|
||||
|
||||
.mi-mod-referral-hero__title {
|
||||
font-size: var(--font-size-lg, 18px);
|
||||
font-weight: 700;
|
||||
color: var(--text-inverse, #fff);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mi-mod-referral-hero__desc {
|
||||
font-size: var(--font-size-sm, 12px);
|
||||
color: rgba(255, 212, 184, 1);
|
||||
margin-top: 6px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mi-mod-referral-code {
|
||||
margin-top: 16px;
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mi-mod-referral-code__label {
|
||||
font-size: var(--font-size-sm, 12px);
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mi-mod-referral-code__value {
|
||||
font-size: 24px;
|
||||
font-weight: 800;
|
||||
color: var(--text-inverse, #fff);
|
||||
letter-spacing: 2px;
|
||||
margin-top: 6px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mi-mod-referral-stats {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-around;
|
||||
}
|
||||
|
||||
.mi-mod-referral-stat {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mi-mod-referral-stat__num {
|
||||
font-size: var(--font-size-xl, 20px);
|
||||
font-weight: 800;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mi-mod-referral-stat__num--orange {
|
||||
color: var(--accent-orange, #FF6B35);
|
||||
}
|
||||
|
||||
.mi-mod-referral-stat__num--green {
|
||||
color: var(--success-green, #2ECC71);
|
||||
}
|
||||
|
||||
.mi-mod-referral-stat__num--amber {
|
||||
color: var(--warning-amber, #F39C12);
|
||||
}
|
||||
|
||||
.mi-mod-referral-stat__label {
|
||||
font-size: var(--font-size-sm, 12px);
|
||||
color: var(--text-muted, #5E6F8D);
|
||||
margin-top: 4px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mi-mod-referral-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid var(--border-light, #E9EDF2);
|
||||
}
|
||||
|
||||
.mi-mod-referral-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.mi-mod-referral-row__info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.mi-mod-referral-row__name {
|
||||
font-size: var(--font-size-base, 14px);
|
||||
font-weight: 600;
|
||||
color: var(--text-dark, #1E2A3A);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mi-mod-referral-row__time {
|
||||
font-size: var(--font-size-sm, 12px);
|
||||
color: var(--text-light, #8A99B4);
|
||||
margin-top: 2px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mi-mod-referral-row__right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.mi-mod-referral-row__status {
|
||||
font-size: var(--font-size-sm, 12px);
|
||||
color: var(--text-muted, #5E6F8D);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mi-mod-referral-row__reward {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--accent-orange, #FF6B35);
|
||||
margin-top: 2px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* 我的课程 */
|
||||
.mi-mod-course-card {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
border-radius: var(--radius-md, 20px);
|
||||
background: var(--bg-white, #fff);
|
||||
box-shadow: var(--shadow-sm);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.mi-mod-course-card__banner {
|
||||
width: 88px;
|
||||
height: 88px;
|
||||
border-radius: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mi-mod-course-card__content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.mi-mod-course-card__title {
|
||||
font-size: var(--font-size-base, 14px);
|
||||
font-weight: 600;
|
||||
color: var(--text-dark, #1E2A3A);
|
||||
}
|
||||
|
||||
.mi-mod-course-card__coach {
|
||||
font-size: var(--font-size-sm, 12px);
|
||||
color: var(--text-muted, #5E6F8D);
|
||||
}
|
||||
|
||||
.mi-mod-course-card__progress {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.mi-mod-course-card__progress-bar {
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background: var(--bg-gray, #F2F5F9);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mi-mod-course-card__progress-fill {
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
background: var(--gradient-orange);
|
||||
}
|
||||
|
||||
.mi-mod-course-card__progress-text {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted, #5E6F8D);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mi-mod-course-card__meta {
|
||||
font-size: 11px;
|
||||
color: var(--text-light, #8A99B4);
|
||||
}
|
||||
|
||||
.mi-mod-course-card__next {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--primary-deep, #1A4A6F);
|
||||
}
|
||||
|
||||
/* 签到记录 */
|
||||
.mi-mod-checkin-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid var(--border-light, #E9EDF2);
|
||||
}
|
||||
|
||||
.mi-mod-checkin-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.mi-mod-checkin-row__icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mi-mod-checkin-row__icon--group {
|
||||
background: rgba(255, 107, 53, 0.12);
|
||||
}
|
||||
|
||||
.mi-mod-checkin-row__icon--private {
|
||||
background: rgba(11, 43, 75, 0.1);
|
||||
}
|
||||
|
||||
.mi-mod-checkin-row__icon--free {
|
||||
background: rgba(46, 204, 113, 0.12);
|
||||
}
|
||||
|
||||
.mi-mod-checkin-row__icon-img {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.mi-mod-checkin-row__info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mi-mod-checkin-row__title {
|
||||
font-size: var(--font-size-base, 14px);
|
||||
font-weight: 600;
|
||||
color: var(--text-dark, #1E2A3A);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mi-mod-checkin-row__time {
|
||||
font-size: var(--font-size-sm, 12px);
|
||||
color: var(--text-muted, #5E6F8D);
|
||||
margin-top: 2px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mi-mod-checkin-row__tag {
|
||||
padding: 4px 10px;
|
||||
border-radius: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mi-mod-checkin-row__tag--group {
|
||||
background: rgba(255, 107, 53, 0.12);
|
||||
}
|
||||
|
||||
.mi-mod-checkin-row__tag--private {
|
||||
background: rgba(11, 43, 75, 0.1);
|
||||
}
|
||||
|
||||
.mi-mod-checkin-row__tag--free {
|
||||
background: rgba(46, 204, 113, 0.12);
|
||||
}
|
||||
|
||||
.mi-mod-checkin-row__tag-text {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted, #5E6F8D);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/* 子页面根容器:锁定变量,与 H5 theme-light 一致 */
|
||||
.scroll-container {
|
||||
box-sizing: border-box;
|
||||
--primary-dark: #0B2B4B;
|
||||
--primary-deep: #1A4A6F;
|
||||
--accent-orange: #FF6B35;
|
||||
--accent-orange-light: #FF8C5A;
|
||||
--bg-light: #F9FAFE;
|
||||
--bg-white: #FFFFFF;
|
||||
--text-dark: #1E2A3A;
|
||||
--text-muted: #5E6F8D;
|
||||
--text-light: #8A99B4;
|
||||
--text-inverse: #FFFFFF;
|
||||
--border-light: #E9EDF2;
|
||||
--success-green: #2ECC71;
|
||||
--spacing-xs: 4px;
|
||||
--spacing-sm: 8px;
|
||||
--spacing-md: 16px;
|
||||
--spacing-lg: 24px;
|
||||
--spacing-xl: 32px;
|
||||
--font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||
--font-size-xs: 11px;
|
||||
--font-size-sm: 12px;
|
||||
--font-size-base: 14px;
|
||||
--font-size-md: 16px;
|
||||
--font-size-lg: 18px;
|
||||
--font-size-xl: 20px;
|
||||
--font-size-2xl: 22px;
|
||||
--font-size-3xl: 24px;
|
||||
--font-size-4xl: 28px;
|
||||
--font-size-5xl: 32px;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
.scroll-container {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.scroll-container > view {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* ========== 子页面统一布局(参考 base.css 间距变量) ========== */
|
||||
|
||||
.bt-page,
|
||||
.booking-page {
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
box-sizing: border-box;
|
||||
overflow-x: hidden;
|
||||
background-color: var(--bg-light, #F9FAFE);
|
||||
}
|
||||
|
||||
/* 导航栏下方首个区块:与固定顶栏留出间距 */
|
||||
.bt-page > .sub-nav + .mi-mod-tabs,
|
||||
.bt-page > .sub-nav + .mi-course-list__filters,
|
||||
.bt-page > .sub-nav + .bt-page__action-bar,
|
||||
.bt-page > .sub-nav + .bt-page__body,
|
||||
.booking-page > .sub-nav + .booking-page__tabs {
|
||||
margin-top: var(--spacing-md, 16px);
|
||||
}
|
||||
|
||||
/* tabs / 筛选栏下方内容区:避免重复过大顶边距 */
|
||||
.bt-page > .sub-nav + .mi-mod-tabs + .bt-page__action-bar + .bt-page__body,
|
||||
.bt-page > .sub-nav + .mi-mod-tabs + .bt-page__body,
|
||||
.bt-page > .sub-nav + .mi-course-list__filters + .bt-page__body,
|
||||
.booking-page > .sub-nav + .booking-page__tabs + .bt-page__action-bar + .booking-page__body,
|
||||
.booking-page > .sub-nav + .booking-page__tabs + .booking-page__body {
|
||||
padding-top: var(--spacing-sm, 8px);
|
||||
}
|
||||
|
||||
/* 导航栏下直接跟操作栏(无 tabs) */
|
||||
.bt-page > .sub-nav + .bt-page__action-bar + .bt-page__body {
|
||||
padding-top: var(--spacing-sm, 8px);
|
||||
}
|
||||
|
||||
/* 页面内次级操作栏(原导航栏右侧按钮下移至此) */
|
||||
.bt-page__action-bar {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-sm, 8px);
|
||||
padding: 0 var(--spacing-md, 16px) var(--spacing-sm, 8px);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.bt-page__action-bar--end {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.bt-page__action-bar-text {
|
||||
flex: 1;
|
||||
font-size: var(--font-size-sm, 12px);
|
||||
color: var(--text-muted, #5E6F8D);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.bt-page__action-link {
|
||||
flex-shrink: 0;
|
||||
font-size: var(--font-size-sm, 12px);
|
||||
font-weight: 600;
|
||||
color: var(--primary-deep, #1A4A6F);
|
||||
padding: 6px 12px;
|
||||
border-radius: var(--radius-full, 999px);
|
||||
background-color: var(--bg-white, #FFFFFF);
|
||||
border: 1px solid var(--border-light, #E9EDF2);
|
||||
}
|
||||
|
||||
.bt-page__action-link--primary {
|
||||
color: var(--text-inverse, #FFFFFF);
|
||||
border-color: transparent;
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
.Pixso-frame-2_791 {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
overflow-x: hidden;
|
||||
overflow-y: visible;
|
||||
background-color: var(--bg-light, #F9FAFE);
|
||||
}
|
||||
|
||||
.frame-content-2_802 {
|
||||
box-sizing: border-box;
|
||||
padding-bottom: calc(88px + env(safe-area-inset-bottom)) !important;
|
||||
}
|
||||
|
||||
.user-info-save-bar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 100;
|
||||
padding: 12px 16px calc(12px + env(safe-area-inset-bottom));
|
||||
background-color: var(--bg-white, #ffffff);
|
||||
box-shadow: 0 -2px 12px rgba(26, 25, 24, 0.06);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.user-info-save-bar__btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
background-color: var(--accent-orange, #FF6B35);
|
||||
}
|
||||
|
||||
.user-info-save-bar__text {
|
||||
font-size: var(--font-size-md, 16px);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 600;
|
||||
color: var(--text-inverse, #ffffff);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.avatar-block {
|
||||
width: 100%;
|
||||
height: 120px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 2px 10px rgba(26, 25, 24, 0.03);
|
||||
background-color: var(--bg-white);
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.avatar-block__inner {
|
||||
position: relative;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
background-color: var(--bg-light, #f9fafe);
|
||||
}
|
||||
|
||||
.avatar-block__photo {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.avatar-block__change {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
background-color: rgba(0, 0, 0, 0.55);
|
||||
z-index: 2;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.avatar-block__icon {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
filter: brightness(0) invert(1);
|
||||
}
|
||||
|
||||
.avatar-block__text {
|
||||
font-size: var(--font-size-xs, 11px);
|
||||
line-height: 1;
|
||||
color: #ffffff;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.frame-content-2_811,
|
||||
.frame-content-2_817,
|
||||
.frame-content-2_826,
|
||||
.frame-content-2_842,
|
||||
.frame-content-2_848,
|
||||
.frame-content-2_859 {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.Pixso-paragraph-2_813,
|
||||
.Pixso-paragraph-2_844,
|
||||
.Pixso-paragraph-2_861 {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.gender-btn {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 14px;
|
||||
border-radius: 8px;
|
||||
background-color: var(--bg-light, #F9FAFE);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.gender-btn__icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.gender-btn__text {
|
||||
font-size: var(--font-size-base);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 500;
|
||||
color: var(--text-light, #8A99B4);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.gender-btn--active {
|
||||
background-color: var(--accent-orange, #FF6B35);
|
||||
}
|
||||
|
||||
.gender-btn--active .gender-btn__text {
|
||||
color: var(--text-inverse, #ffffff);
|
||||
}
|
||||
|
||||
.goal-tags {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.goal-tag {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 7px 16px;
|
||||
border-radius: 100px;
|
||||
border: 1px solid rgba(209, 208, 205, 1);
|
||||
background-color: var(--bg-white, #ffffff);
|
||||
flex-shrink: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.goal-tag__text {
|
||||
font-size: var(--font-size-base);
|
||||
font-family: var(--font-family);
|
||||
font-weight: 500;
|
||||
color: var(--text-muted, #5E6F8D);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.goal-tag--selected {
|
||||
border-color: var(--accent-orange, #FF6B35);
|
||||
background-color: var(--accent-orange, #FF6B35);
|
||||
}
|
||||
|
||||
.goal-tag--selected .goal-tag__text {
|
||||
color: var(--text-inverse, #ffffff);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,33 @@
|
||||
@font-face {
|
||||
font-family: "iconfont"; /* Project id */
|
||||
src: url('tabbar.ttf?t=1780818759010') format('truetype');
|
||||
}
|
||||
|
||||
.iconfont {
|
||||
font-family: "iconfont" !important;
|
||||
font-size: 16px;
|
||||
font-style: normal;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.icon-home:before {
|
||||
content: "\e666";
|
||||
}
|
||||
|
||||
.icon-course:before {
|
||||
content: "\e692";
|
||||
}
|
||||
|
||||
.icon-train:before {
|
||||
content: "\e8be";
|
||||
}
|
||||
|
||||
.icon-discover:before {
|
||||
content: "\e726";
|
||||
}
|
||||
|
||||
.icon-profile:before {
|
||||
content: "\e501";
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,97 @@
|
||||
<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>
|
||||
@@ -0,0 +1,131 @@
|
||||
<template>
|
||||
<SkeletonBase>
|
||||
<view class="skeleton-banner"></view>
|
||||
|
||||
<view class="skeleton-entry">
|
||||
<view v-for="i in 4" :key="i" class="skeleton-entry-item">
|
||||
<view class="skeleton-icon"></view>
|
||||
<view class="skeleton-text"></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="skeleton-section">
|
||||
<view class="skeleton-section-title"></view>
|
||||
<view v-for="i in 3" :key="i" class="skeleton-course-item">
|
||||
<view class="skeleton-course-img"></view>
|
||||
<view class="skeleton-course-info">
|
||||
<view class="skeleton-course-title"></view>
|
||||
<view class="skeleton-course-desc"></view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</SkeletonBase>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import SkeletonBase from './SkeletonBase.vue'
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.skeleton-banner {
|
||||
height: 300rpx;
|
||||
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
margin: 20rpx;
|
||||
border-radius: 20rpx;
|
||||
}
|
||||
|
||||
.skeleton-entry {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
padding: 30rpx 20rpx;
|
||||
}
|
||||
|
||||
.skeleton-entry-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.skeleton-icon {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
border-radius: 40rpx;
|
||||
}
|
||||
|
||||
.skeleton-text {
|
||||
width: 60rpx;
|
||||
height: 24rpx;
|
||||
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
border-radius: 12rpx;
|
||||
}
|
||||
|
||||
.skeleton-section {
|
||||
padding: 20rpx;
|
||||
}
|
||||
|
||||
.skeleton-section-title {
|
||||
height: 40rpx;
|
||||
width: 200rpx;
|
||||
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
border-radius: 8rpx;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.skeleton-course-item {
|
||||
display: flex;
|
||||
gap: 20rpx;
|
||||
margin-bottom: 24rpx;
|
||||
padding: 20rpx;
|
||||
background: #fff;
|
||||
border-radius: 20rpx;
|
||||
}
|
||||
|
||||
.skeleton-course-img {
|
||||
width: 160rpx;
|
||||
height: 160rpx;
|
||||
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
border-radius: 16rpx;
|
||||
}
|
||||
|
||||
.skeleton-course-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.skeleton-course-title {
|
||||
height: 36rpx;
|
||||
width: 80%;
|
||||
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
border-radius: 8rpx;
|
||||
}
|
||||
|
||||
.skeleton-course-desc {
|
||||
height: 28rpx;
|
||||
width: 60%;
|
||||
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
border-radius: 8rpx;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,169 @@
|
||||
<!-- components/Skeleton/ListSkeleton.vue -->
|
||||
<template>
|
||||
<SkeletonBase>
|
||||
<!-- Tab区域 -->
|
||||
<view v-if="showTabs" class="skeleton-tabs">
|
||||
<view v-for="i in tabCount" :key="i" class="skeleton-tab skeleton-shimmer"></view>
|
||||
</view>
|
||||
<!-- 列表项 -->
|
||||
<view class="skeleton-list" :style="{ padding: listPadding }">
|
||||
<view v-for="i in count" :key="i" class="skeleton-item">
|
||||
<!-- 卡片式布局(默认) -->
|
||||
<template v-if="layout === 'card'">
|
||||
<view class="skeleton-card-item">
|
||||
<view class="skeleton-card-icon skeleton-shimmer"></view>
|
||||
<view class="skeleton-card-body">
|
||||
<view class="skeleton-card-title skeleton-shimmer"></view>
|
||||
<view class="skeleton-card-desc skeleton-shimmer"></view>
|
||||
<view class="skeleton-card-footer">
|
||||
<view class="skeleton-card-tag skeleton-shimmer"></view>
|
||||
<view class="skeleton-card-time skeleton-shimmer"></view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
<!-- 简洁列表布局 -->
|
||||
<template v-else-if="layout === 'simple'">
|
||||
<view class="skeleton-simple-item">
|
||||
<view class="skeleton-simple-title skeleton-shimmer"></view>
|
||||
<view class="skeleton-simple-meta">
|
||||
<view class="skeleton-simple-line skeleton-shimmer"></view>
|
||||
<view class="skeleton-simple-line skeleton-shimmer short"></view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</view>
|
||||
</SkeletonBase>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import SkeletonBase from './SkeletonBase.vue'
|
||||
|
||||
defineProps({
|
||||
count: { type: Number, default: 6 },
|
||||
layout: { type: String, default: 'card' },
|
||||
showTabs: { type: Boolean, default: false },
|
||||
tabCount: { type: Number, default: 3 },
|
||||
listPadding: { type: String, default: '0 24rpx' }
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.skeleton-tabs {
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
padding: 20rpx 24rpx;
|
||||
border-bottom: 1rpx solid #f0f0f0;
|
||||
}
|
||||
|
||||
.skeleton-tab {
|
||||
height: 60rpx;
|
||||
width: 140rpx;
|
||||
border-radius: 12rpx;
|
||||
}
|
||||
|
||||
.skeleton-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
padding-top: 16rpx;
|
||||
}
|
||||
|
||||
/* 卡片式 */
|
||||
.skeleton-card-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 24rpx 20rpx;
|
||||
background: #fff;
|
||||
border-radius: 16rpx;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.skeleton-card-icon {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
border-radius: 16rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.skeleton-card-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12rpx;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.skeleton-card-title {
|
||||
height: 32rpx;
|
||||
width: 60%;
|
||||
border-radius: 8rpx;
|
||||
}
|
||||
|
||||
.skeleton-card-desc {
|
||||
height: 28rpx;
|
||||
width: 80%;
|
||||
border-radius: 8rpx;
|
||||
}
|
||||
|
||||
.skeleton-card-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 4rpx;
|
||||
}
|
||||
|
||||
.skeleton-card-tag {
|
||||
height: 24rpx;
|
||||
width: 80rpx;
|
||||
border-radius: 6rpx;
|
||||
}
|
||||
|
||||
.skeleton-card-time {
|
||||
height: 24rpx;
|
||||
width: 120rpx;
|
||||
border-radius: 6rpx;
|
||||
}
|
||||
|
||||
/* 简洁式 */
|
||||
.skeleton-simple-item {
|
||||
padding: 28rpx 24rpx;
|
||||
background: #fff;
|
||||
border-radius: 16rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.skeleton-simple-title {
|
||||
height: 34rpx;
|
||||
width: 55%;
|
||||
border-radius: 8rpx;
|
||||
}
|
||||
|
||||
.skeleton-simple-meta {
|
||||
display: flex;
|
||||
gap: 24rpx;
|
||||
}
|
||||
|
||||
.skeleton-simple-line {
|
||||
height: 26rpx;
|
||||
width: 180rpx;
|
||||
border-radius: 6rpx;
|
||||
|
||||
&.short {
|
||||
width: 100rpx;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.skeleton-shimmer) {
|
||||
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,179 @@
|
||||
<!-- components/Skeleton/MemberCenterSkeleton.vue -->
|
||||
<template>
|
||||
<SkeletonBase>
|
||||
<!-- 头像和个人信息 -->
|
||||
<view class="skeleton-header">
|
||||
<view class="skeleton-avatar skeleton-shimmer"></view>
|
||||
<view class="skeleton-header-info">
|
||||
<view class="skeleton-name skeleton-shimmer"></view>
|
||||
<view class="skeleton-level skeleton-shimmer"></view>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 统计数据 -->
|
||||
<view class="skeleton-stats">
|
||||
<view v-for="i in 3" :key="i" class="skeleton-stat-item">
|
||||
<view class="skeleton-stat-num skeleton-shimmer"></view>
|
||||
<view class="skeleton-stat-label skeleton-shimmer"></view>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 会员卡 -->
|
||||
<view class="skeleton-card">
|
||||
<view class="skeleton-card-title skeleton-shimmer"></view>
|
||||
<view class="skeleton-card-body">
|
||||
<view class="skeleton-card-line skeleton-shimmer"></view>
|
||||
<view class="skeleton-card-line skeleton-shimmer short"></view>
|
||||
<view class="skeleton-card-btn skeleton-shimmer"></view>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 快捷入口 -->
|
||||
<view class="skeleton-entry">
|
||||
<view v-for="i in 4" :key="i" class="skeleton-entry-item">
|
||||
<view class="skeleton-entry-icon skeleton-shimmer"></view>
|
||||
<view class="skeleton-entry-label skeleton-shimmer"></view>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 底部占位 -->
|
||||
<view class="skeleton-spacer"></view>
|
||||
</SkeletonBase>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import SkeletonBase from './SkeletonBase.vue'
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.skeleton-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 40rpx 32rpx 24rpx;
|
||||
}
|
||||
|
||||
.skeleton-avatar {
|
||||
width: 120rpx;
|
||||
height: 120rpx;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.skeleton-header-info {
|
||||
margin-left: 24rpx;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.skeleton-name {
|
||||
height: 36rpx;
|
||||
width: 160rpx;
|
||||
border-radius: 8rpx;
|
||||
}
|
||||
|
||||
.skeleton-level {
|
||||
height: 28rpx;
|
||||
width: 120rpx;
|
||||
border-radius: 8rpx;
|
||||
}
|
||||
|
||||
.skeleton-stats {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
padding: 24rpx 32rpx;
|
||||
}
|
||||
|
||||
.skeleton-stat-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.skeleton-stat-num {
|
||||
height: 40rpx;
|
||||
width: 80rpx;
|
||||
border-radius: 8rpx;
|
||||
}
|
||||
|
||||
.skeleton-stat-label {
|
||||
height: 24rpx;
|
||||
width: 60rpx;
|
||||
border-radius: 8rpx;
|
||||
}
|
||||
|
||||
.skeleton-card {
|
||||
margin: 0 24rpx 24rpx;
|
||||
padding: 32rpx;
|
||||
background: #fff;
|
||||
border-radius: 20rpx;
|
||||
}
|
||||
|
||||
.skeleton-card-title {
|
||||
height: 32rpx;
|
||||
width: 140rpx;
|
||||
border-radius: 8rpx;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.skeleton-card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.skeleton-card-line {
|
||||
height: 28rpx;
|
||||
width: 70%;
|
||||
border-radius: 8rpx;
|
||||
|
||||
&.short {
|
||||
width: 45%;
|
||||
}
|
||||
}
|
||||
|
||||
.skeleton-card-btn {
|
||||
height: 56rpx;
|
||||
width: 160rpx;
|
||||
border-radius: 12rpx;
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
|
||||
.skeleton-entry {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
padding: 24rpx 32rpx;
|
||||
}
|
||||
|
||||
.skeleton-entry-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.skeleton-entry-icon {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
border-radius: 20rpx;
|
||||
}
|
||||
|
||||
.skeleton-entry-label {
|
||||
height: 24rpx;
|
||||
width: 60rpx;
|
||||
border-radius: 8rpx;
|
||||
}
|
||||
|
||||
.skeleton-spacer {
|
||||
height: 200rpx;
|
||||
}
|
||||
|
||||
:deep(.skeleton-shimmer) {
|
||||
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<view class="message-skeleton">
|
||||
<view v-for="i in 6" :key="i" class="skeleton-item">
|
||||
<view class="skeleton-icon skeleton-shimmer"></view>
|
||||
<view class="skeleton-content">
|
||||
<view class="skeleton-title skeleton-shimmer"></view>
|
||||
<view class="skeleton-desc skeleton-shimmer"></view>
|
||||
<view class="skeleton-footer">
|
||||
<view class="skeleton-tag skeleton-shimmer"></view>
|
||||
<view class="skeleton-time skeleton-shimmer"></view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.message-skeleton {
|
||||
padding: 8rpx 24rpx;
|
||||
}
|
||||
|
||||
.skeleton-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 24rpx 0;
|
||||
margin-bottom: 12rpx;
|
||||
background: var(--bg-white);
|
||||
border-radius: 16rpx;
|
||||
}
|
||||
|
||||
.skeleton-icon {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
border-radius: 20rpx;
|
||||
margin-right: 20rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.skeleton-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.skeleton-title {
|
||||
height: 32rpx;
|
||||
width: 60%;
|
||||
border-radius: 8rpx;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
.skeleton-desc {
|
||||
height: 28rpx;
|
||||
width: 85%;
|
||||
border-radius: 8rpx;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.skeleton-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.skeleton-tag {
|
||||
height: 24rpx;
|
||||
width: 80rpx;
|
||||
border-radius: 8rpx;
|
||||
}
|
||||
|
||||
.skeleton-time {
|
||||
height: 24rpx;
|
||||
width: 100rpx;
|
||||
border-radius: 8rpx;
|
||||
}
|
||||
|
||||
:deep(.skeleton-shimmer) {
|
||||
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<view class="skeleton" :style="{ padding: padding }">
|
||||
<slot />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
padding: {
|
||||
type: String,
|
||||
default: '20rpx'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.skeleton {
|
||||
background-color: #f5f5f5;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
:deep(.skeleton-shimmer) {
|
||||
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
}
|
||||
|
||||
:deep(.skeleton-line) {
|
||||
height: 32rpx;
|
||||
border-radius: 16rpx;
|
||||
}
|
||||
|
||||
:deep(.skeleton-block) {
|
||||
border-radius: 16rpx;
|
||||
}
|
||||
|
||||
:deep(.skeleton-circle) {
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,292 @@
|
||||
<!-- components/TabBar.vue -->
|
||||
<template>
|
||||
<view v-if="shouldShowTabBar" class="tab-bar-wrapper">
|
||||
<view class="tab-bar">
|
||||
<view
|
||||
v-for="(tab, index) in tabs"
|
||||
:key="tab.path"
|
||||
:class="['tab-item', { active: currentActiveIndex === index }]"
|
||||
hover-class="tab-item--hover"
|
||||
@tap.stop="onTabTap(index)"
|
||||
>
|
||||
<!-- 判断是否使用字体图标(我的页面用字体,其他用图片) -->
|
||||
<text
|
||||
v-if="tab.useFontIcon"
|
||||
:class="['iconfont', tab.icon]"
|
||||
class="tab-icon-font"
|
||||
:style="{ fontSize: tab.fontSize}"
|
||||
></text>
|
||||
<text class="tab-label">{{ tab.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||
import {
|
||||
PAGE,
|
||||
TAB_ROUTES,
|
||||
getCurrentRoutePath,
|
||||
getTabIndexByRoute
|
||||
} from '@/common/constants/routes.js'
|
||||
|
||||
const props = defineProps({
|
||||
active: { type: Number, default: -1 },
|
||||
activeTab: { type: Number, default: -1 }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:active', 'tab-change'])
|
||||
|
||||
const currentActiveIndex = ref(-1)
|
||||
const shouldShowTabBar = ref(true)
|
||||
|
||||
const HIDE_TABBAR_PAGES = [
|
||||
'pages/memberInfo/courseList',
|
||||
'pages/memberInfo/courseDetail',
|
||||
'pages/memberInfo/booking',
|
||||
'pages/memberInfo/bodyTestReport',
|
||||
'pages/groupCourse/list',
|
||||
'pages/groupCourse/detail',
|
||||
'pages/checkIn/checkIn',
|
||||
'pages/memberInfo/myCourses',
|
||||
'pages/memberInfo/coupons',
|
||||
'pages/memberInfo/points',
|
||||
'pages/memberInfo/pointsMall',
|
||||
'pages/memberInfo/referral',
|
||||
'pages/memberInfo/userInfo',
|
||||
]
|
||||
|
||||
function getActiveIndexFromRoute() {
|
||||
const routePath = getCurrentRoutePath()
|
||||
const index = getTabIndexByRoute(routePath)
|
||||
return index >= 0 ? index : 0
|
||||
}
|
||||
|
||||
function syncActiveState() {
|
||||
const routeIndex = getActiveIndexFromRoute()
|
||||
if (routeIndex >= 0) {
|
||||
currentActiveIndex.value = routeIndex
|
||||
return
|
||||
}
|
||||
if (props.active >= 0) {
|
||||
currentActiveIndex.value = props.active
|
||||
} else if (props.activeTab >= 0) {
|
||||
currentActiveIndex.value = props.activeTab
|
||||
} else {
|
||||
currentActiveIndex.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
function checkShouldShow() {
|
||||
let routePath = getCurrentRoutePath()
|
||||
if (routePath.startsWith('/')) {
|
||||
routePath = routePath.slice(1)
|
||||
}
|
||||
if (routePath.includes('?')) {
|
||||
routePath = routePath.split('?')[0]
|
||||
}
|
||||
const shouldHide = HIDE_TABBAR_PAGES.includes(routePath)
|
||||
shouldShowTabBar.value = !shouldHide
|
||||
}
|
||||
|
||||
let routeWatcher = null
|
||||
let appRouteCallback = null
|
||||
let isNavigating = false
|
||||
|
||||
onMounted(() => {
|
||||
syncActiveState()
|
||||
checkShouldShow()
|
||||
// #ifndef MP-WEIXIN
|
||||
// H5和其他平台使用轮询监听路由变化
|
||||
routeWatcher = setInterval(() => {
|
||||
// 导航期间不更新状态,避免覆盖用户点击的索引
|
||||
if (isNavigating) return
|
||||
const newIndex = getActiveIndexFromRoute()
|
||||
if (newIndex !== currentActiveIndex.value) {
|
||||
currentActiveIndex.value = newIndex
|
||||
}
|
||||
checkShouldShow()
|
||||
}, 200)
|
||||
// #endif
|
||||
// #ifdef MP-WEIXIN
|
||||
if (typeof uni.onAppRoute === 'function') {
|
||||
appRouteCallback = () => {
|
||||
setTimeout(() => {
|
||||
syncActiveState()
|
||||
checkShouldShow()
|
||||
}, 100)
|
||||
}
|
||||
uni.onAppRoute(appRouteCallback)
|
||||
}
|
||||
// #endif
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
// #ifndef MP-WEIXIN
|
||||
// H5和其他平台清理定时器
|
||||
if (routeWatcher) {
|
||||
clearInterval(routeWatcher)
|
||||
routeWatcher = null
|
||||
}
|
||||
// #endif
|
||||
// #ifdef MP-WEIXIN
|
||||
if (appRouteCallback && typeof uni.offAppRoute === 'function') {
|
||||
uni.offAppRoute(appRouteCallback)
|
||||
appRouteCallback = null
|
||||
}
|
||||
// #endif
|
||||
})
|
||||
|
||||
watch(() => props.active, () => {
|
||||
const routeIndex = getActiveIndexFromRoute()
|
||||
if (routeIndex !== currentActiveIndex.value) { syncActiveState() }
|
||||
})
|
||||
|
||||
// tabs 配置:只有"我的"用字体图标
|
||||
const tabs = [
|
||||
{
|
||||
path: PAGE.INDEX,
|
||||
icon: 'icon-home',
|
||||
label: '首页',
|
||||
useFontIcon: true,
|
||||
fontSize:"36rpx"
|
||||
},
|
||||
{
|
||||
path: PAGE.COURSE,
|
||||
icon: 'icon-course',
|
||||
label: '课程',
|
||||
useFontIcon: true,
|
||||
fontSize:"36rpx"
|
||||
},
|
||||
{
|
||||
path: PAGE.MEMBER,
|
||||
icon: 'icon-profile',
|
||||
label: '我的',
|
||||
useFontIcon: true,
|
||||
fontSize:"36rpx"
|
||||
}
|
||||
]
|
||||
|
||||
let isSwitching = false
|
||||
|
||||
function onTabTap(index) {
|
||||
if (isSwitching) return
|
||||
const targetPath = TAB_ROUTES[index]
|
||||
const currentPath = TAB_ROUTES[currentActiveIndex.value]
|
||||
if (targetPath === currentPath) return
|
||||
console.log('Tab 点击:', index, targetPath)
|
||||
// 立即更新状态
|
||||
currentActiveIndex.value = index
|
||||
emit('update:active', index)
|
||||
emit('tab-change', index)
|
||||
// 设置导航标志,阻止轮询覆盖状态
|
||||
isNavigating = true
|
||||
uni.showLoading({ title: '加载中...', mask: true })
|
||||
isSwitching = true
|
||||
uni.switchTab({
|
||||
url: targetPath,
|
||||
success: () => { console.log('switchTab 成功:', targetPath) },
|
||||
fail: (err) => {
|
||||
console.error('switchTab 失败:', err)
|
||||
uni.reLaunch({ url: targetPath })
|
||||
},
|
||||
complete: () => {
|
||||
uni.hideLoading()
|
||||
setTimeout(() => {
|
||||
isSwitching = false
|
||||
isNavigating = false
|
||||
// #ifdef MP-WEIXIN
|
||||
syncActiveState()
|
||||
// #endif
|
||||
checkShouldShow()
|
||||
}, 100)
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
// 引入字体图标 CSS(定义 @font-face)
|
||||
@import '/common/style/tabbar_icon/tabbar.css';
|
||||
|
||||
// 固定容器 - 确保TabBar始终在屏幕底部
|
||||
.tab-bar-wrapper {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 9999;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tab-bar-wrapper .tab-bar {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.tab-bar {
|
||||
height: 120rpx;
|
||||
background: white;
|
||||
backdrop-filter: blur(24px);
|
||||
-webkit-backdrop-filter: blur(24px);
|
||||
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 24rpx var(--tabbar-shadow);
|
||||
border-radius: 32rpx 32rpx 0 0;
|
||||
/* 防闪烁优化 */
|
||||
transform: translateZ(0);
|
||||
-webkit-transform: translateZ(0);
|
||||
will-change: transform;
|
||||
backface-visibility: hidden;
|
||||
-webkit-backface-visibility: hidden;
|
||||
perspective: 1000;
|
||||
-webkit-perspective: 1000;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
padding: 12rpx 24rpx;
|
||||
transition: all 0.1s ease;
|
||||
}
|
||||
|
||||
.tab-item:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
// 图片图标样式
|
||||
.tab-icon {
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
}
|
||||
|
||||
// 字体图标样式
|
||||
.tab-icon-font {
|
||||
font-size: 44rpx;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
// 字体图标颜色控制(根据选中状态)
|
||||
.tab-item .iconfont {
|
||||
color: rgba(150, 150, 165, 1);
|
||||
}
|
||||
|
||||
.tab-item.active .iconfont {
|
||||
color: rgba(130, 220, 130, 0.9);
|
||||
}
|
||||
|
||||
.tab-label {
|
||||
font-size: 22rpx;
|
||||
color: rgba(150, 150, 165, 1);
|
||||
}
|
||||
|
||||
.tab-item.active .tab-label {
|
||||
color: rgba(130, 220, 130, 0.9);
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,85 @@
|
||||
<!-- components/GlobalLoading.vue -->
|
||||
<template>
|
||||
<view v-if="visible" class="global-loading">
|
||||
<view class="loading-mask"></view>
|
||||
<view class="loading-content">
|
||||
<view class="loading-spinner"></view>
|
||||
<text class="loading-text">{{ text }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const visible = ref(false)
|
||||
const text = ref('加载中...')
|
||||
|
||||
// 显示
|
||||
function show(loadingText = '加载中...') {
|
||||
visible.value = true
|
||||
text.value = loadingText
|
||||
}
|
||||
|
||||
// 隐藏
|
||||
function hide() {
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
// 挂载到全局
|
||||
if (typeof uni !== 'undefined') {
|
||||
uni.$globalLoading = { show, hide }
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.global-loading {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.loading-mask {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.loading-content {
|
||||
position: relative;
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
border-radius: 16rpx;
|
||||
padding: 32rpx 48rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 48rpx;
|
||||
height: 48rpx;
|
||||
border: 4rpx solid rgba(255, 255, 255, 0.3);
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
color: #fff;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* 登录弹窗组件使用示例
|
||||
*
|
||||
* 该组件提供了统一的登录拦截功能,可以在任何页面使用
|
||||
*/
|
||||
|
||||
## 方式一:在页面中直接使用LoginModal组件
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<view>
|
||||
<!-- 其他内容 -->
|
||||
|
||||
<!-- 引入登录弹窗 -->
|
||||
<LoginModal
|
||||
ref="loginModalRef"
|
||||
v-model="showLoginModal"
|
||||
title="登录后享受更多服务"
|
||||
subtitle="登录即表示同意相关协议"
|
||||
:show-close="true"
|
||||
:mask-closable="true"
|
||||
@loginSuccess="onLoginSuccess"
|
||||
@loginError="onLoginError"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, nextTick } from 'vue'
|
||||
import { registerLoginModal } from '@/common/hooks/useLoginGuard.js'
|
||||
import LoginModal from '@/components/global/LoginModal.vue'
|
||||
|
||||
const showLoginModal = ref(false)
|
||||
const loginModalRef = ref(null)
|
||||
|
||||
// 注册到全局
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
if (loginModalRef.value) {
|
||||
registerLoginModal(loginModalRef.value)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// 显示登录弹窗
|
||||
function showLogin() {
|
||||
showLoginModal.value = true
|
||||
}
|
||||
|
||||
// 登录成功回调
|
||||
function onLoginSuccess(result) {
|
||||
console.log('登录成功', result)
|
||||
// 刷新页面数据等
|
||||
}
|
||||
|
||||
// 登录失败回调
|
||||
function onLoginError(error) {
|
||||
console.log('登录失败', error)
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
## 方式二:使用 useLoginGuard Hook
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { useLoginGuard } from '@/common/hooks/useLoginGuard.js'
|
||||
|
||||
const { requireLogin, checkLogin } = useLoginGuard()
|
||||
|
||||
// 示例1:需要登录才能执行的操作
|
||||
async function handleBookCourse() {
|
||||
// 检查是否已登录
|
||||
if (!checkLogin()) {
|
||||
return
|
||||
}
|
||||
// 执行预约操作...
|
||||
}
|
||||
|
||||
// 示例2:弹窗式登录(推荐)
|
||||
async function handlePurchase() {
|
||||
const loggedIn = await requireLogin({
|
||||
title: '请先登录',
|
||||
subtitle: '登录后即可购买会员卡',
|
||||
onSuccess: (user) => {
|
||||
console.log('登录成功,用户信息:', user)
|
||||
}
|
||||
})
|
||||
|
||||
if (loggedIn) {
|
||||
// 执行购买操作
|
||||
await purchaseCard()
|
||||
}
|
||||
}
|
||||
|
||||
// 示例3:安全执行函数
|
||||
async function handleCheckIn() {
|
||||
const result = await requireLogin().safeExecute(async () => {
|
||||
// 只有登录后才能执行这里的代码
|
||||
return await checkInApi()
|
||||
})
|
||||
|
||||
if (result) {
|
||||
uni.showToast({ title: '签到成功' })
|
||||
}
|
||||
}
|
||||
|
||||
// 示例4:包装API调用
|
||||
async function handleGetCoupons() {
|
||||
const { callLoggedInApi } = useLoginGuard()
|
||||
|
||||
try {
|
||||
const coupons = await callLoggedInApi(getCouponsApi, [], {
|
||||
title: '领取优惠券'
|
||||
})
|
||||
console.log('优惠券列表:', coupons)
|
||||
} catch (e) {
|
||||
if (e.message === '用户未登录') {
|
||||
// 用户未登录,弹窗已显示
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
## 方式三:在组件中使用登录守卫Mixin
|
||||
|
||||
```vue
|
||||
<script>
|
||||
import { loginGuardMixin } from '@/common/hooks/useLoginGuard.js'
|
||||
|
||||
export default {
|
||||
mixins: [loginGuardMixin()],
|
||||
|
||||
methods: {
|
||||
async onQuickAction(type) {
|
||||
// 自动检查登录状态
|
||||
if (!await this.ensureLogin()) {
|
||||
return
|
||||
}
|
||||
|
||||
// 已登录,执行操作
|
||||
if (type === 'book') {
|
||||
this.goToBooking()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
## 登录弹窗组件Props说明
|
||||
|
||||
| 属性 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| modelValue | Boolean | false | 控制显示/隐藏 |
|
||||
| title | String | '登录后享受更多服务' | 弹窗标题 |
|
||||
| subtitle | String | '登录即表示同意相关协议' | 弹窗副标题 |
|
||||
| showClose | Boolean | true | 是否显示关闭按钮 |
|
||||
| modalStyle | String | 'center' | 弹窗样式:'center'居中,'bottom'底部 |
|
||||
| maskClosable | Boolean | true | 点击遮罩是否关闭 |
|
||||
| onLoginSuccess | Function | null | 登录成功回调 |
|
||||
| onLoginError | Function | null | 登录失败回调 |
|
||||
|
||||
## 登录弹窗组件Events
|
||||
|
||||
| 事件名 | 说明 | 回调参数 |
|
||||
|--------|------|----------|
|
||||
| update:modelValue | 显示状态变化 | Boolean |
|
||||
| loginSuccess | 登录成功 | Object: 登录结果 |
|
||||
| loginError | 登录失败 | Error: 错误对象 |
|
||||
| close | 弹窗关闭 | - |
|
||||
|
||||
## 登录弹窗组件暴露的方法
|
||||
|
||||
| 方法名 | 说明 | 参数 |
|
||||
|--------|------|------|
|
||||
| show() | 显示弹窗 | - |
|
||||
| hide() | 隐藏弹窗 | - |
|
||||
| switchCard(card) | 切换登录方式 | 'phone'一键登录 / 'sms'验证码登录 |
|
||||
|
||||
## 全局登录拦截示例
|
||||
|
||||
在 `App.vue` 中注册全局登录弹窗:
|
||||
|
||||
```vue
|
||||
<script>
|
||||
import LoginModal from '@/components/global/LoginModal.vue'
|
||||
import { registerLoginModal } from '@/common/hooks/useLoginGuard.js'
|
||||
|
||||
export default {
|
||||
components: { LoginModal },
|
||||
onLaunch() {
|
||||
// 注册全局登录弹窗
|
||||
this.$nextTick(() => {
|
||||
if (this.$refs.globalLoginModal) {
|
||||
registerLoginModal(this.$refs.globalLoginModal)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view id="app">
|
||||
<router-view />
|
||||
<LoginModal ref="globalLoginModal" />
|
||||
</view>
|
||||
</template>
|
||||
```
|
||||
|
||||
## API路径配置
|
||||
|
||||
登录弹窗组件使用的API路径定义在 `@/api/main.js`:
|
||||
|
||||
```javascript
|
||||
// 一键登录
|
||||
export function phoneLogin(data) {
|
||||
return request.post('/auth/phone/login', data)
|
||||
}
|
||||
|
||||
// 短信验证码登录
|
||||
export function smsLogin(data) {
|
||||
return request.post('/auth/phone/sms/login', data)
|
||||
}
|
||||
|
||||
// 发送验证码
|
||||
export function sendSmsCode(phone) {
|
||||
return request.post('/auth/phone/sms/send', { phone })
|
||||
}
|
||||
```
|
||||
|
||||
如需修改API路径,请编辑 `main.js` 文件中的相应函数。
|
||||
@@ -0,0 +1,691 @@
|
||||
<template>
|
||||
<!-- 登录弹窗组件 -->
|
||||
<view class="login-modal-mask" v-if="visible" @tap="handleMaskTap">
|
||||
<view class="login-modal" :class="{ 'login-modal--center': modalStyle === 'center' }" @tap.stop>
|
||||
<!-- 关闭按钮 -->
|
||||
<view class="login-modal__close" v-if="showClose" @tap="handleClose">
|
||||
<text class="icon-close">×</text>
|
||||
</view>
|
||||
|
||||
<!-- 弹窗内容 -->
|
||||
<view class="login-modal__content">
|
||||
<!-- 头部 -->
|
||||
<view class="login-modal__header">
|
||||
<view class="logo-wrapper">
|
||||
<image class="logo-image" src="/static/logo.png" mode="aspectFit" />
|
||||
<text class="app-name">活氧舱</text>
|
||||
</view>
|
||||
<text class="modal-title">{{ title }}</text>
|
||||
<text class="modal-subtitle">{{ subtitle }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 一键登录卡片 -->
|
||||
<view class="login-card" :class="{ active: activeCard === 'phone' }">
|
||||
<view class="card-body">
|
||||
<text class="card-title">本机号码登录</text>
|
||||
<text class="auth-tip">认证服务由中国移动提供</text>
|
||||
|
||||
<button class="login-btn primary" :class="{ disabled: isLoading, loading: isLoading }"
|
||||
@tap="handlePhoneLogin" :disabled="isLoading">
|
||||
<text>{{ isLoading ? '登录中...' : '一键登录' }}</text>
|
||||
</button>
|
||||
|
||||
<button class="login-btn switch-btn" @tap="switchToSms">
|
||||
<text>短信验证码登录</text>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 短信验证码登录卡片 -->
|
||||
<view class="login-card sms-card" :class="{ active: activeCard === 'sms' }">
|
||||
<view class="card-body">
|
||||
<text class="card-title">验证码登录</text>
|
||||
<text class="auth-tip">认证服务由中国移动提供</text>
|
||||
|
||||
<view class="form-group">
|
||||
<view class="input-wrapper" :class="{ focused: isPhoneFocused }">
|
||||
<input v-model="smsPhone" type="number" placeholder="请输入手机号" maxlength="11"
|
||||
class="phone-input" confirm-type="next" @focus="isPhoneFocused = true"
|
||||
@blur="isPhoneFocused = false" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="form-group">
|
||||
<view class="input-wrapper code-input" :class="{ focused: isCodeFocused }">
|
||||
<input v-model="smsCode" type="number" placeholder="请输入验证码" maxlength="6"
|
||||
class="phone-input" confirm-type="done" @focus="isCodeFocused = true"
|
||||
@blur="isCodeFocused = false" />
|
||||
<button class="send-code-btn" :class="{ disabled: countdown > 0 || isLoading }"
|
||||
@tap="handleSendCode" :disabled="countdown > 0 || isLoading">
|
||||
<text>{{ countdown > 0 ? `${countdown}秒后重发` : '发送验证码' }}</text>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<button class="login-btn primary" :class="{ disabled: !canSmsSubmit, loading: isLoading }"
|
||||
@tap="handleSmsLogin" :disabled="!canSmsSubmit || isLoading">
|
||||
<text>{{ isLoading ? '登录中...' : '登录' }}</text>
|
||||
</button>
|
||||
|
||||
<button class="login-btn switch-btn" @tap="switchToPhone">
|
||||
<text>一键登录</text>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 协议 -->
|
||||
<view class="agreement-text">
|
||||
<checkbox-group @change="onAgreementChange">
|
||||
<checkbox :checked="agreed" value="agreed" color="#FF8C42" />
|
||||
</checkbox-group>
|
||||
<text>登录即表示同意</text>
|
||||
<text class="link" @tap="showAgreement">《用户协议》</text>
|
||||
<text>和</text>
|
||||
<text class="link" @tap="showPrivacy">《隐私政策》</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Toast提示 -->
|
||||
<view class="custom-toast" :class="{ show: toastShow }">
|
||||
<text class="toast-text">{{ toastMessage }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { oneClickLogin, loginWithPhone, sendCode } from '@/api/main.js'
|
||||
|
||||
// Props定义
|
||||
const props = defineProps({
|
||||
// 是否显示弹窗
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
// 弹窗标题
|
||||
title: {
|
||||
type: String,
|
||||
default: '登录后享受更多服务'
|
||||
},
|
||||
// 弹窗副标题
|
||||
subtitle: {
|
||||
type: String,
|
||||
default: '登录即表示同意相关协议'
|
||||
},
|
||||
// 是否显示关闭按钮
|
||||
showClose: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
// 弹窗样式: 'center'居中, 'bottom'底部
|
||||
modalStyle: {
|
||||
type: String,
|
||||
default: 'center'
|
||||
},
|
||||
// 是否点击遮罩层关闭
|
||||
maskClosable: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
// 登录成功回调
|
||||
onLoginSuccess: {
|
||||
type: Function,
|
||||
default: null
|
||||
},
|
||||
// 登录失败回调
|
||||
onLoginError: {
|
||||
type: Function,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
// Emits定义
|
||||
const emit = defineEmits(['update:modelValue', 'loginSuccess', 'loginError', 'close'])
|
||||
|
||||
// 响应式状态
|
||||
const visible = ref(false)
|
||||
const activeCard = ref('phone') // 'phone'一键登录, 'sms'验证码登录
|
||||
const isLoading = ref(false)
|
||||
const isPhoneFocused = ref(false)
|
||||
const isCodeFocused = ref(false)
|
||||
const smsPhone = ref('')
|
||||
const smsCode = ref('')
|
||||
const countdown = ref(0)
|
||||
const agreed = ref(false)
|
||||
const toastShow = ref(false)
|
||||
const toastMessage = ref('')
|
||||
|
||||
// 计算属性
|
||||
const canSmsSubmit = computed(() => {
|
||||
return smsPhone.value.length === 11 && smsCode.value.length === 6 && agreed.value
|
||||
})
|
||||
|
||||
// 监听modelValue变化
|
||||
watch(() => props.modelValue, (newVal) => {
|
||||
visible.value = newVal
|
||||
if (newVal) {
|
||||
// 打开弹窗时重置状态
|
||||
resetState()
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// 重置状态
|
||||
function resetState() {
|
||||
activeCard.value = 'phone'
|
||||
isLoading.value = false
|
||||
smsPhone.value = ''
|
||||
smsCode.value = ''
|
||||
countdown.value = 0
|
||||
agreed.value = false
|
||||
}
|
||||
|
||||
// 显示Toast
|
||||
function showToast(message, duration = 2000) {
|
||||
toastMessage.value = message
|
||||
toastShow.value = true
|
||||
setTimeout(() => {
|
||||
toastShow.value = false
|
||||
}, duration)
|
||||
}
|
||||
|
||||
// 显示用户协议
|
||||
function showAgreement() {
|
||||
uni.showModal({
|
||||
title: '用户协议',
|
||||
content: '这里是用户协议内容...',
|
||||
showCancel: false
|
||||
})
|
||||
}
|
||||
|
||||
// 显示隐私政策
|
||||
function showPrivacy() {
|
||||
uni.showModal({
|
||||
title: '隐私政策',
|
||||
content: '这里隐私政策内容...',
|
||||
showCancel: false
|
||||
})
|
||||
}
|
||||
|
||||
// 切换到短信登录
|
||||
function switchToSms() {
|
||||
activeCard.value = 'sms'
|
||||
}
|
||||
|
||||
// 切换到一键登录
|
||||
function switchToPhone() {
|
||||
activeCard.value = 'phone'
|
||||
}
|
||||
|
||||
// 协议勾选变化
|
||||
function onAgreementChange(e) {
|
||||
agreed.value = e.detail.value.includes('agreed')
|
||||
}
|
||||
|
||||
// 发送验证码
|
||||
async function handleSendCode() {
|
||||
if (countdown.value > 0 || isLoading.value) return
|
||||
|
||||
if (!smsPhone.value || smsPhone.value.length !== 11) {
|
||||
showToast('请输入正确的手机号')
|
||||
return
|
||||
}
|
||||
|
||||
if (!agreed.value) {
|
||||
showToast('请先同意用户协议')
|
||||
return
|
||||
}
|
||||
|
||||
isLoading.value = true
|
||||
try {
|
||||
await sendCode({ phone: smsPhone.value })
|
||||
showToast('验证码已发送')
|
||||
countdown.value = 60
|
||||
const timer = setInterval(() => {
|
||||
countdown.value--
|
||||
if (countdown.value <= 0) {
|
||||
clearInterval(timer)
|
||||
}
|
||||
}, 1000)
|
||||
} catch (e) {
|
||||
console.error('发送验证码失败:', e)
|
||||
showToast(e.message || '发送验证码失败')
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 一键登录
|
||||
async function handlePhoneLogin() {
|
||||
if (isLoading.value) return
|
||||
|
||||
if (!agreed.value) {
|
||||
showToast('请先同意用户协议')
|
||||
return
|
||||
}
|
||||
|
||||
isLoading.value = true
|
||||
|
||||
try {
|
||||
// #ifdef APP-PLUS
|
||||
// App环境下使用一键登录
|
||||
await new Promise((resolve, reject) => {
|
||||
uni.login({
|
||||
provider: 'univerify',
|
||||
univerifyStyle: {
|
||||
fullScreen: false,
|
||||
backgroundColor: '#ffffff',
|
||||
icon: {
|
||||
path: '/static/logo.png'
|
||||
},
|
||||
closeIcon: {
|
||||
path: '/static/close.png'
|
||||
},
|
||||
phoneNum: {
|
||||
color: '#333333',
|
||||
fontSize: '18px'
|
||||
},
|
||||
slogan: {
|
||||
color: '#999999',
|
||||
fontSize: '12px'
|
||||
},
|
||||
authButton: {
|
||||
normalColor: '#FF8C42',
|
||||
highlightColor: '#FF7A2C',
|
||||
disabledColor: '#CCCCCC',
|
||||
textColor: '#FFFFFF',
|
||||
title: '本机号码一键登录'
|
||||
},
|
||||
otherLoginButton: {
|
||||
visible: false
|
||||
},
|
||||
protocols: []
|
||||
},
|
||||
success: async (res) => {
|
||||
try {
|
||||
// 调用后端API进行一键登录
|
||||
const result = await oneClickLogin({
|
||||
accessToken: res.authResult.access_token,
|
||||
openid: res.authResult.openid
|
||||
})
|
||||
|
||||
if (result.code === 200 || result.data) {
|
||||
handleLoginSuccess(result)
|
||||
resolve(result)
|
||||
} else {
|
||||
reject(new Error(result.message || '登录失败'))
|
||||
}
|
||||
} catch (e) {
|
||||
reject(e)
|
||||
}
|
||||
},
|
||||
fail: (err) => {
|
||||
console.error('一键登录失败:', err)
|
||||
reject(new Error(err.errMsg || '一键登录失败'))
|
||||
}
|
||||
})
|
||||
})
|
||||
// #endif
|
||||
|
||||
// #ifndef APP-PLUS
|
||||
// 非App环境(小程序、H5等)提示使用其他方式
|
||||
uni.showToast({
|
||||
title: '请使用短信验证码登录',
|
||||
icon: 'none'
|
||||
})
|
||||
activeCard.value = 'sms'
|
||||
// #endif
|
||||
|
||||
} catch (e) {
|
||||
console.error('一键登录失败:', e)
|
||||
showToast(e.message || '一键登录失败')
|
||||
if (props.onLoginError) {
|
||||
props.onLoginError(e)
|
||||
}
|
||||
emit('loginError', e)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 短信验证码登录
|
||||
async function handleSmsLogin() {
|
||||
if (!canSmsSubmit.value || isLoading.value) return
|
||||
|
||||
isLoading.value = true
|
||||
|
||||
try {
|
||||
const result = await loginWithPhone({
|
||||
phone: smsPhone.value,
|
||||
code: smsCode.value
|
||||
})
|
||||
|
||||
handleLoginSuccess(result)
|
||||
|
||||
} catch (e) {
|
||||
console.error('短信登录失败:', e)
|
||||
showToast(e.message || '登录失败')
|
||||
if (props.onLoginError) {
|
||||
props.onLoginError(e)
|
||||
}
|
||||
emit('loginError', e)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 处理登录成功
|
||||
function handleLoginSuccess(result) {
|
||||
console.log('登录成功:', result)
|
||||
// 保存token和用户信息到本地缓存
|
||||
if (result.data?.token) {
|
||||
uni.setStorageSync('token', result.data.token)
|
||||
}
|
||||
// 直接缓存登录响应数据
|
||||
if (result.data) {
|
||||
uni.setStorageSync('loginMemberInfo', result.data)
|
||||
}
|
||||
|
||||
showToast('登录成功')
|
||||
|
||||
if (props.onLoginSuccess) {
|
||||
props.onLoginSuccess(result)
|
||||
}
|
||||
emit('loginSuccess', result)
|
||||
|
||||
uni.$emit('loginModal:success', result)
|
||||
|
||||
setTimeout(() => {
|
||||
handleClose()
|
||||
}, 1500)
|
||||
}
|
||||
|
||||
// 关闭弹窗
|
||||
function handleClose() {
|
||||
visible.value = false
|
||||
emit('update:modelValue', false)
|
||||
emit('close')
|
||||
|
||||
uni.$emit('loginModal:close')
|
||||
}
|
||||
|
||||
// 点击遮罩层
|
||||
function handleMaskTap() {
|
||||
if (props.maskClosable) {
|
||||
handleClose()
|
||||
}
|
||||
}
|
||||
|
||||
// 对外暴露的方法
|
||||
defineExpose({
|
||||
// 显示弹窗
|
||||
show() {
|
||||
visible.value = true
|
||||
emit('update:modelValue', true)
|
||||
},
|
||||
// 隐藏弹窗
|
||||
hide() {
|
||||
handleClose()
|
||||
},
|
||||
// 切换登录方式
|
||||
switchCard(card) {
|
||||
activeCard.value = card
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.login-modal-mask {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.login-modal {
|
||||
position: relative;
|
||||
width: 90%;
|
||||
max-width: 650rpx;
|
||||
background: #fff;
|
||||
border-radius: 24rpx;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 20rpx 60rpx rgba(0, 0, 0, 0.15);
|
||||
|
||||
&--center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.login-modal__close {
|
||||
position: absolute;
|
||||
top: 20rpx;
|
||||
right: 20rpx;
|
||||
width: 50rpx;
|
||||
height: 50rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10;
|
||||
|
||||
.icon-close {
|
||||
font-size: 48rpx;
|
||||
color: #999;
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.login-modal__content {
|
||||
width: 100%;
|
||||
padding: 40rpx 50rpx 50rpx;
|
||||
}
|
||||
|
||||
.login-modal__header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-bottom: 40rpx;
|
||||
}
|
||||
|
||||
.logo-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
margin-bottom: 20rpx;
|
||||
|
||||
.logo-image {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
border-radius: 16rpx;
|
||||
}
|
||||
|
||||
.app-name {
|
||||
font-size: 36rpx;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.modal-subtitle {
|
||||
font-size: 24rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
display: none;
|
||||
|
||||
&.active {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.sms-card {
|
||||
display: none;
|
||||
|
||||
&.active {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24rpx;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.auth-tip {
|
||||
font-size: 22rpx;
|
||||
color: #999;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
width: 100%;
|
||||
height: 88rpx;
|
||||
background: #F5F7FA;
|
||||
border-radius: 16rpx;
|
||||
border: 2rpx solid transparent;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 24rpx;
|
||||
box-sizing: border-box;
|
||||
transition: all 0.3s;
|
||||
|
||||
&.focused {
|
||||
border-color: #FF8C42;
|
||||
background: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.phone-input {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.code-input {
|
||||
padding-right: 0;
|
||||
|
||||
.phone-input {
|
||||
width: 55%;
|
||||
}
|
||||
}
|
||||
|
||||
.send-code-btn {
|
||||
width: 40%;
|
||||
height: 64rpx;
|
||||
line-height: 64rpx;
|
||||
background: #FF8C42;
|
||||
color: #fff;
|
||||
font-size: 24rpx;
|
||||
border-radius: 8rpx;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
|
||||
&.disabled {
|
||||
background: #ccc;
|
||||
}
|
||||
|
||||
&::after {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
width: 100%;
|
||||
height: 88rpx;
|
||||
line-height: 88rpx;
|
||||
border-radius: 44rpx;
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
|
||||
&.primary {
|
||||
background: linear-gradient(135deg, #FF8C42 0%, #FF6B2C 100%);
|
||||
color: #fff;
|
||||
border: none;
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
background: #ccc;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&.loading {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
&.switch-btn {
|
||||
background: transparent;
|
||||
color: #FF8C42;
|
||||
border: 2rpx solid #FF8C42;
|
||||
}
|
||||
|
||||
&::after {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
.agreement-text {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8rpx;
|
||||
margin-top: 24rpx;
|
||||
font-size: 22rpx;
|
||||
color: #999;
|
||||
|
||||
.link {
|
||||
color: #FF8C42;
|
||||
}
|
||||
}
|
||||
|
||||
.custom-toast {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%) scale(0);
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
color: #fff;
|
||||
padding: 24rpx 48rpx;
|
||||
border-radius: 12rpx;
|
||||
font-size: 28rpx;
|
||||
z-index: 99999;
|
||||
opacity: 0;
|
||||
transition: all 0.3s;
|
||||
|
||||
&.show {
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,278 @@
|
||||
<template>
|
||||
<view
|
||||
class="verify-code-input"
|
||||
@tap="focusInput"
|
||||
:style="{
|
||||
'--vci-active-color': activeColor,
|
||||
'--vci-filled-color': filledColor,
|
||||
'--vci-border-color': borderColor
|
||||
}"
|
||||
>
|
||||
<view class="vci-title" v-if="title">
|
||||
<text class="vci-title__text">{{ title }}</text>
|
||||
</view>
|
||||
<view class="vci-desc" v-if="desc">
|
||||
<text class="vci-desc__text">{{ desc }}</text>
|
||||
</view>
|
||||
|
||||
<view class="vci-code-box">
|
||||
<view
|
||||
v-for="(item, index) in codeLength"
|
||||
:key="index"
|
||||
class="vci-code-item"
|
||||
:class="{
|
||||
'vci-code-item--active': isFocused && currentIndex === index,
|
||||
'vci-code-item--filled': code.length > index,
|
||||
'vci-code-item--underline': type === 'underline',
|
||||
'vci-code-item--box': type === 'box'
|
||||
}"
|
||||
>
|
||||
<text v-if="code[index] && !mask" class="vci-code-text">{{ code[index] }}</text>
|
||||
<view v-else-if="code[index] && mask" class="vci-code-dot"></view>
|
||||
<view v-if="isFocused && currentIndex === index && code.length === index" class="vci-code-cursor"></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="vci-tips" v-if="errorMsg">
|
||||
<text class="vci-tips__text">{{ errorMsg }}</text>
|
||||
</view>
|
||||
|
||||
<input
|
||||
ref="inputRef"
|
||||
class="vci-hidden-input"
|
||||
type="number"
|
||||
:maxlength="codeLength"
|
||||
:value="code"
|
||||
:focus="isFocused"
|
||||
:adjust-position="false"
|
||||
@input="onInput"
|
||||
@focus="onFocus"
|
||||
@blur="onBlur"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
desc: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
length: {
|
||||
type: Number,
|
||||
default: 6
|
||||
},
|
||||
mask: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: 'box'
|
||||
},
|
||||
errorMsg: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
autoFocus: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
activeColor: {
|
||||
type: String,
|
||||
default: '#1677FF'
|
||||
},
|
||||
filledColor: {
|
||||
type: String,
|
||||
default: '#1A202C'
|
||||
},
|
||||
borderColor: {
|
||||
type: String,
|
||||
default: '#E2E8F0'
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'complete', 'focus', 'blur'])
|
||||
|
||||
const code = ref(props.modelValue)
|
||||
const isFocused = ref(false)
|
||||
const currentIndex = ref(0)
|
||||
const inputRef = ref(null)
|
||||
|
||||
const codeLength = computed(() => props.length)
|
||||
|
||||
watch(() => props.modelValue, (val) => {
|
||||
code.value = val
|
||||
currentIndex.value = Math.min(val.length, codeLength.value - 1)
|
||||
})
|
||||
|
||||
function focusInput() {
|
||||
isFocused.value = true
|
||||
}
|
||||
|
||||
function onInput(e) {
|
||||
let val = e.detail.value.replace(/\D/g, '').slice(0, codeLength.value)
|
||||
code.value = val
|
||||
currentIndex.value = Math.min(val.length, codeLength.value - 1)
|
||||
emit('update:modelValue', val)
|
||||
if (val.length === codeLength.value) {
|
||||
emit('complete', val)
|
||||
}
|
||||
}
|
||||
|
||||
function onFocus() {
|
||||
isFocused.value = true
|
||||
currentIndex.value = code.value.length
|
||||
emit('focus')
|
||||
}
|
||||
|
||||
function onBlur() {
|
||||
isFocused.value = false
|
||||
emit('blur')
|
||||
}
|
||||
|
||||
function clear() {
|
||||
code.value = ''
|
||||
currentIndex.value = 0
|
||||
emit('update:modelValue', '')
|
||||
}
|
||||
|
||||
function setFocus(val) {
|
||||
isFocused.value = val
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
clear,
|
||||
focusInput,
|
||||
setFocus
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.verify-code-input {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.vci-title {
|
||||
margin-bottom: 8px;
|
||||
|
||||
&__text {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1A202C;
|
||||
}
|
||||
}
|
||||
|
||||
.vci-desc {
|
||||
margin-bottom: 32px;
|
||||
|
||||
&__text {
|
||||
font-size: 13px;
|
||||
color: #718096;
|
||||
}
|
||||
}
|
||||
|
||||
.vci-code-box {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.vci-code-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&--box {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
background: #FFFFFF;
|
||||
border: 1px solid var(--vci-border-color, #E2E8F0);
|
||||
border-radius: 8px;
|
||||
|
||||
&.vci-code-item--active {
|
||||
border-color: var(--vci-active-color, #1677FF);
|
||||
}
|
||||
|
||||
&.vci-code-item--filled {
|
||||
border-color: var(--vci-filled-color, #1A202C);
|
||||
}
|
||||
}
|
||||
|
||||
&--underline {
|
||||
width: 40px;
|
||||
height: 50px;
|
||||
border-bottom: 2px solid var(--vci-border-color, #E2E8F0);
|
||||
|
||||
&.vci-code-item--active {
|
||||
border-bottom-color: var(--vci-active-color, #FF8C42);
|
||||
}
|
||||
|
||||
&.vci-code-item--filled {
|
||||
border-bottom-color: var(--vci-filled-color, #1A202C);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.vci-code-text {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: var(--vci-filled-color, #1A202C);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.vci-code-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: var(--vci-filled-color, #1A202C);
|
||||
}
|
||||
|
||||
.vci-code-cursor {
|
||||
position: absolute;
|
||||
width: 2px;
|
||||
height: 24px;
|
||||
background: var(--vci-active-color, #1677FF);
|
||||
animation: vci-blink 1s infinite;
|
||||
}
|
||||
|
||||
@keyframes vci-blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
|
||||
.vci-tips {
|
||||
text-align: center;
|
||||
margin-bottom: 16px;
|
||||
|
||||
&__text {
|
||||
font-size: 13px;
|
||||
color: #E53E3E;
|
||||
}
|
||||
}
|
||||
|
||||
.vci-hidden-input {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
top: -9999px;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,549 @@
|
||||
<template>
|
||||
<!-- 团课卡片容器 -->
|
||||
<view class="course-card" @click="goDetail">
|
||||
<!-- 卡片顶部图片区域 -->
|
||||
<view class="card-top">
|
||||
<!-- 图片骨架屏 -->
|
||||
<view v-if="!imageLoaded" class="skeleton skeleton-image"></view>
|
||||
<!-- 课程封面图片 -->
|
||||
<image
|
||||
:src="getCourseCoverUrl(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-tags">
|
||||
<view class="tag-item duration-tag">
|
||||
<uni-icons type="time" size="14" color="#8A99B4" />
|
||||
<text>{{ formatDuration(course.startTime, course.endTime) }}</text>
|
||||
</view>
|
||||
<view
|
||||
v-for="label in courseTypeLabels"
|
||||
:key="label.id"
|
||||
class="tag-item type-tag"
|
||||
:style="{ background: `${label.color}15`, color: label.color }"
|
||||
>
|
||||
<text>{{ label.labelName }}</text>
|
||||
</view>
|
||||
</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>{{ buttonText }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { getCourseCoverUrl } from '@/utils/request.js'
|
||||
|
||||
// 图片加载状态
|
||||
const imageLoaded = ref(false)
|
||||
|
||||
const props = defineProps({
|
||||
course: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
courseTypeLabels: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
booked: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
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 && !props.booked
|
||||
})
|
||||
|
||||
const buttonText = computed(() => {
|
||||
if (props.booked) return '已预约'
|
||||
if (!canBook.value && props.course.currentMembers >= props.course.maxMembers) return '已满员'
|
||||
if (!canBook.value && Number(props.course.status) !== 0) return '已结束'
|
||||
return '立即预约'
|
||||
})
|
||||
|
||||
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)
|
||||
|
||||
if (isNaN(start.getTime()) || isNaN(end.getTime())) {
|
||||
console.warn(`[CourseCard] 无效的时间格式: startTime=${startStr}, endTime=${endStr}`)
|
||||
return ''
|
||||
}
|
||||
|
||||
const diffMs = end.getTime() - start.getTime()
|
||||
if (diffMs <= 0) {
|
||||
console.warn(`[CourseCard] 结束时间小于或等于开始时间: startTime=${startStr}, endTime=${endStr}`)
|
||||
return ''
|
||||
}
|
||||
|
||||
const maxDurationMinutes = 24 * 60
|
||||
const minutes = Math.floor(diffMs / 60000)
|
||||
|
||||
if (minutes > maxDurationMinutes) {
|
||||
console.warn(`[CourseCard] 课程时长超过24小时,已修正: ${minutes}分钟 -> ${maxDurationMinutes}分钟`)
|
||||
return `${maxDurationMinutes}分钟以上`
|
||||
}
|
||||
|
||||
return `${minutes}分钟`
|
||||
}
|
||||
|
||||
const goDetail = () => {
|
||||
emit('detail', Number(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-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
/* 标签项 */
|
||||
.tag-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6rpx;
|
||||
padding: 10rpx 20rpx;
|
||||
border-radius: 20rpx;
|
||||
font-size: 24rpx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 时长标签 */
|
||||
.duration-tag {
|
||||
background: linear-gradient(135deg, #EFF6FF 0%, #DBEAFE 100%);
|
||||
color: #3B82F6;
|
||||
}
|
||||
|
||||
/* 类型标签 */
|
||||
.type-tag {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 卡片底部区域 */
|
||||
.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>
|
||||
@@ -0,0 +1,270 @@
|
||||
<template>
|
||||
<view class="filter-section">
|
||||
<!-- 课程类型筛选 -->
|
||||
<picker
|
||||
mode="selector"
|
||||
:range="courseTypeOptions"
|
||||
range-key="label"
|
||||
:value="courseTypeIndex"
|
||||
@change="onCourseTypeChange"
|
||||
>
|
||||
<view class="filter-item">
|
||||
<uni-icons type="apps" size="18" color="#5E6F8D" class="filter-icon" />
|
||||
<text class="filter-text">{{ courseTypeOptions[courseTypeIndex]?.label || '全部类型' }}</text>
|
||||
<uni-icons type="right" size="20" color="#A0AEC0" class="filter-arrow" />
|
||||
</view>
|
||||
</picker>
|
||||
|
||||
<!-- 时间区间筛选 -->
|
||||
<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, computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
timeRangeText: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
sortOptions: {
|
||||
type: Array,
|
||||
default: () => [
|
||||
{ label: '默认排序', value: 'default', priceSort: null, remainingMost: false },
|
||||
{ label: '价格从低到高', value: 'priceAsc', priceSort: 'asc', remainingMost: false },
|
||||
{ label: '价格从高到低', value: 'priceDesc', priceSort: 'desc', remainingMost: false },
|
||||
{ label: '剩余名额最多', value: 'remainingMost', priceSort: null, remainingMost: true }
|
||||
]
|
||||
},
|
||||
sortIndex: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
courseTypes: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
currentCourseTypeId: {
|
||||
type: [Number, String, null],
|
||||
default: null,
|
||||
validator: (val) => val === null || val === '' || !isNaN(Number(val))
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:sortIndex', 'timePick', 'courseTypeChange'])
|
||||
|
||||
const localSortIndex = ref(props.sortIndex)
|
||||
|
||||
const courseTypeOptions = computed(() => {
|
||||
return [{ id: null, label: '全部类型' }, ...props.courseTypes]
|
||||
})
|
||||
|
||||
const courseTypeIndex = computed(() => {
|
||||
if (props.currentCourseTypeId === null || props.currentCourseTypeId === '') return 0
|
||||
const currentId = Number(props.currentCourseTypeId)
|
||||
const index = courseTypeOptions.value.findIndex(item => Number(item.id) === currentId)
|
||||
return index >= 0 ? index : 0
|
||||
})
|
||||
|
||||
watch(() => props.sortIndex, (val) => {
|
||||
localSortIndex.value = val
|
||||
})
|
||||
|
||||
const onSortChange = (e) => {
|
||||
localSortIndex.value = e.detail.value
|
||||
const sortOption = props.sortOptions[localSortIndex.value]
|
||||
|
||||
console.log('[FilterSection] 排序方式变更:', {
|
||||
index: localSortIndex.value,
|
||||
value: sortOption
|
||||
})
|
||||
|
||||
if (sortOption.priceSort && sortOption.remainingMost) {
|
||||
console.warn('[FilterSection] 排序参数冲突警告: priceSort和remainingMost不能同时设置')
|
||||
}
|
||||
|
||||
emit('update:sortIndex', localSortIndex.value)
|
||||
}
|
||||
|
||||
const onCourseTypeChange = (e) => {
|
||||
const index = e.detail.value
|
||||
const selectedType = courseTypeOptions.value[index]
|
||||
// 确保返回数字类型而非字符串
|
||||
const typeId = selectedType.id !== null ? Number(selectedType.id) : null
|
||||
console.log('[FilterSection] 课程类型变更:', {
|
||||
index,
|
||||
id: typeId,
|
||||
label: selectedType.label
|
||||
})
|
||||
emit('courseTypeChange', typeId)
|
||||
}
|
||||
|
||||
const handleTimePick = () => {
|
||||
console.log('[FilterSection] 触发时间选择器')
|
||||
emit('timePick')
|
||||
}
|
||||
|
||||
const getFilterParams = () => {
|
||||
const sortOption = props.sortOptions[localSortIndex.value]
|
||||
const selectedType = courseTypeOptions.value[courseTypeIndex.value]
|
||||
|
||||
const params = {
|
||||
sortType: sortOption.value,
|
||||
priceSort: sortOption.priceSort,
|
||||
remainingMost: sortOption.remainingMost,
|
||||
courseTypeId: selectedType.id !== null ? Number(selectedType.id) : null,
|
||||
courseTypeName: selectedType.label,
|
||||
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;
|
||||
flex-wrap: wrap;
|
||||
gap: 16rpx;
|
||||
|
||||
.filter-item {
|
||||
flex: 1;
|
||||
min-width: calc(33.33% - 12rpx);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8rpx;
|
||||
padding: 18rpx 16rpx;
|
||||
background: #F5F7FA;
|
||||
border-radius: 16rpx;
|
||||
font-size: 24rpx;
|
||||
color: #5E6F8D;
|
||||
|
||||
.filter-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filter-arrow {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filter-text {
|
||||
max-width: 100rpx;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,165 @@
|
||||
<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>
|
||||
@@ -0,0 +1,191 @@
|
||||
<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>
|
||||
@@ -0,0 +1,746 @@
|
||||
<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: '未来7天', value: 'future7d' },
|
||||
{ label: '未来30天', value: 'future30d' },
|
||||
{ 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()
|
||||
}
|
||||
|
||||
// 判断是否是未来日期(限制最多选择未来90天)
|
||||
function isFuture(year, month, day) {
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
const maxFuture = new Date(today)
|
||||
maxFuture.setDate(maxFuture.getDate() + 90) // 最多允许选择未来90天
|
||||
const date = new Date(year, month - 1, day)
|
||||
return date > maxFuture
|
||||
}
|
||||
|
||||
// 切换日期选择器
|
||||
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':
|
||||
// 近7天:以今天为终点,往前推7天
|
||||
startDate = new Date(today.getTime() - 7 * 24 * 60 * 60 * 1000)
|
||||
endDate = today
|
||||
break
|
||||
case '30d':
|
||||
// 近30天:以今天为终点,往前推30天
|
||||
startDate = new Date(today.getTime() - 30 * 24 * 60 * 60 * 1000)
|
||||
endDate = today
|
||||
break
|
||||
case 'future7d':
|
||||
// 未来7天:以今天为起点,往后推7天
|
||||
startDate = today
|
||||
endDate = new Date(today.getTime() + 7 * 24 * 60 * 60 * 1000)
|
||||
break
|
||||
case 'future30d':
|
||||
// 未来30天:以今天为起点,往后推30天
|
||||
startDate = today
|
||||
endDate = new Date(today.getTime() + 30 * 24 * 60 * 60 * 1000)
|
||||
break
|
||||
case 'month':
|
||||
// 本月:当月1号到当月最后一天
|
||||
startDate = new Date(today.getFullYear(), today.getMonth(), 1)
|
||||
endDate = new Date(today.getFullYear(), today.getMonth() + 1, 0)
|
||||
break
|
||||
case 'lastMonth':
|
||||
// 上月:上月1号到上月最后一天
|
||||
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>
|
||||
@@ -0,0 +1,255 @@
|
||||
<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" @click="previewImage(index)">
|
||||
<!-- 添加 lazy-load 属性实现懒加载 -->
|
||||
<image
|
||||
:src="banner.image"
|
||||
mode="aspectFill"
|
||||
class="banner-image"
|
||||
lazy-load
|
||||
:show-menu-by-longpress="false"
|
||||
@load="onImageLoad(index)"
|
||||
@error="onImageError(index)"
|
||||
/>
|
||||
<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 v-if="!imageLoaded[index]" class="image-placeholder">
|
||||
<view class="loading-spinner"></view>
|
||||
</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, onMounted } from 'vue'
|
||||
import { getActiveBanners } from '@/api/main.js'
|
||||
|
||||
// 默认静态数据(后端无数据时作为兜底)
|
||||
const fallbackBanners = [
|
||||
{
|
||||
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: '全方位 · 打造完美体态'
|
||||
}
|
||||
]
|
||||
|
||||
// 先用回退数据初始化,确保 swiper 立即可见
|
||||
const banners = ref([...fallbackBanners])
|
||||
|
||||
const currentIndex = ref(0)
|
||||
const imageLoaded = ref(fallbackBanners.map(() => false))
|
||||
const isFetching = ref(false)
|
||||
|
||||
async function fetchBanners() {
|
||||
isFetching.value = true
|
||||
try {
|
||||
// 禁用缓存,确保每次获取最新数据
|
||||
const res = await getActiveBanners({ cache: false })
|
||||
// 处理后端返回数据:可能是数组或 { data: [...] }
|
||||
const data = Array.isArray(res) ? res : (res?.data || [])
|
||||
|
||||
if (data.length > 0) {
|
||||
// 映射后端字段到组件字段,替换回退数据
|
||||
banners.value = data.map(item => ({
|
||||
image: item.imageUrl || '',
|
||||
title: item.title || '',
|
||||
subtitle: item.subtitle || '',
|
||||
desc: item.description || ''
|
||||
}))
|
||||
// 更新 imageLoaded 状态
|
||||
imageLoaded.value = banners.value.map(() => false)
|
||||
console.log('[BannerSwiper] 轮播图数据加载成功,共', data.length, '条')
|
||||
} else {
|
||||
console.log('[BannerSwiper] 后端无轮播图数据,使用回退数据')
|
||||
// 保持已有的 fallbackBanners,无需重新赋值
|
||||
imageLoaded.value = banners.value.map(() => false)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[BannerSwiper] 轮播图数据加载失败,使用回退数据:', err)
|
||||
// 保持已有的 fallbackBanners,无需重新赋值
|
||||
imageLoaded.value = banners.value.map(() => false)
|
||||
} finally {
|
||||
isFetching.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchBanners()
|
||||
})
|
||||
|
||||
const onSwiperChange = (e) => {
|
||||
currentIndex.value = e.detail.current
|
||||
}
|
||||
|
||||
const previewImage = (index) => {
|
||||
const urls = banners.map(banner => banner.image)
|
||||
uni.previewImage({
|
||||
urls: urls,
|
||||
current: urls[index],
|
||||
indicator: 'default',
|
||||
loop: true
|
||||
})
|
||||
}
|
||||
|
||||
const onImageLoad = (index) => {
|
||||
imageLoaded.value[index] = true
|
||||
console.log(`图片 ${index} 加载完成`)
|
||||
}
|
||||
|
||||
const onImageError = (index) => {
|
||||
console.error(`图片 ${index} 加载失败, URL:`, banners.value[index]?.image?.substring(0, 120))
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.banner-container {
|
||||
width: 100%;
|
||||
padding: 0 24rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.banner-swiper {
|
||||
width: 100%;
|
||||
height: 360rpx;
|
||||
border-radius: 20rpx;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.banner-content {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.banner-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.image-placeholder {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(135deg, #f5f7fa 0%, #e8ecf1 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 60rpx;
|
||||
height: 60rpx;
|
||||
border: 4rpx solid rgba(255, 255, 255, 0.3);
|
||||
border-top: 4rpx solid #7AB5CC;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.banner-overlay {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(135deg, rgba(0,0,0,0.15) 0%, rgba(0,0,0,0.05) 50%, rgba(0,0,0,0.25) 100%);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.banner-text {
|
||||
position: absolute;
|
||||
left: 36rpx;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.banner-title {
|
||||
display: block;
|
||||
font-size: 48rpx;
|
||||
font-weight: 800;
|
||||
color: #ffffff;
|
||||
margin-bottom: 8rpx;
|
||||
text-shadow: 0 4rpx 16rpx rgba(80, 150, 190, 0.4);
|
||||
}
|
||||
|
||||
.banner-subtitle {
|
||||
display: block;
|
||||
font-size: 56rpx;
|
||||
font-weight: 800;
|
||||
color: #E0F0FA;
|
||||
margin-bottom: 16rpx;
|
||||
text-shadow: 0 4rpx 16rpx rgba(80, 150, 190, 0.4);
|
||||
}
|
||||
|
||||
.banner-desc {
|
||||
display: block;
|
||||
font-size: 26rpx;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
.banner-dots {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 16rpx;
|
||||
padding: 20rpx 0 8rpx;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 48rpx;
|
||||
height: 8rpx;
|
||||
border-radius: 9999rpx;
|
||||
background: #D0E4EE;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.dot.active {
|
||||
width: 64rpx;
|
||||
background: linear-gradient(90deg, #7AB5CC, #9CCFDF);
|
||||
}
|
||||
</style>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user