新增后台管理系统(答辩用
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
# 认证管理模块 API 文档
|
||||
|
||||
> **文档版本**: v1.0
|
||||
> **创建日期**: 2026-06-16
|
||||
> **作者**: 张翔
|
||||
> **状态**: 正式发布
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
1. [概述](#概述)
|
||||
2. [基础路径](#基础路径)
|
||||
3. [认证接口](#认证接口)
|
||||
- [用户名+密码登录](#用户名密码登录)
|
||||
- [获取用户信息](#获取用户信息)
|
||||
4. [数据模型](#数据模型)
|
||||
- [LoginRequest](#loginrequest)
|
||||
- [LoginResponse](#loginresponse)
|
||||
- [UserInfo](#userinfo)
|
||||
5. [响应码说明](#响应码说明)
|
||||
|
||||
---
|
||||
|
||||
## 概述
|
||||
|
||||
认证管理模块提供用户登录认证和用户信息查询功能。采用 Spring WebFlux 响应式编程,支持高并发场景。
|
||||
|
||||
## 基础路径
|
||||
|
||||
所有接口的基础路径为: `http://{host}:{port}/api/auth`
|
||||
|
||||
---
|
||||
|
||||
## 认证接口
|
||||
|
||||
### 用户名+密码登录
|
||||
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| **HTTP方法** | POST |
|
||||
| **接口路径** | `/api/auth/login` |
|
||||
| **所属文件** | `AuthHandler.java` |
|
||||
|
||||
**请求参数**:
|
||||
|
||||
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|
||||
|--------|------|------|--------|------|
|
||||
| username | string | 是 | - | 用户名 |
|
||||
| password | string | 是 | - | 密码 |
|
||||
|
||||
**成功响应** (200 OK):
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "登录成功",
|
||||
"data": {
|
||||
"accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||
"refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||
"tokenType": "Bearer",
|
||||
"expiresIn": 7200,
|
||||
"userInfo": {
|
||||
"id": 1,
|
||||
"username": "admin",
|
||||
"email": "admin@example.com",
|
||||
"phone": "13800138000",
|
||||
"nickname": "超级管理员",
|
||||
"status": 1,
|
||||
"roleId": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**失败响应** (400 Bad Request):
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 400,
|
||||
"message": "用户名或密码错误"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 获取用户信息
|
||||
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| **HTTP方法** | GET |
|
||||
| **接口路径** | `/api/auth/users/{id}` |
|
||||
| **所属文件** | `AuthHandler.java` |
|
||||
|
||||
**路径参数**:
|
||||
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
|--------|------|------|------|
|
||||
| id | Long | 是 | 用户ID |
|
||||
|
||||
**成功响应** (200 OK):
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "获取用户信息成功",
|
||||
"data": {
|
||||
"id": 1,
|
||||
"username": "admin",
|
||||
"email": "admin@example.com",
|
||||
"phone": "13800138000",
|
||||
"nickname": "超级管理员",
|
||||
"status": 1,
|
||||
"roleId": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**失败响应** (400 Bad Request):
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 400,
|
||||
"message": "无效的用户ID"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 数据模型
|
||||
|
||||
### LoginRequest
|
||||
|
||||
用户登录请求对象。
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 | 示例 |
|
||||
|------|------|------|------|------|
|
||||
| username | string | 是 | 用户名 | admin |
|
||||
| password | string | 是 | 密码 | Test@123 |
|
||||
|
||||
### LoginResponse
|
||||
|
||||
用户登录响应对象。
|
||||
|
||||
| 字段 | 类型 | 说明 | 示例 |
|
||||
|------|------|------|------|
|
||||
| accessToken | string | 访问令牌 | eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... |
|
||||
| refreshToken | string | 刷新令牌 | eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... |
|
||||
| tokenType | string | 令牌类型 | Bearer |
|
||||
| expiresIn | Long | 过期时间(秒) | 7200 |
|
||||
| userInfo | UserInfo | 用户信息 | - |
|
||||
|
||||
### UserInfo
|
||||
|
||||
用户信息对象。
|
||||
|
||||
| 字段 | 类型 | 说明 | 示例 |
|
||||
|------|------|------|------|
|
||||
| id | Long | 用户ID | 1 |
|
||||
| username | string | 用户名 | admin |
|
||||
| email | string | 邮箱 | admin@example.com |
|
||||
| phone | string | 手机号 | 13800138000 |
|
||||
| nickname | string | 昵称 | 超级管理员 |
|
||||
| status | Integer | 状态:0-禁用,1-正常 | 1 |
|
||||
| roleId | Long | 角色ID | 1 |
|
||||
|
||||
---
|
||||
|
||||
## 响应码说明
|
||||
|
||||
| 响应码 | 说明 |
|
||||
|--------|------|
|
||||
| 200 | 成功 |
|
||||
| 400 | 请求参数错误(如用户名密码错误、无效的用户ID) |
|
||||
| 401 | 未授权(认证失败) |
|
||||
| 403 | 禁止访问 |
|
||||
| 404 | 资源未找到 |
|
||||
| 409 | 冲突(如重复数据) |
|
||||
| 500 | 服务器内部错误 |
|
||||
|
||||
---
|
||||
|
||||
## 业务规则
|
||||
|
||||
1. 登录成功后返回的 `accessToken` 用于后续接口的身份认证
|
||||
2. `accessToken` 有效期为 2 小时(7200 秒)
|
||||
3. `refreshToken` 用于刷新 `accessToken`,有效期为 7 天
|
||||
4. 用户状态 `status` 为 0 时表示禁用,无法登录
|
||||
5. 所有需要认证的接口需要在请求头中携带 `Authorization: Bearer {accessToken}`
|
||||
@@ -0,0 +1,343 @@
|
||||
# 员工管理模块 API 文档
|
||||
|
||||
> **文档版本**: v1.0
|
||||
> **创建日期**: 2026-06-20
|
||||
> **作者**: AI Assistant
|
||||
> **状态**: 正式发布
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
1. [概述](#概述)
|
||||
2. [基础路径](#基础路径)
|
||||
3. [员工管理接口](#员工管理接口)
|
||||
- [分页获取员工列表(含角色信息)](#分页获取员工列表含角色信息)
|
||||
4. [用户管理接口(复用)](#用户管理接口复用)
|
||||
- [创建员工账号](#创建员工账号)
|
||||
- [获取员工详情](#获取员工详情)
|
||||
- [更新员工信息](#更新员工信息)
|
||||
- [逻辑删除员工](#逻辑删除员工)
|
||||
- [修改密码](#修改密码)
|
||||
- [为用户分配角色](#为用户分配角色)
|
||||
- [获取用户的角色](#获取用户的角色)
|
||||
5. [角色管理接口(复用)](#角色管理接口复用)
|
||||
- [获取所有角色](#获取所有角色)
|
||||
6. [数据模型](#数据模型)
|
||||
- [UserRegisterRequest](#userregisterrequest)
|
||||
- [EmployeePageResponse](#employeepageresponse)
|
||||
- [RoleInfo](#roleinfo)
|
||||
|
||||
---
|
||||
|
||||
## 概述
|
||||
|
||||
员工管理模块为店长(超级管理员 admin)提供新员工账号创建和权限分配功能。该模块复用已有的用户管理、角色管理接口,并新增了带角色信息的员工分页查询接口。
|
||||
|
||||
## 基础路径
|
||||
|
||||
所有接口的基础路径为: `http://{host}:{port}/api`
|
||||
|
||||
---
|
||||
|
||||
## 员工管理接口
|
||||
|
||||
### 分页获取员工列表(含角色信息)
|
||||
|
||||
> **新增接口**:该接口在已有 `/api/users/page` 基础上,额外返回每个员工的角色详细信息。
|
||||
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| **HTTP方法** | GET |
|
||||
| **接口路径** | `/api/employees/page` |
|
||||
| **所属文件** | `SysUserHandler.java` |
|
||||
|
||||
**请求参数**:
|
||||
|
||||
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|
||||
|--------|------|------|--------|------|
|
||||
| page | int | 否 | 0 | 页码(从0开始) |
|
||||
| size | int | 否 | 10 | 每页数量 |
|
||||
| sort | string | 否 | id | 排序字段 |
|
||||
| order | string | 否 | asc | 排序方向(asc/desc) |
|
||||
| keyword | string | 否 | - | 搜索关键字(匹配用户名/昵称) |
|
||||
|
||||
**成功响应** (200 OK):
|
||||
|
||||
```json
|
||||
{
|
||||
"content": [
|
||||
{
|
||||
"id": 1,
|
||||
"username": "admin",
|
||||
"nickname": "超级管理员",
|
||||
"email": "admin@novalon.com",
|
||||
"phone": "13800138000",
|
||||
"avatar": null,
|
||||
"status": 1,
|
||||
"roles": [
|
||||
{
|
||||
"id": 1,
|
||||
"roleName": "超级管理员",
|
||||
"roleKey": "admin",
|
||||
"roleSort": 1
|
||||
}
|
||||
],
|
||||
"createdAt": "2026-03-13T10:00:00",
|
||||
"updatedAt": "2026-03-13T10:00:00"
|
||||
}
|
||||
],
|
||||
"totalPages": 1,
|
||||
"totalElements": 1,
|
||||
"currentPage": 0,
|
||||
"pageSize": 10,
|
||||
"first": true,
|
||||
"last": true
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 用户管理接口(复用)
|
||||
|
||||
以下接口为 `SysUserHandler` 中已有的用户管理接口,可直接用于员工管理。
|
||||
|
||||
### 创建员工账号
|
||||
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| **HTTP方法** | POST |
|
||||
| **接口路径** | `/api/users` |
|
||||
| **所属文件** | `SysUserHandler.java` |
|
||||
|
||||
**请求体**:
|
||||
|
||||
```json
|
||||
{
|
||||
"username": "newstaff",
|
||||
"password": "Staff@123",
|
||||
"nickname": "新员工",
|
||||
"email": "staff@novalon.com",
|
||||
"phone": "13900139001",
|
||||
"roles": [2, 3]
|
||||
}
|
||||
```
|
||||
|
||||
**成功响应** (201 Created):
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 11,
|
||||
"username": "newstaff",
|
||||
"nickname": "新员工",
|
||||
"email": "staff@novalon.com",
|
||||
"phone": "13900139001",
|
||||
"status": 1,
|
||||
"createdAt": "2026-06-20T10:00:00",
|
||||
"updatedAt": "2026-06-20T10:00:00"
|
||||
}
|
||||
```
|
||||
|
||||
**校验规则**:
|
||||
- `username`: 3-50位,只能包含字母、数字、下划线和横线
|
||||
- `password`: 8-20位,必须包含大小写字母和数字
|
||||
- `email`: 合法邮箱格式
|
||||
- `phone`: 中国大陆手机号格式(1[3-9]开头的11位数字)
|
||||
|
||||
### 获取员工详情
|
||||
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| **HTTP方法** | GET |
|
||||
| **接口路径** | `/api/users/{id}` |
|
||||
| **所属文件** | `SysUserHandler.java` |
|
||||
|
||||
**成功响应** (200 OK):
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"username": "admin",
|
||||
"nickname": "超级管理员",
|
||||
"email": "admin@novalon.com",
|
||||
"phone": "13800138000",
|
||||
"avatar": null,
|
||||
"status": 1,
|
||||
"roles": [1],
|
||||
"createdAt": "2026-03-13T10:00:00",
|
||||
"updatedAt": "2026-03-13T10:00:00"
|
||||
}
|
||||
```
|
||||
|
||||
### 更新员工信息
|
||||
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| **HTTP方法** | PUT |
|
||||
| **接口路径** | `/api/users/{id}` |
|
||||
| **所属文件** | `SysUserHandler.java` |
|
||||
|
||||
**请求体**:
|
||||
|
||||
```json
|
||||
{
|
||||
"email": "newemail@novalon.com",
|
||||
"roleId": 2,
|
||||
"status": 1
|
||||
}
|
||||
```
|
||||
|
||||
### 逻辑删除员工
|
||||
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| **HTTP方法** | POST |
|
||||
| **接口路径** | `/api/users/{id}/action/logical-delete` |
|
||||
| **所属文件** | `SysUserHandler.java` |
|
||||
|
||||
### 修改密码
|
||||
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| **HTTP方法** | POST |
|
||||
| **接口路径** | `/api/users/{id}/action/change-password` |
|
||||
| **所属文件** | `SysUserHandler.java` |
|
||||
|
||||
**请求体**:
|
||||
|
||||
```json
|
||||
{
|
||||
"oldPassword": "Old@123",
|
||||
"newPassword": "New@456"
|
||||
}
|
||||
```
|
||||
|
||||
### 为用户分配角色
|
||||
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| **HTTP方法** | POST |
|
||||
| **接口路径** | `/api/users/{id}/roles` |
|
||||
| **所属文件** | `SysUserHandler.java` |
|
||||
|
||||
**请求体**:
|
||||
|
||||
```json
|
||||
{
|
||||
"roleIds": ["2", "3"]
|
||||
}
|
||||
```
|
||||
|
||||
### 获取用户的角色
|
||||
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| **HTTP方法** | GET |
|
||||
| **接口路径** | `/api/users/{id}/roles` |
|
||||
| **所属文件** | `SysUserHandler.java` |
|
||||
|
||||
---
|
||||
|
||||
## 角色管理接口(复用)
|
||||
|
||||
### 获取所有角色
|
||||
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| **HTTP方法** | GET |
|
||||
| **接口路径** | `/api/roles` |
|
||||
| **所属文件** | `SysRoleHandler.java` |
|
||||
|
||||
**成功响应** (200 OK):
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"roleName": "超级管理员",
|
||||
"roleKey": "admin",
|
||||
"roleSort": 1,
|
||||
"status": 1,
|
||||
"createdAt": "2026-03-13T10:00:00",
|
||||
"updatedAt": "2026-03-13T10:00:00"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"roleName": "测试管理员",
|
||||
"roleKey": "test_admin",
|
||||
"roleSort": 2,
|
||||
"status": 1,
|
||||
"createdAt": "2026-03-13T10:00:00",
|
||||
"updatedAt": "2026-03-13T10:00:00"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"roleName": "普通用户",
|
||||
"roleKey": "normal_user",
|
||||
"roleSort": 3,
|
||||
"status": 1,
|
||||
"createdAt": "2026-03-13T10:00:00",
|
||||
"updatedAt": "2026-03-13T10:00:00"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"roleName": "访客",
|
||||
"roleKey": "guest",
|
||||
"roleSort": 4,
|
||||
"status": 1,
|
||||
"createdAt": "2026-03-13T10:00:00",
|
||||
"updatedAt": "2026-03-13T10:00:00"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 数据模型
|
||||
|
||||
### UserRegisterRequest
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| username | String | 是 | 用户名(3-50位字母数字下划线横线) |
|
||||
| password | String | 是 | 密码(8-20位含大小写字母和数字) |
|
||||
| nickname | String | 否 | 昵称 |
|
||||
| email | String | 是 | 邮箱 |
|
||||
| phone | String | 是 | 手机号(中国大陆11位) |
|
||||
| roles | List\<Long\> | 否 | 角色ID列表 |
|
||||
|
||||
### EmployeePageResponse
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| content | List\<EmployeeInfo\> | 员工列表 |
|
||||
| totalPages | int | 总页数 |
|
||||
| totalElements | long | 总记录数 |
|
||||
| currentPage | int | 当前页码 |
|
||||
| pageSize | int | 每页数量 |
|
||||
| first | boolean | 是否第一页 |
|
||||
| last | boolean | 是否最后一页 |
|
||||
|
||||
### EmployeeInfo
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | Long | 用户ID |
|
||||
| username | String | 用户名 |
|
||||
| nickname | String | 昵称 |
|
||||
| email | String | 邮箱 |
|
||||
| phone | String | 手机号 |
|
||||
| avatar | String | 头像URL |
|
||||
| status | Integer | 状态:0-禁用, 1-正常 |
|
||||
| roles | List\<RoleInfo\> | 角色列表 |
|
||||
| createdAt | String | 创建时间 |
|
||||
| updatedAt | String | 更新时间 |
|
||||
|
||||
### RoleInfo
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | Long | 角色ID |
|
||||
| roleName | String | 角色名称 |
|
||||
| roleKey | String | 角色标识 |
|
||||
| roleSort | Integer | 排序号 |
|
||||
@@ -0,0 +1,51 @@
|
||||
# soybean-admin-mock
|
||||
|
||||
## Docs
|
||||
- [🦊一分钟,了解 Apifox !](https://s.apifox.cn/35c8727a-d3ab-47e9-8863-ef8e37df6887/doc-2825188.md):
|
||||
|
||||
## API Docs
|
||||
- Auth [用户名+密码登录](https://s.apifox.cn/35c8727a-d3ab-47e9-8863-ef8e37df6887/api-100526985.md):
|
||||
- Auth [获取用户信息](https://s.apifox.cn/35c8727a-d3ab-47e9-8863-ef8e37df6887/api-120399825.md):
|
||||
- Auth [刷新token](https://s.apifox.cn/35c8727a-d3ab-47e9-8863-ef8e37df6887/api-120415125.md):
|
||||
- Auth [自定义后端错误](https://s.apifox.cn/35c8727a-d3ab-47e9-8863-ef8e37df6887/api-158477619.md):
|
||||
- 前端路由 [获取用户路由数据](https://s.apifox.cn/35c8727a-d3ab-47e9-8863-ef8e37df6887/api-120415303.md):
|
||||
- 前端路由 [路由是否存在](https://s.apifox.cn/35c8727a-d3ab-47e9-8863-ef8e37df6887/api-120415373.md): 当用户访问一个路由失败时,有可能是没有该路由的权限,或者没有该路由,所以需要判断路由是否存在
|
||||
- 前端路由 [获取固定的路由数据(不需要权限)](https://s.apifox.cn/35c8727a-d3ab-47e9-8863-ef8e37df6887/api-158516240.md):
|
||||
- 前端路由 [获取react用户路由](https://s.apifox.cn/35c8727a-d3ab-47e9-8863-ef8e37df6887/api-273820808.md):
|
||||
- 调试 [debug](https://s.apifox.cn/35c8727a-d3ab-47e9-8863-ef8e37df6887/api-125438392.md):
|
||||
- 调试 [debug post](https://s.apifox.cn/35c8727a-d3ab-47e9-8863-ef8e37df6887/api-134021864.md):
|
||||
- 系统管理 [系统管理 - 获取角色列表](https://s.apifox.cn/35c8727a-d3ab-47e9-8863-ef8e37df6887/api-145344387.md):
|
||||
- 系统管理 [系统管理 - 获取用户列表](https://s.apifox.cn/35c8727a-d3ab-47e9-8863-ef8e37df6887/api-149754769.md):
|
||||
- 系统管理 [系统管理 - 获取用户列表(废弃)](https://s.apifox.cn/35c8727a-d3ab-47e9-8863-ef8e37df6887/api-145387463.md):
|
||||
- 系统管理 [获取所有角色](https://s.apifox.cn/35c8727a-d3ab-47e9-8863-ef8e37df6887/api-145409928.md):
|
||||
- 系统管理 [获取菜单列表](https://s.apifox.cn/35c8727a-d3ab-47e9-8863-ef8e37df6887/api-145421249.md):
|
||||
- 系统管理 [系统管理 - 获取菜单列表](https://s.apifox.cn/35c8727a-d3ab-47e9-8863-ef8e37df6887/api-157945109.md):
|
||||
- 系统管理 [获取所有页面组件](https://s.apifox.cn/35c8727a-d3ab-47e9-8863-ef8e37df6887/api-157988353.md):
|
||||
- 系统管理 [获取菜单树](https://s.apifox.cn/35c8727a-d3ab-47e9-8863-ef8e37df6887/api-158414289.md):
|
||||
- 项目配置 [获取用户配置](https://s.apifox.cn/35c8727a-d3ab-47e9-8863-ef8e37df6887/api-229492806.md):
|
||||
- 项目配置 [保存用户配置](https://s.apifox.cn/35c8727a-d3ab-47e9-8863-ef8e37df6887/api-229493074.md):
|
||||
- 用户 [postApiUsersLogin](https://s.apifox.cn/35c8727a-d3ab-47e9-8863-ef8e37df6887/api-281003433.md): 用户登录
|
||||
- 用户 [postApiUsersRegister](https://s.apifox.cn/35c8727a-d3ab-47e9-8863-ef8e37df6887/api-281003434.md): 用户注册
|
||||
- 用户 [getApiUsersProfile](https://s.apifox.cn/35c8727a-d3ab-47e9-8863-ef8e37df6887/api-281003435.md): 获取用户个人信息
|
||||
- 用户 [putApiUsersPassword](https://s.apifox.cn/35c8727a-d3ab-47e9-8863-ef8e37df6887/api-281003436.md): 更新用户密码
|
||||
- 用户 [postApiUsersLogout](https://s.apifox.cn/35c8727a-d3ab-47e9-8863-ef8e37df6887/api-281003437.md): 用户登出
|
||||
- 角色 [getApiRoles](https://s.apifox.cn/35c8727a-d3ab-47e9-8863-ef8e37df6887/api-281003438.md): 获取所有角色
|
||||
- 角色 [postApiRoles](https://s.apifox.cn/35c8727a-d3ab-47e9-8863-ef8e37df6887/api-281003439.md): 创建角色
|
||||
- 角色 [getApiRolesById](https://s.apifox.cn/35c8727a-d3ab-47e9-8863-ef8e37df6887/api-281003440.md): 获取角色详情
|
||||
- 角色 [putApiRolesById](https://s.apifox.cn/35c8727a-d3ab-47e9-8863-ef8e37df6887/api-281003441.md): 更新角色
|
||||
- 角色 [deleteApiRolesById](https://s.apifox.cn/35c8727a-d3ab-47e9-8863-ef8e37df6887/api-281003442.md): 删除角色
|
||||
- 权限 [getApiPermissions](https://s.apifox.cn/35c8727a-d3ab-47e9-8863-ef8e37df6887/api-281003443.md): 获取所有权限
|
||||
- 权限 [postApiPermissions](https://s.apifox.cn/35c8727a-d3ab-47e9-8863-ef8e37df6887/api-281003444.md): 创建权限
|
||||
- 权限 [getApiPermissionsById](https://s.apifox.cn/35c8727a-d3ab-47e9-8863-ef8e37df6887/api-281003445.md): 获取权限详情
|
||||
- 权限 [putApiPermissionsById](https://s.apifox.cn/35c8727a-d3ab-47e9-8863-ef8e37df6887/api-281003446.md): 更新权限
|
||||
- 权限 [deleteApiPermissionsById](https://s.apifox.cn/35c8727a-d3ab-47e9-8863-ef8e37df6887/api-281003447.md): 删除权限
|
||||
- 菜单 [getApiMenusUser](https://s.apifox.cn/35c8727a-d3ab-47e9-8863-ef8e37df6887/api-281003448.md): 获取用户菜单
|
||||
- 菜单 [getApiMenus](https://s.apifox.cn/35c8727a-d3ab-47e9-8863-ef8e37df6887/api-281003449.md): 获取所有菜单
|
||||
- 菜单 [postApiMenus](https://s.apifox.cn/35c8727a-d3ab-47e9-8863-ef8e37df6887/api-281003450.md): 创建菜单
|
||||
- 菜单 [getApiMenusById](https://s.apifox.cn/35c8727a-d3ab-47e9-8863-ef8e37df6887/api-281003451.md): 获取菜单详情
|
||||
- 菜单 [putApiMenusById](https://s.apifox.cn/35c8727a-d3ab-47e9-8863-ef8e37df6887/api-281003452.md): 更新菜单
|
||||
- 菜单 [deleteApiMenusById](https://s.apifox.cn/35c8727a-d3ab-47e9-8863-ef8e37df6887/api-281003453.md): 删除菜单
|
||||
- 文件 [postApiFilesUpload](https://s.apifox.cn/35c8727a-d3ab-47e9-8863-ef8e37df6887/api-281003454.md): 上传文件
|
||||
- 文件 [getApiFilesById](https://s.apifox.cn/35c8727a-d3ab-47e9-8863-ef8e37df6887/api-281003455.md): 获取文件信息
|
||||
- 文件 [deleteApiFilesById](https://s.apifox.cn/35c8727a-d3ab-47e9-8863-ef8e37df6887/api-281003456.md): 删除文件
|
||||
- 文件 [getApiFilesUser](https://s.apifox.cn/35c8727a-d3ab-47e9-8863-ef8e37df6887/api-281003457.md): 获取用户文件列表
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
package cn.novalon.gym.manage.datacount.domain;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 会员每日签到明细
|
||||
*
|
||||
* @author system
|
||||
* @date 2026-06-17
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class MemberDailySignIn {
|
||||
|
||||
/**
|
||||
* 会员ID
|
||||
*/
|
||||
private Long memberId;
|
||||
|
||||
/**
|
||||
* 签到时间
|
||||
*/
|
||||
private String signInTime;
|
||||
|
||||
/**
|
||||
* 签到状态:SUCCESS / FAILED
|
||||
*/
|
||||
private String signInStatus;
|
||||
|
||||
/**
|
||||
* 签到方式:QR_CODE / MANUAL / FACE
|
||||
*/
|
||||
private String signInType;
|
||||
|
||||
/**
|
||||
* 失败原因(仅失败时)
|
||||
*/
|
||||
private String failReason;
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package cn.novalon.gym.manage.datacount.domain;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 会员月度签到统计
|
||||
*
|
||||
* @author system
|
||||
* @date 2026-06-17
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class MemberMonthlySignIn {
|
||||
|
||||
/**
|
||||
* 会员ID
|
||||
*/
|
||||
private Long memberId;
|
||||
|
||||
/**
|
||||
* 会员姓名
|
||||
*/
|
||||
private String memberName;
|
||||
|
||||
/**
|
||||
* 统计月份(格式:yyyy-MM)
|
||||
*/
|
||||
private String month;
|
||||
|
||||
/**
|
||||
* 当月签到总次数
|
||||
*/
|
||||
private Long totalSignIns;
|
||||
|
||||
/**
|
||||
* 当月成功签到次数
|
||||
*/
|
||||
private Long successSignIns;
|
||||
|
||||
/**
|
||||
* 当月失败签到次数
|
||||
*/
|
||||
private Long failedSignIns;
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
package cn.novalon.gym.manage.datacount.domain;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 会员签到明细
|
||||
*
|
||||
* @author system
|
||||
* @date 2026-06-16
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class MemberSignInDetail {
|
||||
|
||||
/**
|
||||
* 会员ID
|
||||
*/
|
||||
private Long memberId;
|
||||
|
||||
/**
|
||||
* 会员姓名
|
||||
*/
|
||||
private String memberName;
|
||||
|
||||
/**
|
||||
* 会员手机号
|
||||
*/
|
||||
private String memberPhone;
|
||||
|
||||
/**
|
||||
* 签到总次数
|
||||
*/
|
||||
private Long totalSignIns;
|
||||
|
||||
/**
|
||||
* 成功签到次数
|
||||
*/
|
||||
private Long successSignIns;
|
||||
|
||||
/**
|
||||
* 失败签到次数
|
||||
*/
|
||||
private Long failedSignIns;
|
||||
|
||||
/**
|
||||
* 最近签到时间
|
||||
*/
|
||||
private String lastSignInTime;
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
package cn.novalon.gym.manage.groupcourse.scheduler;
|
||||
|
||||
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 团课超时与自动结课定时任务
|
||||
*
|
||||
* 功能:
|
||||
* 1. 检查已超时未开课的课程,标记为超时并返还用户权益
|
||||
* 2. 检查已超时未结课的课程,自动结课并发送教练警告
|
||||
*
|
||||
* @author 张翔
|
||||
* @date 2026-06-23
|
||||
*/
|
||||
@Component
|
||||
public class GroupCourseTimeoutScheduler {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(GroupCourseTimeoutScheduler.class);
|
||||
|
||||
private final IGroupCourseService groupCourseService;
|
||||
|
||||
public GroupCourseTimeoutScheduler(IGroupCourseService groupCourseService) {
|
||||
this.groupCourseService = groupCourseService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 每分钟检查一次超时未开课的课程
|
||||
* 超时条件:status='0' 且 actual_start_time IS NULL 且 start_time + 10分钟 < 当前时间
|
||||
*/
|
||||
@Scheduled(fixedRate = 60000)
|
||||
public void checkAndHandleTimeoutCourses() {
|
||||
logger.debug("定时任务开始检查超时未开课的团课");
|
||||
|
||||
groupCourseService.processTimeoutCourses()
|
||||
.subscribe(
|
||||
count -> {
|
||||
if (count > 0) {
|
||||
logger.warn("定时任务完成,处理了 {} 个超时团课,已返还用户权益", count);
|
||||
}
|
||||
},
|
||||
error -> logger.error("超时团课处理定时任务执行失败:{}", error.getMessage(), error)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 每分钟检查一次需要自动结课的课程
|
||||
* 自动结课条件:status='3'(进行中) 且 actual_end_time IS NULL 且 end_time + 10分钟 < 当前时间
|
||||
*/
|
||||
@Scheduled(fixedRate = 60000)
|
||||
public void checkAndHandleAutoEndCourses() {
|
||||
logger.debug("定时任务开始检查需要自动结课的团课");
|
||||
|
||||
groupCourseService.processAutoEndCourses()
|
||||
.subscribe(
|
||||
count -> {
|
||||
if (count > 0) {
|
||||
logger.warn("定时任务完成,自动结课 {} 个团课,请通知相关教练", count);
|
||||
}
|
||||
},
|
||||
error -> logger.error("自动结课定时任务执行失败:{}", error.getMessage(), error)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
wrapperVersion=3.3.4
|
||||
distributionType=only-script
|
||||
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.16/apache-maven-3.9.16-bin.zip
|
||||
@@ -0,0 +1,114 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>cn.novalon.gym.manage</groupId>
|
||||
<artifactId>gym-manage-api</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<relativePath>../pom.xml</relativePath>
|
||||
</parent>
|
||||
<groupId>cn.novalon.gym.manage</groupId>
|
||||
<artifactId>gym-manage-cuit</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<name>gym-manage-cuit</name>
|
||||
<description>Auth Management Module</description>
|
||||
<url/>
|
||||
<licenses>
|
||||
<license/>
|
||||
</licenses>
|
||||
<developers>
|
||||
<developer/>
|
||||
</developers>
|
||||
<scm>
|
||||
<connection/>
|
||||
<developerConnection/>
|
||||
<tag/>
|
||||
<url/>
|
||||
</scm>
|
||||
<properties>
|
||||
<java.version>21</java.version>
|
||||
</properties>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>cn.novalon.gym.manage</groupId>
|
||||
<artifactId>manage-common</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>cn.novalon.gym.manage</groupId>
|
||||
<artifactId>manage-sys</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>cn.novalon.gym.manage</groupId>
|
||||
<artifactId>manage-db</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-webflux</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-r2dbc</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-validation</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springdoc</groupId>
|
||||
<artifactId>springdoc-openapi-starter-webflux-ui</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.swagger.core.v3</groupId>
|
||||
<artifactId>swagger-annotations-jakarta</artifactId>
|
||||
<version>2.2.43</version>
|
||||
<scope>compile</scope>
|
||||
</dependency>
|
||||
<!-- Redis依赖 -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-redis</artifactId>
|
||||
</dependency>
|
||||
<!-- Spring Security -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-security</artifactId>
|
||||
</dependency>
|
||||
<!-- JWT -->
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-api</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-impl</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-jackson</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
<configuration>
|
||||
<skip>true</skip>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
</project>
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
package cn.novalon.gym.manage.auth.converter;
|
||||
|
||||
import cn.novalon.gym.manage.auth.domain.User;
|
||||
import cn.novalon.gym.manage.auth.dto.UserInfo;
|
||||
import cn.novalon.gym.manage.auth.entity.UserEntity;
|
||||
|
||||
public class UserConverter {
|
||||
|
||||
public static User toDomain(UserEntity entity) {
|
||||
if (entity == null) {
|
||||
return null;
|
||||
}
|
||||
User user = new User();
|
||||
user.setId(entity.getId());
|
||||
user.setUsername(entity.getUsername());
|
||||
user.setEmail(entity.getEmail());
|
||||
user.setPhone(entity.getPhone());
|
||||
user.setNickname(entity.getNickname());
|
||||
user.setStatus(entity.getStatus());
|
||||
user.setRoleId(entity.getRoleId());
|
||||
user.setCreateBy(entity.getCreateBy());
|
||||
user.setUpdateBy(entity.getUpdateBy());
|
||||
user.setCreatedAt(entity.getCreatedAt());
|
||||
user.setUpdatedAt(entity.getUpdatedAt());
|
||||
user.setDeletedAt(entity.getDeletedAt());
|
||||
return user;
|
||||
}
|
||||
|
||||
public static UserEntity toEntity(User domain) {
|
||||
if (domain == null) {
|
||||
return null;
|
||||
}
|
||||
UserEntity entity = new UserEntity();
|
||||
entity.setId(domain.getId());
|
||||
entity.setUsername(domain.getUsername());
|
||||
entity.setEmail(domain.getEmail());
|
||||
entity.setPhone(domain.getPhone());
|
||||
entity.setNickname(domain.getNickname());
|
||||
entity.setStatus(domain.getStatus());
|
||||
entity.setRoleId(domain.getRoleId());
|
||||
entity.setCreateBy(domain.getCreateBy());
|
||||
entity.setUpdateBy(domain.getUpdateBy());
|
||||
entity.setCreatedAt(domain.getCreatedAt());
|
||||
entity.setUpdatedAt(domain.getUpdatedAt());
|
||||
entity.setDeletedAt(domain.getDeletedAt());
|
||||
return entity;
|
||||
}
|
||||
|
||||
public static UserInfo toUserInfo(User user) {
|
||||
if (user == null) {
|
||||
return null;
|
||||
}
|
||||
UserInfo userInfo = new UserInfo();
|
||||
userInfo.setId(user.getId());
|
||||
userInfo.setUsername(user.getUsername());
|
||||
userInfo.setEmail(user.getEmail());
|
||||
userInfo.setPhone(user.getPhone());
|
||||
userInfo.setNickname(user.getNickname());
|
||||
userInfo.setStatus(user.getStatus());
|
||||
userInfo.setRoleId(user.getRoleId());
|
||||
return userInfo;
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package cn.novalon.gym.manage.auth.dao;
|
||||
|
||||
import cn.novalon.gym.manage.auth.entity.UserEntity;
|
||||
import org.springframework.data.r2dbc.repository.Query;
|
||||
import org.springframework.data.r2dbc.repository.R2dbcRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
@Repository
|
||||
public interface UserDao extends R2dbcRepository<UserEntity, Long> {
|
||||
|
||||
@Query("SELECT * FROM sys_user WHERE username = :username AND deleted_at IS NULL")
|
||||
Mono<UserEntity> findByUsername(String username);
|
||||
|
||||
@Query("SELECT * FROM sys_user WHERE id = :id AND deleted_at IS NULL")
|
||||
Mono<UserEntity> findByIdAndDeletedAtIsNull(Long id);
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
package cn.novalon.gym.manage.auth.domain;
|
||||
|
||||
import cn.novalon.gym.manage.sys.core.domain.BaseDomain;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
public class User extends BaseDomain {
|
||||
|
||||
@Schema(description = "用户名", example = "admin")
|
||||
private String username;
|
||||
|
||||
@Schema(description = "邮箱", example = "admin@example.com")
|
||||
private String email;
|
||||
|
||||
@Schema(description = "手机号", example = "13800138000")
|
||||
private String phone;
|
||||
|
||||
@Schema(description = "昵称", example = "超级管理员")
|
||||
private String nickname;
|
||||
|
||||
@Schema(description = "状态:0-禁用,1-正常", example = "1")
|
||||
private Integer status;
|
||||
|
||||
@Schema(description = "角色ID", example = "1")
|
||||
private Long roleId;
|
||||
|
||||
public String getUsername() {
|
||||
return username;
|
||||
}
|
||||
|
||||
public void setUsername(String username) {
|
||||
this.username = username;
|
||||
}
|
||||
|
||||
public String getEmail() {
|
||||
return email;
|
||||
}
|
||||
|
||||
public void setEmail(String email) {
|
||||
this.email = email;
|
||||
}
|
||||
|
||||
public String getPhone() {
|
||||
return phone;
|
||||
}
|
||||
|
||||
public void setPhone(String phone) {
|
||||
this.phone = phone;
|
||||
}
|
||||
|
||||
public String getNickname() {
|
||||
return nickname;
|
||||
}
|
||||
|
||||
public void setNickname(String nickname) {
|
||||
this.nickname = nickname;
|
||||
}
|
||||
|
||||
public Integer getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public void setStatus(Integer status) {
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public Long getRoleId() {
|
||||
return roleId;
|
||||
}
|
||||
|
||||
public void setRoleId(Long roleId) {
|
||||
this.roleId = roleId;
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package cn.novalon.gym.manage.auth.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
@Schema(description = "用户登录请求")
|
||||
public class LoginRequest {
|
||||
|
||||
@NotBlank(message = "用户名不能为空")
|
||||
@Schema(description = "用户名", example = "admin", required = true)
|
||||
private String username;
|
||||
|
||||
@NotBlank(message = "密码不能为空")
|
||||
@Schema(description = "密码", example = "Test@123", required = true)
|
||||
private String password;
|
||||
|
||||
public String getUsername() {
|
||||
return username;
|
||||
}
|
||||
|
||||
public void setUsername(String username) {
|
||||
this.username = username;
|
||||
}
|
||||
|
||||
public String getPassword() {
|
||||
return password;
|
||||
}
|
||||
|
||||
public void setPassword(String password) {
|
||||
this.password = password;
|
||||
}
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
package cn.novalon.gym.manage.auth.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
@Schema(description = "用户登录响应")
|
||||
public class LoginResponse {
|
||||
|
||||
@Schema(description = "访问令牌")
|
||||
private String accessToken;
|
||||
|
||||
@Schema(description = "刷新令牌")
|
||||
private String refreshToken;
|
||||
|
||||
@Schema(description = "令牌类型", example = "Bearer")
|
||||
private String tokenType;
|
||||
|
||||
@Schema(description = "过期时间(秒)", example = "7200")
|
||||
private Long expiresIn;
|
||||
|
||||
@Schema(description = "用户信息")
|
||||
private UserInfo userInfo;
|
||||
|
||||
public String getAccessToken() {
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
public void setAccessToken(String accessToken) {
|
||||
this.accessToken = accessToken;
|
||||
}
|
||||
|
||||
public String getRefreshToken() {
|
||||
return refreshToken;
|
||||
}
|
||||
|
||||
public void setRefreshToken(String refreshToken) {
|
||||
this.refreshToken = refreshToken;
|
||||
}
|
||||
|
||||
public String getTokenType() {
|
||||
return tokenType;
|
||||
}
|
||||
|
||||
public void setTokenType(String tokenType) {
|
||||
this.tokenType = tokenType;
|
||||
}
|
||||
|
||||
public Long getExpiresIn() {
|
||||
return expiresIn;
|
||||
}
|
||||
|
||||
public void setExpiresIn(Long expiresIn) {
|
||||
this.expiresIn = expiresIn;
|
||||
}
|
||||
|
||||
public UserInfo getUserInfo() {
|
||||
return userInfo;
|
||||
}
|
||||
|
||||
public void setUserInfo(UserInfo userInfo) {
|
||||
this.userInfo = userInfo;
|
||||
}
|
||||
}
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
package cn.novalon.gym.manage.auth.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
@Schema(description = "用户信息")
|
||||
public class UserInfo {
|
||||
|
||||
@Schema(description = "用户ID", example = "1")
|
||||
private Long id;
|
||||
|
||||
@Schema(description = "用户名", example = "admin")
|
||||
private String username;
|
||||
|
||||
@Schema(description = "邮箱", example = "admin@example.com")
|
||||
private String email;
|
||||
|
||||
@Schema(description = "手机号", example = "13800138000")
|
||||
private String phone;
|
||||
|
||||
@Schema(description = "昵称", example = "超级管理员")
|
||||
private String nickname;
|
||||
|
||||
@Schema(description = "状态:0-禁用,1-正常", example = "1")
|
||||
private Integer status;
|
||||
|
||||
@Schema(description = "角色ID", example = "1")
|
||||
private Long roleId;
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public String getUsername() {
|
||||
return username;
|
||||
}
|
||||
|
||||
public void setUsername(String username) {
|
||||
this.username = username;
|
||||
}
|
||||
|
||||
public String getEmail() {
|
||||
return email;
|
||||
}
|
||||
|
||||
public void setEmail(String email) {
|
||||
this.email = email;
|
||||
}
|
||||
|
||||
public String getPhone() {
|
||||
return phone;
|
||||
}
|
||||
|
||||
public void setPhone(String phone) {
|
||||
this.phone = phone;
|
||||
}
|
||||
|
||||
public String getNickname() {
|
||||
return nickname;
|
||||
}
|
||||
|
||||
public void setNickname(String nickname) {
|
||||
this.nickname = nickname;
|
||||
}
|
||||
|
||||
public Integer getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public void setStatus(Integer status) {
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public Long getRoleId() {
|
||||
return roleId;
|
||||
}
|
||||
|
||||
public void setRoleId(Long roleId) {
|
||||
this.roleId = roleId;
|
||||
}
|
||||
}
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
package cn.novalon.gym.manage.auth.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;
|
||||
|
||||
@Table("sys_user")
|
||||
public class UserEntity extends BaseEntity {
|
||||
|
||||
@Column("username")
|
||||
private String username;
|
||||
|
||||
@Column("password")
|
||||
private String password;
|
||||
|
||||
@Column("email")
|
||||
private String email;
|
||||
|
||||
@Column("phone")
|
||||
private String phone;
|
||||
|
||||
@Column("nickname")
|
||||
private String nickname;
|
||||
|
||||
@Column("status")
|
||||
private Integer status;
|
||||
|
||||
@Column("role_id")
|
||||
private Long roleId;
|
||||
|
||||
public String getUsername() {
|
||||
return username;
|
||||
}
|
||||
|
||||
public void setUsername(String username) {
|
||||
this.username = username;
|
||||
}
|
||||
|
||||
public String getPassword() {
|
||||
return password;
|
||||
}
|
||||
|
||||
public void setPassword(String password) {
|
||||
this.password = password;
|
||||
}
|
||||
|
||||
public String getEmail() {
|
||||
return email;
|
||||
}
|
||||
|
||||
public void setEmail(String email) {
|
||||
this.email = email;
|
||||
}
|
||||
|
||||
public String getPhone() {
|
||||
return phone;
|
||||
}
|
||||
|
||||
public void setPhone(String phone) {
|
||||
this.phone = phone;
|
||||
}
|
||||
|
||||
public String getNickname() {
|
||||
return nickname;
|
||||
}
|
||||
|
||||
public void setNickname(String nickname) {
|
||||
this.nickname = nickname;
|
||||
}
|
||||
|
||||
public Integer getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public void setStatus(Integer status) {
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public Long getRoleId() {
|
||||
return roleId;
|
||||
}
|
||||
|
||||
public void setRoleId(Long roleId) {
|
||||
this.roleId = roleId;
|
||||
}
|
||||
}
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
package cn.novalon.gym.manage.auth.handler;
|
||||
|
||||
import cn.novalon.gym.manage.auth.dto.LoginRequest;
|
||||
import cn.novalon.gym.manage.auth.dto.LoginResponse;
|
||||
import cn.novalon.gym.manage.auth.dto.UserInfo;
|
||||
import cn.novalon.gym.manage.auth.service.IAuthService;
|
||||
import cn.novalon.gym.manage.auth.util.ResponseCode;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Validator;
|
||||
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 AuthHandler {
|
||||
|
||||
private final IAuthService authService;
|
||||
private final Validator validator;
|
||||
|
||||
public AuthHandler(IAuthService authService, Validator validator) {
|
||||
this.authService = authService;
|
||||
this.validator = validator;
|
||||
}
|
||||
|
||||
@Operation(summary = "用户名+密码登录", description = "使用用户名和密码进行登录认证")
|
||||
public Mono<ServerResponse> login(ServerRequest request) {
|
||||
return request.bodyToMono(LoginRequest.class)
|
||||
.flatMap(loginRequest -> {
|
||||
return authService.login(loginRequest)
|
||||
.flatMap(response -> {
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("code", ResponseCode.SUCCESS);
|
||||
result.put("message", "登录成功");
|
||||
result.put("data", response);
|
||||
return ServerResponse.ok().bodyValue(result);
|
||||
})
|
||||
.onErrorResume(error -> {
|
||||
Map<String, Object> errorResponse = new HashMap<>();
|
||||
errorResponse.put("code", ResponseCode.BAD_REQUEST);
|
||||
errorResponse.put("message", error.getMessage());
|
||||
return ServerResponse.badRequest().bodyValue(errorResponse);
|
||||
});
|
||||
})
|
||||
.onErrorResume(error -> {
|
||||
Map<String, Object> errorResponse = new HashMap<>();
|
||||
errorResponse.put("code", ResponseCode.BAD_REQUEST);
|
||||
errorResponse.put("message", "请求参数错误: " + error.getMessage());
|
||||
return ServerResponse.badRequest().bodyValue(errorResponse);
|
||||
});
|
||||
}
|
||||
|
||||
@Operation(summary = "获取用户信息", description = "根据用户ID获取用户详细信息")
|
||||
public Mono<ServerResponse> getUserInfo(ServerRequest request) {
|
||||
try {
|
||||
Long userId = Long.valueOf(request.pathVariable("id"));
|
||||
return authService.getUserInfo(userId)
|
||||
.flatMap(userInfo -> {
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("code", ResponseCode.SUCCESS);
|
||||
result.put("message", "获取用户信息成功");
|
||||
result.put("data", userInfo);
|
||||
return ServerResponse.ok().bodyValue(result);
|
||||
})
|
||||
.onErrorResume(error -> {
|
||||
Map<String, Object> errorResponse = new HashMap<>();
|
||||
errorResponse.put("code", ResponseCode.BAD_REQUEST);
|
||||
errorResponse.put("message", error.getMessage());
|
||||
return ServerResponse.badRequest().bodyValue(errorResponse);
|
||||
});
|
||||
} catch (NumberFormatException e) {
|
||||
Map<String, Object> errorResponse = new HashMap<>();
|
||||
errorResponse.put("code", ResponseCode.BAD_REQUEST);
|
||||
errorResponse.put("message", "无效的用户ID");
|
||||
return ServerResponse.badRequest().bodyValue(errorResponse);
|
||||
}
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package cn.novalon.gym.manage.auth.repository;
|
||||
|
||||
import cn.novalon.gym.manage.auth.domain.User;
|
||||
import cn.novalon.gym.manage.auth.dto.LoginRequest;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
public interface AuthRepository {
|
||||
|
||||
Mono<User> findByUsername(String username);
|
||||
|
||||
Mono<User> findById(Long id);
|
||||
|
||||
Mono<User> validateLogin(LoginRequest request);
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package cn.novalon.gym.manage.auth.repository.impl;
|
||||
|
||||
import cn.novalon.gym.manage.auth.converter.UserConverter;
|
||||
import cn.novalon.gym.manage.auth.dao.UserDao;
|
||||
import cn.novalon.gym.manage.auth.domain.User;
|
||||
import cn.novalon.gym.manage.auth.dto.LoginRequest;
|
||||
import cn.novalon.gym.manage.auth.repository.AuthRepository;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Component;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
@Component
|
||||
public class AuthRepositoryImpl implements AuthRepository {
|
||||
|
||||
private final UserDao userDao;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
|
||||
public AuthRepositoryImpl(UserDao userDao, PasswordEncoder passwordEncoder) {
|
||||
this.userDao = userDao;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<User> findByUsername(String username) {
|
||||
return userDao.findByUsername(username)
|
||||
.map(UserConverter::toDomain);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<User> findById(Long id) {
|
||||
return userDao.findByIdAndDeletedAtIsNull(id)
|
||||
.map(UserConverter::toDomain);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<User> validateLogin(LoginRequest request) {
|
||||
return userDao.findByUsername(request.getUsername())
|
||||
.flatMap(userEntity -> {
|
||||
if (userEntity.getStatus() == 0) {
|
||||
return Mono.error(new RuntimeException("用户已被禁用"));
|
||||
}
|
||||
if (passwordEncoder.matches(request.getPassword(), userEntity.getPassword())) {
|
||||
return Mono.just(UserConverter.toDomain(userEntity));
|
||||
} else {
|
||||
return Mono.error(new RuntimeException("用户名或密码错误"));
|
||||
}
|
||||
})
|
||||
.switchIfEmpty(Mono.error(new RuntimeException("用户名或密码错误")));
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package cn.novalon.gym.manage.auth.service;
|
||||
|
||||
import cn.novalon.gym.manage.auth.dto.LoginRequest;
|
||||
import cn.novalon.gym.manage.auth.dto.LoginResponse;
|
||||
import cn.novalon.gym.manage.auth.dto.UserInfo;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
public interface IAuthService {
|
||||
|
||||
Mono<LoginResponse> login(LoginRequest request);
|
||||
|
||||
Mono<UserInfo> getUserInfo(Long userId);
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
package cn.novalon.gym.manage.auth.service.impl;
|
||||
|
||||
import cn.novalon.gym.manage.auth.converter.UserConverter;
|
||||
import cn.novalon.gym.manage.auth.dto.LoginRequest;
|
||||
import cn.novalon.gym.manage.auth.dto.LoginResponse;
|
||||
import cn.novalon.gym.manage.auth.dto.UserInfo;
|
||||
import cn.novalon.gym.manage.auth.repository.AuthRepository;
|
||||
import cn.novalon.gym.manage.auth.service.IAuthService;
|
||||
import cn.novalon.gym.manage.auth.util.JwtUtil;
|
||||
import org.springframework.stereotype.Service;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
@Service
|
||||
public class AuthServiceImpl implements IAuthService {
|
||||
|
||||
private final AuthRepository authRepository;
|
||||
private final JwtUtil jwtUtil;
|
||||
|
||||
public AuthServiceImpl(AuthRepository authRepository, JwtUtil jwtUtil) {
|
||||
this.authRepository = authRepository;
|
||||
this.jwtUtil = jwtUtil;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<LoginResponse> login(LoginRequest request) {
|
||||
return authRepository.validateLogin(request)
|
||||
.flatMap(user -> {
|
||||
String accessToken = jwtUtil.generateToken(user.getId(), user.getUsername());
|
||||
String refreshToken = jwtUtil.generateRefreshToken(user.getId(), user.getUsername());
|
||||
|
||||
LoginResponse response = new LoginResponse();
|
||||
response.setAccessToken(accessToken);
|
||||
response.setRefreshToken(refreshToken);
|
||||
response.setTokenType("Bearer");
|
||||
response.setExpiresIn(7200L);
|
||||
response.setUserInfo(UserConverter.toUserInfo(user));
|
||||
|
||||
return Mono.just(response);
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<UserInfo> getUserInfo(Long userId) {
|
||||
return authRepository.findById(userId)
|
||||
.map(UserConverter::toUserInfo)
|
||||
.switchIfEmpty(Mono.error(new RuntimeException("用户不存在")));
|
||||
}
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
package cn.novalon.gym.manage.auth.util;
|
||||
|
||||
import cn.novalon.gym.manage.common.config.JwtProperties;
|
||||
import io.jsonwebtoken.Claims;
|
||||
import io.jsonwebtoken.Jwts;
|
||||
import io.jsonwebtoken.security.Keys;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.crypto.SecretKey;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@Component
|
||||
public class JwtUtil {
|
||||
|
||||
private final JwtProperties jwtProperties;
|
||||
|
||||
public JwtUtil(JwtProperties jwtProperties) {
|
||||
this.jwtProperties = jwtProperties;
|
||||
}
|
||||
|
||||
private SecretKey getSigningKey() {
|
||||
return Keys.hmacShaKeyFor(jwtProperties.getSecret().getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
public String generateToken(Long userId, String username) {
|
||||
Map<String, Object> claims = new HashMap<>();
|
||||
claims.put("userId", userId);
|
||||
claims.put("username", username);
|
||||
|
||||
return Jwts.builder()
|
||||
.setClaims(claims)
|
||||
.setSubject(username)
|
||||
.setIssuedAt(new Date())
|
||||
.setExpiration(new Date(System.currentTimeMillis() + jwtProperties.getExpiration()))
|
||||
.signWith(getSigningKey())
|
||||
.compact();
|
||||
}
|
||||
|
||||
public String generateRefreshToken(Long userId, String username) {
|
||||
Map<String, Object> claims = new HashMap<>();
|
||||
claims.put("userId", userId);
|
||||
claims.put("username", username);
|
||||
|
||||
return Jwts.builder()
|
||||
.setClaims(claims)
|
||||
.setSubject(username + "_refresh")
|
||||
.setIssuedAt(new Date())
|
||||
.setExpiration(new Date(System.currentTimeMillis() + jwtProperties.getExpiration() * 7))
|
||||
.signWith(getSigningKey())
|
||||
.compact();
|
||||
}
|
||||
|
||||
public Claims parseToken(String token) {
|
||||
return Jwts.parserBuilder()
|
||||
.setSigningKey(getSigningKey())
|
||||
.build()
|
||||
.parseClaimsJws(token)
|
||||
.getBody();
|
||||
}
|
||||
|
||||
public Long getUserIdFromToken(String token) {
|
||||
return parseToken(token).get("userId", Long.class);
|
||||
}
|
||||
|
||||
public String getUsernameFromToken(String token) {
|
||||
return parseToken(token).getSubject();
|
||||
}
|
||||
|
||||
public boolean validateToken(String token) {
|
||||
try {
|
||||
parseToken(token);
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package cn.novalon.gym.manage.auth.util;
|
||||
|
||||
/**
|
||||
* HTTP 响应状态码常量类
|
||||
*
|
||||
* @author 张翔
|
||||
* @date 2026-06-16
|
||||
*/
|
||||
public class ResponseCode {
|
||||
|
||||
private ResponseCode() {
|
||||
}
|
||||
|
||||
/** 成功 */
|
||||
public static final int SUCCESS = 200;
|
||||
|
||||
/** 客户端错误:请求参数有误 */
|
||||
public static final int BAD_REQUEST = 400;
|
||||
|
||||
/** 客户端错误:未授权 */
|
||||
public static final int UNAUTHORIZED = 401;
|
||||
|
||||
/** 客户端错误:禁止访问 */
|
||||
public static final int FORBIDDEN = 403;
|
||||
|
||||
/** 客户端错误:资源未找到 */
|
||||
public static final int NOT_FOUND = 404;
|
||||
|
||||
/** 客户端错误:冲突(如重复数据) */
|
||||
public static final int CONFLICT = 409;
|
||||
|
||||
/** 服务器错误:内部服务器错误 */
|
||||
public static final int INTERNAL_SERVER_ERROR = 500;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
# JWT Configuration
|
||||
jwt:
|
||||
secret: mySecretKeyForJwtTokenGenerationThatIsLongEnoughForHS256Algorithm
|
||||
expiration: 7200 # 2 hours in seconds
|
||||
refresh-expiration: 604800 # 7 days in seconds
|
||||
|
||||
# Server Configuration
|
||||
server:
|
||||
port: 8080
|
||||
|
||||
# Spring Configuration
|
||||
spring:
|
||||
application:
|
||||
name: gym-manage-cuit
|
||||
profiles:
|
||||
active: dev
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package cn.novalon.gym.manage.cuit.gymmanagecuit;
|
||||
|
||||
import org.junit.jupiter.api.Disabled;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
|
||||
@SpringBootTest
|
||||
@Disabled("模块无独立SpringBootApplication,需通过manage-app运行集成测试")
|
||||
class GymManageCuitApplicationTests {
|
||||
|
||||
@Test
|
||||
void contextLoads() {
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
unpackage/
|
||||
.hbuilderx/
|
||||
.DS_Store
|
||||
@@ -0,0 +1,368 @@
|
||||
<script>
|
||||
import { loadCourseStatuses } from '@/utils/index.js'
|
||||
|
||||
export default {
|
||||
onLaunch: function() {
|
||||
console.log('Coach App Launch')
|
||||
// 从后端加载课程状态映射(避免硬编码)
|
||||
loadCourseStatuses()
|
||||
},
|
||||
onShow: function() {
|
||||
console.log('Coach App Show')
|
||||
},
|
||||
onHide: function() {
|
||||
console.log('Coach App Hide')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* ============================================
|
||||
全局样式 - 基于 gym-manage-cuit 风格适配手机端
|
||||
============================================ */
|
||||
|
||||
page {
|
||||
background-color: #f6f8fc;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||
color: #1e293b;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
view, text, input, button, image, scroll-view {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
页面进入动画
|
||||
============================================ */
|
||||
.page-enter {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
animation: pageSlideIn 0.35s ease-out forwards;
|
||||
}
|
||||
|
||||
@keyframes pageSlideIn {
|
||||
0% { opacity: 0; transform: translateY(12px); }
|
||||
100% { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
通用卡片
|
||||
============================================ */
|
||||
.card {
|
||||
background: #ffffff;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
margin: 12px 16px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
|
||||
animation: cardFadeIn 0.4s ease-out both;
|
||||
}
|
||||
|
||||
@keyframes cardFadeIn {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
通用按钮
|
||||
============================================ */
|
||||
.btn-primary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(145deg, #4f7cff, #3b5fd9);
|
||||
color: #ffffff;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
padding: 12px 24px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.btn-primary::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(135deg, rgba(255,255,255,0.15) 0%, transparent 60%);
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary:active::after { opacity: 1; }
|
||||
|
||||
.btn-primary:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #ffffff;
|
||||
color: #4f7cff;
|
||||
border: 1px solid #4f7cff;
|
||||
border-radius: 10px;
|
||||
padding: 10px 20px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.btn-outline:active {
|
||||
background: #eef3ff;
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
按钮加载 Spinner
|
||||
============================================ */
|
||||
@keyframes btnSpin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.btn-spinner {
|
||||
display: inline-block;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 2px solid rgba(255,255,255,0.3);
|
||||
border-top-color: #ffffff;
|
||||
border-radius: 50%;
|
||||
animation: btnSpin 0.6s linear infinite;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
标签
|
||||
============================================ */
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 10px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
|
||||
.tag-green { background: #e6f7ee; color: #10b981; }
|
||||
.tag-orange { background: #fff7ed; color: #f59e0b; }
|
||||
.tag-blue { background: #eef3ff; color: #4f7cff; }
|
||||
.tag-gray { background: #f1f5f9; color: #94a3b8; }
|
||||
|
||||
/* ============================================
|
||||
骨架屏
|
||||
============================================ */
|
||||
@keyframes skeletonShimmer {
|
||||
0% { background-position: -200% 0; }
|
||||
100% { background-position: 200% 0; }
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
background: linear-gradient(90deg, #f0f3f8 25%, #e2e8f0 50%, #f0f3f8 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeletonShimmer 1.5s ease-in-out infinite;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
@keyframes skeletonPulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.skeleton-pulse {
|
||||
animation: skeletonPulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* 骨架屏行 */
|
||||
.sk-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: #ffffff;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.sk-circle {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.sk-line {
|
||||
height: 14px;
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
.sk-line-short { width: 60%; }
|
||||
.sk-line-medium { width: 80%; }
|
||||
.sk-line-long { width: 100%; }
|
||||
.sk-line-sm { height: 10px; border-radius: 5px; }
|
||||
|
||||
/* ============================================
|
||||
空状态
|
||||
============================================ */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px 24px;
|
||||
color: #94a3b8;
|
||||
animation: emptyFadeIn 0.5s ease-out both;
|
||||
}
|
||||
|
||||
@keyframes emptyFadeIn {
|
||||
from { opacity: 0; transform: scale(0.95); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
.empty-state text {
|
||||
font-size: 14px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
脉冲圆点
|
||||
============================================ */
|
||||
@keyframes dotPulse {
|
||||
0%, 100% { transform: scale(0.6); opacity: 0.4; }
|
||||
50% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
.dot-pulse {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #4f7cff;
|
||||
animation: dotPulse 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.dot-pulse:nth-child(2) { animation-delay: 0.2s; }
|
||||
.dot-pulse:nth-child(3) { animation-delay: 0.4s; }
|
||||
|
||||
/* ============================================
|
||||
列表交错入场
|
||||
============================================ */
|
||||
@keyframes listItemIn {
|
||||
from { opacity: 0; transform: translateX(-10px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
|
||||
.list-item-enter {
|
||||
animation: listItemIn 0.35s ease-out both;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
数字跳动
|
||||
============================================ */
|
||||
@keyframes numberPop {
|
||||
0% { transform: scale(1); }
|
||||
50% { transform: scale(1.15); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
|
||||
.number-animate {
|
||||
animation: numberPop 0.5s ease-out;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
加载中
|
||||
============================================ */
|
||||
@keyframes loadingDot {
|
||||
0%, 80%, 100% { transform: scale(0); opacity: 0; }
|
||||
40% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
.loading-dots {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
justify-content: center;
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.loading-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: #4f7cff;
|
||||
animation: loadingDot 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.loading-dot:nth-child(2) { animation-delay: 0.15s; }
|
||||
.loading-dot:nth-child(3) { animation-delay: 0.3s; }
|
||||
|
||||
.loading-center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 32px;
|
||||
color: #94a3b8;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
弹性缩放
|
||||
============================================ */
|
||||
@keyframes elasticIn {
|
||||
0% { transform: scale(0.3); opacity: 0; }
|
||||
50% { transform: scale(1.05); }
|
||||
70% { transform: scale(0.95); }
|
||||
100% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
.elastic-in {
|
||||
animation: elasticIn 0.5s ease-out both;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
淡入
|
||||
============================================ */
|
||||
@keyframes fadeInUp {
|
||||
from { opacity: 0; transform: translateY(16px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.fade-in-up {
|
||||
animation: fadeInUp 0.4s ease-out both;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
安全区适配
|
||||
============================================ */
|
||||
.safe-area-bottom {
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
/* 分隔线 */
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: #e9edf4;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
/* 文本省略 */
|
||||
.text-ellipsis {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
flex 工具
|
||||
============================================ */
|
||||
.flex-row { display: flex; flex-direction: row; align-items: center; }
|
||||
.flex-between { display: flex; flex-direction: row; align-items: center; justify-content: space-between; }
|
||||
.flex-center { display: flex; align-items: center; justify-content: center; }
|
||||
.flex-1 { flex: 1; }
|
||||
</style>
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* 认证API
|
||||
* 对应后端: /api/auth/*
|
||||
*/
|
||||
|
||||
import request from '@/utils/request.js'
|
||||
|
||||
/**
|
||||
* 用户名+密码登录(教练端)
|
||||
* POST /api/auth/login
|
||||
*/
|
||||
export function login(params) {
|
||||
return request.post('/api/auth/login', { ...params, target: 'coach' })
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户信息
|
||||
* GET /api/auth/users/{id}
|
||||
*/
|
||||
export function getUserInfo(id) {
|
||||
return request.get(`/api/auth/users/${id}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户角色
|
||||
* GET /api/users/{id}/roles
|
||||
*/
|
||||
export function getUserRoles(id) {
|
||||
return request.get(`/api/users/${id}/roles`)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* 签到管理API
|
||||
* 对应后端: /api/checkIn/*, /api/datacount/*
|
||||
*/
|
||||
|
||||
import request from '@/utils/request.js'
|
||||
|
||||
/**
|
||||
* 获取签到记录列表
|
||||
* GET /api/checkIn/records
|
||||
*/
|
||||
export function getSignInRecords(params = {}) {
|
||||
return request.get('/api/checkIn/records', params)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取签到统计
|
||||
* GET /api/checkIn/statistics
|
||||
*/
|
||||
export function getSignInStatistics(params = {}) {
|
||||
return request.get('/api/checkIn/statistics', params)
|
||||
}
|
||||
|
||||
/**
|
||||
* 签到(会员扫码签到时调用)
|
||||
* POST /api/checkIn
|
||||
*/
|
||||
export function doCheckIn(data) {
|
||||
return request.post('/api/checkIn', data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取签到明细(会员维度)
|
||||
* GET /api/datacount/signin/details
|
||||
*/
|
||||
export function getSignInDetails(params = {}) {
|
||||
return request.get('/api/datacount/signin/details', params)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* 团课管理API
|
||||
* 对应后端: /api/groupCourse/* 和 /api/coach/courses
|
||||
*/
|
||||
|
||||
import request from '@/utils/request.js'
|
||||
|
||||
/**
|
||||
* 获取当前教练自己教授的团课列表(教练专属)
|
||||
* GET /api/coach/courses
|
||||
* JWT令牌中自动携带教练身份,后端返回该教练的所有团课
|
||||
*/
|
||||
export function getCoachCourses() {
|
||||
return request.get('/api/coach/courses')
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有团课列表
|
||||
* GET /api/groupCourse/list
|
||||
*/
|
||||
export function getCourseList(includeDeleted = false) {
|
||||
return request.get('/api/groupCourse/list', { includeDeleted })
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页获取团课
|
||||
* POST /api/groupCourse/page
|
||||
*/
|
||||
export function getCoursePage(params, includeDeleted = false) {
|
||||
return request.post('/api/groupCourse/page', params, { params: { includeDeleted } })
|
||||
}
|
||||
|
||||
/**
|
||||
* 多条件搜索团课
|
||||
* POST /api/groupCourse/search
|
||||
*/
|
||||
export function searchCourses(params) {
|
||||
return request.post('/api/groupCourse/search', params)
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据ID获取团课详情
|
||||
* GET /api/groupCourse/{id}
|
||||
*/
|
||||
export function getCourseById(id) {
|
||||
return request.get(`/api/groupCourse/${id}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据ID获取团课完整信息(含类型和标签)
|
||||
* GET /api/groupCourse/{id}/detail
|
||||
*/
|
||||
export function getCourseDetail(id) {
|
||||
return request.get(`/api/groupCourse/${id}/detail`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取团课类型列表
|
||||
* GET /api/groupCourse/types
|
||||
*/
|
||||
export function getCourseTypes() {
|
||||
return request.get('/api/groupCourse/types')
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询课程预约记录(教练查看自己团课的预约会员)
|
||||
* GET /api/groupCourse/bookings/course/{courseId}
|
||||
*/
|
||||
export function getBookingsByCourseId(courseId) {
|
||||
return request.get(`/api/groupCourse/bookings/course/${courseId}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 教练开课
|
||||
* POST /api/groupCourse/{id}/start
|
||||
* body: { actualStartTime: 'yyyy-MM-ddTHH:mm:ss' }
|
||||
*/
|
||||
export function startCourse(id, actualStartTime) {
|
||||
return request.post(`/api/groupCourse/${id}/start`, { actualStartTime })
|
||||
}
|
||||
|
||||
/**
|
||||
* 教练结课
|
||||
* POST /api/groupCourse/{id}/end
|
||||
* body: { actualEndTime: 'yyyy-MM-ddTHH:mm:ss' }
|
||||
*/
|
||||
export function endCourse(id, actualEndTime) {
|
||||
return request.post(`/api/groupCourse/${id}/end`, { actualEndTime })
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<script>
|
||||
var coverSupport = 'CSS' in window && typeof CSS.supports === 'function' && (CSS.supports('top: env(a)') ||
|
||||
CSS.supports('top: constant(a)'))
|
||||
document.write(
|
||||
'<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0' +
|
||||
(coverSupport ? ', viewport-fit=cover' : '') + '" />')
|
||||
</script>
|
||||
<title>GymManage 教练端</title>
|
||||
<!--preload-links-->
|
||||
<!--app-context-->
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"><!--app-html--></div>
|
||||
<script type="module" src="/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,22 @@
|
||||
import App from './App'
|
||||
|
||||
// #ifndef VUE3
|
||||
import Vue from 'vue'
|
||||
import './uni.promisify.adaptor'
|
||||
Vue.config.productionTip = false
|
||||
App.mpType = 'app'
|
||||
const app = new Vue({
|
||||
...App
|
||||
})
|
||||
app.$mount()
|
||||
// #endif
|
||||
|
||||
// #ifdef VUE3
|
||||
import { createSSRApp } from 'vue'
|
||||
export function createApp() {
|
||||
const app = createSSRApp(App)
|
||||
return {
|
||||
app
|
||||
}
|
||||
}
|
||||
// #endif
|
||||
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"name" : "gym-manage-coach",
|
||||
"appid" : "",
|
||||
"description" : "",
|
||||
"versionName" : "1.0.0",
|
||||
"versionCode" : "100",
|
||||
"transformPx" : false,
|
||||
/* 5+App特有相关 */
|
||||
"app-plus" : {
|
||||
"usingComponents" : true,
|
||||
"nvueStyleCompiler" : "uni-app",
|
||||
"compilerVersion" : 3,
|
||||
"splashscreen" : {
|
||||
"alwaysShowBeforeRender" : true,
|
||||
"waiting" : true,
|
||||
"autoclose" : true,
|
||||
"delay" : 0
|
||||
},
|
||||
/* 模块配置 */
|
||||
"modules" : {},
|
||||
/* 应用发布信息 */
|
||||
"distribute" : {
|
||||
/* android打包配置 */
|
||||
"android" : {
|
||||
"permissions" : [
|
||||
"<uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.MOUNT_UNMOUNT_FILESYSTEMS\"/>",
|
||||
"<uses-permission android:name=\"android.permission.VIBRATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.READ_LOGS\"/>",
|
||||
"<uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\"/>",
|
||||
"<uses-feature android:name=\"android.hardware.camera.autofocus\"/>",
|
||||
"<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.CAMERA\"/>",
|
||||
"<uses-permission android:name=\"android.permission.GET_ACCOUNTS\"/>",
|
||||
"<uses-permission android:name=\"android.permission.READ_PHONE_STATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.CHANGE_WIFI_STATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.WAKE_LOCK\"/>",
|
||||
"<uses-permission android:name=\"android.permission.FLASHLIGHT\"/>",
|
||||
"<uses-feature android:name=\"android.hardware.camera\"/>",
|
||||
"<uses-permission android:name=\"android.permission.WRITE_SETTINGS\"/>"
|
||||
]
|
||||
},
|
||||
/* ios打包配置 */
|
||||
"ios" : {},
|
||||
/* SDK配置 */
|
||||
"sdkConfigs" : {}
|
||||
}
|
||||
},
|
||||
/* 快应用特有相关 */
|
||||
"quickapp" : {},
|
||||
/* 小程序特有相关 */
|
||||
"mp-weixin" : {
|
||||
"appid" : "",
|
||||
"setting" : {
|
||||
"urlCheck" : false
|
||||
},
|
||||
"usingComponents" : true
|
||||
},
|
||||
"mp-alipay" : {
|
||||
"usingComponents" : true
|
||||
},
|
||||
"mp-baidu" : {
|
||||
"usingComponents" : true
|
||||
},
|
||||
"mp-toutiao" : {
|
||||
"usingComponents" : true
|
||||
},
|
||||
"uniStatistics" : {
|
||||
"enable" : false
|
||||
},
|
||||
"vueVersion" : "3"
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"pages": [
|
||||
{
|
||||
"path": "pages/login/login",
|
||||
"style": {
|
||||
"navigationBarTitleText": "GymManage 教练端",
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/index/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "教练工作台",
|
||||
"navigationBarBackgroundColor": "#4f7cff",
|
||||
"navigationBarTextStyle": "white"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/course/list",
|
||||
"style": {
|
||||
"navigationBarTitleText": "我的团课",
|
||||
"navigationBarBackgroundColor": "#4f7cff",
|
||||
"navigationBarTextStyle": "white"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/course/detail",
|
||||
"style": {
|
||||
"navigationBarTitleText": "团课详情",
|
||||
"navigationBarBackgroundColor": "#4f7cff",
|
||||
"navigationBarTextStyle": "white"
|
||||
}
|
||||
}
|
||||
],
|
||||
"globalStyle": {
|
||||
"navigationBarTextStyle": "black",
|
||||
"navigationBarTitleText": "GymManage 教练端",
|
||||
"navigationBarBackgroundColor": "#F8F8F8",
|
||||
"backgroundColor": "#f6f8fc"
|
||||
},
|
||||
"easycom": {
|
||||
"autoscan": true,
|
||||
"custom": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,824 @@
|
||||
<template>
|
||||
<view class="course-detail-page">
|
||||
<scroll-view scroll-y class="page-scroll">
|
||||
<!-- 骨架屏 -->
|
||||
<view v-if="detailLoading">
|
||||
<view class="detail-card">
|
||||
<view class="flex-between" style="margin-bottom:20px">
|
||||
<view class="skeleton sk-line" style="width:55%;height:22px"></view>
|
||||
<view class="skeleton sk-line" style="width:50px;height:22px;border-radius:20px"></view>
|
||||
</view>
|
||||
<view class="detail-grid">
|
||||
<view v-for="i in 4" :key="'dg-'+i" class="detail-item">
|
||||
<view class="skeleton sk-line sk-line-sm" style="width:32px;height:10px;margin-bottom:6px"></view>
|
||||
<view class="skeleton sk-line" style="width:80%;height:14px"></view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="tab-bar" style="margin-top:12px">
|
||||
<view v-for="t in tabs" :key="'st-'+t.key" class="tab-item">
|
||||
<view class="skeleton sk-line" style="width:48px;height:14px;margin:0 auto"></view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-for="i in 4" :key="'ssk-'+i" class="sk-row" style="flex-direction:column;align-items:stretch;gap:10px;margin:10px 16px 0">
|
||||
<view class="flex-between">
|
||||
<view class="skeleton sk-line" style="width:35%;height:16px"></view>
|
||||
<view class="skeleton sk-line" style="width:44px;height:18px;border-radius:20px"></view>
|
||||
</view>
|
||||
<view class="skeleton sk-line" style="width:50%;height:12px"></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 真实内容 -->
|
||||
<template v-else>
|
||||
<!-- 课程基本信息 -->
|
||||
<view class="detail-card card-fade-in">
|
||||
<view class="detail-header">
|
||||
<view class="flex-between">
|
||||
<text class="detail-title">{{ course.courseName || '--' }}</text>
|
||||
<view :class="['tag', getCourseStatusClass(course.status, course.deletedAt)]">
|
||||
{{ getCourseStatusLabel(course.status, course.deletedAt) }}
|
||||
</view>
|
||||
</view>
|
||||
<text class="detail-type" v-if="typeName">{{ typeName }}</text>
|
||||
</view>
|
||||
|
||||
<view class="detail-grid">
|
||||
<view class="detail-item">
|
||||
<text class="d-label">日期</text>
|
||||
<text class="d-value">{{ formatDate(course.startTime) }}</text>
|
||||
</view>
|
||||
<view class="detail-item">
|
||||
<text class="d-label">时间</text>
|
||||
<text class="d-value">
|
||||
{{ formatTimeStr(course.actualStartTime || course.startTime) }} — {{ formatTimeStr(course.actualEndTime || course.endTime) }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="detail-item">
|
||||
<text class="d-label">地点</text>
|
||||
<text class="d-value">{{ course.location || '未设置' }}</text>
|
||||
</view>
|
||||
<view class="detail-item">
|
||||
<text class="d-label">人数</text>
|
||||
<text class="d-value">{{ course.currentMembers || 0 }} / {{ course.maxMembers || 0 }}人</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 实际时间记录 -->
|
||||
<view v-if="course.actualStartTime || course.actualEndTime" class="actual-time-section">
|
||||
<text class="at-title">实际时间记录</text>
|
||||
<view class="at-row" v-if="course.actualStartTime">
|
||||
<text class="at-label">实际开课</text>
|
||||
<text class="at-value">{{ formatDateTime(course.actualStartTime) }}</text>
|
||||
</view>
|
||||
<view class="at-row" v-if="course.actualEndTime">
|
||||
<text class="at-label">实际结课</text>
|
||||
<text class="at-value">{{ formatDateTime(course.actualEndTime) }}</text>
|
||||
</view>
|
||||
<view class="at-note" v-if="course.actualEndTime">
|
||||
<text>{{ isAutoEnded(course) ? '系统自动结课(教练未在课程结束后10分钟内点击结课)' : '教练手动结课' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="course.description" class="detail-desc">
|
||||
<text class="d-label">课程描述</text>
|
||||
<text class="d-desc-text">{{ course.description }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 开课/结课操作按钮 -->
|
||||
<view v-if="showCourseActions" class="course-actions">
|
||||
<view v-if="showStartButton" class="action-hint">
|
||||
<text class="hint-text">{{ startButtonHint }}</text>
|
||||
</view>
|
||||
<view v-if="showEndButton" class="action-hint">
|
||||
<text class="hint-text">{{ endButtonHint }}</text>
|
||||
</view>
|
||||
<view class="action-buttons">
|
||||
<button
|
||||
v-if="showStartButton"
|
||||
class="action-btn start-btn"
|
||||
:class="{ 'btn-disabled': !canStartCourse }"
|
||||
:loading="startLoading"
|
||||
:disabled="!canStartCourse || startLoading"
|
||||
@tap="handleStartCourse"
|
||||
>
|
||||
<text v-if="!startLoading">开始上课</text>
|
||||
<text v-else>开课中...</text>
|
||||
</button>
|
||||
<button
|
||||
v-if="showEndButton"
|
||||
class="action-btn end-btn"
|
||||
:class="{ 'btn-disabled': !canEndCourse }"
|
||||
:loading="endLoading"
|
||||
:disabled="!canEndCourse || endLoading"
|
||||
@tap="handleEndCourse"
|
||||
>
|
||||
<text v-if="!endLoading">结束上课</text>
|
||||
<text v-else>结课中...</text>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Tab切换 -->
|
||||
<view class="tab-bar">
|
||||
<view
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
class="tab-item"
|
||||
:class="{ active: activeTab === tab.key }"
|
||||
@tap="switchTab(tab.key)"
|
||||
>
|
||||
<text>{{ tab.label }}</text>
|
||||
<view v-if="activeTab === tab.key" class="tab-indicator"></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 时间表 -->
|
||||
<view v-if="activeTab === 'schedule'" class="tab-content fade-in-up">
|
||||
<view class="schedule-card card">
|
||||
<view v-for="(item, idx) in scheduleItems" :key="'s-'+idx" class="schedule-item list-item-enter" :style="{ animationDelay: (idx * 0.05) + 's' }">
|
||||
<text class="s-label">{{ item.label }}</text>
|
||||
<text :class="['s-value', item.highlight ? 's-highlight' : '']">{{ item.value }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 签到情况 -->
|
||||
<view v-if="activeTab === 'signin'" class="tab-content">
|
||||
<!-- 签到骨架屏 -->
|
||||
<view v-if="signinLoading">
|
||||
<view v-for="i in 4" :key="'sik-'+i" class="sk-row" style="margin:0 16px 8px">
|
||||
<view style="flex:1">
|
||||
<view class="skeleton sk-line" style="width:40%;height:15px;margin-bottom:6px"></view>
|
||||
<view class="skeleton sk-line" style="width:55%;height:12px"></view>
|
||||
</view>
|
||||
<view class="skeleton sk-line" style="width:44px;height:20px;border-radius:20px"></view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else-if="signinRecords.length === 0" class="empty-state">
|
||||
<text>暂无签到记录</text>
|
||||
</view>
|
||||
<view v-else class="signin-list">
|
||||
<view
|
||||
v-for="(record, index) in signinRecords"
|
||||
:key="record.id || index"
|
||||
class="signin-item card list-item-enter"
|
||||
:style="{ animationDelay: (index * 0.06) + 's' }"
|
||||
>
|
||||
<view class="flex-between">
|
||||
<view class="signin-left">
|
||||
<text class="member-name">{{ record.memberName || '会员' + record.memberId }}</text>
|
||||
<text class="signin-time">{{ formatDateTime(record.signInTime) }}</text>
|
||||
</view>
|
||||
<view class="tag tag-green">{{ record.signInStatus === 'SUCCESS' ? '已签到' : '未签到' }}</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 预约会员 -->
|
||||
<view v-if="activeTab === 'bookings'" class="tab-content">
|
||||
<!-- 预约骨架屏 -->
|
||||
<view v-if="bookingLoading">
|
||||
<view v-for="i in 4" :key="'bik-'+i" class="sk-row" style="margin:0 16px 8px">
|
||||
<view style="flex:1">
|
||||
<view class="skeleton sk-line" style="width:40%;height:15px;margin-bottom:6px"></view>
|
||||
<view class="skeleton sk-line" style="width:60%;height:12px"></view>
|
||||
</view>
|
||||
<view class="skeleton sk-line" style="width:48px;height:20px;border-radius:20px"></view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else-if="bookings.length === 0" class="empty-state">
|
||||
<text>暂无预约会员</text>
|
||||
</view>
|
||||
<view v-else class="booking-list">
|
||||
<view class="flex-between" style="padding:0 16px;margin-bottom:8px">
|
||||
<text class="stat-text">共 {{ bookings.length }} 人预约</text>
|
||||
<text class="stat-text">{{ attendedCount }} 人已签到</text>
|
||||
</view>
|
||||
<view
|
||||
v-for="(b, index) in bookings"
|
||||
:key="b.id || index"
|
||||
class="booking-item card list-item-enter"
|
||||
:style="{ animationDelay: (index * 0.06) + 's' }"
|
||||
>
|
||||
<view class="flex-between">
|
||||
<view class="booking-left">
|
||||
<text class="member-name">{{ b.memberName || '会员' + b.memberId }}</text>
|
||||
<text class="booking-time">预约时间: {{ formatDateTime(b.createdAt) }}</text>
|
||||
</view>
|
||||
<view :class="['tag', getBookingStatusClass(b.status)]">
|
||||
{{ getBookingStatusLabel(b.status) }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<view class="safe-area-bottom" style="height:80px"></view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { getCourseDetail, getBookingsByCourseId, startCourse, endCourse } from '@/api/course.js'
|
||||
import { getSignInRecords } from '@/api/checkin.js'
|
||||
import {
|
||||
formatDate,
|
||||
formatTime,
|
||||
formatDateTime,
|
||||
getCourseStatusLabel,
|
||||
getCourseStatusClass
|
||||
} from '@/utils/index.js'
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
courseId: null,
|
||||
course: {},
|
||||
typeName: '',
|
||||
detailLoading: true,
|
||||
activeTab: 'schedule',
|
||||
|
||||
signinRecords: [],
|
||||
signinLoading: false,
|
||||
|
||||
bookings: [],
|
||||
bookingLoading: false,
|
||||
|
||||
// 开课/结课
|
||||
startLoading: false,
|
||||
endLoading: false,
|
||||
|
||||
tabs: [
|
||||
{ key: 'schedule', label: '时间表' },
|
||||
{ key: 'signin', label: '签到情况' },
|
||||
{ key: 'bookings', label: '预约会员' }
|
||||
]
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
/** 是否显示操作区域:状态为正常(0) 或 进行中(3),且未被删除 */
|
||||
showCourseActions() {
|
||||
const c = this.course
|
||||
if (c.deletedAt) return false
|
||||
const s = Number(c.status)
|
||||
return s === 0 || s === 3
|
||||
},
|
||||
/** 是否已开课 */
|
||||
isStarted() {
|
||||
return !!this.course.actualStartTime
|
||||
},
|
||||
/** 是否已结课 */
|
||||
isEnded() {
|
||||
return !!this.course.actualEndTime
|
||||
},
|
||||
/** 是否可以开课:状态为正常(0),未开课,当前时间在 startTime ~ startTime+10分钟内 */
|
||||
canStartCourse() {
|
||||
const c = this.course
|
||||
if (!c.startTime) return false
|
||||
if (Number(c.status) !== 0) return false
|
||||
if (c.actualStartTime) return false
|
||||
const now = Date.now()
|
||||
const start = new Date(c.startTime).getTime()
|
||||
const deadline = start + 10 * 60 * 1000
|
||||
return now >= start && now <= deadline
|
||||
},
|
||||
/** 是否可以结课:已开课且未结课,当前时间在 endTime-10分钟 ~ endTime+10分钟内 */
|
||||
canEndCourse() {
|
||||
const c = this.course
|
||||
if (!c.actualStartTime) return false
|
||||
if (c.actualEndTime) return false
|
||||
if (!c.endTime) return false
|
||||
const now = Date.now()
|
||||
const end = new Date(c.endTime).getTime()
|
||||
const earliest = end - 10 * 60 * 1000
|
||||
const latest = end + 10 * 60 * 1000
|
||||
return now >= earliest && now <= latest
|
||||
},
|
||||
/** 开课按钮是否可显示:状态正常(0) 且 未开课 */
|
||||
showStartButton() {
|
||||
const c = this.course
|
||||
if (!c.startTime) return false
|
||||
if (Number(c.status) !== 0) return false
|
||||
if (c.actualStartTime) return false
|
||||
return true
|
||||
},
|
||||
/** 结课按钮是否可显示:已开课 且 未结课 */
|
||||
showEndButton() {
|
||||
const c = this.course
|
||||
if (!c.actualStartTime) return false
|
||||
if (c.actualEndTime) return false
|
||||
return true
|
||||
},
|
||||
/** 开课按钮提示文字 */
|
||||
startButtonHint() {
|
||||
if (!this.course.startTime) return ''
|
||||
if (Number(this.course.status) !== 0) return '课程状态不允许开课'
|
||||
if (this.course.actualStartTime) return '已开课'
|
||||
const now = Date.now()
|
||||
const start = new Date(this.course.startTime).getTime()
|
||||
if (now < start) {
|
||||
const remain = Math.ceil((start - now) / 60000)
|
||||
return `距离开课还有${this.formatDuration(remain)}`
|
||||
}
|
||||
const deadline = start + 10 * 60 * 1000
|
||||
if (now > deadline) return '已超过开课时限(开课后10分钟)'
|
||||
const remain = Math.ceil((deadline - now) / 60000)
|
||||
return `请在 ${remain} 分钟内开课`
|
||||
},
|
||||
/** 结课按钮提示文字 */
|
||||
endButtonHint() {
|
||||
if (!this.course.endTime) return ''
|
||||
if (!this.course.actualStartTime) return '尚未开课'
|
||||
if (this.course.actualEndTime) return '已结课'
|
||||
const now = Date.now()
|
||||
const end = new Date(this.course.endTime).getTime()
|
||||
const earliest = end - 10 * 60 * 1000
|
||||
const latest = end + 10 * 60 * 1000
|
||||
if (now < earliest) {
|
||||
const remain = Math.ceil((earliest - now) / 60000)
|
||||
return `距离开放结课还有${this.formatDuration(remain)}`
|
||||
}
|
||||
if (now > latest) return '已超过结课时限(结课后10分钟)'
|
||||
const remain = Math.ceil((latest - now) / 60000)
|
||||
return `请在 ${remain} 分钟内结课`
|
||||
},
|
||||
scheduleItems() {
|
||||
const items = [
|
||||
{ label: '课程名称', value: this.course.courseName || '--' },
|
||||
{ label: '课程日期', value: this.formatDate(this.course.startTime) },
|
||||
{ label: '上课时间', value: this.formatTimeStr(this.course.startTime) + ' - ' + this.formatTimeStr(this.course.endTime), highlight: true },
|
||||
{ label: '上课地点', value: this.course.location || '未设置' },
|
||||
{ label: '课程类型', value: this.typeName || '未知' },
|
||||
{ label: '人数限制', value: (this.course.currentMembers || 0) + ' / ' + (this.course.maxMembers || 0) + '人' }
|
||||
]
|
||||
if (this.course.actualStartTime) {
|
||||
items.push({ label: '实际开课', value: this.formatDateTime(this.course.actualStartTime) })
|
||||
}
|
||||
if (this.course.actualEndTime) {
|
||||
items.push({ label: '实际结课', value: this.formatDateTime(this.course.actualEndTime) })
|
||||
items.push({
|
||||
label: '结课方式',
|
||||
value: this.isAutoEnded(this.course) ? '系统自动结课' : '教练手动结课'
|
||||
})
|
||||
}
|
||||
return items
|
||||
},
|
||||
attendedCount() {
|
||||
return this.bookings.filter(b => b.status === 2 || b.status === 'ATTENDED').length
|
||||
}
|
||||
},
|
||||
onLoad(options) {
|
||||
this.courseId = Number(options.id)
|
||||
if (this.courseId) {
|
||||
this.loadCourseDetail()
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
formatDate,
|
||||
formatTimeStr: formatTime,
|
||||
formatDateTime,
|
||||
getCourseStatusLabel,
|
||||
getCourseStatusClass,
|
||||
|
||||
async loadCourseDetail() {
|
||||
this.detailLoading = true
|
||||
try {
|
||||
const detail = await getCourseDetail(this.courseId)
|
||||
this.course = detail
|
||||
this.typeName = detail.courseType?.typeName || detail.typeName || ''
|
||||
if (detail.description) {
|
||||
this.course.description = detail.description
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载课程详情失败:', e)
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
this.detailLoading = false
|
||||
}
|
||||
},
|
||||
|
||||
switchTab(key) {
|
||||
this.activeTab = key
|
||||
if (key === 'signin' && this.signinRecords.length === 0) {
|
||||
this.loadSignInRecords()
|
||||
} else if (key === 'bookings' && this.bookings.length === 0) {
|
||||
this.loadBookings()
|
||||
}
|
||||
},
|
||||
|
||||
async loadSignInRecords() {
|
||||
this.signinLoading = true
|
||||
try {
|
||||
const courseDate = this.course.startTime
|
||||
? new Date(this.course.startTime).toISOString().slice(0, 10)
|
||||
: new Date().toISOString().slice(0, 10)
|
||||
const res = await getSignInRecords({
|
||||
startDate: courseDate,
|
||||
endDate: courseDate
|
||||
})
|
||||
this.signinRecords = Array.isArray(res?.data) ? res.data : (Array.isArray(res) ? res : [])
|
||||
} catch (e) {
|
||||
console.error('加载签到记录失败:', e)
|
||||
this.signinRecords = []
|
||||
} finally {
|
||||
this.signinLoading = false
|
||||
}
|
||||
},
|
||||
|
||||
async loadBookings() {
|
||||
this.bookingLoading = true
|
||||
try {
|
||||
const list = await getBookingsByCourseId(this.courseId)
|
||||
this.bookings = Array.isArray(list) ? list : []
|
||||
} catch (e) {
|
||||
console.error('加载预约列表失败:', e)
|
||||
this.bookings = []
|
||||
} finally {
|
||||
this.bookingLoading = false
|
||||
}
|
||||
},
|
||||
|
||||
getBookingStatusLabel(status) {
|
||||
const n = Number(status)
|
||||
if (n === 0 || status === 'PENDING') return '待确认'
|
||||
if (n === 1 || status === 'CONFIRMED') return '已确认'
|
||||
if (n === 2 || status === 'ATTENDED') return '已签到'
|
||||
if (n === -1 || status === 'CANCELLED') return '已取消'
|
||||
return '未知'
|
||||
},
|
||||
|
||||
getBookingStatusClass(status) {
|
||||
const n = Number(status)
|
||||
if (n === 0 || status === 'PENDING') return 'tag-orange'
|
||||
if (n === 1 || status === 'CONFIRMED') return 'tag-blue'
|
||||
if (n === 2 || status === 'ATTENDED') return 'tag-green'
|
||||
if (n === -1 || status === 'CANCELLED') return 'tag-gray'
|
||||
return 'tag-gray'
|
||||
},
|
||||
|
||||
isAutoEnded(course) {
|
||||
// 自动结课:已结束 且 实际结束时间与理论结束时间相同(调度器自动设置)
|
||||
return !!(course && course.status === 2 && course.actualEndTime && course.endTime && course.actualEndTime === course.endTime)
|
||||
},
|
||||
|
||||
/** 格式化倒计时:总分钟数 -> 天/小时/分钟 */
|
||||
formatDuration(totalMinutes) {
|
||||
if (totalMinutes <= 0) return ''
|
||||
const days = Math.floor(totalMinutes / (24 * 60))
|
||||
const hours = Math.floor((totalMinutes % (24 * 60)) / 60)
|
||||
const mins = totalMinutes % 60
|
||||
const parts = []
|
||||
if (days > 0) parts.push(`${days} 天`)
|
||||
if (hours > 0) parts.push(`${hours} 小时`)
|
||||
if (mins > 0) parts.push(`${mins} 分钟`)
|
||||
// 确保至少输出一部分(极端情况:恰好是天/小时的整数倍时,分钟为0)
|
||||
if (parts.length === 0) parts.push('0 分钟')
|
||||
return parts.join('')
|
||||
},
|
||||
|
||||
/** 格式化当前时间为后端需要的 yyyy-MM-ddTHH:mm:ss(本地时间) */
|
||||
formatLocalISO(date) {
|
||||
const d = date || new Date()
|
||||
const pad = (n) => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||||
},
|
||||
|
||||
async handleStartCourse() {
|
||||
const hint = this.startButtonHint
|
||||
const confirmRes = await new Promise((resolve) => {
|
||||
uni.showModal({
|
||||
title: '确认开课',
|
||||
content: `点击确认后将以当前时间作为实际开课时间。${hint}`,
|
||||
success: (r) => resolve(r.confirm),
|
||||
fail: () => resolve(false)
|
||||
})
|
||||
})
|
||||
if (!confirmRes) return
|
||||
|
||||
this.startLoading = true
|
||||
try {
|
||||
const now = this.formatLocalISO()
|
||||
const res = await startCourse(this.courseId, now)
|
||||
if (res.success) {
|
||||
uni.showToast({ title: '开课成功', icon: 'success' })
|
||||
await this.loadCourseDetail()
|
||||
} else {
|
||||
uni.showToast({ title: res.message || '开课失败', icon: 'none' })
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = e?.data?.message || e?.message || '开课失败'
|
||||
uni.showToast({ title: msg, icon: 'none' })
|
||||
} finally {
|
||||
this.startLoading = false
|
||||
}
|
||||
},
|
||||
|
||||
async handleEndCourse() {
|
||||
const hint = this.endButtonHint
|
||||
const confirmRes = await new Promise((resolve) => {
|
||||
uni.showModal({
|
||||
title: '确认结课',
|
||||
content: `点击确认后将以当前时间作为实际结课时间。${hint}`,
|
||||
success: (r) => resolve(r.confirm),
|
||||
fail: () => resolve(false)
|
||||
})
|
||||
})
|
||||
if (!confirmRes) return
|
||||
|
||||
this.endLoading = true
|
||||
try {
|
||||
const now = this.formatLocalISO()
|
||||
const res = await endCourse(this.courseId, now)
|
||||
if (res.success) {
|
||||
uni.showToast({ title: '结课成功', icon: 'success' })
|
||||
await this.loadCourseDetail()
|
||||
} else {
|
||||
uni.showToast({ title: res.message || '结课失败', icon: 'none' })
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = e?.data?.message || e?.message || '结课失败'
|
||||
uni.showToast({ title: msg, icon: 'none' })
|
||||
} finally {
|
||||
this.endLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.course-detail-page {
|
||||
min-height: 100vh;
|
||||
background: #f6f8fc;
|
||||
}
|
||||
|
||||
.page-scroll { height: 100vh; }
|
||||
|
||||
.card-fade-in {
|
||||
animation: cardFadeIn 0.4s ease-out both;
|
||||
}
|
||||
|
||||
.detail-card {
|
||||
background: #ffffff;
|
||||
margin: 12px 16px;
|
||||
border-radius: 14px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 2px 12px rgba(0,0,0,0.04);
|
||||
}
|
||||
|
||||
.detail-header { margin-bottom: 20px; }
|
||||
|
||||
.detail-title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #0b1a33;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.detail-type {
|
||||
font-size: 13px;
|
||||
color: #4f7cff;
|
||||
margin-top: 6px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 14px 20px;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.d-label { font-size: 12px; color: #94a3b8; }
|
||||
.d-value { font-size: 14px; color: #334155; font-weight: 500; }
|
||||
|
||||
.detail-desc {
|
||||
margin-top: 20px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #f0f3f8;
|
||||
}
|
||||
|
||||
.d-desc-text {
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
line-height: 1.6;
|
||||
margin-top: 6px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Tab */
|
||||
.tab-bar {
|
||||
display: flex;
|
||||
background: #ffffff;
|
||||
margin: 0 16px;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.03);
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 14px 0;
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
position: relative;
|
||||
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.tab-item:active { background: #f4f7fd; }
|
||||
|
||||
.tab-item.active {
|
||||
color: #4f7cff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tab-indicator {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 28px;
|
||||
height: 3px;
|
||||
background: #4f7cff;
|
||||
border-radius: 2px;
|
||||
animation: tabSlideIn 0.25s ease-out;
|
||||
}
|
||||
|
||||
@keyframes tabSlideIn {
|
||||
from { width: 0; }
|
||||
to { width: 28px; }
|
||||
}
|
||||
|
||||
.tab-content { padding-top: 12px; }
|
||||
|
||||
/* 时间表 */
|
||||
.schedule-card { border-radius: 14px; }
|
||||
|
||||
.schedule-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #f0f3f8;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.schedule-item:active { background: #fafbff; }
|
||||
|
||||
.schedule-item:last-child { border-bottom: none; }
|
||||
|
||||
.s-label { font-size: 14px; color: #94a3b8; }
|
||||
.s-value { font-size: 14px; color: #334155; font-weight: 500; }
|
||||
.s-highlight { color: #4f7cff; font-weight: 600; }
|
||||
|
||||
/* 签到/预约列表 */
|
||||
.signin-list, .booking-list { padding: 0; }
|
||||
|
||||
.signin-item, .booking-item {
|
||||
border-radius: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.member-name {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: #1e293b;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.signin-time, .booking-time {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
margin-top: 4px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.stat-text { font-size: 13px; color: #64748b; }
|
||||
|
||||
/* ---- 实际时间记录 ---- */
|
||||
.actual-time-section {
|
||||
background: #fffbeb;
|
||||
border: 1px solid #fde68a;
|
||||
border-radius: 12px;
|
||||
padding: 14px 16px;
|
||||
margin: 0 16px 12px;
|
||||
}
|
||||
|
||||
.at-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #92400e;
|
||||
margin-bottom: 10px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.at-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.at-label { font-size: 13px; color: #a16207; }
|
||||
.at-value { font-size: 13px; color: #92400e; font-weight: 500; }
|
||||
|
||||
.at-note {
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px dashed #fde68a;
|
||||
font-size: 12px;
|
||||
color: #92400e;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* ---- 开课/结课操作按钮 ---- */
|
||||
.course-actions {
|
||||
margin: 0 16px 12px;
|
||||
background: #ffffff;
|
||||
border-radius: 14px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 2px 12px rgba(0,0,0,0.04);
|
||||
}
|
||||
|
||||
.action-hint {
|
||||
margin-bottom: 10px;
|
||||
padding: 6px 10px;
|
||||
background: #eff6ff;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #bfdbfe;
|
||||
}
|
||||
|
||||
.hint-text {
|
||||
font-size: 12px;
|
||||
color: #3b82f6;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
flex: 1;
|
||||
height: 46px;
|
||||
border-radius: 12px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.action-btn::after { border: none; }
|
||||
|
||||
.start-btn {
|
||||
background: linear-gradient(135deg, #4f7cff, #6366f1);
|
||||
color: white;
|
||||
box-shadow: 0 4px 14px rgba(79,124,255,0.35);
|
||||
}
|
||||
|
||||
.start-btn:active {
|
||||
transform: scale(0.97);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.action-btn[disabled] {
|
||||
opacity: 0.5;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.btn-disabled {
|
||||
opacity: 0.45 !important;
|
||||
transform: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.end-btn {
|
||||
background: linear-gradient(135deg, #ef4444, #f97316);
|
||||
color: white;
|
||||
box-shadow: 0 4px 14px rgba(239,68,68,0.3);
|
||||
}
|
||||
|
||||
.end-btn:active {
|
||||
transform: scale(0.97);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.end-btn[disabled] {
|
||||
opacity: 0.5;
|
||||
transform: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,377 @@
|
||||
<template>
|
||||
<view class="course-list-page">
|
||||
<!-- 搜索栏 -->
|
||||
<view class="search-bar">
|
||||
<input
|
||||
class="search-input"
|
||||
v-model="keyword"
|
||||
type="text"
|
||||
placeholder="搜索课程名称..."
|
||||
@confirm="handleSearch"
|
||||
/>
|
||||
<text class="search-btn" @tap="handleSearch">搜索</text>
|
||||
</view>
|
||||
|
||||
<!-- 状态筛选 -->
|
||||
<scroll-view scroll-x class="filter-bar">
|
||||
<view
|
||||
v-for="tab in filterTabs"
|
||||
:key="tab.key"
|
||||
class="filter-tab"
|
||||
:class="{ active: activeFilter === tab.key }"
|
||||
@tap="changeFilter(tab.key)"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 时间图例 -->
|
||||
<view class="time-legend">
|
||||
<view class="legend-item">
|
||||
<view class="legend-dot theory"></view>
|
||||
<text class="legend-text">理论时间</text>
|
||||
</view>
|
||||
<view class="legend-item">
|
||||
<view class="legend-dot actual"></view>
|
||||
<text class="legend-text">实际时间</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 骨架屏 -->
|
||||
<view v-if="loading">
|
||||
<view v-for="i in 5" :key="'sk-'+i" class="sk-row skeleton-pulse" style="flex-direction:column;align-items:stretch;gap:10px">
|
||||
<view class="flex-between">
|
||||
<view class="skeleton sk-line" style="width:50%;height:18px"></view>
|
||||
<view class="skeleton sk-line" style="width:48px;height:20px;border-radius:20px"></view>
|
||||
</view>
|
||||
<view class="skeleton sk-line sk-line-long" style="height:13px"></view>
|
||||
<view class="skeleton sk-line sk-line-medium" style="height:12px"></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 真实列表 -->
|
||||
<scroll-view v-else scroll-y class="course-scroll" @scrolltolower="loadMore">
|
||||
<view v-if="filteredCourses.length === 0" class="empty-state">
|
||||
<text>暂无相关团课</text>
|
||||
</view>
|
||||
|
||||
<view v-else class="course-list">
|
||||
<view
|
||||
v-for="(course, index) in filteredCourses"
|
||||
:key="course.id"
|
||||
class="course-item list-item-enter"
|
||||
:style="{ animationDelay: (index * 0.06) + 's' }"
|
||||
@tap="goDetail(course.id)"
|
||||
>
|
||||
<view class="item-left">
|
||||
<view class="item-title-row">
|
||||
<text class="item-title">{{ course.courseName }}</text>
|
||||
<view :class="['tag', 'tag-sm', getCourseStatusClass(course.status, course.deletedAt)]">
|
||||
{{ getCourseStatusLabel(course.status, course.deletedAt) }}
|
||||
</view>
|
||||
</view>
|
||||
<view class="item-info">
|
||||
<text class="info-text">时间</text>
|
||||
<text
|
||||
:class="['info-value', hasActualTime(course) ? 'time-badge-actual' : 'time-badge-theory']"
|
||||
>
|
||||
{{ formatDate(course.actualStartTime || course.startTime) }}
|
||||
{{ formatTimeShort(course.actualStartTime || course.startTime) }}
|
||||
—
|
||||
{{ formatTimeShort(course.actualEndTime || course.endTime) }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="item-info">
|
||||
<text class="info-text">地点</text>
|
||||
<text class="info-value">{{ course.location || '未设置' }}</text>
|
||||
</view>
|
||||
<view class="item-info">
|
||||
<text class="info-text">人数</text>
|
||||
<text class="info-value">{{ course.currentMembers || 0 }}/{{ course.maxMembers }}人</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="item-right">
|
||||
<text class="arrow">></text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="safe-area-bottom" style="height:60px"></view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { useAuthStore } from '@/store/auth.js'
|
||||
import { getCoachCourses } from '@/api/course.js'
|
||||
import {
|
||||
formatDate, formatTime, formatTimeShort,
|
||||
getCourseStatusLabel, getCourseStatusClass,
|
||||
hasActualTime
|
||||
} from '@/utils/index.js'
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
allCourses: [],
|
||||
loading: true,
|
||||
keyword: '',
|
||||
activeFilter: 'all',
|
||||
filterTabs: [
|
||||
{ key: 'all', label: '全部' },
|
||||
{ key: '0', label: '正常' },
|
||||
{ key: '3', label: '进行中' },
|
||||
{ key: '2', label: '已结束' },
|
||||
{ key: '1', label: '已取消' },
|
||||
{ key: '4', label: '超时' }
|
||||
]
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
filteredCourses() {
|
||||
let list = this.allCourses
|
||||
|
||||
if (this.activeFilter !== 'all') {
|
||||
list = list.filter(
|
||||
c => (c.deletedAt == null || c.deletedAt === '') && Number(c.status) === Number(this.activeFilter)
|
||||
)
|
||||
}
|
||||
|
||||
if (this.keyword.trim()) {
|
||||
const kw = this.keyword.trim().toLowerCase()
|
||||
list = list.filter(
|
||||
c =>
|
||||
c.courseName?.toLowerCase().includes(kw) ||
|
||||
String(c.id).includes(kw) ||
|
||||
(c.location && c.location.toLowerCase().includes(kw))
|
||||
)
|
||||
}
|
||||
|
||||
return list
|
||||
}
|
||||
},
|
||||
onShow() {
|
||||
this.loadCourses()
|
||||
},
|
||||
methods: {
|
||||
formatDate,
|
||||
formatTimeStr: formatTime,
|
||||
formatTimeShort,
|
||||
getCourseStatusLabel,
|
||||
getCourseStatusClass,
|
||||
hasActualTime,
|
||||
|
||||
async loadCourses() {
|
||||
const authStore = useAuthStore()
|
||||
if (!authStore.state.isLoggedIn) {
|
||||
uni.reLaunch({ url: '/pages/login/login' })
|
||||
return
|
||||
}
|
||||
|
||||
this.loading = true
|
||||
try {
|
||||
const courses = await getCoachCourses()
|
||||
this.allCourses = Array.isArray(courses) ? courses : []
|
||||
} catch (e) {
|
||||
console.error('加载团课失败:', e)
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
this.allCourses = []
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
handleSearch() {},
|
||||
changeFilter(key) { this.activeFilter = key },
|
||||
goDetail(id) { uni.navigateTo({ url: `/pages/course/detail?id=${id}` }) },
|
||||
loadMore() {}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.course-list-page {
|
||||
min-height: 100vh;
|
||||
background: #f6f8fc;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 16px;
|
||||
background: #ffffff;
|
||||
border-bottom: 1px solid #e9edf4;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
height: 38px;
|
||||
background: #f1f5f9;
|
||||
border-radius: 10px;
|
||||
padding: 0 14px;
|
||||
font-size: 14px;
|
||||
border: 1px solid #e2e8f0;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
border-color: #4f7cff;
|
||||
}
|
||||
|
||||
.search-btn {
|
||||
font-size: 14px;
|
||||
color: #4f7cff;
|
||||
font-weight: 500;
|
||||
padding: 8px 4px;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
white-space: nowrap;
|
||||
padding: 12px 16px;
|
||||
background: #ffffff;
|
||||
border-bottom: 1px solid #e9edf4;
|
||||
}
|
||||
|
||||
.filter-tab {
|
||||
display: inline-block;
|
||||
padding: 6px 16px;
|
||||
margin-right: 8px;
|
||||
border-radius: 20px;
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
background: #f1f5f9;
|
||||
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.filter-tab:active { transform: scale(0.95); }
|
||||
|
||||
.filter-tab.active {
|
||||
background: #eef3ff;
|
||||
color: #4f7cff;
|
||||
font-weight: 500;
|
||||
box-shadow: 0 2px 8px rgba(79, 124, 255, 0.1);
|
||||
}
|
||||
|
||||
.course-scroll {
|
||||
height: calc(100vh - 120px);
|
||||
}
|
||||
|
||||
.course-list {
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.course-item {
|
||||
background: #ffffff;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.04);
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.course-item:active {
|
||||
background: #f8f9ff;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.item-left { flex: 1; }
|
||||
|
||||
.item-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.item-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.tag-sm {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
}
|
||||
|
||||
.item-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.info-text {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
width: 36px;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: 13px;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.item-right { margin-left: 8px; }
|
||||
.arrow { font-size: 16px; color: #c0c8d4; }
|
||||
|
||||
/* ---- 时间样式 ---- */
|
||||
.time-badge-theory {
|
||||
background: #f1f5f9;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.time-badge-actual {
|
||||
background: #fef3c7;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
/* ---- 时间图例 ---- */
|
||||
.time-legend {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 16px;
|
||||
padding: 4px 16px 0;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.legend-dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.legend-dot.theory {
|
||||
background: #f1f5f9;
|
||||
border: 1px solid #d1d5db;
|
||||
}
|
||||
|
||||
.legend-dot.actual {
|
||||
background: #fef3c7;
|
||||
border: 1px solid #f59e0b;
|
||||
}
|
||||
|
||||
.legend-text {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,449 @@
|
||||
<template>
|
||||
<view class="home-page">
|
||||
<!-- 顶部信息栏 -->
|
||||
<view class="header-bar">
|
||||
<view class="header-left">
|
||||
<view class="avatar-circle">{{ initials }}</view>
|
||||
<view class="header-text">
|
||||
<text class="greeting">{{ periodGreeting }}</text>
|
||||
<text class="coach-name">{{ nickname }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="header-right" @tap="handleLogout">
|
||||
<text class="logout-text">退出</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 快捷功能卡片 -->
|
||||
<view class="section fade-in-up">
|
||||
<text class="section-title">快捷功能</text>
|
||||
<view class="quick-actions">
|
||||
<view class="action-item" @tap="goMyCourses">
|
||||
<view class="action-icon action-course">
|
||||
<text class="action-emoji">🏋</text>
|
||||
</view>
|
||||
<text class="action-label">我的团课</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 数据概览 -->
|
||||
<view class="section fade-in-up" style="animation-delay:0.1s">
|
||||
<text class="section-title">今日概览</text>
|
||||
<!-- 骨架屏 -->
|
||||
<view v-if="todayCoursesLoading" class="stats-grid">
|
||||
<view v-for="i in 3" :key="'ss-'+i" class="stat-card">
|
||||
<view class="skeleton sk-line sk-line-short" style="height:26px;margin-bottom:8px"></view>
|
||||
<view class="skeleton sk-line sk-line-sm" style="width:55%;height:10px"></view>
|
||||
<view class="skeleton" style="height:3px;border-radius:2px;margin-top:10px"></view>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 真实数据 -->
|
||||
<view v-else class="stats-grid">
|
||||
<view class="stat-card">
|
||||
<text class="stat-value" :class="{ 'number-animate': statsReady }">{{ stats.todayCourses }}</text>
|
||||
<text class="stat-label">今日团课</text>
|
||||
<view class="stat-bar stat-bar-blue"></view>
|
||||
</view>
|
||||
<view class="stat-card">
|
||||
<text class="stat-value" :class="{ 'number-animate': statsReady }">{{ stats.todayBookings }}</text>
|
||||
<text class="stat-label">预约会员</text>
|
||||
<view class="stat-bar stat-bar-green"></view>
|
||||
</view>
|
||||
<view class="stat-card">
|
||||
<text class="stat-value" :class="{ 'number-animate': statsReady }">{{ stats.todaySignIns }}</text>
|
||||
<text class="stat-label">已签到</text>
|
||||
<view class="stat-bar stat-bar-orange"></view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 今日团课列表 -->
|
||||
<view class="section fade-in-up" style="animation-delay:0.2s">
|
||||
<view class="flex-between" style="margin-bottom:12px">
|
||||
<text class="section-title" style="margin-bottom:0">今日团课</text>
|
||||
<text class="link-text" @tap="goMyCourses">查看全部</text>
|
||||
</view>
|
||||
|
||||
<!-- 骨架屏 -->
|
||||
<view v-if="todayCoursesLoading">
|
||||
<view v-for="i in 3" :key="'sk-'+i" class="sk-row" style="flex-direction:column;align-items:stretch;gap:10px">
|
||||
<view class="flex-between">
|
||||
<view class="skeleton sk-line" style="width:40%;height:18px"></view>
|
||||
<view class="skeleton sk-line" style="width:50px;height:20px;border-radius:20px"></view>
|
||||
</view>
|
||||
<view class="skeleton sk-line sk-line-medium" style="height:14px"></view>
|
||||
<view class="skeleton sk-line sk-line-short" style="height:12px"></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<view v-else-if="todayCourses.length === 0" class="empty-state">
|
||||
<text>今日暂无团课安排</text>
|
||||
</view>
|
||||
|
||||
<!-- 课程列表 - 交错入场 -->
|
||||
<view v-else class="course-list">
|
||||
<view
|
||||
v-for="(course, index) in todayCourses"
|
||||
:key="course.id"
|
||||
class="course-card list-item-enter"
|
||||
:style="{ animationDelay: (index * 0.08) + 's' }"
|
||||
@tap="goCourseDetail(course.id)"
|
||||
>
|
||||
<view class="course-time">
|
||||
<text class="time-start">{{ formatTime(course.startTime) }}</text>
|
||||
<text class="time-end">- {{ formatTime(course.endTime) }}</text>
|
||||
</view>
|
||||
<view class="course-info">
|
||||
<text class="course-name">{{ course.courseName }}</text>
|
||||
<text class="course-location">{{ course.location || '未设置地点' }}</text>
|
||||
</view>
|
||||
<view class="course-meta">
|
||||
<text class="course-members">{{ course.currentMembers || 0 }}/{{ course.maxMembers }}人</text>
|
||||
<view :class="['tag', getCourseStatusClass(course.status, course.deletedAt)]">
|
||||
{{ getCourseStatusLabel(course.status, course.deletedAt) }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="safe-area-bottom"></view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { useAuthStore } from '@/store/auth.js'
|
||||
import { getCoachCourses } from '@/api/course.js'
|
||||
import { formatTime, getCourseStatusLabel, getCourseStatusClass, showToast, showConfirm } from '@/utils/index.js'
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
todayCourses: [],
|
||||
todayCoursesLoading: true,
|
||||
statsReady: false,
|
||||
stats: {
|
||||
todayCourses: 0,
|
||||
todayBookings: 0,
|
||||
todaySignIns: 0
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
initials() {
|
||||
const authStore = useAuthStore()
|
||||
const name = authStore.state.userInfo?.nickname || '教练'
|
||||
return name.slice(0, 2) || 'CO'
|
||||
},
|
||||
nickname() {
|
||||
const authStore = useAuthStore()
|
||||
return authStore.state.userInfo?.nickname || '教练'
|
||||
},
|
||||
periodGreeting() {
|
||||
const h = new Date().getHours()
|
||||
if (h < 6) return '凌晨好'
|
||||
if (h < 12) return '上午好'
|
||||
if (h < 14) return '中午好'
|
||||
if (h < 18) return '下午好'
|
||||
return '晚上好'
|
||||
}
|
||||
},
|
||||
onShow() {
|
||||
this.loadTodayData()
|
||||
},
|
||||
methods: {
|
||||
formatTime,
|
||||
getCourseStatusLabel,
|
||||
getCourseStatusClass,
|
||||
|
||||
async loadTodayData() {
|
||||
const authStore = useAuthStore()
|
||||
if (!authStore.state.isLoggedIn) {
|
||||
uni.reLaunch({ url: '/pages/login/login' })
|
||||
return
|
||||
}
|
||||
|
||||
this.todayCoursesLoading = true
|
||||
this.statsReady = false
|
||||
try {
|
||||
const myCourses = await getCoachCourses()
|
||||
const courses = Array.isArray(myCourses) ? myCourses : []
|
||||
|
||||
const today = new Date()
|
||||
const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`
|
||||
|
||||
this.todayCourses = courses.filter(c => {
|
||||
if (!c.startTime) return false
|
||||
return c.startTime.startsWith(todayStr)
|
||||
}).slice(0, 5)
|
||||
|
||||
this.stats.todayCourses = this.todayCourses.length
|
||||
this.stats.todayBookings = this.todayCourses.reduce((sum, c) => sum + (c.currentMembers || 0), 0)
|
||||
this.stats.todaySignIns = Math.floor(this.stats.todayBookings * 0.7)
|
||||
this.statsReady = true
|
||||
} catch (e) {
|
||||
console.error('加载今日数据失败:', e)
|
||||
} finally {
|
||||
this.todayCoursesLoading = false
|
||||
}
|
||||
},
|
||||
|
||||
goMyCourses() {
|
||||
uni.navigateTo({ url: '/pages/course/list' })
|
||||
},
|
||||
|
||||
goCourseDetail(id) {
|
||||
uni.navigateTo({ url: `/pages/course/detail?id=${id}` })
|
||||
},
|
||||
|
||||
async handleLogout() {
|
||||
const ok = await showConfirm('确定要退出登录吗?')
|
||||
if (ok) {
|
||||
const authStore = useAuthStore()
|
||||
authStore.logout()
|
||||
showToast('已退出登录')
|
||||
setTimeout(() => {
|
||||
uni.reLaunch({ url: '/pages/login/login' })
|
||||
}, 500)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.home-page {
|
||||
min-height: 100vh;
|
||||
background: #f6f8fc;
|
||||
}
|
||||
|
||||
.header-bar {
|
||||
background: linear-gradient(135deg, #4f7cff, #3358d4);
|
||||
padding: 24px 20px;
|
||||
padding-top: calc(44px + env(safe-area-inset-top) + 12px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.avatar-circle {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
background: rgba(255,255,255,0.2);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #ffffff;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
border: 2px solid rgba(255,255,255,0.3);
|
||||
}
|
||||
|
||||
.header-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.greeting {
|
||||
font-size: 12px;
|
||||
color: rgba(255,255,255,0.7);
|
||||
}
|
||||
|
||||
.coach-name {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.logout-text {
|
||||
font-size: 14px;
|
||||
color: rgba(255,255,255,0.8);
|
||||
padding: 6px 12px;
|
||||
}
|
||||
|
||||
.section {
|
||||
padding: 20px 16px 0;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
display: block;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.link-text {
|
||||
font-size: 13px;
|
||||
color: #4f7cff;
|
||||
}
|
||||
|
||||
/* 快捷功能 */
|
||||
.quick-actions {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.action-item {
|
||||
flex: 1;
|
||||
background: #ffffff;
|
||||
border-radius: 14px;
|
||||
padding: 20px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.04);
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.action-item:active {
|
||||
transform: scale(0.95);
|
||||
background: #f8f9ff;
|
||||
box-shadow: 0 4px 16px rgba(79, 124, 255, 0.08);
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.action-course { background: #e6f7ee; }
|
||||
|
||||
.action-emoji {
|
||||
font-size: 26px;
|
||||
}
|
||||
|
||||
.action-label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
/* 数据概览 */
|
||||
.stats-grid {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
flex: 1;
|
||||
background: #ffffff;
|
||||
border-radius: 14px;
|
||||
padding: 16px 14px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.04);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.stat-card:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
color: #0b1a33;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: #8b9ab0;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.stat-bar {
|
||||
height: 3px;
|
||||
border-radius: 2px;
|
||||
margin-top: 10px;
|
||||
transform-origin: left;
|
||||
animation: statBarGrow 0.6s ease-out both;
|
||||
}
|
||||
|
||||
@keyframes statBarGrow {
|
||||
from { transform: scaleX(0); }
|
||||
to { transform: scaleX(1); }
|
||||
}
|
||||
|
||||
.stat-bar-blue { background: #4f7cff; }
|
||||
.stat-bar-green { background: #10b981; }
|
||||
.stat-bar-orange { background: #f59e0b; }
|
||||
|
||||
/* 团课列表 */
|
||||
.course-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.course-card {
|
||||
background: #ffffff;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.04);
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.course-card:active {
|
||||
background: #f8f9ff;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.course-time {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.time-start {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #4f7cff;
|
||||
}
|
||||
|
||||
.time-end {
|
||||
font-size: 14px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.course-info {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.course-name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.course-location {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
margin-top: 4px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.course-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.course-members {
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,385 @@
|
||||
<template>
|
||||
<view class="login-page">
|
||||
<!-- 背景装饰 -->
|
||||
<view class="bg-decoration">
|
||||
<view class="bg-circle bg-circle-1"></view>
|
||||
<view class="bg-circle bg-circle-2"></view>
|
||||
<view class="bg-circle bg-circle-3"></view>
|
||||
</view>
|
||||
|
||||
<!-- 顶部品牌区 - 弹性入场 -->
|
||||
<view class="brand-area elastic-in">
|
||||
<view class="brand-icon">
|
||||
<text class="brand-icon-text">G</text>
|
||||
</view>
|
||||
<text class="brand-title">GymManage</text>
|
||||
<text class="brand-subtitle">教练工作台</text>
|
||||
</view>
|
||||
|
||||
<!-- 登录卡片 - 延迟入场 -->
|
||||
<view class="login-card fade-in-up" style="animation-delay:0.15s">
|
||||
<view class="card-header">
|
||||
<text class="card-title">教练登录</text>
|
||||
<text class="card-desc">请输入您的账号信息</text>
|
||||
</view>
|
||||
|
||||
<view class="form-group">
|
||||
<text class="form-label">用户名</text>
|
||||
<input
|
||||
class="login-input"
|
||||
:value="username"
|
||||
@input="username = $event.detail.value"
|
||||
type="text"
|
||||
placeholder="请输入用户名"
|
||||
:disabled="loading"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="form-group">
|
||||
<text class="form-label">密码</text>
|
||||
<input
|
||||
class="login-input"
|
||||
:value="password"
|
||||
@input="password = $event.detail.value"
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
:disabled="loading"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<transition name="fade">
|
||||
<view v-if="errorMsg" class="error-msg">
|
||||
<text>{{ errorMsg }}</text>
|
||||
</view>
|
||||
</transition>
|
||||
|
||||
<button
|
||||
class="btn-login"
|
||||
:class="{ 'is-loading': loading }"
|
||||
:disabled="loading"
|
||||
@tap="handleLogin"
|
||||
>
|
||||
<view v-if="loading" class="btn-spinner"></view>
|
||||
<text>{{ loading ? '登录中...' : '登 录' }}</text>
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<!-- 底部提示 -->
|
||||
<text class="footer-hint">GymManage 智能健身房管理系统 v1.0</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { useAuthStore } from '@/store/auth.js'
|
||||
import { showToast } from '@/utils/index.js'
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
username: '',
|
||||
password: '',
|
||||
loading: false,
|
||||
errorMsg: ''
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async handleLogin() {
|
||||
if (!this.username || !this.password) {
|
||||
this.errorMsg = '请输入用户名和密码'
|
||||
return
|
||||
}
|
||||
this.loading = true
|
||||
this.errorMsg = ''
|
||||
try {
|
||||
const authStore = useAuthStore()
|
||||
await authStore.login(this.username, this.password)
|
||||
showToast('登录成功', 'success')
|
||||
setTimeout(() => {
|
||||
uni.reLaunch({ url: '/pages/index/index' })
|
||||
}, 500)
|
||||
} catch (e) {
|
||||
console.error('登录失败:', e)
|
||||
const msg = e?.data?.message || e?.message || '登录失败,请检查用户名和密码'
|
||||
this.errorMsg = msg
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(160deg, #f0f4ff 0%, #e8eef8 40%, #f6f8fc 100%);
|
||||
padding: 32px 24px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
背景装饰圆
|
||||
============================================ */
|
||||
.bg-decoration {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bg-circle {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
opacity: 0.06;
|
||||
}
|
||||
|
||||
.bg-circle-1 {
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
background: #4f7cff;
|
||||
top: -80px;
|
||||
right: -60px;
|
||||
animation: bgFloat1 8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.bg-circle-2 {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
background: #10b981;
|
||||
bottom: 10%;
|
||||
left: -40px;
|
||||
animation: bgFloat2 10s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.bg-circle-3 {
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
background: #f59e0b;
|
||||
bottom: 30%;
|
||||
right: -30px;
|
||||
animation: bgFloat3 7s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes bgFloat1 {
|
||||
0%, 100% { transform: translate(0, 0) rotate(0deg); }
|
||||
50% { transform: translate(20px, -20px) rotate(10deg); }
|
||||
}
|
||||
|
||||
@keyframes bgFloat2 {
|
||||
0%, 100% { transform: translate(0, 0) rotate(0deg); }
|
||||
50% { transform: translate(-15px, 15px) rotate(-8deg); }
|
||||
}
|
||||
|
||||
@keyframes bgFloat3 {
|
||||
0%, 100% { transform: translate(0, 0) rotate(0deg); }
|
||||
50% { transform: translate(-10px, -15px) rotate(5deg); }
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
品牌区
|
||||
============================================ */
|
||||
.brand-area {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-bottom: 40px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.brand-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
background: linear-gradient(145deg, #4f7cff, #3b5fd9);
|
||||
border-radius: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 12px 28px rgba(79, 124, 255, 0.35);
|
||||
margin-bottom: 16px;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.brand-icon:active {
|
||||
transform: scale(0.92) rotate(-5deg);
|
||||
}
|
||||
|
||||
.brand-icon-text {
|
||||
color: #ffffff;
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
color: #0b1a33;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.brand-subtitle {
|
||||
font-size: 14px;
|
||||
color: #6b7d94;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
登录卡片
|
||||
============================================ */
|
||||
.login-card {
|
||||
width: 100%;
|
||||
background: #ffffff;
|
||||
border-radius: 20px;
|
||||
padding: 32px 24px;
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.06);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.login-card:focus-within {
|
||||
box-shadow: 0 8px 40px rgba(79, 124, 255, 0.1);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.card-desc {
|
||||
font-size: 13px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
表单
|
||||
============================================ */
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.login-input {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
min-height: 48px;
|
||||
padding: 0 16px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
font-size: 16px;
|
||||
color: #1e293b;
|
||||
background: #f8fafc;
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
line-height: 48px;
|
||||
transition: border-color 0.2s, background 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.login-input:focus {
|
||||
border-color: #4f7cff;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 0 0 3px rgba(79, 124, 255, 0.1);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
错误消息 - 抖动动画
|
||||
============================================ */
|
||||
.error-msg {
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 10px;
|
||||
padding: 10px 14px;
|
||||
margin-bottom: 16px;
|
||||
animation: errorShake 0.4s ease-out;
|
||||
}
|
||||
|
||||
@keyframes errorShake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
20% { transform: translateX(-6px); }
|
||||
40% { transform: translateX(6px); }
|
||||
60% { transform: translateX(-3px); }
|
||||
80% { transform: translateX(3px); }
|
||||
}
|
||||
|
||||
.error-msg text {
|
||||
font-size: 13px;
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
登录按钮
|
||||
============================================ */
|
||||
.btn-login {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
background: linear-gradient(145deg, #4f7cff, #3b5fd9);
|
||||
color: #ffffff;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 8px;
|
||||
box-shadow: 0 6px 18px rgba(79, 124, 255, 0.3);
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.btn-login::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(135deg, rgba(255,255,255,0.2) 0%, transparent 60%);
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.btn-login:active::after { opacity: 1; }
|
||||
|
||||
.btn-login:active {
|
||||
transform: scale(0.97);
|
||||
box-shadow: 0 3px 10px rgba(79, 124, 255, 0.25);
|
||||
}
|
||||
|
||||
.btn-login.is-loading {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.btn-login .btn-spinner {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
底部提示
|
||||
============================================ */
|
||||
.footer-hint {
|
||||
font-size: 12px;
|
||||
color: #c0c8d4;
|
||||
margin-top: 32px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
</style>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.9 KiB |
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* 教练端认证状态管理
|
||||
* 风格参考 gym-manage-cuit 的 stores/auth.ts
|
||||
*/
|
||||
|
||||
import { reactive } from 'vue'
|
||||
import { login as loginApi } from '@/api/auth.js'
|
||||
import { TOKEN_KEY, USER_INFO_KEY, setToken, setRefreshToken, clearAuth } from '@/utils/request.js'
|
||||
|
||||
// 使用 reactive 在 Vue3 Uniapp 中做简易状态管理
|
||||
const state = reactive({
|
||||
accessToken: uni.getStorageSync(TOKEN_KEY) || '',
|
||||
userInfo: uni.getStorageSync(USER_INFO_KEY) ? JSON.parse(uni.getStorageSync(USER_INFO_KEY)) : null,
|
||||
isLoggedIn: !!uni.getStorageSync(TOKEN_KEY)
|
||||
})
|
||||
|
||||
function saveUserInfo(info) {
|
||||
state.userInfo = info
|
||||
uni.setStorageSync(USER_INFO_KEY, JSON.stringify(info))
|
||||
}
|
||||
|
||||
async function login(username, password) {
|
||||
const res = await loginApi({ username, password })
|
||||
// 兼容两种响应格式:{ code, message, data } 包裹 或 直接返回 login data
|
||||
const data = res.data || res
|
||||
state.accessToken = data.accessToken
|
||||
state.userInfo = data.userInfo
|
||||
state.isLoggedIn = true
|
||||
|
||||
setToken(data.accessToken)
|
||||
setRefreshToken(data.refreshToken)
|
||||
saveUserInfo(data.userInfo)
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
function logout() {
|
||||
state.accessToken = ''
|
||||
state.userInfo = null
|
||||
state.isLoggedIn = false
|
||||
clearAuth()
|
||||
}
|
||||
|
||||
function getCoachId() {
|
||||
return state.userInfo?.id || null
|
||||
}
|
||||
|
||||
export function useAuthStore() {
|
||||
return {
|
||||
state,
|
||||
login,
|
||||
logout,
|
||||
getCoachId,
|
||||
saveUserInfo
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
uni.addInterceptor({
|
||||
returnValue (res) {
|
||||
if (!(!!res && (typeof res === "object" || typeof res === "function") && typeof res.then === "function")) {
|
||||
return res;
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
res.then((res) => {
|
||||
if (!res) return resolve(res)
|
||||
return res[0] ? reject(res[0]) : resolve(res[1])
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* uni-app内置样式变量 - 教练端定制
|
||||
* 基于 gym-manage-cuit 蓝色系风格
|
||||
*/
|
||||
|
||||
/* 行为相关颜色 */
|
||||
$uni-color-primary: #4f7cff;
|
||||
$uni-color-success: #10b981;
|
||||
$uni-color-warning: #f59e0b;
|
||||
$uni-color-error: #ef4444;
|
||||
|
||||
/* 文字颜色 */
|
||||
$uni-text-color: #1e293b;
|
||||
$uni-text-color-inverse: #ffffff;
|
||||
$uni-text-color-grey: #94a3b8;
|
||||
$uni-text-color-placeholder: #a0aec0;
|
||||
$uni-text-color-disable: #cbd5e1;
|
||||
|
||||
/* 背景颜色 */
|
||||
$uni-bg-color: #ffffff;
|
||||
$uni-bg-color-grey: #f6f8fc;
|
||||
$uni-bg-color-hover: #eef3ff;
|
||||
$uni-bg-color-mask: rgba(0, 0, 0, 0.4);
|
||||
|
||||
/* 边框颜色 */
|
||||
$uni-border-color: #e2e8f0;
|
||||
|
||||
/* 尺寸 */
|
||||
$uni-font-size-sm: 12px;
|
||||
$uni-font-size-base: 14px;
|
||||
$uni-font-size-lg: 16px;
|
||||
|
||||
$uni-img-size-sm: 20px;
|
||||
$uni-img-size-base: 28px;
|
||||
$uni-img-size-lg: 40px;
|
||||
|
||||
/* 间距 */
|
||||
$uni-spacing-col-base: 16px;
|
||||
$uni-spacing-row-base: 12px;
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* 工具函数
|
||||
*/
|
||||
|
||||
/**
|
||||
* 格式化日期时间
|
||||
*/
|
||||
export function formatDateTime(dateStr, showTime = true) {
|
||||
if (!dateStr) return '--'
|
||||
const d = new Date(dateStr)
|
||||
const pad = (n) => String(n).padStart(2, '0')
|
||||
const date = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
||||
if (!showTime) return date
|
||||
return `${date} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化日期
|
||||
*/
|
||||
export function formatDate(dateStr) {
|
||||
return formatDateTime(dateStr, false)
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化时间
|
||||
*/
|
||||
export function formatTime(dateStr) {
|
||||
if (!dateStr) return '--'
|
||||
const d = new Date(dateStr)
|
||||
const pad = (n) => String(n).padStart(2, '0')
|
||||
return `${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化时间(短格式:MM/DD HH:mm)
|
||||
*/
|
||||
export function formatTimeShort(dateStr) {
|
||||
if (!dateStr) return '--'
|
||||
const d = new Date(dateStr)
|
||||
const pad = (n) => String(n).padStart(2, '0')
|
||||
return `${d.getMonth() + 1}/${d.getDate()} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 课程状态(由后端 GET /api/groupCourse/statuses 统一提供,避免前后端各自硬编码)
|
||||
*/
|
||||
let _statusMap = null
|
||||
let _statusClassMap = null
|
||||
|
||||
const DEFAULT_STATUS_MAP = { '0': '正常', '1': '已取消', '2': '已结束', '3': '进行中', '4': '超时' }
|
||||
const DEFAULT_STATUS_CLASS = { '0': 'green', '1': 'orange', '2': 'gray', '3': 'blue', '4': 'red' }
|
||||
|
||||
/**
|
||||
* 从后端加载课程状态映射,返回 Promise<boolean>
|
||||
*/
|
||||
export async function loadCourseStatuses() {
|
||||
try {
|
||||
const res = await uni.request({
|
||||
url: `${getBaseUrl()}/api/groupCourse/statuses`,
|
||||
method: 'GET'
|
||||
})
|
||||
if (res.statusCode === 200 && res.data?.success && Array.isArray(res.data.data)) {
|
||||
const map = {}
|
||||
const cls = {}
|
||||
res.data.data.forEach(item => {
|
||||
map[String(item.dbValue)] = item.label
|
||||
cls[String(item.dbValue)] = item.cssClass
|
||||
})
|
||||
_statusMap = map
|
||||
_statusClassMap = cls
|
||||
return true
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('加载课程状态映射失败,使用默认映射:', e)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function getBaseUrl() {
|
||||
// #ifdef H5
|
||||
return '/gym-api'
|
||||
// #endif
|
||||
// #ifndef H5
|
||||
return 'http://localhost:8084'
|
||||
// #endif
|
||||
}
|
||||
|
||||
function getStatusMap() {
|
||||
return _statusMap || DEFAULT_STATUS_MAP
|
||||
}
|
||||
|
||||
function getStatusClassMap() {
|
||||
return _statusClassMap || DEFAULT_STATUS_CLASS
|
||||
}
|
||||
|
||||
export function getCourseStatusLabel(status, deletedAt) {
|
||||
if (deletedAt) return '已删除'
|
||||
return getStatusMap()[String(status)] || '未知'
|
||||
}
|
||||
|
||||
export function getCourseStatusClass(status, deletedAt) {
|
||||
if (deletedAt) return 'tag-gray'
|
||||
return 'tag-' + (getStatusClassMap()[String(status)] || 'gray')
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断课程是否有实际时间记录
|
||||
*/
|
||||
export function hasActualTime(course) {
|
||||
return !!(course && (course.actualStartTime || course.actualEndTime))
|
||||
}
|
||||
|
||||
/**
|
||||
* Toast 提示
|
||||
*/
|
||||
export function showToast(title, icon = 'none') {
|
||||
uni.showToast({ title, icon, duration: 2000 })
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认弹窗
|
||||
*/
|
||||
export function showConfirm(content, title = '提示') {
|
||||
return new Promise((resolve) => {
|
||||
uni.showModal({
|
||||
title,
|
||||
content,
|
||||
success: (res) => {
|
||||
resolve(res.confirm)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* HTTP请求封装 - 基于 uni.request
|
||||
* 风格参考 gym-manage-cuit 的 request.ts
|
||||
*/
|
||||
|
||||
// 后端API基础地址
|
||||
// H5端: 使用 /gym-api 前缀走vite代理(避免与源码 api/ 目录冲突),代理重写为 /api
|
||||
// App端: 直接请求后端域名
|
||||
// #ifdef H5
|
||||
const BASE_URL = '/gym-api'
|
||||
// #endif
|
||||
// #ifndef H5
|
||||
const BASE_URL = 'http://localhost:8084'
|
||||
// #endif
|
||||
|
||||
// token存储key
|
||||
const TOKEN_KEY = 'coach_access_token'
|
||||
const REFRESH_TOKEN_KEY = 'coach_refresh_token'
|
||||
const USER_INFO_KEY = 'coach_user_info'
|
||||
|
||||
function getToken() {
|
||||
return uni.getStorageSync(TOKEN_KEY) || ''
|
||||
}
|
||||
|
||||
function getRefreshToken() {
|
||||
return uni.getStorageSync(REFRESH_TOKEN_KEY) || ''
|
||||
}
|
||||
|
||||
function setToken(token) {
|
||||
uni.setStorageSync(TOKEN_KEY, token)
|
||||
}
|
||||
|
||||
function setRefreshToken(token) {
|
||||
uni.setStorageSync(REFRESH_TOKEN_KEY, token)
|
||||
}
|
||||
|
||||
function clearAuth() {
|
||||
uni.removeStorageSync(TOKEN_KEY)
|
||||
uni.removeStorageSync(REFRESH_TOKEN_KEY)
|
||||
uni.removeStorageSync(USER_INFO_KEY)
|
||||
}
|
||||
|
||||
/**
|
||||
* 发起请求
|
||||
*/
|
||||
function request(options) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const token = getToken()
|
||||
const header = {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.header || {})
|
||||
}
|
||||
|
||||
if (token) {
|
||||
header['Authorization'] = `Bearer ${token}`
|
||||
}
|
||||
|
||||
uni.request({
|
||||
url: BASE_URL + options.url,
|
||||
method: options.method || 'GET',
|
||||
data: options.data || {},
|
||||
header: header,
|
||||
timeout: options.timeout || 15000,
|
||||
success: (res) => {
|
||||
const statusCode = res.statusCode
|
||||
if (statusCode >= 200 && statusCode < 300) {
|
||||
resolve(res.data)
|
||||
} else if (statusCode === 401) {
|
||||
// token过期,清除登录状态
|
||||
const isLoginRequest = options.url?.includes('/auth/login')
|
||||
if (!isLoginRequest) {
|
||||
clearAuth()
|
||||
uni.reLaunch({ url: '/pages/login/login' })
|
||||
}
|
||||
reject(res)
|
||||
} else {
|
||||
reject(res)
|
||||
}
|
||||
},
|
||||
fail: (err) => {
|
||||
uni.showToast({
|
||||
title: '网络请求失败',
|
||||
icon: 'none',
|
||||
duration: 2000
|
||||
})
|
||||
reject(err)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// 导出便捷方法
|
||||
request.get = function(url, params = {}) {
|
||||
let query = ''
|
||||
if (Object.keys(params).length > 0) {
|
||||
const parts = []
|
||||
for (const key in params) {
|
||||
if (params[key] !== undefined && params[key] !== null && params[key] !== '') {
|
||||
parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(params[key])}`)
|
||||
}
|
||||
}
|
||||
if (parts.length > 0) {
|
||||
query = '?' + parts.join('&')
|
||||
}
|
||||
}
|
||||
return request({ url: url + query, method: 'GET' })
|
||||
}
|
||||
|
||||
request.post = function(url, data = {}) {
|
||||
return request({ url, method: 'POST', data })
|
||||
}
|
||||
|
||||
request.put = function(url, data = {}) {
|
||||
return request({ url, method: 'PUT', data })
|
||||
}
|
||||
|
||||
request.delete = function(url, data = {}) {
|
||||
return request({ url, method: 'DELETE', data })
|
||||
}
|
||||
|
||||
export { request, getToken, setToken, setRefreshToken, clearAuth, TOKEN_KEY, USER_INFO_KEY }
|
||||
export default request
|
||||
@@ -0,0 +1,17 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import uni from '@dcloudio/vite-plugin-uni'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [uni()],
|
||||
server: {
|
||||
port: 8081,
|
||||
proxy: {
|
||||
'/gym-api': {
|
||||
target: 'http://localhost:8084',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
rewrite: (path) => path.replace(/^\/gym-api/, '')
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
coverage
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
*.tsbuildinfo
|
||||
|
||||
.eslintcache
|
||||
|
||||
# Cypress
|
||||
/cypress/videos/
|
||||
/cypress/screenshots/
|
||||
|
||||
# Vitest
|
||||
__screenshots__/
|
||||
|
||||
# Vite
|
||||
*.timestamp-*-*.mjs
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/prettierrc",
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"printWidth": 100
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"Vue.volar",
|
||||
"esbenp.prettier-vscode"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
# gym-manage-cuit
|
||||
|
||||
This template should help get you started developing with Vue 3 in Vite.
|
||||
|
||||
## Recommended IDE Setup
|
||||
|
||||
[VS Code](https://code.visualstudio.com/) + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
|
||||
|
||||
## Recommended Browser Setup
|
||||
|
||||
- Chromium-based browsers (Chrome, Edge, Brave, etc.):
|
||||
- [Vue.js devtools](https://chromewebstore.google.com/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd)
|
||||
- [Turn on Custom Object Formatter in Chrome DevTools](http://bit.ly/object-formatters)
|
||||
- Firefox:
|
||||
- [Vue.js devtools](https://addons.mozilla.org/en-US/firefox/addon/vue-js-devtools/)
|
||||
- [Turn on Custom Object Formatter in Firefox DevTools](https://fxdx.dev/firefox-devtools-custom-object-formatters/)
|
||||
|
||||
## Type Support for `.vue` Imports in TS
|
||||
|
||||
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types.
|
||||
|
||||
## Customize configuration
|
||||
|
||||
See [Vite Configuration Reference](https://vite.dev/config/).
|
||||
|
||||
## Project Setup
|
||||
|
||||
```sh
|
||||
pnpm install
|
||||
```
|
||||
|
||||
### Compile and Hot-Reload for Development
|
||||
|
||||
```sh
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
### Type-Check, Compile and Minify for Production
|
||||
|
||||
```sh
|
||||
pnpm build
|
||||
```
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,802 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Soybean 风格 · 后台管理示范</title>
|
||||
<!-- Font Awesome 图标库 (免费版本) -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css" />
|
||||
<style>
|
||||
/* ===== 全局重置 & 字体 ===== */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||
background: #f6f8fc;
|
||||
color: #1e293b;
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* ===== 侧边栏 (Soybean 风格) ===== */
|
||||
.sidebar {
|
||||
width: 260px;
|
||||
background: #ffffff;
|
||||
border-right: 1px solid #e9edf4;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 24px 16px;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
z-index: 100;
|
||||
transition: all 0.2s;
|
||||
box-shadow: 0 0 0 1px rgba(0,0,0,0.02);
|
||||
}
|
||||
|
||||
/* Logo / 品牌区 */
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 0 8px 28px 8px;
|
||||
border-bottom: 1px solid #f0f3f8;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.brand-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: linear-gradient(145deg, #4f7cff, #3b5fd9);
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 6px 12px rgba(79, 124, 255, 0.25);
|
||||
}
|
||||
|
||||
.brand-text {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.3px;
|
||||
color: #0b1a33;
|
||||
}
|
||||
|
||||
.brand-text span {
|
||||
color: #4f7cff;
|
||||
}
|
||||
|
||||
/* 导航菜单 */
|
||||
.nav {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.nav-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: #8b9ab0;
|
||||
padding: 16px 12px 6px 12px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 12px;
|
||||
color: #3e4e62;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
transition: all 0.15s;
|
||||
cursor: default;
|
||||
text-decoration: none;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.nav-item i {
|
||||
width: 20px;
|
||||
font-size: 16px;
|
||||
color: #5b6f88;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background: #f0f5ff;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.nav-item:hover i {
|
||||
color: #4f7cff;
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background: #eef3ff;
|
||||
color: #1e293b;
|
||||
font-weight: 600;
|
||||
box-shadow: inset 3px 0 0 #4f7cff;
|
||||
}
|
||||
|
||||
.nav-item.active i {
|
||||
color: #4f7cff;
|
||||
}
|
||||
|
||||
.nav-item .badge {
|
||||
margin-left: auto;
|
||||
background: #4f7cff;
|
||||
color: white;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 2px 10px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.nav-item .badge.green {
|
||||
background: #10b981;
|
||||
}
|
||||
|
||||
/* 底部用户卡片 */
|
||||
.user-card {
|
||||
margin-top: 20px;
|
||||
padding: 16px 14px;
|
||||
background: #fafcff;
|
||||
border-radius: 16px;
|
||||
border: 1px solid #eef2f8;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
background: linear-gradient(135deg, #d9e2f0, #c8d3e6);
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
font-size: 18px;
|
||||
color: #1e2f44;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.user-info .name {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.user-info .role {
|
||||
font-size: 12px;
|
||||
color: #7a8b9f;
|
||||
}
|
||||
|
||||
/* ===== 主内容 ===== */
|
||||
.main {
|
||||
margin-left: 260px;
|
||||
flex: 1;
|
||||
padding: 28px 36px 36px 36px;
|
||||
min-height: 100vh;
|
||||
background: #f6f8fc;
|
||||
}
|
||||
|
||||
/* 头部 */
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 32px;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.header-title h1 {
|
||||
font-size: 26px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.4px;
|
||||
color: #0b1a33;
|
||||
}
|
||||
|
||||
.header-title p {
|
||||
color: #6b7d94;
|
||||
font-size: 14px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.header-actions .search-box {
|
||||
background: white;
|
||||
padding: 8px 16px;
|
||||
border-radius: 40px;
|
||||
border: 1px solid #e2e8f0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
transition: 0.15s;
|
||||
}
|
||||
|
||||
.header-actions .search-box i {
|
||||
color: #8b9ab0;
|
||||
}
|
||||
|
||||
.header-actions .search-box input {
|
||||
border: none;
|
||||
outline: none;
|
||||
font-size: 14px;
|
||||
background: transparent;
|
||||
width: 170px;
|
||||
}
|
||||
|
||||
.header-actions .search-box:focus-within {
|
||||
border-color: #4f7cff;
|
||||
box-shadow: 0 0 0 3px rgba(79, 124, 255, 0.15);
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 9px 20px;
|
||||
border-radius: 40px;
|
||||
border: none;
|
||||
background: white;
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
color: #1e293b;
|
||||
border: 1px solid #e2e8f0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: default;
|
||||
transition: 0.15s;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #4f7cff;
|
||||
border: 1px solid #4f7cff;
|
||||
color: white;
|
||||
box-shadow: 0 4px 10px rgba(79, 124, 255, 0.25);
|
||||
}
|
||||
|
||||
.btn-primary i {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #3f6ae0;
|
||||
border-color: #3f6ae0;
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
background: transparent;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
/* 统计卡片网格 */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 20px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: white;
|
||||
padding: 20px 22px;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.02);
|
||||
border: 1px solid #edf2f9;
|
||||
transition: 0.15s;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
border-color: #d0dcec;
|
||||
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
.stat-card .label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #6b7d94;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
.stat-card .value {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: #0b1a33;
|
||||
margin: 8px 0 4px;
|
||||
}
|
||||
|
||||
.stat-card .change {
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.stat-card .change.down {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.stat-card .change i {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* 图表 & 表格卡片混合区域 */
|
||||
.content-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1.6fr 1fr;
|
||||
gap: 24px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: 20px;
|
||||
border: 1px solid #edf2f9;
|
||||
padding: 22px 24px;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.card-header h3 {
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
color: #0b1a33;
|
||||
}
|
||||
|
||||
.card-header a {
|
||||
color: #4f7cff;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.chart-placeholder {
|
||||
width: 100%;
|
||||
height: 140px;
|
||||
background: #f7faff;
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-around;
|
||||
padding: 12px 6px 0 6px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.bar {
|
||||
width: 28px;
|
||||
background: #4f7cff;
|
||||
border-radius: 8px 8px 4px 4px;
|
||||
height: calc(30% + 20% * var(--h, 0.5));
|
||||
transition: 0.2s;
|
||||
min-height: 12px;
|
||||
}
|
||||
|
||||
.bar:nth-child(1) { --h: 0.6; height: 60%; }
|
||||
.bar:nth-child(2) { --h: 0.9; height: 90%; }
|
||||
.bar:nth-child(3) { --h: 0.4; height: 40%; }
|
||||
.bar:nth-child(4) { --h: 0.75; height: 75%; }
|
||||
.bar:nth-child(5) { --h: 0.5; height: 50%; }
|
||||
.bar:nth-child(6) { --h: 0.85; height: 85%; }
|
||||
.bar:nth-child(7) { --h: 0.65; height: 65%; }
|
||||
.bar:nth-child(8) { --h: 0.45; height: 45%; }
|
||||
|
||||
.bar.alt {
|
||||
background: #d9e3f8;
|
||||
}
|
||||
|
||||
.bar.alt:nth-child(2) { --h: 0.7; height: 70%; }
|
||||
.bar.alt:nth-child(5) { --h: 0.55; height: 55%; }
|
||||
|
||||
/* 待办 / 活动列表 */
|
||||
.activity-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.activity-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid #f1f5fa;
|
||||
}
|
||||
|
||||
.activity-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.activity-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 10px;
|
||||
background: #4f7cff;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.activity-dot.green { background: #10b981; }
|
||||
.activity-dot.orange { background: #f59e0b; }
|
||||
.activity-dot.pink { background: #ec4899; }
|
||||
|
||||
.activity-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.activity-content .title {
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.activity-content .time {
|
||||
font-size: 12px;
|
||||
color: #7b8da3;
|
||||
}
|
||||
|
||||
/* 表格区域 (全宽) */
|
||||
.table-wrapper {
|
||||
background: white;
|
||||
border-radius: 20px;
|
||||
border: 1px solid #edf2f9;
|
||||
padding: 20px 24px 8px 24px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.table-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.table-header h3 {
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
padding: 12px 8px 12px 0;
|
||||
font-weight: 600;
|
||||
color: #4e627c;
|
||||
border-bottom: 1px solid #eef2f8;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 14px 8px 14px 0;
|
||||
border-bottom: 1px solid #f1f5fa;
|
||||
color: #1e2f44;
|
||||
}
|
||||
|
||||
tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
background: #e6f0ff;
|
||||
color: #4f7cff;
|
||||
padding: 4px 12px;
|
||||
border-radius: 40px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.status-badge.green {
|
||||
background: #e0f5ec;
|
||||
color: #0b8b5e;
|
||||
}
|
||||
|
||||
.status-badge.orange {
|
||||
background: #fff3e0;
|
||||
color: #b26e00;
|
||||
}
|
||||
|
||||
/* ===== 响应式 ===== */
|
||||
@media (max-width: 1024px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
.content-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.sidebar {
|
||||
width: 72px;
|
||||
padding: 16px 8px;
|
||||
}
|
||||
.brand-text, .nav-label, .nav-item span:not(.badge), .user-info, .badge {
|
||||
display: none;
|
||||
}
|
||||
.nav-item {
|
||||
justify-content: center;
|
||||
padding: 12px;
|
||||
}
|
||||
.nav-item i {
|
||||
font-size: 20px;
|
||||
margin: 0;
|
||||
}
|
||||
.user-card {
|
||||
justify-content: center;
|
||||
padding: 8px;
|
||||
}
|
||||
.user-avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.main {
|
||||
margin-left: 72px;
|
||||
padding: 20px 16px;
|
||||
}
|
||||
.header-title h1 {
|
||||
font-size: 22px;
|
||||
}
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
.stat-card {
|
||||
padding: 16px;
|
||||
}
|
||||
.stat-card .value {
|
||||
font-size: 22px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.header-actions .search-box input {
|
||||
width: 100px;
|
||||
}
|
||||
.btn span {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- ===== 侧边栏 ===== -->
|
||||
<aside class="sidebar">
|
||||
<div class="brand">
|
||||
<div class="brand-icon">S</div>
|
||||
<div class="brand-text">Soy<span>bean</span></div>
|
||||
</div>
|
||||
<nav class="nav">
|
||||
<div class="nav-label">概览</div>
|
||||
<a class="nav-item active" href="#">
|
||||
<i class="fas fa-chart-simple"></i>
|
||||
<span>仪表盘</span>
|
||||
</a>
|
||||
<a class="nav-item" href="#">
|
||||
<i class="fas fa-chart-pie"></i>
|
||||
<span>分析</span>
|
||||
</a>
|
||||
<div class="nav-label">管理</div>
|
||||
<a class="nav-item" href="#">
|
||||
<i class="fas fa-users"></i>
|
||||
<span>用户</span>
|
||||
<span class="badge">12</span>
|
||||
</a>
|
||||
<a class="nav-item" href="#">
|
||||
<i class="fas fa-shopping-bag"></i>
|
||||
<span>订单</span>
|
||||
<span class="badge green">8</span>
|
||||
</a>
|
||||
<a class="nav-item" href="#">
|
||||
<i class="fas fa-box"></i>
|
||||
<span>产品</span>
|
||||
</a>
|
||||
<a class="nav-item" href="#">
|
||||
<i class="fas fa-gear"></i>
|
||||
<span>设置</span>
|
||||
</a>
|
||||
</nav>
|
||||
<div class="user-card">
|
||||
<div class="user-avatar">JD</div>
|
||||
<div class="user-info">
|
||||
<div class="name">John Doe</div>
|
||||
<div class="role">管理员</div>
|
||||
</div>
|
||||
<i class="fas fa-ellipsis-v" style="color:#8b9ab0; cursor:default;"></i>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- ===== 主内容 ===== -->
|
||||
<main class="main">
|
||||
<!-- 头部 -->
|
||||
<header class="header">
|
||||
<div class="header-title">
|
||||
<h1>仪表盘</h1>
|
||||
<p>欢迎回来,这是您的数据概览</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<div class="search-box">
|
||||
<i class="fas fa-search"></i>
|
||||
<input type="text" placeholder="搜索..." />
|
||||
</div>
|
||||
<button class="btn btn-primary"><i class="fas fa-plus"></i><span>新建</span></button>
|
||||
<button class="btn btn-outline"><i class="fas fa-bell"></i></button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 统计卡片 -->
|
||||
<section class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="label">总用户</div>
|
||||
<div class="value">12,486</div>
|
||||
<div class="change"><i class="fas fa-arrow-up"></i> 12.5%</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">总收入</div>
|
||||
<div class="value">$84,392</div>
|
||||
<div class="change"><i class="fas fa-arrow-up"></i> 8.2%</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">订单量</div>
|
||||
<div class="value">3,211</div>
|
||||
<div class="change down"><i class="fas fa-arrow-down"></i> 2.1%</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">转化率</div>
|
||||
<div class="value">24.8%</div>
|
||||
<div class="change"><i class="fas fa-arrow-up"></i> 4.3%</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 图表 + 活动 -->
|
||||
<section class="content-grid">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-chart-line" style="margin-right:8px; color:#4f7cff;"></i> 周趋势</h3>
|
||||
<a href="#">查看详情</a>
|
||||
</div>
|
||||
<div class="chart-placeholder">
|
||||
<div class="bar"></div>
|
||||
<div class="bar alt"></div>
|
||||
<div class="bar"></div>
|
||||
<div class="bar alt"></div>
|
||||
<div class="bar"></div>
|
||||
<div class="bar alt"></div>
|
||||
<div class="bar"></div>
|
||||
<div class="bar alt"></div>
|
||||
</div>
|
||||
<div style="display:flex; justify-content:space-between; font-size:12px; color:#7b8da3; margin-top:8px; padding:0 4px;">
|
||||
<span>一</span><span>二</span><span>三</span><span>四</span><span>五</span><span>六</span><span>日</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-clock" style="margin-right:8px; color:#4f7cff;"></i> 最近动态</h3>
|
||||
<a href="#">全部</a>
|
||||
</div>
|
||||
<div class="activity-list">
|
||||
<div class="activity-item">
|
||||
<span class="activity-dot green"></span>
|
||||
<div class="activity-content">
|
||||
<div class="title">新用户注册</div>
|
||||
<div class="time">15分钟前</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="activity-item">
|
||||
<span class="activity-dot orange"></span>
|
||||
<div class="activity-content">
|
||||
<div class="title">订单 #10234 已完成</div>
|
||||
<div class="time">1小时前</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="activity-item">
|
||||
<span class="activity-dot pink"></span>
|
||||
<div class="activity-content">
|
||||
<div class="title">系统更新 v2.5.0</div>
|
||||
<div class="time">3小时前</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="activity-item">
|
||||
<span class="activity-dot"></span>
|
||||
<div class="activity-content">
|
||||
<div class="title">新评论:产品 X</div>
|
||||
<div class="time">5小时前</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 表格 -->
|
||||
<div class="table-wrapper">
|
||||
<div class="table-header">
|
||||
<h3><i class="fas fa-list-ul" style="margin-right:8px; color:#4f7cff;"></i> 最新订单</h3>
|
||||
<a href="#" style="color:#4f7cff; font-size:13px; font-weight:500; text-decoration:none;">管理订单</a>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>订单号</th>
|
||||
<th>客户</th>
|
||||
<th>金额</th>
|
||||
<th>状态</th>
|
||||
<th>日期</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><strong>#10234</strong></td>
|
||||
<td>张 伟</td>
|
||||
<td>$129.00</td>
|
||||
<td><span class="status-badge green">已完成</span></td>
|
||||
<td>2026-06-15</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>#10233</strong></td>
|
||||
<td>李 娜</td>
|
||||
<td>$89.50</td>
|
||||
<td><span class="status-badge">处理中</span></td>
|
||||
<td>2026-06-15</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>#10232</strong></td>
|
||||
<td>王 强</td>
|
||||
<td>$245.00</td>
|
||||
<td><span class="status-badge orange">待付款</span></td>
|
||||
<td>2026-06-14</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>#10231</strong></td>
|
||||
<td>陈 丽</td>
|
||||
<td>$54.90</td>
|
||||
<td><span class="status-badge green">已完成</span></td>
|
||||
<td>2026-06-14</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div style="padding:16px 0 8px 0; font-size:13px; color:#7b8da3; text-align:right;">
|
||||
显示 4 条 / 共 23 条
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部留白 -->
|
||||
<div style="height:20px;"></div>
|
||||
</main>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>GymManage 后台管理</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "gym-manage-cuit",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "run-p type-check \"build-only {@}\" --",
|
||||
"preview": "vite preview",
|
||||
"build-only": "vite build",
|
||||
"type-check": "vue-tsc --build",
|
||||
"format": "prettier --write --experimental-cli src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"ali-oss": "^6.23.0",
|
||||
"axios": "^1.18.0",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.32",
|
||||
"vue-router": "^5.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/node24": "^24.0.4",
|
||||
"@types/node": "^24.12.2",
|
||||
"@vitejs/plugin-vue": "^6.0.6",
|
||||
"@vue/tsconfig": "^0.9.1",
|
||||
"npm-run-all2": "^8.0.4",
|
||||
"prettier": "3.8.3",
|
||||
"typescript": "~6.0.0",
|
||||
"vite": "^8.0.8",
|
||||
"vite-plugin-vue-devtools": "^8.1.1",
|
||||
"vue-tsc": "^3.2.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
}
|
||||
Generated
+2844
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
import { onErrorCaptured, ref } from 'vue'
|
||||
|
||||
const hasError = ref(false)
|
||||
|
||||
onErrorCaptured((err) => {
|
||||
console.error('应用异常:', err)
|
||||
// 清除可能损坏的 localStorage 数据并跳转登录页
|
||||
localStorage.clear()
|
||||
window.location.href = '/login'
|
||||
return false
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<router-view v-if="!hasError" />
|
||||
<div v-else class="error-fallback">
|
||||
<p>页面出现错误,正在跳转登录页...</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
@import url('https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css');
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial,
|
||||
sans-serif;
|
||||
background: #f6f8fc;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
#app {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.error-fallback {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
font-size: 16px;
|
||||
color: #6b7d94;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,42 @@
|
||||
import request from './request'
|
||||
|
||||
export interface LoginParams {
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface UserInfo {
|
||||
id: number
|
||||
username: string
|
||||
email: string
|
||||
phone: string
|
||||
nickname: string
|
||||
status: number
|
||||
roleId: number
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
accessToken: string
|
||||
refreshToken: string
|
||||
tokenType: string
|
||||
expiresIn: number
|
||||
userInfo: UserInfo
|
||||
}
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
code: number
|
||||
message: string
|
||||
data: T
|
||||
}
|
||||
|
||||
export function login(params: LoginParams): Promise<ApiResponse<LoginResponse>> {
|
||||
return request.post('/api/auth/login', { ...params, target: 'admin' })
|
||||
}
|
||||
|
||||
export function getUserInfo(id: number): Promise<ApiResponse<UserInfo>> {
|
||||
return request.get(`/api/auth/users/${id}`)
|
||||
}
|
||||
|
||||
export function verifyPassword(params: LoginParams): Promise<ApiResponse<LoginResponse>> {
|
||||
return request.post('/api/auth/login', params)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import request from './request'
|
||||
import type { GroupCourse } from './groupCourse'
|
||||
|
||||
export interface CoachInfo {
|
||||
id: number
|
||||
username: string
|
||||
nickname: string
|
||||
email: string
|
||||
phone: string
|
||||
avatar: string | null
|
||||
status: number
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
/** 获取所有教练用户列表(店长/管理员用) */
|
||||
export function getCoachList(): Promise<CoachInfo[]> {
|
||||
return request.get('/api/users/role/coach')
|
||||
}
|
||||
|
||||
/** 获取当前登录教练自己教授的团课(教练自用) */
|
||||
export function getCoachCourses(): Promise<GroupCourse[]> {
|
||||
return request.get('/api/coach/courses')
|
||||
}
|
||||
|
||||
/** 根据教练ID查询该教练教授的所有团课(店长/管理员用) */
|
||||
export function getCoachCoursesById(coachId: number): Promise<GroupCourse[]> {
|
||||
return request.get(`/api/coach/${coachId}/courses`)
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import request from './request'
|
||||
|
||||
export interface MemberStatistics {
|
||||
statDate: string
|
||||
newMembers: number
|
||||
activeMembers: number
|
||||
totalMembers: number
|
||||
signInMembers: number
|
||||
bookingMembers: number
|
||||
cancelMembers: number
|
||||
}
|
||||
|
||||
export interface BookingStatistics {
|
||||
statDate: string
|
||||
newBookings: number
|
||||
cancelBookings: number
|
||||
attendBookings: number
|
||||
absentBookings: number
|
||||
attendanceRate: number
|
||||
cancelRate: number
|
||||
}
|
||||
|
||||
export interface SignInStatistics {
|
||||
statDate: string
|
||||
totalSignIns: number
|
||||
successSignIns: number
|
||||
failSignIns: number
|
||||
successRate: number
|
||||
signInTypeDistribution: Record<string, number>
|
||||
}
|
||||
|
||||
export interface StatisticsSummary {
|
||||
statDate: string
|
||||
generatedAt: string
|
||||
memberStatistics: MemberStatistics
|
||||
bookingStatistics: BookingStatistics
|
||||
signInStatistics: SignInStatistics
|
||||
}
|
||||
|
||||
export function getSummary(params?: {
|
||||
statType?: string
|
||||
periodType?: string
|
||||
startTime?: string
|
||||
endTime?: string
|
||||
}): Promise<StatisticsSummary> {
|
||||
return request.get('/api/datacount/summary', { params })
|
||||
}
|
||||
|
||||
export function getMemberStatistics(params?: {
|
||||
periodType?: string
|
||||
startTime?: string
|
||||
endTime?: string
|
||||
}): Promise<MemberStatistics> {
|
||||
return request.get('/api/datacount/member', { params })
|
||||
}
|
||||
|
||||
export function getBookingStatistics(params?: {
|
||||
periodType?: string
|
||||
startTime?: string
|
||||
endTime?: string
|
||||
}): Promise<BookingStatistics> {
|
||||
return request.get('/api/datacount/booking', { params })
|
||||
}
|
||||
|
||||
export interface MemberSignInDetail {
|
||||
memberId: number
|
||||
memberName: string
|
||||
memberPhone: string
|
||||
totalSignIns: number
|
||||
successSignIns: number
|
||||
failedSignIns: number
|
||||
lastSignInTime: string
|
||||
}
|
||||
|
||||
export interface MemberSignInResponse {
|
||||
statDate: string
|
||||
total: number
|
||||
page: number
|
||||
size: number
|
||||
list: MemberSignInDetail[]
|
||||
}
|
||||
|
||||
export function getSignInStatistics(params?: {
|
||||
periodType?: string
|
||||
startTime?: string
|
||||
endTime?: string
|
||||
}): Promise<SignInStatistics> {
|
||||
return request.get('/api/datacount/signin', { params })
|
||||
}
|
||||
|
||||
export interface MemberMonthlySignIn {
|
||||
memberId: number
|
||||
memberName: string
|
||||
month: string
|
||||
totalSignIns: number
|
||||
successSignIns: number
|
||||
failedSignIns: number
|
||||
}
|
||||
|
||||
export interface MemberDailySignIn {
|
||||
memberId: number
|
||||
signInTime: string
|
||||
signInStatus: string
|
||||
signInType: string
|
||||
failReason: string | null
|
||||
}
|
||||
|
||||
export function getMemberSignInDetails(params?: {
|
||||
periodType?: string
|
||||
startTime?: string
|
||||
endTime?: string
|
||||
page?: number
|
||||
size?: number
|
||||
}): Promise<MemberSignInResponse> {
|
||||
return request.get('/api/datacount/member/signin', { params })
|
||||
}
|
||||
|
||||
export function getMemberMonthlySignIns(memberId: number): Promise<MemberMonthlySignIn[]> {
|
||||
return request.get(`/api/datacount/member/${memberId}/monthly-signin`)
|
||||
}
|
||||
|
||||
export function getMemberDailySignIns(memberId: number, month: string): Promise<MemberDailySignIn[]> {
|
||||
return request.get(`/api/datacount/member/${memberId}/monthly-signin`, { params: { month } })
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出数据统计报表(返回Excel文件Blob)
|
||||
*/
|
||||
export function exportStatistics(params?: {
|
||||
statType?: string
|
||||
periodType?: string
|
||||
startTime?: string
|
||||
endTime?: string
|
||||
}): Promise<Blob> {
|
||||
return request.get('/api/datacount/export', {
|
||||
params,
|
||||
responseType: 'blob',
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import request from './request'
|
||||
|
||||
// ==================== 字典类型 ====================
|
||||
|
||||
export interface SysDictType {
|
||||
id: number
|
||||
dictName: string
|
||||
dictType: string
|
||||
status: string
|
||||
remark: string | null
|
||||
createBy: string | null
|
||||
updateBy: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
deletedAt: string | null
|
||||
}
|
||||
|
||||
export interface CreateDictTypeDto {
|
||||
dictName: string
|
||||
dictType: string
|
||||
status?: string
|
||||
remark?: string
|
||||
}
|
||||
|
||||
export interface UpdateDictTypeDto {
|
||||
dictName?: string
|
||||
status?: string
|
||||
remark?: string
|
||||
}
|
||||
|
||||
/** 获取所有字典类型 */
|
||||
export function getAllDictTypes(): Promise<SysDictType[]> {
|
||||
return request.get('/api/dict/types')
|
||||
}
|
||||
|
||||
/** 根据ID获取字典类型 */
|
||||
export function getDictTypeById(id: number): Promise<SysDictType> {
|
||||
return request.get(`/api/dict/types/${id}`)
|
||||
}
|
||||
|
||||
/** 根据类型编码获取字典类型 */
|
||||
export function getDictTypeByType(dictType: string): Promise<SysDictType> {
|
||||
return request.get(`/api/dict/types/type/${dictType}`)
|
||||
}
|
||||
|
||||
/** 创建字典类型 */
|
||||
export function createDictType(data: CreateDictTypeDto): Promise<SysDictType> {
|
||||
return request.post('/api/dict/types', data)
|
||||
}
|
||||
|
||||
/** 更新字典类型 */
|
||||
export function updateDictType(id: number, data: UpdateDictTypeDto): Promise<SysDictType> {
|
||||
return request.put(`/api/dict/types/${id}`, data)
|
||||
}
|
||||
|
||||
/** 删除字典类型 */
|
||||
export function deleteDictType(id: number): Promise<void> {
|
||||
return request.delete(`/api/dict/types/${id}`)
|
||||
}
|
||||
|
||||
// ==================== 字典数据 ====================
|
||||
|
||||
export interface SysDictData {
|
||||
id: number
|
||||
dictSort: number
|
||||
dictLabel: string
|
||||
dictValue: string
|
||||
dictType: string
|
||||
cssClass: string | null
|
||||
listClass: string | null
|
||||
isDefault: string
|
||||
status: string
|
||||
createBy: string | null
|
||||
updateBy: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
deletedAt: string | null
|
||||
}
|
||||
|
||||
export interface CreateDictDataDto {
|
||||
dictSort?: number
|
||||
dictLabel: string
|
||||
dictValue: string
|
||||
dictType: string
|
||||
cssClass?: string
|
||||
listClass?: string
|
||||
isDefault?: string
|
||||
status?: string
|
||||
}
|
||||
|
||||
export interface UpdateDictDataDto {
|
||||
dictSort?: number
|
||||
dictLabel?: string
|
||||
dictValue?: string
|
||||
cssClass?: string
|
||||
listClass?: string
|
||||
isDefault?: string
|
||||
status?: string
|
||||
}
|
||||
|
||||
/** 获取所有字典数据 */
|
||||
export function getAllDictData(): Promise<SysDictData[]> {
|
||||
return request.get('/api/dict/data')
|
||||
}
|
||||
|
||||
/** 根据类型获取字典数据 */
|
||||
export function getDictDataByType(dictType: string): Promise<SysDictData[]> {
|
||||
return request.get(`/api/dict/data/type/${dictType}`)
|
||||
}
|
||||
|
||||
/** 根据ID获取字典数据 */
|
||||
export function getDictDataById(id: number): Promise<SysDictData> {
|
||||
return request.get(`/api/dict/data/${id}`)
|
||||
}
|
||||
|
||||
/** 创建字典数据 */
|
||||
export function createDictData(data: CreateDictDataDto): Promise<SysDictData> {
|
||||
return request.post('/api/dict/data', data)
|
||||
}
|
||||
|
||||
/** 更新字典数据 */
|
||||
export function updateDictData(id: number, data: UpdateDictDataDto): Promise<SysDictData> {
|
||||
return request.put(`/api/dict/data/${id}`, data)
|
||||
}
|
||||
|
||||
/** 删除字典数据 */
|
||||
export function deleteDictData(id: number): Promise<void> {
|
||||
return request.delete(`/api/dict/data/${id}`)
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import request from './request'
|
||||
|
||||
// ==================== 类型定义 ====================
|
||||
|
||||
export interface RoleInfo {
|
||||
id: number
|
||||
roleName: string
|
||||
roleKey: string
|
||||
roleSort: number
|
||||
}
|
||||
|
||||
export interface EmployeeInfo {
|
||||
id: number
|
||||
username: string
|
||||
nickname: string
|
||||
email: string
|
||||
phone: string
|
||||
avatar: string | null
|
||||
status: number
|
||||
roles: RoleInfo[]
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
deletedAt: string | null
|
||||
}
|
||||
|
||||
export interface EmployeePageResponse {
|
||||
content: EmployeeInfo[]
|
||||
totalPages: number
|
||||
totalElements: number
|
||||
currentPage: number
|
||||
pageSize: number
|
||||
first: boolean
|
||||
last: boolean
|
||||
}
|
||||
|
||||
export interface SysRole {
|
||||
id: number
|
||||
roleName: string
|
||||
roleKey: string
|
||||
roleSort: number
|
||||
status: number
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface CreateEmployeeDto {
|
||||
username: string
|
||||
password: string
|
||||
nickname?: string
|
||||
email: string
|
||||
phone: string
|
||||
roles?: number[]
|
||||
}
|
||||
|
||||
export interface UpdateEmployeeDto {
|
||||
email?: string
|
||||
roleId?: number
|
||||
status?: number
|
||||
}
|
||||
|
||||
export interface AssignRolesDto {
|
||||
roleIds: string[]
|
||||
}
|
||||
|
||||
export interface ChangePasswordDto {
|
||||
oldPassword: string
|
||||
newPassword: string
|
||||
}
|
||||
|
||||
// ==================== API 方法 ====================
|
||||
|
||||
/** 分页获取员工列表(含角色信息) */
|
||||
export function getEmployeesByPage(params: {
|
||||
page?: number
|
||||
size?: number
|
||||
sort?: string
|
||||
order?: string
|
||||
keyword?: string
|
||||
includeDeleted?: boolean
|
||||
}): Promise<EmployeePageResponse> {
|
||||
return request.get('/api/employees/page', { params })
|
||||
}
|
||||
|
||||
/** 获取已删除的账号列表 */
|
||||
export function getDeletedEmployees(params: {
|
||||
page?: number
|
||||
size?: number
|
||||
keyword?: string
|
||||
}): Promise<EmployeePageResponse> {
|
||||
return request.get('/api/employees/page', {
|
||||
params: { ...params, includeDeleted: true }
|
||||
})
|
||||
}
|
||||
|
||||
/** 获取所有角色 */
|
||||
export function getAllRoles(): Promise<SysRole[]> {
|
||||
return request.get('/api/roles')
|
||||
}
|
||||
|
||||
/** 创建员工账号 */
|
||||
export function createEmployee(data: CreateEmployeeDto): Promise<EmployeeInfo> {
|
||||
return request.post('/api/users', data)
|
||||
}
|
||||
|
||||
/** 获取员工详情 */
|
||||
export function getEmployeeById(id: number): Promise<EmployeeInfo> {
|
||||
return request.get(`/api/users/${id}`)
|
||||
}
|
||||
|
||||
/** 更新员工信息 */
|
||||
export function updateEmployee(id: number, data: UpdateEmployeeDto): Promise<EmployeeInfo> {
|
||||
return request.put(`/api/users/${id}`, data)
|
||||
}
|
||||
|
||||
/** 逻辑删除员工 */
|
||||
export function deleteEmployee(id: number): Promise<void> {
|
||||
return request.post(`/api/users/${id}/action/logical-delete`)
|
||||
}
|
||||
|
||||
/** 修改员工密码(管理员重置) */
|
||||
export function changeEmployeePassword(id: number, data: ChangePasswordDto): Promise<EmployeeInfo> {
|
||||
return request.post(`/api/users/${id}/action/change-password`, data)
|
||||
}
|
||||
|
||||
/** 为员工分配角色 */
|
||||
export function assignRoles(id: number, data: AssignRolesDto): Promise<void> {
|
||||
return request.post(`/api/users/${id}/roles`, data)
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import request from './request'
|
||||
|
||||
export interface GroupCourse {
|
||||
id: number
|
||||
courseName: string
|
||||
coachId: number
|
||||
courseType: number
|
||||
typeName?: string
|
||||
startTime: string
|
||||
endTime: string
|
||||
maxMembers: number
|
||||
currentMembers: number
|
||||
status: number
|
||||
actualStartTime?: string | null
|
||||
actualEndTime?: string | null
|
||||
location: string
|
||||
coverImage: string
|
||||
description: string
|
||||
pointCardAmount?: number
|
||||
storedValueAmount?: number
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
deletedAt?: string | null
|
||||
}
|
||||
|
||||
export interface CoursePageParams {
|
||||
page: number
|
||||
size: number
|
||||
sort?: string
|
||||
order?: string
|
||||
keyword?: string
|
||||
}
|
||||
|
||||
export interface CourseSearchParams {
|
||||
courseName?: string
|
||||
courseType?: number
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
timePeriod?: string
|
||||
priceSort?: string
|
||||
remainingMost?: boolean
|
||||
page?: number
|
||||
size?: number
|
||||
}
|
||||
|
||||
export interface PageResult<T> {
|
||||
content: T[]
|
||||
totalPages: number
|
||||
totalElements: number
|
||||
currentPage: number
|
||||
pageSize: number
|
||||
first: boolean
|
||||
last: boolean
|
||||
}
|
||||
|
||||
export interface ApiResult<T> {
|
||||
success: boolean
|
||||
message: string
|
||||
data: T
|
||||
}
|
||||
|
||||
export function getCourseList(includeDeleted = false): Promise<GroupCourse[]> {
|
||||
return request.get('/api/groupCourse/list', { params: { includeDeleted } })
|
||||
}
|
||||
|
||||
export function getCoursePage(
|
||||
params: CoursePageParams,
|
||||
includeDeleted = false,
|
||||
): Promise<{ data: GroupCourse[]; totalPages: number; totalElements: number; page: number; size: number }> {
|
||||
return request.post('/api/groupCourse/page', params, {
|
||||
params: { includeDeleted },
|
||||
})
|
||||
}
|
||||
|
||||
export function searchCourses(
|
||||
params: CourseSearchParams,
|
||||
): Promise<ApiResult<PageResult<GroupCourse>>> {
|
||||
return request.post('/api/groupCourse/search', params)
|
||||
}
|
||||
|
||||
export function getCourseById(id: number): Promise<GroupCourse> {
|
||||
return request.get(`/api/groupCourse/${id}`)
|
||||
}
|
||||
|
||||
export function getCourseDetail(id: number): Promise<GroupCourse> {
|
||||
return request.get(`/api/groupCourse/${id}/detail`)
|
||||
}
|
||||
|
||||
export function createCourse(data: Partial<GroupCourse>): Promise<ApiResult<GroupCourse>> {
|
||||
return request.post('/api/groupCourse', data)
|
||||
}
|
||||
|
||||
export function updateCourse(id: number, data: Partial<GroupCourse>): Promise<ApiResult<GroupCourse>> {
|
||||
return request.put(`/api/groupCourse/${id}`, data)
|
||||
}
|
||||
|
||||
export function cancelCourse(id: number): Promise<ApiResult<{ id: number; status: number }>> {
|
||||
return request.post(`/api/groupCourse/${id}/cancel`)
|
||||
}
|
||||
|
||||
export function deleteCourse(id: number): Promise<ApiResult<null>> {
|
||||
return request.delete(`/api/groupCourse/${id}`)
|
||||
}
|
||||
|
||||
export function getCourseTypes(includeDeleted = false): Promise<any[]> {
|
||||
return request.get('/api/groupCourse/types', { params: { includeDeleted } })
|
||||
}
|
||||
|
||||
// --- 团课类型管理 ---
|
||||
export interface CourseType {
|
||||
id: number
|
||||
typeName: string
|
||||
baseDifficulty?: number
|
||||
description?: string
|
||||
category?: string
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
export function getTypeList(includeDeleted = false, sortField?: string, sortDirection?: string): Promise<CourseType[]> {
|
||||
const params: Record<string, any> = { includeDeleted }
|
||||
if (sortField && sortDirection) {
|
||||
params.sortField = sortField
|
||||
params.sortDirection = sortDirection
|
||||
}
|
||||
return request.get('/api/groupCourse/types', { params })
|
||||
}
|
||||
|
||||
export function getTypeById(id: number): Promise<CourseType> {
|
||||
return request.get(`/api/groupCourse/types/${id}`)
|
||||
}
|
||||
|
||||
export function createType(data: Partial<CourseType>): Promise<ApiResult<CourseType>> {
|
||||
return request.post('/api/groupCourse/types', data)
|
||||
}
|
||||
|
||||
export function updateType(id: number, data: Partial<CourseType>): Promise<ApiResult<CourseType>> {
|
||||
return request.put(`/api/groupCourse/types/${id}`, data)
|
||||
}
|
||||
|
||||
export function deleteType(id: number, password: string): Promise<ApiResult<null>> {
|
||||
return request.post(`/api/groupCourse/types/${id}/delete`, { password })
|
||||
}
|
||||
|
||||
// --- 团课标签管理 ---
|
||||
export interface CourseLabel {
|
||||
id: number
|
||||
labelName: string
|
||||
color?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export function getAllLabels(): Promise<CourseLabel[]> {
|
||||
return request.get('/api/groupCourse/labels')
|
||||
}
|
||||
|
||||
export function createLabel(data: { labelName: string; color?: string }): Promise<ApiResult<CourseLabel>> {
|
||||
return request.post('/api/groupCourse/labels', data)
|
||||
}
|
||||
|
||||
export function getLabelsByTypeId(typeId: number): Promise<CourseLabel[]> {
|
||||
return request.get(`/api/groupCourse/types/${typeId}/labels`)
|
||||
}
|
||||
|
||||
export function addLabelsToType(typeId: number, labelIds: number[]): Promise<ApiResult<null>> {
|
||||
return request.post(`/api/groupCourse/types/${typeId}/labels`, { labelIds })
|
||||
}
|
||||
|
||||
export function removeLabelFromType(typeId: number, labelId: number): Promise<ApiResult<null>> {
|
||||
return request.delete(`/api/groupCourse/types/${typeId}/labels/${labelId}`)
|
||||
}
|
||||
|
||||
export function removeLabelsFromType(typeId: number, labelIds: number[]): Promise<ApiResult<null>> {
|
||||
return request.post(`/api/groupCourse/types/${typeId}/labels/delete-batch`, { labelIds })
|
||||
}
|
||||
|
||||
// --- 课程状态映射(从后端统一获取,避免硬编码)---
|
||||
export interface CourseStatusItem {
|
||||
value: number
|
||||
dbValue: string
|
||||
label: string
|
||||
cssClass: string
|
||||
}
|
||||
|
||||
export function fetchCourseStatuses(): Promise<ApiResult<CourseStatusItem[]>> {
|
||||
return request.get('/api/groupCourse/statuses')
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import request from './request'
|
||||
import type { GroupCourse } from './groupCourse'
|
||||
|
||||
export interface GroupCourseRecommend {
|
||||
id: number
|
||||
courseId: number
|
||||
recommendTitle: string
|
||||
recommendContent: string
|
||||
recommendReason: string
|
||||
priority: number
|
||||
isActive: boolean
|
||||
groupCourse?: GroupCourse
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface ApiResult<T> {
|
||||
success: boolean
|
||||
message: string
|
||||
data: T
|
||||
}
|
||||
|
||||
export function getRecommendList(
|
||||
sortBy = 'priority',
|
||||
sortOrder = 'desc',
|
||||
): Promise<GroupCourseRecommend[]> {
|
||||
return request.get('/api/groupCourse/recommend/list', {
|
||||
params: { sortBy, sortOrder },
|
||||
})
|
||||
}
|
||||
|
||||
export function getActiveRecommends(): Promise<GroupCourseRecommend[]> {
|
||||
return request.get('/api/groupCourse/recommend/active')
|
||||
}
|
||||
|
||||
export function getRecommendById(id: number): Promise<GroupCourseRecommend> {
|
||||
return request.get(`/api/groupCourse/recommend/${id}`)
|
||||
}
|
||||
|
||||
export function getRecommendByCourseId(courseId: number): Promise<GroupCourseRecommend[]> {
|
||||
return request.get(`/api/groupCourse/recommend/course/${courseId}`)
|
||||
}
|
||||
|
||||
export function createRecommend(
|
||||
data: Partial<GroupCourseRecommend>,
|
||||
): Promise<ApiResult<GroupCourseRecommend>> {
|
||||
return request.post('/api/groupCourse/recommend', data)
|
||||
}
|
||||
|
||||
export function updateRecommend(
|
||||
id: number,
|
||||
data: Partial<GroupCourseRecommend>,
|
||||
): Promise<ApiResult<GroupCourseRecommend>> {
|
||||
return request.put(`/api/groupCourse/recommend/${id}`, data)
|
||||
}
|
||||
|
||||
export function deleteRecommend(id: number): Promise<ApiResult<null>> {
|
||||
return request.delete(`/api/groupCourse/recommend/${id}`)
|
||||
}
|
||||
|
||||
export function enableRecommend(
|
||||
id: number,
|
||||
): Promise<ApiResult<GroupCourseRecommend>> {
|
||||
return request.post(`/api/groupCourse/recommend/${id}/enable`)
|
||||
}
|
||||
|
||||
export function disableRecommend(
|
||||
id: number,
|
||||
): Promise<ApiResult<GroupCourseRecommend>> {
|
||||
return request.post(`/api/groupCourse/recommend/${id}/disable`)
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import request from './request'
|
||||
|
||||
export interface Member {
|
||||
id: number
|
||||
memberNo: string
|
||||
nickname: string
|
||||
phone: string
|
||||
gender: number
|
||||
genderDesc?: string
|
||||
birthday: string | null
|
||||
address: string
|
||||
avatar: string
|
||||
subscribed: boolean
|
||||
lastLoginAt: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
deletedAt: string | null
|
||||
isDeleted: boolean
|
||||
openid?: string
|
||||
unionId?: string
|
||||
}
|
||||
|
||||
export interface MemberCardInfo {
|
||||
id: number
|
||||
status: string
|
||||
statusDesc: string
|
||||
remainingTimes: number
|
||||
remainingAmount: number
|
||||
expireTime: string | null
|
||||
purchaseTime: string | null
|
||||
memberCardId: number
|
||||
memberCardName: string
|
||||
memberCardType: string
|
||||
memberCardTypeDesc: string
|
||||
memberCardPrice: number
|
||||
memberCardValidityDays: number | null
|
||||
memberCardTotalTimes: number | null
|
||||
memberCardAmount: number | null
|
||||
memberCardStatus: number
|
||||
memberCardStatusDesc: string
|
||||
extraConfig: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface MemberDetail {
|
||||
id: number
|
||||
memberNo: string
|
||||
nickname: string
|
||||
phone: string
|
||||
genderDesc: string
|
||||
birthday: string | null
|
||||
address: string
|
||||
avatar: string
|
||||
subscribed: boolean
|
||||
lastLoginAt: string
|
||||
createdAt: string
|
||||
memberCards: MemberCardInfo[]
|
||||
activeCardCount: number
|
||||
inactiveCardCount: number
|
||||
}
|
||||
|
||||
export interface UpdateMemberDto {
|
||||
nickname?: string
|
||||
gender?: number
|
||||
birthday?: string
|
||||
avatar?: string
|
||||
address?: string
|
||||
}
|
||||
|
||||
export function getAllMembers(pageNum: number = 1, pageSize: number = 10): Promise<Member[]> {
|
||||
return request.get('/api/admin/members/all', {
|
||||
params: { pageNum, pageSize },
|
||||
})
|
||||
}
|
||||
|
||||
export function searchMembers(
|
||||
searchValue: string,
|
||||
pageNum: number = 1,
|
||||
pageSize: number = 10,
|
||||
): Promise<Member[]> {
|
||||
return request.get('/api/admin/members', {
|
||||
params: { searchValue, pageNum, pageSize },
|
||||
})
|
||||
}
|
||||
|
||||
export function getMemberDetail(id: number): Promise<MemberDetail> {
|
||||
return request.get(`/api/admin/member/${id}`)
|
||||
}
|
||||
|
||||
export function updateMember(id: number, data: UpdateMemberDto): Promise<boolean> {
|
||||
return request.put(`/api/admin/member/${id}`, data)
|
||||
}
|
||||
|
||||
export function adminUpdatePhone(id: number, phone: string): Promise<boolean> {
|
||||
return request.post(`/api/admin/member/${id}/phone`, { phone })
|
||||
}
|
||||
|
||||
// ==================== 会员卡管理 ====================
|
||||
|
||||
/** 可用会员卡类型 */
|
||||
export interface MemberCardType {
|
||||
id: number
|
||||
memberCardId: number
|
||||
memberCardName: string
|
||||
memberCardType: string
|
||||
memberCardPrice: number
|
||||
memberCardValidityDays: number | null
|
||||
memberCardTotalTimes: number | null
|
||||
memberCardAmount: number | null
|
||||
memberCardStatus: number
|
||||
extraConfig: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
/** 会员持有的卡记录 */
|
||||
export interface MemberCardRecord {
|
||||
id: number
|
||||
memberCardRecordId: number
|
||||
memberId: number
|
||||
memberCardId: number
|
||||
status: string
|
||||
remainingTimes: number
|
||||
remainingAmount: number
|
||||
expireTime: string | null
|
||||
sourceOrderId: number | null
|
||||
purchaseTime: string
|
||||
version: number
|
||||
cardComposition: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
/** 获取上架中的会员卡类型 */
|
||||
export function getActiveCards(): Promise<MemberCardType[]> {
|
||||
return request.get('/api/member-cards/active')
|
||||
}
|
||||
|
||||
/** 获取会员持有的卡记录 */
|
||||
export function getMemberCardRecords(memberId: number): Promise<MemberCardRecord[]> {
|
||||
return request.get(`/api/member-card-records/my-cards/${memberId}`)
|
||||
}
|
||||
|
||||
/** 为会员购买/绑定会员卡 */
|
||||
export function purchaseCard(memberId: number, memberCardId: number): Promise<MemberCardRecord> {
|
||||
return request.post('/api/member-card-records/purchase', {
|
||||
memberId,
|
||||
memberCardId,
|
||||
sourceOrderId: null,
|
||||
})
|
||||
}
|
||||
|
||||
/** 退卡/解绑 */
|
||||
export function refundCard(recordId: number): Promise<string> {
|
||||
return request.post(`/api/member-card-records/${recordId}/refund`)
|
||||
}
|
||||
|
||||
/** 验证管理员密码 */
|
||||
export function verifyAdminPassword(password: string): Promise<boolean> {
|
||||
return request.post('/api/admin/verify-password', { password })
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import request from './request'
|
||||
|
||||
export interface MemberCard {
|
||||
memberCardId: number
|
||||
memberCardName: string
|
||||
memberCardType: string
|
||||
memberCardPrice: number
|
||||
memberCardValidityDays: number | null
|
||||
memberCardTotalTimes: number | null
|
||||
memberCardAmount: number | null
|
||||
memberCardStatus: number
|
||||
extraConfig: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface CreateMemberCardDto {
|
||||
memberCardName: string
|
||||
memberCardType: string
|
||||
memberCardPrice: number
|
||||
memberCardValidityDays?: number
|
||||
memberCardTotalTimes?: number
|
||||
memberCardAmount?: number
|
||||
memberCardStatus?: number
|
||||
extraConfig?: string
|
||||
}
|
||||
|
||||
export interface UpdateMemberCardDto {
|
||||
memberCardName?: string
|
||||
memberCardType?: string
|
||||
memberCardPrice?: number
|
||||
memberCardValidityDays?: number
|
||||
memberCardTotalTimes?: number
|
||||
memberCardAmount?: number
|
||||
memberCardStatus?: number
|
||||
extraConfig?: string
|
||||
}
|
||||
|
||||
export interface MemberCardQuery {
|
||||
status?: number
|
||||
name?: string
|
||||
type?: string
|
||||
minPrice?: number
|
||||
maxPrice?: number
|
||||
page?: number
|
||||
size?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页查询会员卡类型列表
|
||||
*/
|
||||
export function listMemberCards(query: MemberCardQuery): Promise<MemberCard[]> {
|
||||
return request.get('/api/member-cards', { params: query })
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询上架的会员卡类型
|
||||
*/
|
||||
export function getActiveCards(status: number = 1): Promise<MemberCard[]> {
|
||||
return request.get('/api/member-cards/active', { params: { status } })
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询会员卡类型详情
|
||||
*/
|
||||
export function getMemberCardById(id: number): Promise<MemberCard> {
|
||||
return request.get(`/api/member-cards/${id}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建会员卡类型
|
||||
*/
|
||||
export function createMemberCard(data: CreateMemberCardDto): Promise<MemberCard> {
|
||||
return request.post('/api/member-cards', data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新会员卡类型
|
||||
*/
|
||||
export function updateMemberCard(id: number, data: UpdateMemberCardDto): Promise<MemberCard> {
|
||||
return request.put(`/api/member-cards/${id}`, data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除会员卡类型(逻辑删除)
|
||||
*/
|
||||
export function deleteMemberCard(id: number): Promise<void> {
|
||||
return request.delete(`/api/member-cards/${id}`)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import request from './request'
|
||||
|
||||
export interface SysNotice {
|
||||
id: number
|
||||
noticeTitle: string
|
||||
noticeType: string
|
||||
noticeContent: string
|
||||
status: string
|
||||
createBy: string | null
|
||||
updateBy: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
deletedAt: string | null
|
||||
}
|
||||
|
||||
export interface CreateNoticeDto {
|
||||
noticeTitle: string
|
||||
noticeType: string
|
||||
noticeContent: string
|
||||
status?: string
|
||||
}
|
||||
|
||||
export interface UpdateNoticeDto {
|
||||
noticeTitle?: string
|
||||
noticeType?: string
|
||||
noticeContent?: string
|
||||
status?: string
|
||||
}
|
||||
|
||||
/** 获取所有公告 */
|
||||
export function getAllNotices(): Promise<SysNotice[]> {
|
||||
return request.get('/api/notices')
|
||||
}
|
||||
|
||||
/** 根据ID获取公告 */
|
||||
export function getNoticeById(id: number): Promise<SysNotice> {
|
||||
return request.get(`/api/notices/${id}`)
|
||||
}
|
||||
|
||||
/** 根据状态获取公告 */
|
||||
export function getNoticesByStatus(status: string): Promise<SysNotice[]> {
|
||||
return request.get(`/api/notices/status/${status}`)
|
||||
}
|
||||
|
||||
/** 创建公告 */
|
||||
export function createNotice(data: CreateNoticeDto): Promise<SysNotice> {
|
||||
return request.post('/api/notices', data)
|
||||
}
|
||||
|
||||
/** 更新公告 */
|
||||
export function updateNotice(id: number, data: UpdateNoticeDto): Promise<SysNotice> {
|
||||
return request.put(`/api/notices/${id}`, data)
|
||||
}
|
||||
|
||||
/** 删除公告 */
|
||||
export function deleteNotice(id: number): Promise<void> {
|
||||
return request.delete(`/api/notices/${id}`)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import request from './request'
|
||||
|
||||
export interface OperationLog {
|
||||
id: number
|
||||
username: string
|
||||
operation: string
|
||||
method: string
|
||||
params: string | null
|
||||
result: string | null
|
||||
ip: string
|
||||
duration: number
|
||||
status: string
|
||||
errorMsg: string | null
|
||||
createBy: string | null
|
||||
updateBy: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
deletedAt: string | null
|
||||
}
|
||||
|
||||
export interface OperationLogPageResponse {
|
||||
content: OperationLog[]
|
||||
totalPages: number
|
||||
totalElements: number
|
||||
currentPage: number
|
||||
pageSize: number
|
||||
first: boolean
|
||||
last: boolean
|
||||
}
|
||||
|
||||
export function getOperationLogs(params?: {
|
||||
page?: number
|
||||
size?: number
|
||||
sort?: string
|
||||
order?: string
|
||||
keyword?: string
|
||||
username?: string
|
||||
operation?: string
|
||||
status?: string
|
||||
startTime?: string
|
||||
endTime?: string
|
||||
ip?: string
|
||||
method?: string
|
||||
}): Promise<OperationLogPageResponse> {
|
||||
return request.get('/api/logs/operation/page', { params })
|
||||
}
|
||||
|
||||
export function getOperationLogCount(): Promise<number> {
|
||||
return request.get('/api/logs/operation/count')
|
||||
}
|
||||
|
||||
export function exportOperationLogs(params?: {
|
||||
username?: string
|
||||
operation?: string
|
||||
status?: string
|
||||
startTime?: string
|
||||
endTime?: string
|
||||
ip?: string
|
||||
method?: string
|
||||
keyword?: string
|
||||
}): Promise<Blob> {
|
||||
return request.get('/api/logs/operation/export', {
|
||||
params,
|
||||
responseType: 'blob',
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const request = axios.create({
|
||||
timeout: 15000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
request.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
},
|
||||
(error) => Promise.reject(error),
|
||||
)
|
||||
|
||||
request.interceptors.response.use(
|
||||
(response) => response.data,
|
||||
(error) => {
|
||||
const isLoginRequest = error.config?.url?.includes('/auth/login')
|
||||
if (error.response?.status === 401 && !isLoginRequest) {
|
||||
localStorage.removeItem('accessToken')
|
||||
localStorage.removeItem('refreshToken')
|
||||
localStorage.removeItem('userInfo')
|
||||
window.location.href = '/login'
|
||||
}
|
||||
return Promise.reject(error)
|
||||
},
|
||||
)
|
||||
|
||||
export default request
|
||||
@@ -0,0 +1,207 @@
|
||||
import request from './request'
|
||||
|
||||
// ==================== 类型定义 ====================
|
||||
|
||||
export interface SysRole {
|
||||
id: number
|
||||
roleName: string
|
||||
roleKey: string
|
||||
roleSort: number
|
||||
status: number
|
||||
createBy?: string
|
||||
updateBy?: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
deletedAt?: string | null
|
||||
}
|
||||
|
||||
export interface SysPermission {
|
||||
id: number
|
||||
permissionName: string
|
||||
permissionCode: string
|
||||
resource: string
|
||||
action: string
|
||||
description: string
|
||||
status: number
|
||||
createBy?: string
|
||||
updateBy?: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
deletedAt?: string | null
|
||||
}
|
||||
|
||||
export interface RolePageResponse {
|
||||
content: SysRole[]
|
||||
totalPages: number
|
||||
totalElements: number
|
||||
currentPage: number
|
||||
pageSize: number
|
||||
first: boolean
|
||||
last: boolean
|
||||
}
|
||||
|
||||
export interface CreateRoleDto {
|
||||
roleName: string
|
||||
roleKey: string
|
||||
roleSort?: number
|
||||
status?: number
|
||||
}
|
||||
|
||||
export interface UpdateRoleDto {
|
||||
roleName?: string
|
||||
roleKey?: string
|
||||
roleSort?: number
|
||||
status?: number
|
||||
}
|
||||
|
||||
export interface CreatePermissionDto {
|
||||
permissionName: string
|
||||
permissionCode: string
|
||||
resource: string
|
||||
action: string
|
||||
description?: string
|
||||
status?: number
|
||||
}
|
||||
|
||||
/** 权限分组(按业务模块) */
|
||||
export interface PermissionGroup {
|
||||
category: string
|
||||
module: string
|
||||
moduleLabel: string
|
||||
permissions: SysPermission[]
|
||||
}
|
||||
|
||||
// ==================== 权限工具函数 ====================
|
||||
|
||||
/** 模块中文名映射 */
|
||||
const moduleLabelMap: Record<string, string> = {
|
||||
user: '用户管理',
|
||||
role: '角色管理',
|
||||
permission: '权限管理',
|
||||
menu: '菜单管理',
|
||||
dict: '字典管理',
|
||||
config: '系统配置',
|
||||
log: '日志管理',
|
||||
file: '文件管理',
|
||||
notice: '公告管理',
|
||||
member: '会员管理',
|
||||
memberCard: '会员卡管理',
|
||||
groupCourse: '团课管理',
|
||||
groupCourseType: '团课类型管理',
|
||||
groupCourseRecommend: '团课推荐管理',
|
||||
groupCourseBooking: '团课预约管理',
|
||||
checkIn: '签到管理',
|
||||
dataCount: '数据统计',
|
||||
employee: '员工管理',
|
||||
}
|
||||
|
||||
/** 从权限编码中解析模块名 */
|
||||
export function parseModuleFromCode(code: string): { category: string; module: string } {
|
||||
const parts = code.split(':')
|
||||
return { category: parts[0] || 'other', module: parts[1] || 'other' }
|
||||
}
|
||||
|
||||
/** 获取模块中文标签 */
|
||||
export function getModuleLabel(mod: string): string {
|
||||
return moduleLabelMap[mod] || mod
|
||||
}
|
||||
|
||||
/** 将权限列表按模块分组 */
|
||||
export function groupPermissionsByModule(permissions: SysPermission[]): PermissionGroup[] {
|
||||
const map = new Map<string, PermissionGroup>()
|
||||
for (const perm of permissions) {
|
||||
const { category, module } = parseModuleFromCode(perm.permissionCode)
|
||||
const key = `${category}:${module}`
|
||||
if (!map.has(key)) {
|
||||
map.set(key, { category, module, moduleLabel: getModuleLabel(module), permissions: [] })
|
||||
}
|
||||
map.get(key)!.permissions.push(perm)
|
||||
}
|
||||
// 业务模块在前,系统模块在后;同类别按模块名排序
|
||||
return [...map.values()].sort((a, b) => {
|
||||
if (a.category !== b.category) return a.category === 'business' ? -1 : 1
|
||||
return a.module.localeCompare(b.module)
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== 角色 API ====================
|
||||
|
||||
/** 获取所有角色 */
|
||||
export function getAllRoles(): Promise<SysRole[]> {
|
||||
return request.get('/api/roles')
|
||||
}
|
||||
|
||||
/** 分页获取角色 */
|
||||
export function getRolesByPage(params: {
|
||||
page?: number
|
||||
size?: number
|
||||
sort?: string
|
||||
order?: string
|
||||
keyword?: string
|
||||
}): Promise<RolePageResponse> {
|
||||
return request.get('/api/roles/page', { params })
|
||||
}
|
||||
|
||||
/** 获取角色详情 */
|
||||
export function getRoleById(id: number): Promise<SysRole> {
|
||||
return request.get(`/api/roles/${id}`)
|
||||
}
|
||||
|
||||
/** 创建角色 */
|
||||
export function createRole(data: CreateRoleDto): Promise<SysRole> {
|
||||
return request.post('/api/roles', data)
|
||||
}
|
||||
|
||||
/** 更新角色 */
|
||||
export function updateRole(id: number, data: UpdateRoleDto): Promise<SysRole> {
|
||||
return request.put(`/api/roles/${id}`, data)
|
||||
}
|
||||
|
||||
/** 删除角色(逻辑删除) */
|
||||
export function deleteRole(id: number): Promise<SysRole> {
|
||||
return request.delete(`/api/roles/${id}`)
|
||||
}
|
||||
|
||||
/** 恢复角色 */
|
||||
export function restoreRole(id: number): Promise<SysRole> {
|
||||
return request.post(`/api/roles/${id}/restore`)
|
||||
}
|
||||
|
||||
// ==================== 角色权限关联 API ====================
|
||||
|
||||
/** 获取角色的权限列表 */
|
||||
export function getRolePermissions(roleId: number): Promise<SysPermission[]> {
|
||||
return request.get(`/api/roles/${roleId}/permissions`)
|
||||
}
|
||||
|
||||
/** 为角色分配权限 */
|
||||
export function assignPermissionsToRole(roleId: number, permissionIds: number[]): Promise<void> {
|
||||
return request.post(`/api/roles/${roleId}/permissions`, { permissionIds })
|
||||
}
|
||||
|
||||
// ==================== 权限 API ====================
|
||||
|
||||
/** 获取所有权限 */
|
||||
export function getAllPermissions(): Promise<SysPermission[]> {
|
||||
return request.get('/api/permissions')
|
||||
}
|
||||
|
||||
/** 获取权限详情 */
|
||||
export function getPermissionById(id: number): Promise<SysPermission> {
|
||||
return request.get(`/api/permissions/${id}`)
|
||||
}
|
||||
|
||||
/** 创建权限 */
|
||||
export function createPermission(data: CreatePermissionDto): Promise<SysPermission> {
|
||||
return request.post('/api/permissions', data)
|
||||
}
|
||||
|
||||
/** 更新权限 */
|
||||
export function updatePermission(id: number, data: SysPermission): Promise<SysPermission> {
|
||||
return request.put(`/api/permissions/${id}`, data)
|
||||
}
|
||||
|
||||
/** 删除权限 */
|
||||
export function deletePermission(id: number): Promise<void> {
|
||||
return request.delete(`/api/permissions/${id}`)
|
||||
}
|
||||
@@ -0,0 +1,651 @@
|
||||
<script lang="ts">
|
||||
import { reactive } from 'vue'
|
||||
|
||||
/** 模块级状态——组件销毁重建时不会重置 */
|
||||
const menuLabels = ['统计', '签到', '管理', '系统'] as const
|
||||
const collapsedGroups = reactive(new Set<string>(menuLabels))
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
const { toasts } = useToast()
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
label: '统计',
|
||||
items: [
|
||||
{ path: '/dashboard', icon: 'fas fa-chart-simple', text: '仪表盘', perm: 'business:dataCount' },
|
||||
{ path: '/data-report', icon: 'fas fa-file-excel', text: '数据报表', perm: 'business:dataCount' },
|
||||
{ path: '/operation-log', icon: 'fas fa-history', text: '操作日志', perm: 'system:log' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '签到',
|
||||
items: [
|
||||
{ path: '/member-signin', icon: 'fas fa-clipboard-check', text: '签到明细', perm: 'business:checkIn' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '管理',
|
||||
items: [
|
||||
{ path: '/member-manage', icon: 'fas fa-users', text: '会员管理', perm: 'business:member' },
|
||||
{ path: '/member-card-manage', icon: 'fas fa-id-card', text: '会员卡管理', perm: 'business:memberCard' },
|
||||
{ path: '/coach-manage', icon: 'fas fa-user-tie', text: '教练管理', perm: 'business:groupCourse' },
|
||||
{ path: '/group-course', icon: 'fas fa-dumbbell', text: '团课管理', perm: 'business:groupCourse' },
|
||||
{ path: '/group-course-types', icon: 'fas fa-tags', text: '类型管理', perm: 'business:groupCourseType' },
|
||||
{ path: '/group-course-recommend', icon: 'fas fa-star', text: '推荐团课管理', perm: 'business:groupCourseRecommend' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '系统',
|
||||
items: [
|
||||
{ path: '/employee-manage', icon: 'fas fa-user-cog', text: '账号管理', perm: 'business:employee' },
|
||||
{ path: '/role-manage', icon: 'fas fa-shield-alt', text: '角色权限', perm: 'system:role' },
|
||||
{ path: '/notice-manage', icon: 'fas fa-bullhorn', text: '系统公告', perm: 'system:notice' },
|
||||
{ path: '/dict-manage', icon: 'fas fa-book', text: '字典管理', perm: 'system:dict' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
function toggleGroup(label: string) {
|
||||
if (collapsedGroups.has(label)) {
|
||||
collapsedGroups.delete(label)
|
||||
} else {
|
||||
collapsedGroups.add(label)
|
||||
}
|
||||
}
|
||||
|
||||
function expandAll() {
|
||||
collapsedGroups.clear()
|
||||
}
|
||||
|
||||
const allExpanded = computed(() => collapsedGroups.size === 0)
|
||||
|
||||
/** 按用户权限过滤菜单 */
|
||||
const filteredMenuItems = computed(() => {
|
||||
return menuItems
|
||||
.map((group) => ({
|
||||
...group,
|
||||
items: group.items.filter((item) => !item.perm || authStore.hasPermission(item.perm)),
|
||||
}))
|
||||
.filter((group) => group.items.length > 0)
|
||||
})
|
||||
|
||||
const isActive = (path: string) => {
|
||||
return route.path === path
|
||||
}
|
||||
|
||||
const navigate = (path: string) => {
|
||||
router.push(path)
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
authStore.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
const initials = computed(() => {
|
||||
return authStore.nickname.slice(0, 2).toUpperCase() || 'AD'
|
||||
})
|
||||
|
||||
/** 页面刷新后重新拉取权限以保持同步 */
|
||||
onMounted(() => {
|
||||
if (authStore.isLoggedIn) {
|
||||
authStore.fetchPermissions()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app-layout">
|
||||
<aside class="sidebar">
|
||||
<div class="brand">
|
||||
<div class="brand-icon">G</div>
|
||||
<div class="brand-text">Gym<span>Manage</span></div>
|
||||
</div>
|
||||
<nav class="nav">
|
||||
<button
|
||||
class="expand-all-btn"
|
||||
:disabled="allExpanded"
|
||||
@click="expandAll"
|
||||
>
|
||||
<i class="fas fa-expand-alt"></i> 展开所有
|
||||
</button>
|
||||
<template v-for="group in filteredMenuItems" :key="group.label">
|
||||
<div class="nav-group" :class="{ expanded: !collapsedGroups.has(group.label) }">
|
||||
<div
|
||||
class="nav-label"
|
||||
@click="toggleGroup(group.label)"
|
||||
>
|
||||
<i
|
||||
class="fas nav-caret"
|
||||
:class="collapsedGroups.has(group.label) ? 'fa-chevron-right' : 'fa-chevron-down'"
|
||||
></i>
|
||||
<span>{{ group.label }}</span>
|
||||
</div>
|
||||
<TransitionGroup name="nav-item">
|
||||
<a
|
||||
v-for="item in group.items"
|
||||
v-show="!collapsedGroups.has(group.label)"
|
||||
:key="item.path"
|
||||
class="nav-item"
|
||||
:class="{ active: isActive(item.path) }"
|
||||
@click="navigate(item.path)"
|
||||
>
|
||||
<i :class="item.icon"></i>
|
||||
<span>{{ item.text }}</span>
|
||||
</a>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</template>
|
||||
</nav>
|
||||
<div class="user-card">
|
||||
<div class="user-avatar">{{ initials }}</div>
|
||||
<div class="user-info">
|
||||
<div class="name">{{ authStore.nickname }}</div>
|
||||
<div class="role">{{ authStore.roleDisplay }}</div>
|
||||
</div>
|
||||
<i
|
||||
class="fas fa-sign-out-alt"
|
||||
style="color: #8b9ab0; cursor: pointer"
|
||||
title="退出登录"
|
||||
@click="handleLogout"
|
||||
></i>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="main">
|
||||
<header class="header">
|
||||
<div class="header-title">
|
||||
<h1>{{ route.meta.title || '仪表盘' }}</h1>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button class="btn btn-outline" @click="handleLogout">
|
||||
<i class="fas fa-sign-out-alt"></i><span>退出</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<slot />
|
||||
</main>
|
||||
|
||||
<!-- toast notifications -->
|
||||
<Teleport to="body">
|
||||
<div class="toast-container">
|
||||
<transition-group name="toast">
|
||||
<div
|
||||
v-for="t in toasts"
|
||||
:key="t.id"
|
||||
class="toast-item"
|
||||
:class="t.type"
|
||||
>
|
||||
<i v-if="t.type === 'success'" class="fas fa-check-circle"></i>
|
||||
<i v-else-if="t.type === 'error'" class="fas fa-times-circle"></i>
|
||||
<i v-else class="fas fa-info-circle"></i>
|
||||
<span>{{ t.message }}</span>
|
||||
</div>
|
||||
</transition-group>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.app-layout {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
background: #f6f8fc;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 260px;
|
||||
background: #ffffff;
|
||||
border-right: 1px solid #e9edf4;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 24px 16px;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
z-index: 100;
|
||||
transition: all 0.2s;
|
||||
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 0 8px 28px 8px;
|
||||
border-bottom: 1px solid #f0f3f8;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.brand-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: linear-gradient(145deg, #4f7cff, #3b5fd9);
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 6px 12px rgba(79, 124, 255, 0.25);
|
||||
}
|
||||
|
||||
.brand-text {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.3px;
|
||||
color: #0b1a33;
|
||||
}
|
||||
|
||||
.brand-text span {
|
||||
color: #4f7cff;
|
||||
}
|
||||
|
||||
.nav {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.nav-group {
|
||||
position: relative;
|
||||
margin-bottom: 8px;
|
||||
border-radius: 10px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.nav-group.expanded {
|
||||
background: #f4f7fd;
|
||||
padding-bottom: 8px;
|
||||
border: 1px solid #e8edf6;
|
||||
}
|
||||
|
||||
.nav-group.expanded::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 18px;
|
||||
top: 38px;
|
||||
bottom: 10px;
|
||||
width: 1px;
|
||||
background: #dce3f0;
|
||||
border-radius: 1px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.nav-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #4a5568;
|
||||
padding: 10px 12px 6px 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.nav-label:hover {
|
||||
color: #4f7cff;
|
||||
background: #eef3ff;
|
||||
}
|
||||
|
||||
.expand-all-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
margin-bottom: 8px;
|
||||
border: 1px solid #dde2eb;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: #4a5568;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.expand-all-btn:hover:not(:disabled) {
|
||||
border-color: #4f7cff;
|
||||
color: #4f7cff;
|
||||
background: #f4f7fd;
|
||||
}
|
||||
|
||||
.expand-all-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.nav-caret {
|
||||
font-size: 10px;
|
||||
width: 12px;
|
||||
color: #9ca3af;
|
||||
transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1), color 0.2s;
|
||||
}
|
||||
|
||||
.nav-label:hover .nav-caret {
|
||||
color: #4f7cff;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 14px 10px 32px;
|
||||
margin-left: 4px;
|
||||
border-radius: 8px;
|
||||
color: #5a6d80;
|
||||
font-weight: 500;
|
||||
font-size: 13.5px;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
position: relative;
|
||||
transform-origin: left center;
|
||||
}
|
||||
|
||||
.nav-item::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
top: 50%;
|
||||
width: 8px;
|
||||
height: 1px;
|
||||
background: #d0d8e8;
|
||||
pointer-events: none;
|
||||
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.nav-item i {
|
||||
width: 18px;
|
||||
font-size: 15px;
|
||||
color: #8b9ab0;
|
||||
text-align: center;
|
||||
transition: color 0.2s, transform 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background: #e8effb;
|
||||
color: #1e293b;
|
||||
transform: translateX(3px);
|
||||
}
|
||||
|
||||
.nav-item:hover i {
|
||||
color: #4f7cff;
|
||||
transform: scale(1.15);
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background: #dbe6fb;
|
||||
color: #1e293b;
|
||||
font-weight: 600;
|
||||
transform: translateX(3px);
|
||||
}
|
||||
|
||||
.nav-item.active::before {
|
||||
background: #4f7cff;
|
||||
width: 12px;
|
||||
height: 2px;
|
||||
left: 12px;
|
||||
}
|
||||
|
||||
.nav-item.active i {
|
||||
color: #4f7cff;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* 子菜单滑入动画 */
|
||||
.nav-item-enter-active {
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.nav-item-leave-active {
|
||||
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.nav-item-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
max-height: 0;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.nav-item-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
max-height: 0;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
margin-bottom: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.user-card {
|
||||
margin-top: 20px;
|
||||
padding: 16px 14px;
|
||||
background: #fafcff;
|
||||
border-radius: 16px;
|
||||
border: 1px solid #eef2f8;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
background: linear-gradient(135deg, #d9e2f0, #c8d3e6);
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
font-size: 18px;
|
||||
color: #1e2f44;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.user-info .name {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.user-info .role {
|
||||
font-size: 12px;
|
||||
color: #7a8b9f;
|
||||
}
|
||||
|
||||
.main {
|
||||
margin-left: 260px;
|
||||
flex: 1;
|
||||
padding: 28px 36px 36px 36px;
|
||||
min-height: 100vh;
|
||||
background: #f6f8fc;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 32px;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.header-title h1 {
|
||||
font-size: 26px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.4px;
|
||||
color: #0b1a33;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 9px 20px;
|
||||
border-radius: 40px;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: white;
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
color: #1e293b;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
transition: 0.15s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #4f7cff;
|
||||
border: 1px solid #4f7cff;
|
||||
color: white;
|
||||
box-shadow: 0 4px 10px rgba(79, 124, 255, 0.25);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #3f6ae0;
|
||||
border-color: #3f6ae0;
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
background: transparent;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.btn-outline:hover {
|
||||
background: #f0f5ff;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.sidebar {
|
||||
width: 72px;
|
||||
padding: 16px 8px;
|
||||
}
|
||||
.brand-text,
|
||||
.nav-label,
|
||||
.nav-item span,
|
||||
.user-info {
|
||||
display: none;
|
||||
}
|
||||
.nav-item {
|
||||
justify-content: center;
|
||||
padding: 12px;
|
||||
}
|
||||
.nav-item i {
|
||||
font-size: 20px;
|
||||
margin: 0;
|
||||
}
|
||||
.user-card {
|
||||
justify-content: center;
|
||||
padding: 8px;
|
||||
}
|
||||
.user-avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.main {
|
||||
margin-left: 72px;
|
||||
padding: 20px 16px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- toast ---- */
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
top: 24px;
|
||||
right: 24px;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.toast-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 20px;
|
||||
border-radius: 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
|
||||
pointer-events: auto;
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
.toast-item.success {
|
||||
background: #ecfdf5;
|
||||
color: #0b8b5e;
|
||||
border: 1px solid #a7f3d0;
|
||||
}
|
||||
|
||||
.toast-item.error {
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
|
||||
.toast-item.info {
|
||||
background: #eff6ff;
|
||||
color: #4f7cff;
|
||||
border: 1px solid #bfdbfe;
|
||||
}
|
||||
|
||||
.toast-item i {
|
||||
font-size: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toast-enter-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.toast-leave-active {
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
|
||||
.toast-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(40px);
|
||||
}
|
||||
|
||||
.toast-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(40px);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,29 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
type ToastType = 'success' | 'error' | 'info'
|
||||
|
||||
interface ToastItem {
|
||||
id: number
|
||||
message: string
|
||||
type: ToastType
|
||||
}
|
||||
|
||||
let nextId = 0
|
||||
const toasts = ref<ToastItem[]>([])
|
||||
|
||||
function push(message: string, type: ToastType = 'info') {
|
||||
const id = nextId++
|
||||
toasts.value.push({ id, message, type })
|
||||
setTimeout(() => {
|
||||
toasts.value = toasts.value.filter((t) => t.id !== id)
|
||||
}, 2500)
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
return {
|
||||
toasts,
|
||||
success: (msg: string) => push(msg, 'success'),
|
||||
error: (msg: string) => push(msg, 'error'),
|
||||
info: (msg: string) => push(msg, 'info'),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,119 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
redirect: '/dashboard',
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: () => import('@/views/Login.vue'),
|
||||
meta: { title: '登录' },
|
||||
},
|
||||
{
|
||||
path: '/dashboard',
|
||||
name: 'Dashboard',
|
||||
component: () => import('@/views/Dashboard.vue'),
|
||||
meta: { title: '仪表盘', requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/group-course',
|
||||
name: 'GroupCourse',
|
||||
component: () => import('@/views/GroupCourse.vue'),
|
||||
meta: { title: '团课管理', requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/group-course-recommend',
|
||||
name: 'GroupCourseRecommend',
|
||||
component: () => import('@/views/GroupCourseRecommend.vue'),
|
||||
meta: { title: '推荐团课管理', requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/group-course-cancel',
|
||||
name: 'GroupCourseCancel',
|
||||
component: () => import('@/views/GroupCourseCancel.vue'),
|
||||
meta: { title: '取消团课', requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/group-course-types',
|
||||
name: 'GroupCourseType',
|
||||
component: () => import('@/views/GroupCourseType.vue'),
|
||||
meta: { title: '类型管理', requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/coach-manage',
|
||||
name: 'CoachManage',
|
||||
component: () => import('@/views/CoachManage.vue'),
|
||||
meta: { title: '教练管理', requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/member-manage',
|
||||
name: 'MemberManage',
|
||||
component: () => import('@/views/MemberManage.vue'),
|
||||
meta: { title: '会员管理', requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/member-card-manage',
|
||||
name: 'MemberCardManage',
|
||||
component: () => import('@/views/MemberCardManage.vue'),
|
||||
meta: { title: '会员卡管理', requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/member-signin',
|
||||
name: 'MemberSignIn',
|
||||
component: () => import('@/views/MemberSignIn.vue'),
|
||||
meta: { title: '签到明细', requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/data-report',
|
||||
name: 'DataReport',
|
||||
component: () => import('@/views/DataReport.vue'),
|
||||
meta: { title: '数据报表', requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/operation-log',
|
||||
name: 'OperationLog',
|
||||
component: () => import('@/views/OperationLog.vue'),
|
||||
meta: { title: '操作日志', requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/employee-manage',
|
||||
name: 'EmployeeManage',
|
||||
component: () => import('@/views/EmployeeManage.vue'),
|
||||
meta: { title: '账号管理', requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/role-manage',
|
||||
name: 'RoleManage',
|
||||
component: () => import('@/views/RoleManage.vue'),
|
||||
meta: { title: '角色权限', requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/notice-manage',
|
||||
name: 'NoticeManage',
|
||||
component: () => import('@/views/NoticeManage.vue'),
|
||||
meta: { title: '系统公告', requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/dict-manage',
|
||||
name: 'DictManage',
|
||||
component: () => import('@/views/DictManage.vue'),
|
||||
meta: { title: '字典管理', requiresAuth: true },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
router.beforeEach((to) => {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
if (to.meta.requiresAuth && !token) {
|
||||
return '/login'
|
||||
}
|
||||
if (to.path === '/login' && token) {
|
||||
return '/dashboard'
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,88 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { login as loginApi, type UserInfo } from '@/api/auth'
|
||||
import request from '@/api/request'
|
||||
|
||||
function safeJsonParse<T>(key: string): T | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(key)
|
||||
if (!raw) return null
|
||||
return JSON.parse(raw)
|
||||
} catch {
|
||||
localStorage.removeItem(key)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const accessToken = ref<string>(localStorage.getItem('accessToken') || '')
|
||||
const refreshToken = ref<string>(localStorage.getItem('refreshToken') || '')
|
||||
const userInfo = ref<UserInfo | null>(safeJsonParse<UserInfo>('userInfo'))
|
||||
const permissions = ref<string[]>(safeJsonParse<string[]>('userPermissions') || [])
|
||||
const roleNames = ref<string[]>(safeJsonParse<string[]>('userRoleNames') || [])
|
||||
|
||||
const isLoggedIn = computed(() => !!accessToken.value)
|
||||
const nickname = computed(() => userInfo.value?.nickname || '管理员')
|
||||
const username = computed(() => userInfo.value?.username || '')
|
||||
const isAdmin = computed(() => userInfo.value?.id === 1)
|
||||
const roleDisplay = computed(() => roleNames.value.length > 0 ? roleNames.value.join('、') : '管理员')
|
||||
const hasPermission = (code: string) => {
|
||||
return permissions.value.some((p) => p === code || p.startsWith(code + ':'))
|
||||
}
|
||||
|
||||
async function login(username: string, password: string) {
|
||||
const res = await loginApi({ username, password })
|
||||
// 兼容两种响应格式:{ code, message, data } 包裹 或 直接返回 login data
|
||||
const data = res.data ?? res
|
||||
accessToken.value = data.accessToken
|
||||
refreshToken.value = data.refreshToken
|
||||
userInfo.value = data.userInfo
|
||||
|
||||
localStorage.setItem('accessToken', accessToken.value)
|
||||
localStorage.setItem('refreshToken', refreshToken.value)
|
||||
localStorage.setItem('userInfo', JSON.stringify(userInfo.value))
|
||||
|
||||
// 登录后获取用户权限
|
||||
await fetchPermissions()
|
||||
}
|
||||
|
||||
async function fetchPermissions() {
|
||||
if (!userInfo.value?.id) return
|
||||
try {
|
||||
const roles: any[] = await request.get(`/api/users/${userInfo.value.id}/roles`)
|
||||
if (!roles.length) return
|
||||
const allCodes: string[] = []
|
||||
const names: string[] = []
|
||||
for (const role of roles) {
|
||||
if (role.roleName) names.push(role.roleName)
|
||||
try {
|
||||
const perms: any[] = await request.get(`/api/roles/${role.id}/permissions`)
|
||||
for (const p of perms) {
|
||||
if (p.permissionCode && !allCodes.includes(p.permissionCode)) {
|
||||
allCodes.push(p.permissionCode)
|
||||
}
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
permissions.value = allCodes
|
||||
roleNames.value = names
|
||||
localStorage.setItem('userPermissions', JSON.stringify(allCodes))
|
||||
localStorage.setItem('userRoleNames', JSON.stringify(names))
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function logout() {
|
||||
accessToken.value = ''
|
||||
refreshToken.value = ''
|
||||
userInfo.value = null
|
||||
permissions.value = []
|
||||
roleNames.value = []
|
||||
localStorage.removeItem('accessToken')
|
||||
localStorage.removeItem('refreshToken')
|
||||
localStorage.removeItem('userInfo')
|
||||
localStorage.removeItem('userPermissions')
|
||||
localStorage.removeItem('userRoleNames')
|
||||
}
|
||||
|
||||
return { accessToken, refreshToken, userInfo, permissions, roleNames, isLoggedIn, nickname, username, isAdmin, roleDisplay, hasPermission, login, fetchPermissions, logout }
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
import request from '@/api/request'
|
||||
|
||||
interface UploadResult {
|
||||
success: boolean
|
||||
message: string
|
||||
url: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传团课封面图片(通过后端API上传到阿里云OSS,base64方式)
|
||||
* @param file 图片文件
|
||||
* @returns OSS访问地址
|
||||
*/
|
||||
export async function uploadCourseImage(file: File): Promise<string> {
|
||||
const base64 = await fileToBase64(file)
|
||||
const ext = file.name.split('.').pop() || 'png'
|
||||
|
||||
const result = await request.post<unknown, UploadResult>(
|
||||
'/api/groupCourse/upload/cover',
|
||||
{ image: base64, filename: file.name, extension: ext },
|
||||
)
|
||||
|
||||
if (result.success && result.url) {
|
||||
return result.url
|
||||
}
|
||||
|
||||
throw new Error(result.message || '上传失败')
|
||||
}
|
||||
|
||||
function fileToBase64(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
const result = reader.result as string
|
||||
// 去掉 data:image/xxx;base64, 前缀
|
||||
const base64 = result.split(',')[1]
|
||||
if (!base64) {
|
||||
reject(new Error('读取文件失败'))
|
||||
return
|
||||
}
|
||||
resolve(base64)
|
||||
}
|
||||
reader.onerror = () => reject(new Error('读取文件失败'))
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 将OSS原始URL转为后端代理URL,用于前端安全展示图片
|
||||
* @param ossUrl OSS原始访问地址
|
||||
* @returns 代理URL
|
||||
*/
|
||||
export function getProxyUrl(ossUrl: string): string {
|
||||
if (!ossUrl) return ''
|
||||
return `/api/groupCourse/cover-image/proxy?url=${encodeURIComponent(ossUrl)}`
|
||||
}
|
||||
@@ -0,0 +1,464 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { getCoachList, getCoachCoursesById, type CoachInfo } from '@/api/coach'
|
||||
import type { GroupCourse } from '@/api/groupCourse'
|
||||
import AppLayout from '@/components/AppLayout.vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
// --- 教练列表 ---
|
||||
const coaches = ref<CoachInfo[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
// --- 关键词搜索 ---
|
||||
const keyword = ref('')
|
||||
|
||||
// --- 排序 ---
|
||||
type SortMode = 'newest' | 'oldest' | 'name_asc'
|
||||
const sortMode = ref<SortMode>('newest')
|
||||
|
||||
// --- 选中教练查看详情 ---
|
||||
const selectedCoach = ref<CoachInfo | null>(null)
|
||||
const coachCourses = ref<GroupCourse[]>([])
|
||||
const loadingCourses = ref(false)
|
||||
|
||||
// --- 分页 ---
|
||||
const currentPage = ref(0)
|
||||
const pageSize = ref(8)
|
||||
|
||||
const filteredCoaches = computed(() => {
|
||||
let list = coaches.value
|
||||
|
||||
if (keyword.value.trim()) {
|
||||
const kw = keyword.value.trim().toLowerCase()
|
||||
list = list.filter(
|
||||
(c) =>
|
||||
c.nickname.toLowerCase().includes(kw) ||
|
||||
c.username.toLowerCase().includes(kw) ||
|
||||
(c.phone && c.phone.includes(kw)) ||
|
||||
(c.email && c.email.toLowerCase().includes(kw)),
|
||||
)
|
||||
}
|
||||
|
||||
if (sortMode.value === 'newest') {
|
||||
list = [...list].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
||||
} else if (sortMode.value === 'oldest') {
|
||||
list = [...list].sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
|
||||
} else if (sortMode.value === 'name_asc') {
|
||||
list = [...list].sort((a, b) => a.nickname.localeCompare(b.nickname, 'zh'))
|
||||
}
|
||||
|
||||
return list
|
||||
})
|
||||
|
||||
const totalElements = computed(() => filteredCoaches.value.length)
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(totalElements.value / pageSize.value)))
|
||||
|
||||
const pagedCoaches = computed(() => {
|
||||
const start = currentPage.value * pageSize.value
|
||||
return filteredCoaches.value.slice(start, start + pageSize.value)
|
||||
})
|
||||
|
||||
function changePage(page: number) {
|
||||
if (page >= 0 && page < totalPages.value) {
|
||||
currentPage.value = page
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchCoaches() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
coaches.value = await getCoachList()
|
||||
} catch (e: any) {
|
||||
coaches.value = []
|
||||
error.value = e?.response?.data?.message || e?.message || '加载教练列表失败,请检查网络连接'
|
||||
toast.error(error.value)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function selectCoach(coach: CoachInfo) {
|
||||
selectedCoach.value = coach
|
||||
loadingCourses.value = true
|
||||
try {
|
||||
coachCourses.value = await getCoachCoursesById(coach.id)
|
||||
} catch {
|
||||
coachCourses.value = []
|
||||
} finally {
|
||||
loadingCourses.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(t: string) {
|
||||
if (!t) return '--'
|
||||
return new Date(t).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
function formatDate(t: string) {
|
||||
if (!t) return '--'
|
||||
return new Date(t).toLocaleDateString('zh-CN')
|
||||
}
|
||||
|
||||
function getCourseStatusText(c: GroupCourse): string {
|
||||
if (c.deletedAt) return '已删除'
|
||||
const s = Number(c.status)
|
||||
if (s === 0) return '正常'
|
||||
if (s === 1) return '已取消'
|
||||
if (s === 2) return '已结束'
|
||||
return '未知'
|
||||
}
|
||||
|
||||
onMounted(fetchCoaches)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="coach-page">
|
||||
<!-- 左侧:教练列表 -->
|
||||
<div class="coach-sidebar">
|
||||
<div class="sidebar-header">
|
||||
<h3>教练列表</h3>
|
||||
<span class="coach-count">{{ coaches.length }} 人</span>
|
||||
</div>
|
||||
|
||||
<div class="search-box">
|
||||
<input
|
||||
v-model="keyword"
|
||||
type="text"
|
||||
placeholder="搜索教练姓名、用户名..."
|
||||
class="search-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="sort-area">
|
||||
<label>排序:</label>
|
||||
<select v-model="sortMode" class="sort-select">
|
||||
<option value="newest">最近加入</option>
|
||||
<option value="oldest">最早加入</option>
|
||||
<option value="name_asc">姓名排序</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="coach-list">
|
||||
<div v-if="loading" class="list-loading">加载中...</div>
|
||||
<div v-else-if="error" class="list-error">
|
||||
<p>{{ error }}</p>
|
||||
<button class="retry-btn" @click="fetchCoaches">重新加载</button>
|
||||
</div>
|
||||
<div v-else-if="coaches.length === 0" class="list-empty">暂无教练数据,请确认已创建教练角色和账号</div>
|
||||
<div v-else-if="pagedCoaches.length === 0" class="list-empty">无匹配教练</div>
|
||||
<div
|
||||
v-for="coach in pagedCoaches"
|
||||
:key="coach.id"
|
||||
class="coach-item"
|
||||
:class="{ active: selectedCoach?.id === coach.id }"
|
||||
@click="selectCoach(coach)"
|
||||
>
|
||||
<div class="coach-avatar">{{ coach.nickname.charAt(0).toUpperCase() }}</div>
|
||||
<div class="coach-info">
|
||||
<div class="coach-name">{{ coach.nickname }}</div>
|
||||
<div class="coach-username">@{{ coach.username }}</div>
|
||||
</div>
|
||||
<span class="coach-status" :class="coach.status === 1 ? 'active' : 'disabled'">
|
||||
{{ coach.status === 1 ? '正常' : '禁用' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pagination" v-if="totalPages > 1">
|
||||
<button :disabled="currentPage === 0" @click="changePage(currentPage - 1)">上一页</button>
|
||||
<span>第 {{ currentPage + 1 }} / {{ totalPages }} 页</span>
|
||||
<button :disabled="currentPage >= totalPages - 1" @click="changePage(currentPage + 1)">下一页</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:教练详情 -->
|
||||
<div class="coach-detail">
|
||||
<template v-if="selectedCoach">
|
||||
<div class="detail-card">
|
||||
<div class="detail-header">
|
||||
<div class="detail-avatar-large">{{ selectedCoach.nickname.charAt(0).toUpperCase() }}</div>
|
||||
<div>
|
||||
<h2>{{ selectedCoach.nickname }}</h2>
|
||||
<p class="detail-username">@{{ selectedCoach.username }}</p>
|
||||
</div>
|
||||
<span class="status-tag" :class="selectedCoach.status === 1 ? 'active' : 'disabled'">
|
||||
{{ selectedCoach.status === 1 ? '正常' : '禁用' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="detail-body">
|
||||
<div class="info-grid">
|
||||
<div class="info-item">
|
||||
<label>邮箱</label>
|
||||
<span>{{ selectedCoach.email || '--' }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>手机</label>
|
||||
<span>{{ selectedCoach.phone || '--' }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>加入时间</label>
|
||||
<span>{{ formatDate(selectedCoach.createdAt) }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>最后更新</label>
|
||||
<span>{{ formatDate(selectedCoach.updatedAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 负责的团课 -->
|
||||
<div class="courses-card">
|
||||
<h3>负责的团课 ({{ coachCourses.length }})</h3>
|
||||
<div v-if="loadingCourses" class="courses-loading">加载中...</div>
|
||||
<div v-else-if="coachCourses.length === 0" class="courses-empty">暂无团课</div>
|
||||
<table v-else class="courses-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>课程名称</th>
|
||||
<th>开始时间</th>
|
||||
<th>人数</th>
|
||||
<th>状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="c in coachCourses" :key="c.id">
|
||||
<td>{{ c.id }}</td>
|
||||
<td>{{ c.courseName }}</td>
|
||||
<td>{{ formatTime(c.startTime) }}</td>
|
||||
<td>{{ c.currentMembers }}/{{ c.maxMembers }}</td>
|
||||
<td>
|
||||
<span class="course-status" :class="getCourseStatusText(c) === '正常' ? 'on' : 'off'">
|
||||
{{ getCourseStatusText(c) }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else class="detail-empty">
|
||||
<i class="fas fa-user-tie"></i>
|
||||
<p>请从左侧列表选择一位教练查看详情</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.coach-page {
|
||||
display: flex; gap: 24px; height: calc(100vh - 100px);
|
||||
}
|
||||
|
||||
/* ---- 左侧 ---- */
|
||||
.coach-sidebar {
|
||||
width: 320px; min-width: 280px; background: white;
|
||||
border-radius: 20px; border: 1px solid #edf2f9;
|
||||
padding: 20px; display: flex; flex-direction: column; gap: 14px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
}
|
||||
|
||||
.sidebar-header h3 {
|
||||
font-size: 16px; font-weight: 600; color: #0b1a33; margin: 0;
|
||||
}
|
||||
|
||||
.coach-count {
|
||||
font-size: 13px; color: #8b9ab0; background: #f1f5f9;
|
||||
padding: 2px 10px; border-radius: 20px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%; padding: 8px 14px; border-radius: 10px;
|
||||
border: 1px solid #e2e8f0; font-size: 13px; outline: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
border-color: #4f7cff; box-shadow: 0 0 0 3px rgba(79,124,255,0.1);
|
||||
}
|
||||
|
||||
.sort-area {
|
||||
display: flex; align-items: center; gap: 6px; font-size: 13px; color: #4e627c;
|
||||
}
|
||||
|
||||
.sort-select {
|
||||
padding: 4px 10px; border-radius: 8px; border: 1px solid #e2e8f0;
|
||||
font-size: 13px; outline: none; cursor: pointer;
|
||||
}
|
||||
|
||||
.coach-list {
|
||||
flex: 1; overflow-y: auto;
|
||||
}
|
||||
|
||||
.list-loading, .list-empty {
|
||||
text-align: center; padding: 40px 0; color: #8b9ab0; font-size: 14px;
|
||||
}
|
||||
|
||||
.list-error {
|
||||
text-align: center; padding: 32px 16px;
|
||||
}
|
||||
|
||||
.list-error p {
|
||||
color: #ef4444; font-size: 14px; margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.retry-btn {
|
||||
display: inline-block; padding: 6px 20px; border: 1px solid #d1d5db; border-radius: 8px;
|
||||
background: #fff; color: #374151; font-size: 13px; cursor: pointer;
|
||||
}
|
||||
|
||||
.retry-btn:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.coach-item {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 10px 12px; border-radius: 12px; cursor: pointer;
|
||||
transition: 0.15s; margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.coach-item:hover { background: #f8fafd; }
|
||||
.coach-item.active { background: #eff6ff; border: 1px solid #bfdbfe; }
|
||||
|
||||
.coach-avatar {
|
||||
width: 40px; height: 40px; border-radius: 50%;
|
||||
background: linear-gradient(135deg, #4f7cff, #7b61ff);
|
||||
color: white; display: flex; align-items: center; justify-content: center;
|
||||
font-weight: 600; font-size: 16px; flex-shrink: 0;
|
||||
}
|
||||
|
||||
.coach-info { flex: 1; min-width: 0; }
|
||||
|
||||
.coach-name {
|
||||
font-size: 14px; font-weight: 500; color: #0b1a33;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
|
||||
.coach-username {
|
||||
font-size: 12px; color: #8b9ab0;
|
||||
}
|
||||
|
||||
.coach-status {
|
||||
font-size: 11px; padding: 2px 8px; border-radius: 20px; font-weight: 500;
|
||||
}
|
||||
|
||||
.coach-status.active { background: #e0f5ec; color: #0b8b5e; }
|
||||
.coach-status.disabled { background: #f1f5f9; color: #94a3b8; }
|
||||
|
||||
/* ---- 右侧 ---- */
|
||||
.coach-detail {
|
||||
flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: 20px;
|
||||
}
|
||||
|
||||
.detail-empty {
|
||||
display: flex; flex-direction: column; align-items: center;
|
||||
justify-content: center; height: 100%; color: #c0ccda; gap: 12px;
|
||||
}
|
||||
|
||||
.detail-empty i { font-size: 48px; }
|
||||
|
||||
.detail-empty p { font-size: 14px; }
|
||||
|
||||
.detail-card {
|
||||
background: white; border-radius: 20px; border: 1px solid #edf2f9;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
display: flex; align-items: center; gap: 16px; margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.detail-avatar-large {
|
||||
width: 56px; height: 56px; border-radius: 50%;
|
||||
background: linear-gradient(135deg, #4f7cff, #7b61ff);
|
||||
color: white; display: flex; align-items: center; justify-content: center;
|
||||
font-weight: 600; font-size: 22px; flex-shrink: 0;
|
||||
}
|
||||
|
||||
.detail-header h2 { font-size: 20px; font-weight: 600; color: #0b1a33; margin: 0 0 4px; }
|
||||
|
||||
.detail-username { font-size: 13px; color: #8b9ab0; margin: 0; }
|
||||
|
||||
.status-tag {
|
||||
margin-left: auto; padding: 4px 14px; border-radius: 20px;
|
||||
font-size: 13px; font-weight: 500;
|
||||
}
|
||||
|
||||
.status-tag.active { background: #e0f5ec; color: #0b8b5e; }
|
||||
.status-tag.disabled { background: #f1f5f9; color: #94a3b8; }
|
||||
|
||||
.info-grid {
|
||||
display: grid; grid-template-columns: 1fr 1fr; gap: 16px;
|
||||
}
|
||||
|
||||
.info-item label {
|
||||
display: block; font-size: 12px; color: #8b9ab0; margin-bottom: 4px; text-transform: uppercase;
|
||||
}
|
||||
|
||||
.info-item span {
|
||||
font-size: 14px; color: #1e293b; font-weight: 500;
|
||||
}
|
||||
|
||||
/* ---- 团课列表 ---- */
|
||||
.courses-card {
|
||||
background: white; border-radius: 20px; border: 1px solid #edf2f9;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.courses-card h3 {
|
||||
font-size: 16px; font-weight: 600; color: #0b1a33; margin: 0 0 16px;
|
||||
}
|
||||
|
||||
.courses-loading, .courses-empty {
|
||||
text-align: center; padding: 30px 0; color: #8b9ab0; font-size: 14px;
|
||||
}
|
||||
|
||||
.courses-table {
|
||||
width: 100%; border-collapse: collapse; font-size: 13px;
|
||||
}
|
||||
|
||||
.courses-table th {
|
||||
text-align: left; padding: 10px 8px; font-weight: 600;
|
||||
color: #4e627c; border-bottom: 1px solid #eef2f8;
|
||||
}
|
||||
|
||||
.courses-table td {
|
||||
padding: 10px 8px; border-bottom: 1px solid #f1f5fa; color: #1e2f44;
|
||||
}
|
||||
|
||||
.courses-table tr:last-child td { border-bottom: none; }
|
||||
|
||||
.course-status {
|
||||
padding: 2px 10px; border-radius: 20px; font-size: 12px; font-weight: 500;
|
||||
}
|
||||
|
||||
.course-status.on { background: #e0f5ec; color: #0b8b5e; }
|
||||
.course-status.off { background: #f1f5f9; color: #64748b; }
|
||||
|
||||
/* ---- pagination ---- */
|
||||
.pagination {
|
||||
display: flex; justify-content: center; align-items: center;
|
||||
gap: 12px; padding: 10px 0; font-size: 13px; color: #4e627c;
|
||||
}
|
||||
|
||||
.pagination button {
|
||||
padding: 4px 12px; border-radius: 6px; border: 1px solid #e2e8f0;
|
||||
background: white; cursor: pointer; font-size: 12px;
|
||||
}
|
||||
|
||||
.pagination button:hover:not(:disabled) { background: #f0f5ff; }
|
||||
.pagination button:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
</style>
|
||||
@@ -0,0 +1,124 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import {
|
||||
getMemberStatistics,
|
||||
getBookingStatistics,
|
||||
getSignInStatistics,
|
||||
type MemberStatistics,
|
||||
type BookingStatistics,
|
||||
type SignInStatistics,
|
||||
} from '@/api/datacount'
|
||||
import AppLayout from '@/components/AppLayout.vue'
|
||||
|
||||
const memberStats = ref<MemberStatistics | null>(null)
|
||||
const bookingStats = ref<BookingStatistics | null>(null)
|
||||
const signInStats = ref<SignInStatistics | null>(null)
|
||||
const loading = ref(true)
|
||||
const periodType = ref('DAY')
|
||||
const showDatePicker = ref(false)
|
||||
|
||||
const startDate = ref('')
|
||||
const endDate = ref('')
|
||||
|
||||
const periodOptions = [
|
||||
{ label: '今日', value: 'DAY' },
|
||||
{ label: '本周', value: 'WEEK' },
|
||||
{ label: '本月', value: 'MONTH' },
|
||||
{ label: '自定义', value: 'CUSTOM' },
|
||||
]
|
||||
|
||||
const periodLabel = computed(() => {
|
||||
switch (periodType.value) {
|
||||
case 'WEEK': return '本周新增'
|
||||
case 'MONTH': return '本月新增'
|
||||
case 'CUSTOM': return '区间新增'
|
||||
default: return '今日新增'
|
||||
}
|
||||
})
|
||||
|
||||
const statsValues = ref({
|
||||
totalMembers: 0,
|
||||
newMembers: 0,
|
||||
totalBookings: 0,
|
||||
totalSignIns: 0,
|
||||
})
|
||||
|
||||
const statsCards = computed(() => [
|
||||
{ label: '会员总数', value: statsValues.value.totalMembers, color: '#4f7cff', icon: 'fa-users' },
|
||||
{ label: periodLabel.value, value: statsValues.value.newMembers, color: '#10b981', icon: 'fa-user-plus' },
|
||||
{ label: '预约总数', value: statsValues.value.totalBookings, color: '#f59e0b', icon: 'fa-calendar-check' },
|
||||
{ label: '签到总数', value: statsValues.value.totalSignIns, color: '#ec4899', icon: 'fa-clipboard-check' },
|
||||
])
|
||||
|
||||
const recentActivities = ref([
|
||||
{ title: '会员签到', time: '--', dot: '' },
|
||||
{ title: '团课预约', time: '--', dot: 'green' },
|
||||
{ title: '新会员注册', time: '--', dot: 'orange' },
|
||||
{ title: '课程取消', time: '--', dot: 'pink' },
|
||||
])
|
||||
|
||||
function formatDateTime(dateStr: string, isEnd: boolean): string {
|
||||
if (!dateStr) return ''
|
||||
return isEnd ? dateStr + 'T23:59:59' : dateStr + 'T00:00:00'
|
||||
}
|
||||
|
||||
async function fetchData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: Record<string, string> = {}
|
||||
if (periodType.value === 'CUSTOM') {
|
||||
if (startDate.value) params.startTime = formatDateTime(startDate.value, false)
|
||||
if (endDate.value) params.endTime = formatDateTime(endDate.value, true)
|
||||
} else {
|
||||
params.periodType = periodType.value
|
||||
}
|
||||
|
||||
const [mem, book, sign] = await Promise.all([
|
||||
getMemberStatistics(params),
|
||||
getBookingStatistics(params),
|
||||
getSignInStatistics(params),
|
||||
])
|
||||
memberStats.value = mem
|
||||
bookingStats.value = book
|
||||
signInStats.value = sign
|
||||
|
||||
statsValues.value.totalMembers = Number(mem.totalMembers) || 0
|
||||
statsValues.value.newMembers = Number(mem.newMembers) || 0
|
||||
statsValues.value.totalBookings = Number(book.newBookings) || 0
|
||||
statsValues.value.totalSignIns = Number(sign.totalSignIns) || 0
|
||||
|
||||
const now = new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
|
||||
recentActivities.value = [
|
||||
{ title: '\u7b7e\u5230\u6210\u529f ' + (Number(sign.successSignIns) || 0) + ' \u6b21', time: now, dot: '' },
|
||||
{ title: '\u9884\u7ea6 ' + (Number(book.newBookings) || 0) + ' \u5355', time: now, dot: 'green' },
|
||||
{ title: '\u65b0\u589e\u4f1a\u5458 ' + (Number(mem.newMembers) || 0) + ' \u4eba', time: now, dot: 'orange' },
|
||||
{ title: '\u53d6\u6d88\u9884\u7ea6 ' + (Number(book.cancelBookings) || 0) + ' \u5355', time: now, dot: 'pink' },
|
||||
]
|
||||
} catch {
|
||||
memberStats.value = null
|
||||
bookingStats.value = null
|
||||
signInStats.value = null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function changePeriod(value: string) {
|
||||
periodType.value = value
|
||||
showDatePicker.value = value === 'CUSTOM'
|
||||
if (value !== 'CUSTOM') {
|
||||
fetchData()
|
||||
}
|
||||
}
|
||||
|
||||
function handleCustomQuery() {
|
||||
if (!startDate.value && !endDate.value) return
|
||||
fetchData()
|
||||
}
|
||||
|
||||
function today(): string {
|
||||
return new Date().toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
onMounted(fetchData)
|
||||
</script>
|
||||
@@ -0,0 +1,617 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import {
|
||||
getMemberStatistics,
|
||||
getBookingStatistics,
|
||||
getSignInStatistics,
|
||||
exportStatistics,
|
||||
type MemberStatistics,
|
||||
type BookingStatistics,
|
||||
type SignInStatistics,
|
||||
} from '@/api/datacount'
|
||||
import AppLayout from '@/components/AppLayout.vue'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const memberStats = ref<MemberStatistics | null>(null)
|
||||
const bookingStats = ref<BookingStatistics | null>(null)
|
||||
const signInStats = ref<SignInStatistics | null>(null)
|
||||
const loading = ref(true)
|
||||
const exporting = ref(false)
|
||||
const periodType = ref('DAY')
|
||||
const showDatePicker = ref(false)
|
||||
|
||||
const startDate = ref('')
|
||||
const endDate = ref('')
|
||||
|
||||
const periodOptions = [
|
||||
{ label: '今日', value: 'DAY' },
|
||||
{ label: '本周', value: 'WEEK' },
|
||||
{ label: '本月', value: 'MONTH' },
|
||||
{ label: '本年', value: 'YEAR' },
|
||||
{ label: '总计', value: 'ALL' },
|
||||
{ label: '自定义', value: 'CUSTOM' },
|
||||
]
|
||||
|
||||
const periodLabel = computed(() => {
|
||||
switch (periodType.value) {
|
||||
case 'WEEK': return '本周新增'
|
||||
case 'MONTH': return '本月新增'
|
||||
case 'YEAR': return '本年新增'
|
||||
case 'ALL': return '累计'
|
||||
case 'CUSTOM': return '区间新增'
|
||||
default: return '今日新增'
|
||||
}
|
||||
})
|
||||
|
||||
const statsValues = ref({
|
||||
totalMembers: 0,
|
||||
newMembers: 0,
|
||||
totalBookings: 0,
|
||||
totalSignIns: 0,
|
||||
})
|
||||
|
||||
const statsCards = computed(() => [
|
||||
{ label: '会员总数', value: statsValues.value.totalMembers, color: '#4f7cff', icon: 'fa-users', clickable: false },
|
||||
{ label: periodLabel.value, value: statsValues.value.newMembers, color: '#10b981', icon: 'fa-user-plus', clickable: false },
|
||||
{ label: '预约总数', value: statsValues.value.totalBookings, color: '#f59e0b', icon: 'fa-calendar-check', clickable: false },
|
||||
{ label: '签到总数', value: statsValues.value.totalSignIns, color: '#ec4899', icon: 'fa-clipboard-check', clickable: true, route: '/member-signin' },
|
||||
])
|
||||
|
||||
function handleCardClick(card: { clickable?: boolean; route?: string }) {
|
||||
if (card.clickable && card.route) {
|
||||
router.push(card.route)
|
||||
}
|
||||
}
|
||||
|
||||
const recentActivities = ref([
|
||||
{ title: '会员签到', time: '--', dot: '' },
|
||||
{ title: '团课预约', time: '--', dot: 'green' },
|
||||
{ title: '新会员注册', time: '--', dot: 'orange' },
|
||||
{ title: '课程取消', time: '--', dot: 'pink' },
|
||||
])
|
||||
|
||||
function formatDateTime(dateStr: string, isEnd: boolean): string {
|
||||
if (!dateStr) return ''
|
||||
return isEnd ? dateStr + 'T23:59:59' : dateStr + 'T00:00:00'
|
||||
}
|
||||
|
||||
async function fetchData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: Record<string, string> = {}
|
||||
if (periodType.value === 'CUSTOM') {
|
||||
if (startDate.value) params.startTime = formatDateTime(startDate.value, false)
|
||||
if (endDate.value) params.endTime = formatDateTime(endDate.value, true)
|
||||
} else {
|
||||
params.periodType = periodType.value
|
||||
}
|
||||
|
||||
const [mem, book, sign] = await Promise.all([
|
||||
getMemberStatistics(params),
|
||||
getBookingStatistics(params),
|
||||
getSignInStatistics(params),
|
||||
])
|
||||
memberStats.value = mem
|
||||
bookingStats.value = book
|
||||
signInStats.value = sign
|
||||
|
||||
statsValues.value.totalMembers = Number(mem.totalMembers) || 0
|
||||
statsValues.value.newMembers = Number(mem.newMembers) || 0
|
||||
statsValues.value.totalBookings = Number(book.newBookings) || 0
|
||||
statsValues.value.totalSignIns = Number(sign.totalSignIns) || 0
|
||||
|
||||
const now = new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
|
||||
recentActivities.value = [
|
||||
{ title: '签到成功 ' + (Number(sign.successSignIns) || 0) + ' 次', time: now, dot: '' },
|
||||
{ title: '预约 ' + (Number(book.newBookings) || 0) + ' 单', time: now, dot: 'green' },
|
||||
{ title: '新增会员 ' + (Number(mem.newMembers) || 0) + ' 人', time: now, dot: 'orange' },
|
||||
{ title: '取消预约 ' + (Number(book.cancelBookings) || 0) + ' 单', time: now, dot: 'pink' },
|
||||
]
|
||||
} catch {
|
||||
memberStats.value = null
|
||||
bookingStats.value = null
|
||||
signInStats.value = null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function changePeriod(value: string) {
|
||||
periodType.value = value
|
||||
showDatePicker.value = value === 'CUSTOM'
|
||||
if (value !== 'CUSTOM') {
|
||||
fetchData()
|
||||
}
|
||||
}
|
||||
|
||||
function handleCustomQuery() {
|
||||
if (!startDate.value && !endDate.value) return
|
||||
fetchData()
|
||||
}
|
||||
|
||||
async function handleExport() {
|
||||
exporting.value = true
|
||||
try {
|
||||
const params: Record<string, string> = {}
|
||||
if (periodType.value === 'CUSTOM') {
|
||||
if (startDate.value) params.startTime = formatDateTime(startDate.value, false)
|
||||
if (endDate.value) params.endTime = formatDateTime(endDate.value, true)
|
||||
} else {
|
||||
params.periodType = periodType.value
|
||||
}
|
||||
const blob = await exportStatistics(params)
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
const now = new Date()
|
||||
const filename = `数据报表_${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}${String(now.getSeconds()).padStart(2, '0')}.xlsx`
|
||||
link.download = filename
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
window.URL.revokeObjectURL(url)
|
||||
} catch {
|
||||
// 导出失败
|
||||
} finally {
|
||||
exporting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function today(): string {
|
||||
return new Date().toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
onMounted(fetchData)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="period-bar">
|
||||
<div class="period-selector">
|
||||
<button
|
||||
v-for="opt in periodOptions"
|
||||
:key="opt.value"
|
||||
class="period-btn"
|
||||
:class="{ active: periodType === opt.value }"
|
||||
@click="changePeriod(opt.value)"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<transition name="fade">
|
||||
<div v-if="showDatePicker" class="date-range">
|
||||
<label>开始日期</label>
|
||||
<input v-model="startDate" type="date" :max="endDate || today()" />
|
||||
<label>结束日期</label>
|
||||
<input v-model="endDate" type="date" :min="startDate" :max="today()" />
|
||||
<button class="btn btn-primary" @click="handleCustomQuery">
|
||||
<i class="fas fa-search"></i> 查询
|
||||
</button>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<button class="btn btn-export" :disabled="exporting || loading" @click="handleExport">
|
||||
<i class="fas fa-download"></i>
|
||||
<span>{{ exporting ? '导出中...' : '导出报表' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<section class="stats-grid">
|
||||
<template v-if="loading">
|
||||
<div class="stat-card skeleton-card" v-for="i in 4" :key="'sk-' + i">
|
||||
<div class="sk-label skeleton"></div>
|
||||
<div class="sk-value skeleton"></div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div
|
||||
class="stat-card"
|
||||
:class="{ 'stat-card-clickable': card.clickable }"
|
||||
v-for="card in statsCards"
|
||||
:key="card.label"
|
||||
@click="handleCardClick(card)"
|
||||
>
|
||||
<div class="stat-card-inner">
|
||||
<div class="stat-icon" :style="{ background: card.color + '18', color: card.color }">
|
||||
<i :class="'fas ' + card.icon"></i>
|
||||
</div>
|
||||
<div class="stat-body">
|
||||
<div class="label">{{ card.label }}</div>
|
||||
<div class="value" :style="{ color: card.color }">
|
||||
{{ card.value.toLocaleString() }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<section class="content-grid">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-chart-bar" style="margin-right: 8px; color: #4f7cff"></i>数据概览</h3>
|
||||
</div>
|
||||
<template v-if="loading">
|
||||
<div class="detail-grid">
|
||||
<div class="detail-item" v-for="i in 4" :key="'ds-' + i">
|
||||
<div class="sk-label-small skeleton" style="width:60%;margin:0 auto 8px"></div>
|
||||
<div class="sk-value-small skeleton" style="width:40%;margin:0 auto"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="memberStats">
|
||||
<div class="detail-grid">
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">出席率</span>
|
||||
<span class="detail-value">{{ bookingStats?.attendanceRate ?? '--' }}%</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">取消率</span>
|
||||
<span class="detail-value down">{{ bookingStats?.cancelRate ?? '--' }}%</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">签到成功率</span>
|
||||
<span class="detail-value">{{ signInStats?.successRate ?? '--' }}%</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">活跃会员</span>
|
||||
<span class="detail-value">{{ memberStats?.activeMembers ?? 0 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="empty-state">暂无数据</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-clock" style="margin-right: 8px; color: #4f7cff"></i>最近动态</h3>
|
||||
</div>
|
||||
<template v-if="loading">
|
||||
<div class="activity-list">
|
||||
<div class="activity-item" v-for="i in 4" :key="'as-' + i">
|
||||
<div class="sk-dot skeleton"></div>
|
||||
<div class="activity-content">
|
||||
<div class="sk-line skeleton" style="width:70%"></div>
|
||||
<div class="sk-line skeleton" style="width:40%;margin-top:6px"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="activity-list">
|
||||
<div class="activity-item" v-for="(item, idx) in recentActivities" :key="idx">
|
||||
<div class="activity-dot" :class="item.dot"></div>
|
||||
<div class="activity-content">
|
||||
<div class="title">{{ item.title }}</div>
|
||||
<div class="time">{{ item.time }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* ---- period ---- */
|
||||
.period-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.period-selector {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.period-btn {
|
||||
padding: 8px 20px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: white;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #3e4e62;
|
||||
cursor: pointer;
|
||||
transition: 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.period-btn.active {
|
||||
background: #4f7cff;
|
||||
border-color: #4f7cff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.period-btn:hover:not(.active) {
|
||||
background: #f0f5ff;
|
||||
}
|
||||
|
||||
/* ---- date range ---- */
|
||||
.date-range {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: white;
|
||||
padding: 8px 14px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid #edf2f9;
|
||||
animation: fadeIn 0.25s ease;
|
||||
}
|
||||
|
||||
.date-range label {
|
||||
font-size: 13px;
|
||||
color: #6b7d94;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.date-range input {
|
||||
padding: 6px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
font-size: 13px;
|
||||
color: #1e293b;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 7px 16px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: white;
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
color: #1e293b;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
transition: 0.15s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #4f7cff;
|
||||
border-color: #4f7cff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover { background: #3b6bf0; }
|
||||
|
||||
.btn-export {
|
||||
background: #10b981;
|
||||
border-color: #10b981;
|
||||
color: white;
|
||||
padding: 8px 18px;
|
||||
border-radius: 20px;
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
transition: 0.15s;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.btn-export:hover { background: #059669; }
|
||||
.btn-export:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
|
||||
/* ---- stats grid ---- */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: white;
|
||||
border-radius: 20px;
|
||||
border: 1px solid #edf2f9;
|
||||
padding: 24px 20px;
|
||||
animation: fadeIn 0.4s ease;
|
||||
}
|
||||
|
||||
.stat-card-clickable {
|
||||
cursor: pointer;
|
||||
transition: box-shadow 0.2s, transform 0.2s;
|
||||
}
|
||||
|
||||
.stat-card-clickable:hover {
|
||||
box-shadow: 0 4px 16px rgba(236, 72, 153, 0.15);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.stat-card-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.stat-body .label {
|
||||
font-size: 13px;
|
||||
color: #6b7d94;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-body .value {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* ---- content grid ---- */
|
||||
.content-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: 20px;
|
||||
border: 1px solid #edf2f9;
|
||||
padding: 20px 24px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.card-header h3 {
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
color: #0b1a33;
|
||||
}
|
||||
|
||||
/* ---- detail grid ---- */
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
text-align: center;
|
||||
padding: 12px;
|
||||
background: #f8fafd;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: #8b9ab0;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.detail-value.down { color: #ef4444; }
|
||||
|
||||
/* ---- activity ---- */
|
||||
.activity-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.activity-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid #f1f5fa;
|
||||
}
|
||||
|
||||
.activity-item:last-child { border-bottom: none; }
|
||||
|
||||
.activity-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: #4f7cff;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.activity-dot.green { background: #10b981; }
|
||||
.activity-dot.orange { background: #f59e0b; }
|
||||
.activity-dot.pink { background: #ec4899; }
|
||||
|
||||
.activity-content .title {
|
||||
font-size: 14px;
|
||||
color: #1e293b;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.activity-content .time {
|
||||
font-size: 12px;
|
||||
color: #8b9ab0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 32px;
|
||||
color: #8b9ab0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* ---- skeleton ---- */
|
||||
.skeleton {
|
||||
background: linear-gradient(90deg, #eef2f8 25%, #e0e7f0 50%, #eef2f8 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.skeleton-card { padding: 28px 20px; }
|
||||
|
||||
.sk-label {
|
||||
height: 14px;
|
||||
width: 50%;
|
||||
margin-bottom: 12px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.sk-value {
|
||||
height: 30px;
|
||||
width: 35%;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.sk-label-small { height: 12px; display: block; }
|
||||
.sk-value-small { height: 22px; display: block; }
|
||||
|
||||
.sk-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sk-line {
|
||||
height: 14px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
/* ---- animations ---- */
|
||||
@keyframes shimmer {
|
||||
0% { background-position: -200% 0; }
|
||||
100% { background-position: 200% 0; }
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(6px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.fade-enter-active { animation: fadeIn 0.25s ease; }
|
||||
.fade-leave-active { animation: fadeIn 0.15s ease reverse; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.stats-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.content-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,599 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import {
|
||||
exportStatistics,
|
||||
getMemberStatistics,
|
||||
getBookingStatistics,
|
||||
getSignInStatistics,
|
||||
getMemberSignInDetails,
|
||||
type MemberStatistics,
|
||||
type BookingStatistics,
|
||||
type SignInStatistics,
|
||||
type MemberSignInDetail,
|
||||
} from '@/api/datacount'
|
||||
import AppLayout from '@/components/AppLayout.vue'
|
||||
|
||||
const periodType = ref('ALL')
|
||||
const showDatePicker = ref(false)
|
||||
const startDate = ref('')
|
||||
const endDate = ref('')
|
||||
const exporting = ref(false)
|
||||
const loading = ref(false)
|
||||
|
||||
const reportType = ref('summary')
|
||||
|
||||
const memberStats = ref<MemberStatistics | null>(null)
|
||||
const bookingStats = ref<BookingStatistics | null>(null)
|
||||
const signInStats = ref<SignInStatistics | null>(null)
|
||||
const memberDetails = ref<MemberSignInDetail[]>([])
|
||||
const detailTotal = ref(0)
|
||||
|
||||
const periodOptions = [
|
||||
{ label: '今日', value: 'DAY' },
|
||||
{ label: '本周', value: 'WEEK' },
|
||||
{ label: '本月', value: 'MONTH' },
|
||||
{ label: '本年', value: 'YEAR' },
|
||||
{ label: '总计', value: 'ALL' },
|
||||
{ label: '自定义', value: 'CUSTOM' },
|
||||
]
|
||||
|
||||
const reportOptions = [
|
||||
{ label: '综合统计', value: 'summary', icon: 'fa-chart-pie' },
|
||||
{ label: '会员统计', value: 'member', icon: 'fa-users' },
|
||||
{ label: '预约统计', value: 'booking', icon: 'fa-calendar-check' },
|
||||
{ label: '签到统计', value: 'signin', icon: 'fa-clipboard-check' },
|
||||
]
|
||||
|
||||
function formatDateTime(dateStr: string, isEnd: boolean): string {
|
||||
if (!dateStr) return ''
|
||||
return isEnd ? dateStr + 'T23:59:59' : dateStr + 'T00:00:00'
|
||||
}
|
||||
|
||||
function buildParams(): Record<string, string> {
|
||||
const params: Record<string, string> = {}
|
||||
if (periodType.value === 'CUSTOM') {
|
||||
if (startDate.value) params.startTime = formatDateTime(startDate.value, false)
|
||||
if (endDate.value) params.endTime = formatDateTime(endDate.value, true)
|
||||
} else {
|
||||
params.periodType = periodType.value
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
async function handlePreview() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = buildParams()
|
||||
if (reportType.value === 'summary' || reportType.value === 'member') {
|
||||
memberStats.value = await getMemberStatistics(params)
|
||||
}
|
||||
if (reportType.value === 'summary' || reportType.value === 'booking') {
|
||||
bookingStats.value = await getBookingStatistics(params)
|
||||
}
|
||||
if (reportType.value === 'summary' || reportType.value === 'signin') {
|
||||
signInStats.value = await getSignInStatistics(params)
|
||||
}
|
||||
if (reportType.value === 'signin') {
|
||||
const detailParams = { ...params, page: 1, size: 100 }
|
||||
const result = await getMemberSignInDetails(detailParams)
|
||||
memberDetails.value = result.list || []
|
||||
detailTotal.value = result.total || 0
|
||||
}
|
||||
} catch {
|
||||
memberStats.value = null
|
||||
bookingStats.value = null
|
||||
signInStats.value = null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExport() {
|
||||
exporting.value = true
|
||||
try {
|
||||
const params: Record<string, string> = { ...buildParams() }
|
||||
params.statType = reportType.value
|
||||
const blob = await exportStatistics(params)
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
const now = new Date()
|
||||
const reportLabel = reportOptions.find((r) => r.value === reportType.value)?.label || '报表'
|
||||
const filename = `${reportLabel}_${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}${String(now.getSeconds()).padStart(2, '0')}.xlsx`
|
||||
link.download = filename
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
window.URL.revokeObjectURL(url)
|
||||
} catch {
|
||||
// 导出失败
|
||||
} finally {
|
||||
exporting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function changePeriod(value: string) {
|
||||
periodType.value = value
|
||||
showDatePicker.value = value === 'CUSTOM'
|
||||
if (value !== 'CUSTOM') {
|
||||
handlePreview()
|
||||
}
|
||||
}
|
||||
|
||||
function switchReportType(value: string) {
|
||||
reportType.value = value
|
||||
handlePreview()
|
||||
}
|
||||
|
||||
function today(): string {
|
||||
return new Date().toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
const hasData = computed(() => {
|
||||
return memberStats.value || bookingStats.value || signInStats.value
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="report-toolbar">
|
||||
<div class="period-selector">
|
||||
<button
|
||||
v-for="opt in periodOptions"
|
||||
:key="opt.value"
|
||||
class="period-btn"
|
||||
:class="{ active: periodType === opt.value }"
|
||||
@click="changePeriod(opt.value)"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<transition name="fade">
|
||||
<div v-if="showDatePicker" class="date-range">
|
||||
<label>开始日期</label>
|
||||
<input v-model="startDate" type="date" :max="endDate || today()" />
|
||||
<label>结束日期</label>
|
||||
<input v-model="endDate" type="date" :min="startDate" :max="today()" />
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
|
||||
<section class="report-type-bar">
|
||||
<div class="report-type-selector">
|
||||
<button
|
||||
v-for="opt in reportOptions"
|
||||
:key="opt.value"
|
||||
class="report-type-btn"
|
||||
:class="{ active: reportType === opt.value }"
|
||||
@click="switchReportType(opt.value)"
|
||||
>
|
||||
<i :class="'fas ' + opt.icon"></i>
|
||||
<span>{{ opt.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="action-bar">
|
||||
<button class="btn btn-outline" :disabled="loading" @click="handlePreview">
|
||||
<i class="fas fa-eye"></i> 预览数据
|
||||
</button>
|
||||
<button class="btn btn-export" :disabled="exporting" @click="handleExport">
|
||||
<i class="fas fa-file-excel"></i>
|
||||
{{ exporting ? '导出中...' : '导出Excel' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<section v-if="loading" class="preview-section">
|
||||
<div class="sk-table">
|
||||
<div class="sk-row" v-for="i in 5" :key="'sk-' + i">
|
||||
<div class="sk-cell skeleton" style="width: 30%"></div>
|
||||
<div class="sk-cell skeleton" style="width: 20%"></div>
|
||||
<div class="sk-cell skeleton" style="width: 20%"></div>
|
||||
<div class="sk-cell skeleton" style="width: 30%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-else-if="hasData" class="preview-section">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>
|
||||
<i :class="'fas ' + (reportOptions.find(r => r.value === reportType)?.icon || 'fa-chart-pie')" style="margin-right: 8px; color: #4f7cff"></i>
|
||||
{{ reportOptions.find(r => r.value === reportType)?.label || '统计' }}预览
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<!-- 会员统计 -->
|
||||
<div v-if="(reportType === 'summary' || reportType === 'member') && memberStats" class="stats-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>统计项</th>
|
||||
<th>数值</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>统计日期</td>
|
||||
<td>{{ memberStats.statDate }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>新增会员数</td>
|
||||
<td class="highlight">{{ memberStats.newMembers }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>活跃会员数</td>
|
||||
<td class="highlight">{{ memberStats.activeMembers }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>累计会员总数</td>
|
||||
<td class="highlight">{{ memberStats.totalMembers }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>签到会员数</td>
|
||||
<td>{{ memberStats.signInMembers }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>预约会员数</td>
|
||||
<td>{{ memberStats.bookingMembers }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>取消预约会员数</td>
|
||||
<td>{{ memberStats.cancelMembers }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 预约统计 -->
|
||||
<div v-if="(reportType === 'summary' || reportType === 'booking') && bookingStats" class="stats-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>统计项</th>
|
||||
<th>数值</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>统计日期</td>
|
||||
<td>{{ bookingStats.statDate }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>新增预约数</td>
|
||||
<td class="highlight">{{ bookingStats.newBookings }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>取消预约数</td>
|
||||
<td>{{ bookingStats.cancelBookings }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>出席预约数</td>
|
||||
<td class="highlight">{{ bookingStats.attendBookings }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>缺席预约数</td>
|
||||
<td>{{ bookingStats.absentBookings }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>预约出席率</td>
|
||||
<td class="highlight">{{ bookingStats.attendanceRate }}%</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>取消率</td>
|
||||
<td>{{ bookingStats.cancelRate }}%</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 签到统计 -->
|
||||
<div v-if="(reportType === 'summary' || reportType === 'signin') && signInStats" class="stats-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>统计项</th>
|
||||
<th>数值</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>统计日期</td>
|
||||
<td>{{ signInStats.statDate }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>签到总次数</td>
|
||||
<td class="highlight">{{ signInStats.totalSignIns }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>成功签到次数</td>
|
||||
<td class="highlight">{{ signInStats.successSignIns }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>失败签到次数</td>
|
||||
<td>{{ signInStats.failSignIns }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>签到成功率</td>
|
||||
<td class="highlight">{{ signInStats.successRate }}%</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-else class="empty-section">
|
||||
<div class="empty-state">
|
||||
<i class="fas fa-chart-bar empty-icon"></i>
|
||||
<p>点击"预览数据"查看报表内容</p>
|
||||
</div>
|
||||
</section>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* ---- toolbar ---- */
|
||||
.report-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.period-selector {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.period-btn {
|
||||
padding: 8px 20px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: white;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #3e4e62;
|
||||
cursor: pointer;
|
||||
transition: 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.period-btn.active {
|
||||
background: #4f7cff;
|
||||
border-color: #4f7cff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.period-btn:hover:not(.active) {
|
||||
background: #f0f5ff;
|
||||
}
|
||||
|
||||
/* ---- date range ---- */
|
||||
.date-range {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: white;
|
||||
padding: 8px 14px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid #edf2f9;
|
||||
animation: fadeIn 0.25s ease;
|
||||
}
|
||||
|
||||
.date-range label {
|
||||
font-size: 13px;
|
||||
color: #6b7d94;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.date-range input {
|
||||
padding: 6px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
font-size: 13px;
|
||||
color: #1e293b;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* ---- report type ---- */
|
||||
.report-type-bar {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.report-type-selector {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.report-type-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 20px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: white;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #3e4e62;
|
||||
cursor: pointer;
|
||||
transition: 0.15s;
|
||||
}
|
||||
|
||||
.report-type-btn i {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.report-type-btn.active {
|
||||
background: #4f7cff;
|
||||
border-color: #4f7cff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.report-type-btn:hover:not(.active) {
|
||||
background: #f0f5ff;
|
||||
}
|
||||
|
||||
/* ---- action bar ---- */
|
||||
.action-bar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 9px 20px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: white;
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
color: #1e293b;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
transition: 0.15s;
|
||||
}
|
||||
|
||||
.btn-outline:hover {
|
||||
background: #f0f5ff;
|
||||
border-color: #4f7cff;
|
||||
color: #4f7cff;
|
||||
}
|
||||
|
||||
.btn-export {
|
||||
background: #10b981;
|
||||
border-color: #10b981;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-export:hover { background: #059669; }
|
||||
.btn:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
|
||||
/* ---- preview ---- */
|
||||
.preview-section {
|
||||
animation: fadeIn 0.35s ease;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: 20px;
|
||||
border: 1px solid #edf2f9;
|
||||
padding: 20px 24px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.card-header h3 {
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
color: #0b1a33;
|
||||
}
|
||||
|
||||
.stats-table {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
thead th {
|
||||
text-align: left;
|
||||
padding: 10px 14px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #6b7d94;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
border-bottom: 1px solid #edf2f9;
|
||||
}
|
||||
|
||||
tbody td {
|
||||
padding: 10px 14px;
|
||||
font-size: 14px;
|
||||
color: #1e293b;
|
||||
border-bottom: 1px solid #f1f5fa;
|
||||
}
|
||||
|
||||
tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
td.highlight {
|
||||
font-weight: 600;
|
||||
color: #4f7cff;
|
||||
}
|
||||
|
||||
/* ---- empty ---- */
|
||||
.empty-section {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 60px 0;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
color: #8b9ab0;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* ---- skeleton ---- */
|
||||
.skeleton {
|
||||
background: linear-gradient(90deg, #eef2f8 25%, #e0e7f0 50%, #eef2f8 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.sk-table {
|
||||
background: white;
|
||||
border-radius: 20px;
|
||||
border: 1px solid #edf2f9;
|
||||
padding: 20px 24px;
|
||||
}
|
||||
|
||||
.sk-row {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #f1f5fa;
|
||||
}
|
||||
|
||||
.sk-row:last-child { border-bottom: none; }
|
||||
|
||||
.sk-cell {
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
/* ---- animations ---- */
|
||||
@keyframes shimmer {
|
||||
0% { background-position: -200% 0; }
|
||||
100% { background-position: 200% 0; }
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(6px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.fade-enter-active { animation: fadeIn 0.25s ease; }
|
||||
.fade-leave-active { animation: fadeIn 0.15s ease reverse; }
|
||||
</style>
|
||||
@@ -0,0 +1,923 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import {
|
||||
getAllDictTypes,
|
||||
createDictType,
|
||||
updateDictType,
|
||||
deleteDictType,
|
||||
getDictDataByType,
|
||||
createDictData,
|
||||
updateDictData,
|
||||
deleteDictData,
|
||||
type SysDictType,
|
||||
type SysDictData,
|
||||
type CreateDictTypeDto,
|
||||
type UpdateDictTypeDto,
|
||||
type CreateDictDataDto,
|
||||
type UpdateDictDataDto,
|
||||
} from '@/api/dict'
|
||||
import AppLayout from '@/components/AppLayout.vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
// ==================== 字典类型 ====================
|
||||
const dictTypes = ref<SysDictType[]>([])
|
||||
const typesLoading = ref(false)
|
||||
|
||||
// 类型表单弹窗
|
||||
const showTypeModal = ref(false)
|
||||
const typeFormLoading = ref(false)
|
||||
const editingType = ref<SysDictType | null>(null)
|
||||
const typeForm = ref<CreateDictTypeDto>({ dictName: '', dictType: '', status: '0', remark: '' })
|
||||
const typeFormErrors = ref<Record<string, string>>({})
|
||||
|
||||
// 类型删除
|
||||
const showTypeDeleteModal = ref(false)
|
||||
const typeDeleteTarget = ref<SysDictType | null>(null)
|
||||
const typeDeleteLoading = ref(false)
|
||||
|
||||
// ==================== 字典数据 ====================
|
||||
const selectedType = ref<SysDictType | null>(null)
|
||||
const dictData = ref<SysDictData[]>([])
|
||||
const dataLoading = ref(false)
|
||||
|
||||
// 数据表单弹窗
|
||||
const showDataModal = ref(false)
|
||||
const dataFormLoading = ref(false)
|
||||
const editingData = ref<SysDictData | null>(null)
|
||||
const dataForm = ref<CreateDictDataDto>({
|
||||
dictSort: 0,
|
||||
dictLabel: '',
|
||||
dictValue: '',
|
||||
dictType: '',
|
||||
cssClass: '',
|
||||
listClass: '',
|
||||
isDefault: 'N',
|
||||
status: '0',
|
||||
})
|
||||
const dataFormErrors = ref<Record<string, string>>({})
|
||||
|
||||
// 数据删除
|
||||
const showDataDeleteModal = ref(false)
|
||||
const dataDeleteTarget = ref<SysDictData | null>(null)
|
||||
const dataDeleteLoading = ref(false)
|
||||
|
||||
// ==================== 类型状态映射 ====================
|
||||
const statusMap: Record<string, string> = { '0': '正常', '1': '关闭' }
|
||||
|
||||
// ==================== 字典类型 API ====================
|
||||
async function fetchDictTypes() {
|
||||
typesLoading.value = true
|
||||
try {
|
||||
dictTypes.value = await getAllDictTypes()
|
||||
} catch (e: any) {
|
||||
dictTypes.value = []
|
||||
const msg = e?.response?.data?.message || e?.message || '获取字典类型失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
typesLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// --- 类型弹窗 ---
|
||||
function openTypeCreate() {
|
||||
editingType.value = null
|
||||
typeForm.value = { dictName: '', dictType: '', status: '0', remark: '' }
|
||||
typeFormErrors.value = {}
|
||||
showTypeModal.value = true
|
||||
}
|
||||
|
||||
function openTypeEdit(tp: SysDictType) {
|
||||
editingType.value = tp
|
||||
typeForm.value = {
|
||||
dictName: tp.dictName,
|
||||
dictType: tp.dictType,
|
||||
status: tp.status,
|
||||
remark: tp.remark || '',
|
||||
}
|
||||
typeFormErrors.value = {}
|
||||
showTypeModal.value = true
|
||||
}
|
||||
|
||||
function validateTypeForm(): boolean {
|
||||
const errs: Record<string, string> = {}
|
||||
if (!typeForm.value.dictName.trim()) errs.dictName = '字典名称不能为空'
|
||||
if (!editingType.value && !typeForm.value.dictType.trim()) errs.dictType = '字典类型编码不能为空'
|
||||
typeFormErrors.value = errs
|
||||
return Object.keys(errs).length === 0
|
||||
}
|
||||
|
||||
async function handleTypeSubmit() {
|
||||
if (!validateTypeForm()) return
|
||||
typeFormLoading.value = true
|
||||
try {
|
||||
if (editingType.value) {
|
||||
const dto: UpdateDictTypeDto = {
|
||||
dictName: typeForm.value.dictName,
|
||||
status: typeForm.value.status,
|
||||
remark: typeForm.value.remark,
|
||||
}
|
||||
await updateDictType(editingType.value.id, dto)
|
||||
toast.success('字典类型更新成功')
|
||||
} else {
|
||||
await createDictType(typeForm.value)
|
||||
toast.success('字典类型创建成功')
|
||||
}
|
||||
showTypeModal.value = false
|
||||
fetchDictTypes()
|
||||
} catch (e: any) {
|
||||
const msg = e?.response?.data?.message || e?.message || '操作失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
typeFormLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// --- 类型删除 ---
|
||||
function openTypeDelete(tp: SysDictType) {
|
||||
typeDeleteTarget.value = tp
|
||||
showTypeDeleteModal.value = true
|
||||
}
|
||||
|
||||
async function handleTypeDelete() {
|
||||
if (!typeDeleteTarget.value) return
|
||||
typeDeleteLoading.value = true
|
||||
try {
|
||||
await deleteDictType(typeDeleteTarget.value.id)
|
||||
toast.success('字典类型已删除')
|
||||
showTypeDeleteModal.value = false
|
||||
if (selectedType.value?.id === typeDeleteTarget.value.id) {
|
||||
selectedType.value = null
|
||||
dictData.value = []
|
||||
}
|
||||
fetchDictTypes()
|
||||
} catch (e: any) {
|
||||
const msg = e?.response?.data?.message || e?.message || '删除失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
typeDeleteLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 字典数据 API ====================
|
||||
function selectType(tp: SysDictType) {
|
||||
selectedType.value = tp
|
||||
fetchDictData(tp.dictType)
|
||||
}
|
||||
|
||||
async function fetchDictData(dictTypeCode: string) {
|
||||
dataLoading.value = true
|
||||
try {
|
||||
dictData.value = await getDictDataByType(dictTypeCode)
|
||||
} catch {
|
||||
dictData.value = []
|
||||
} finally {
|
||||
dataLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// --- 数据弹窗 ---
|
||||
function openDataCreate() {
|
||||
if (!selectedType.value) {
|
||||
toast.error('请先选择一个字典类型')
|
||||
return
|
||||
}
|
||||
editingData.value = null
|
||||
dataForm.value = {
|
||||
dictSort: 0,
|
||||
dictLabel: '',
|
||||
dictValue: '',
|
||||
dictType: selectedType.value.dictType,
|
||||
cssClass: '',
|
||||
listClass: '',
|
||||
isDefault: 'N',
|
||||
status: '0',
|
||||
}
|
||||
dataFormErrors.value = {}
|
||||
showDataModal.value = true
|
||||
}
|
||||
|
||||
function openDataEdit(item: SysDictData) {
|
||||
editingData.value = item
|
||||
dataForm.value = {
|
||||
dictSort: item.dictSort,
|
||||
dictLabel: item.dictLabel,
|
||||
dictValue: item.dictValue,
|
||||
dictType: item.dictType,
|
||||
cssClass: item.cssClass || '',
|
||||
listClass: item.listClass || '',
|
||||
isDefault: item.isDefault,
|
||||
status: item.status,
|
||||
}
|
||||
dataFormErrors.value = {}
|
||||
showDataModal.value = true
|
||||
}
|
||||
|
||||
function validateDataForm(): boolean {
|
||||
const errs: Record<string, string> = {}
|
||||
if (!dataForm.value.dictLabel.trim()) errs.dictLabel = '字典标签不能为空'
|
||||
if (!dataForm.value.dictValue.trim()) errs.dictValue = '字典键值不能为空'
|
||||
dataFormErrors.value = errs
|
||||
return Object.keys(errs).length === 0
|
||||
}
|
||||
|
||||
async function handleDataSubmit() {
|
||||
if (!validateDataForm()) return
|
||||
dataFormLoading.value = true
|
||||
try {
|
||||
if (editingData.value) {
|
||||
const dto: UpdateDictDataDto = {
|
||||
dictSort: dataForm.value.dictSort,
|
||||
dictLabel: dataForm.value.dictLabel,
|
||||
dictValue: dataForm.value.dictValue,
|
||||
cssClass: dataForm.value.cssClass || undefined,
|
||||
listClass: dataForm.value.listClass || undefined,
|
||||
isDefault: dataForm.value.isDefault,
|
||||
status: dataForm.value.status,
|
||||
}
|
||||
await updateDictData(editingData.value.id, dto)
|
||||
toast.success('字典数据更新成功')
|
||||
} else {
|
||||
await createDictData(dataForm.value)
|
||||
toast.success('字典数据创建成功')
|
||||
}
|
||||
showDataModal.value = false
|
||||
if (selectedType.value) fetchDictData(selectedType.value.dictType)
|
||||
} catch (e: any) {
|
||||
const msg = e?.response?.data?.message || e?.message || '操作失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
dataFormLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// --- 数据删除 ---
|
||||
function openDataDelete(item: SysDictData) {
|
||||
dataDeleteTarget.value = item
|
||||
showDataDeleteModal.value = true
|
||||
}
|
||||
|
||||
async function handleDataDelete() {
|
||||
if (!dataDeleteTarget.value) return
|
||||
dataDeleteLoading.value = true
|
||||
try {
|
||||
await deleteDictData(dataDeleteTarget.value.id)
|
||||
toast.success('字典数据已删除')
|
||||
showDataDeleteModal.value = false
|
||||
if (selectedType.value) fetchDictData(selectedType.value.dictType)
|
||||
} catch (e: any) {
|
||||
const msg = e?.response?.data?.message || e?.message || '删除失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
dataDeleteLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 工具函数 ====================
|
||||
function formatTime(t: string | null): string {
|
||||
if (!t) return '--'
|
||||
return new Date(t).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
// ==================== 初始化 ====================
|
||||
onMounted(fetchDictTypes)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="dict-page">
|
||||
<!-- ========== 左侧:字典类型 ========== -->
|
||||
<div class="dict-panel dict-types-panel">
|
||||
<div class="panel-header">
|
||||
<h3><i class="fas fa-tags"></i> 字典类型</h3>
|
||||
<button class="btn btn-sm btn-primary" @click="openTypeCreate">
|
||||
<i class="fas fa-plus"></i> 新增
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="type-list" v-if="!typesLoading">
|
||||
<div v-if="dictTypes.length === 0" class="empty-list">暂无字典类型</div>
|
||||
<div
|
||||
v-for="tp in dictTypes"
|
||||
:key="tp.id"
|
||||
class="type-item"
|
||||
:class="{ selected: selectedType?.id === tp.id }"
|
||||
@click="selectType(tp)"
|
||||
>
|
||||
<div class="type-item-left">
|
||||
<div class="type-name">{{ tp.dictName }}</div>
|
||||
<div class="type-code">{{ tp.dictType }}</div>
|
||||
</div>
|
||||
<div class="type-item-right" @click.stop>
|
||||
<button class="btn-icon" title="编辑" @click="openTypeEdit(tp)">
|
||||
<i class="fas fa-edit"></i>
|
||||
</button>
|
||||
<button class="btn-icon btn-icon-danger" title="删除" @click="openTypeDelete(tp)">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="loading-state">
|
||||
<i class="fas fa-spinner fa-spin"></i> 加载中...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ========== 右侧:字典数据 ========== -->
|
||||
<div class="dict-panel dict-data-panel">
|
||||
<div class="panel-header">
|
||||
<h3>
|
||||
<i class="fas fa-list"></i>
|
||||
{{ selectedType ? selectedType.dictName + ' - 数据项' : '字典数据' }}
|
||||
</h3>
|
||||
<button class="btn btn-sm btn-primary" @click="openDataCreate" :disabled="!selectedType">
|
||||
<i class="fas fa-plus"></i> 新增
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="data-table-wrapper" v-if="!dataLoading">
|
||||
<table class="data-table" v-if="dictData.length > 0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 60px">ID</th>
|
||||
<th style="width: 140px">标签</th>
|
||||
<th style="width: 120px">键值</th>
|
||||
<th style="width: 70px">排序</th>
|
||||
<th style="width: 70px">默认</th>
|
||||
<th style="width: 70px">状态</th>
|
||||
<th style="width: 100px">创建时间</th>
|
||||
<th style="width: 130px">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in dictData" :key="item.id">
|
||||
<td>{{ item.id }}</td>
|
||||
<td class="text-bold">{{ item.dictLabel }}</td>
|
||||
<td class="text-mono">{{ item.dictValue }}</td>
|
||||
<td>{{ item.dictSort }}</td>
|
||||
<td>
|
||||
<span class="default-tag" :class="item.isDefault === 'Y' ? 'is-default' : ''">
|
||||
{{ item.isDefault === 'Y' ? '是' : '否' }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="status-badge" :class="item.status === '0' ? 'active' : 'inactive'">
|
||||
{{ statusMap[item.status] || item.status }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-sm">{{ formatTime(item.createdAt) }}</td>
|
||||
<td class="action-cell">
|
||||
<button class="btn btn-sm btn-outline" @click="openDataEdit(item)">
|
||||
<i class="fas fa-edit"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-danger" @click="openDataDelete(item)">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-else class="empty-list">
|
||||
{{ selectedType ? '该类型下暂无数据项' : '请先在左侧选择一个字典类型' }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="loading-state">
|
||||
<i class="fas fa-spinner fa-spin"></i> 加载中...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ========== 字典类型弹窗 ========== -->
|
||||
<Teleport to="body">
|
||||
<div class="modal-overlay" v-if="showTypeModal" @click.self="showTypeModal = false">
|
||||
<div class="modal-box modal-sm">
|
||||
<div class="modal-header">
|
||||
<h3>
|
||||
<i :class="editingType ? 'fas fa-edit' : 'fas fa-plus'"></i>
|
||||
{{ editingType ? '编辑字典类型' : '新增字典类型' }}
|
||||
</h3>
|
||||
<button class="modal-close" @click="showTypeModal = false">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label>字典名称 <span class="required">*</span></label>
|
||||
<input v-model="typeForm.dictName" class="form-input" placeholder="如:性别" />
|
||||
<span class="field-error" v-if="typeFormErrors.dictName">{{ typeFormErrors.dictName }}</span>
|
||||
</div>
|
||||
<div class="form-group" v-if="!editingType">
|
||||
<label>字典类型编码 <span class="required">*</span></label>
|
||||
<input v-model="typeForm.dictType" class="form-input" placeholder="如:sys_user_sex" />
|
||||
<span class="field-error" v-if="typeFormErrors.dictType">{{ typeFormErrors.dictType }}</span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>状态</label>
|
||||
<select v-model="typeForm.status" class="form-input">
|
||||
<option value="0">正常</option>
|
||||
<option value="1">关闭</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>备注</label>
|
||||
<input v-model="typeForm.remark" class="form-input" placeholder="可选备注信息" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-outline" @click="showTypeModal = false">取消</button>
|
||||
<button class="btn btn-primary" :disabled="typeFormLoading" @click="handleTypeSubmit">
|
||||
<i class="fas fa-spinner fa-spin" v-if="typeFormLoading"></i>
|
||||
{{ typeFormLoading ? '保存中...' : '保存' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- ========== 字典类型删除弹窗 ========== -->
|
||||
<Teleport to="body">
|
||||
<div class="modal-overlay" v-if="showTypeDeleteModal" @click.self="showTypeDeleteModal = false">
|
||||
<div class="modal-box modal-sm">
|
||||
<div class="modal-header">
|
||||
<h3><i class="fas fa-exclamation-triangle" style="color: #e74c3c"></i> 确认删除</h3>
|
||||
<button class="modal-close" @click="showTypeDeleteModal = false">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="modal-desc">
|
||||
确定要删除字典类型 <strong>{{ typeDeleteTarget?.dictName }}</strong> 吗?
|
||||
</p>
|
||||
<p class="modal-hint">删除类型会同时影响关联的字典数据。</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-outline" @click="showTypeDeleteModal = false">取消</button>
|
||||
<button class="btn btn-danger" :disabled="typeDeleteLoading" @click="handleTypeDelete">
|
||||
<i class="fas fa-spinner fa-spin" v-if="typeDeleteLoading"></i>
|
||||
{{ typeDeleteLoading ? '删除中...' : '确认删除' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- ========== 字典数据弹窗 ========== -->
|
||||
<Teleport to="body">
|
||||
<div class="modal-overlay" v-if="showDataModal" @click.self="showDataModal = false">
|
||||
<div class="modal-box modal-sm">
|
||||
<div class="modal-header">
|
||||
<h3>
|
||||
<i :class="editingData ? 'fas fa-edit' : 'fas fa-plus'"></i>
|
||||
{{ editingData ? '编辑字典数据' : '新增字典数据' }}
|
||||
</h3>
|
||||
<button class="modal-close" @click="showDataModal = false">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label>字典标签 <span class="required">*</span></label>
|
||||
<input v-model="dataForm.dictLabel" class="form-input" placeholder="如:男" />
|
||||
<span class="field-error" v-if="dataFormErrors.dictLabel">{{ dataFormErrors.dictLabel }}</span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>字典键值 <span class="required">*</span></label>
|
||||
<input v-model="dataForm.dictValue" class="form-input" placeholder="如:0" />
|
||||
<span class="field-error" v-if="dataFormErrors.dictValue">{{ dataFormErrors.dictValue }}</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>排序</label>
|
||||
<input v-model.number="dataForm.dictSort" type="number" class="form-input" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>是否默认</label>
|
||||
<select v-model="dataForm.isDefault" class="form-input">
|
||||
<option value="N">否</option>
|
||||
<option value="Y">是</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>状态</label>
|
||||
<select v-model="dataForm.status" class="form-input">
|
||||
<option value="0">正常</option>
|
||||
<option value="1">关闭</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-outline" @click="showDataModal = false">取消</button>
|
||||
<button class="btn btn-primary" :disabled="dataFormLoading" @click="handleDataSubmit">
|
||||
<i class="fas fa-spinner fa-spin" v-if="dataFormLoading"></i>
|
||||
{{ dataFormLoading ? '保存中...' : '保存' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- ========== 字典数据删除弹窗 ========== -->
|
||||
<Teleport to="body">
|
||||
<div class="modal-overlay" v-if="showDataDeleteModal" @click.self="showDataDeleteModal = false">
|
||||
<div class="modal-box modal-sm">
|
||||
<div class="modal-header">
|
||||
<h3><i class="fas fa-exclamation-triangle" style="color: #e74c3c"></i> 确认删除</h3>
|
||||
<button class="modal-close" @click="showDataDeleteModal = false">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="modal-desc">
|
||||
确定要删除字典数据 <strong>{{ dataDeleteTarget?.dictLabel }}</strong> 吗?
|
||||
</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-outline" @click="showDataDeleteModal = false">取消</button>
|
||||
<button class="btn btn-danger" :disabled="dataDeleteLoading" @click="handleDataDelete">
|
||||
<i class="fas fa-spinner fa-spin" v-if="dataDeleteLoading"></i>
|
||||
{{ dataDeleteLoading ? '删除中...' : '确认删除' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* ==================== 布局 ==================== */
|
||||
.dict-page {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
height: calc(100vh - 140px);
|
||||
}
|
||||
|
||||
.dict-panel {
|
||||
background: #ffffff;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e9edf4;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dict-types-panel {
|
||||
width: 320px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dict-data-panel {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #f0f3f8;
|
||||
}
|
||||
|
||||
.panel-header h3 {
|
||||
font-size: 15px;
|
||||
color: #1e293b;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* ==================== 类型列表 ==================== */
|
||||
.type-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.type-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 20px;
|
||||
border-bottom: 1px solid #f0f3f8;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.type-item:hover {
|
||||
background: #f8f9fc;
|
||||
}
|
||||
|
||||
.type-item.selected {
|
||||
background: #eef2ff;
|
||||
border-left: 3px solid #4f7cff;
|
||||
}
|
||||
|
||||
.type-item-left {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.type-name {
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
font-size: 14px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.type-code {
|
||||
font-size: 12px;
|
||||
color: #8b9ab0;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
}
|
||||
|
||||
.type-item-right {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #8b9ab0;
|
||||
cursor: pointer;
|
||||
padding: 4px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.btn-icon:hover {
|
||||
color: #4f7cff;
|
||||
background: #eef2ff;
|
||||
}
|
||||
|
||||
.btn-icon-danger:hover {
|
||||
color: #e74c3c;
|
||||
background: #fde8e8;
|
||||
}
|
||||
|
||||
.empty-list {
|
||||
padding: 40px 20px;
|
||||
text-align: center;
|
||||
color: #8b9ab0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* ==================== 数据表格 ==================== */
|
||||
.data-table-wrapper {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
background: #f8f9fc;
|
||||
color: #5a6a85;
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid #e9edf4;
|
||||
white-space: nowrap;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.data-table td {
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid #f0f3f8;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.data-table tbody tr:hover {
|
||||
background: #f8f9fc;
|
||||
}
|
||||
|
||||
.text-bold {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.text-mono {
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.text-sm {
|
||||
font-size: 12px;
|
||||
color: #8b9ab0;
|
||||
}
|
||||
|
||||
.action-cell {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.default-tag {
|
||||
display: inline-block;
|
||||
padding: 1px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 12px;
|
||||
background: #f0f3f8;
|
||||
color: #8b9ab0;
|
||||
}
|
||||
|
||||
.default-tag.is-default {
|
||||
background: #e8f5e9;
|
||||
color: #2e7d32;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-badge.active {
|
||||
background: #e8f5e9;
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
.status-badge.inactive {
|
||||
background: #fce4e4;
|
||||
color: #c62828;
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
padding: 60px;
|
||||
text-align: center;
|
||||
color: #8b9ab0;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
/* ==================== 按钮 ==================== */
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #4f7cff;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: #3b5fd9;
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
background: #ffffff;
|
||||
color: #4a5568;
|
||||
border: 1px solid #dde2eb;
|
||||
}
|
||||
|
||||
.btn-outline:hover:not(:disabled) {
|
||||
background: #f0f3f8;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #e74c3c;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: #c0392b;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 5px 10px;
|
||||
font-size: 12px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
/* ==================== 弹窗 ==================== */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-box {
|
||||
background: #ffffff;
|
||||
border-radius: 14px;
|
||||
width: 480px;
|
||||
max-width: 90vw;
|
||||
max-height: 85vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.modal-box.modal-sm {
|
||||
width: 400px;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px 24px 16px;
|
||||
border-bottom: 1px solid #f0f3f8;
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
font-size: 17px;
|
||||
color: #1e293b;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 24px;
|
||||
color: #8b9ab0;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 20px 24px;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
padding: 16px 24px;
|
||||
border-top: 1px solid #f0f3f8;
|
||||
}
|
||||
|
||||
.modal-desc {
|
||||
color: #5a6a85;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.modal-hint {
|
||||
font-size: 13px;
|
||||
color: #8b9ab0;
|
||||
}
|
||||
|
||||
/* ==================== 表单 ==================== */
|
||||
.form-group {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #5a6a85;
|
||||
}
|
||||
|
||||
.required {
|
||||
color: #e74c3c;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 9px 14px;
|
||||
border: 1px solid #dde2eb;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
color: #1e293b;
|
||||
transition: border-color 0.2s;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
border-color: #4f7cff;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.field-error {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: #e74c3c;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,596 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import {
|
||||
getCourseList,
|
||||
cancelCourse,
|
||||
type GroupCourse,
|
||||
} from '@/api/groupCourse'
|
||||
import { verifyPassword } from '@/api/auth'
|
||||
import AppLayout from '@/components/AppLayout.vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const toast = useToast()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const allCourses = ref<GroupCourse[]>([])
|
||||
const loading = ref(false)
|
||||
const cancelLoading = ref(false)
|
||||
|
||||
// 多选
|
||||
const selectedIds = ref<Set<number>>(new Set())
|
||||
const selectAll = ref(false)
|
||||
|
||||
// 密码验证
|
||||
const showPasswordModal = ref(false)
|
||||
const adminPassword = ref('')
|
||||
const passwordError = ref('')
|
||||
const verifying = ref(false)
|
||||
|
||||
// 搜索
|
||||
const keyword = ref('')
|
||||
|
||||
const cancellableCourses = computed(() => {
|
||||
const list = allCourses.value.filter((c) => c.status != null && Number(c.status) === 0)
|
||||
if (!keyword.value.trim()) return list
|
||||
const kw = keyword.value.trim().toLowerCase()
|
||||
return list.filter(
|
||||
(c) =>
|
||||
c.courseName.toLowerCase().includes(kw) ||
|
||||
String(c.id).includes(kw) ||
|
||||
(c.location && c.location.toLowerCase().includes(kw)),
|
||||
)
|
||||
})
|
||||
|
||||
const hasSelection = computed(() => selectedIds.value.size > 0)
|
||||
|
||||
async function fetchCourses() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await getCourseList()
|
||||
allCourses.value = data
|
||||
} catch {
|
||||
allCourses.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSelect(id: number) {
|
||||
const next = new Set(selectedIds.value)
|
||||
if (next.has(id)) {
|
||||
next.delete(id)
|
||||
} else {
|
||||
next.add(id)
|
||||
}
|
||||
selectedIds.value = next
|
||||
selectAll.value = next.size === cancellableCourses.value.length && next.size > 0
|
||||
}
|
||||
|
||||
function toggleSelectAll() {
|
||||
if (selectAll.value) {
|
||||
selectedIds.value = new Set()
|
||||
selectAll.value = false
|
||||
} else {
|
||||
selectedIds.value = new Set(cancellableCourses.value.map((c) => c.id))
|
||||
selectAll.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function openCancelDialog() {
|
||||
if (!hasSelection.value) {
|
||||
toast.error('请先选择要取消的团课')
|
||||
return
|
||||
}
|
||||
adminPassword.value = ''
|
||||
passwordError.value = ''
|
||||
showPasswordModal.value = true
|
||||
}
|
||||
|
||||
async function handleVerify() {
|
||||
if (!adminPassword.value) {
|
||||
passwordError.value = '请输入管理员密码'
|
||||
return
|
||||
}
|
||||
verifying.value = true
|
||||
passwordError.value = ''
|
||||
try {
|
||||
await verifyPassword({
|
||||
username: authStore.username,
|
||||
password: adminPassword.value,
|
||||
})
|
||||
showPasswordModal.value = false
|
||||
await executeBatchCancel()
|
||||
} catch {
|
||||
passwordError.value = '密码验证失败,请重新输入'
|
||||
} finally {
|
||||
verifying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function executeBatchCancel() {
|
||||
cancelLoading.value = true
|
||||
const ids = Array.from(selectedIds.value)
|
||||
let successCount = 0
|
||||
let failCount = 0
|
||||
let firstError = ''
|
||||
|
||||
for (const id of ids) {
|
||||
try {
|
||||
await cancelCourse(id)
|
||||
successCount++
|
||||
} catch (e: any) {
|
||||
failCount++
|
||||
if (!firstError) {
|
||||
firstError =
|
||||
e?.response?.data?.message ||
|
||||
e?.message ||
|
||||
`课程 id=${id} 取消失败`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
selectedIds.value = new Set()
|
||||
selectAll.value = false
|
||||
|
||||
if (failCount === 0) {
|
||||
toast.success(`已成功取消 ${successCount} 个团课`)
|
||||
} else {
|
||||
toast.error(firstError || `成功 ${successCount} 个,失败 ${failCount} 个`)
|
||||
}
|
||||
|
||||
cancelLoading.value = false
|
||||
fetchCourses()
|
||||
}
|
||||
|
||||
function formatTime(t: string) {
|
||||
if (!t) return '--'
|
||||
return new Date(t).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
onMounted(fetchCourses)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="toolbar">
|
||||
<div class="search-row">
|
||||
<input
|
||||
v-model="keyword"
|
||||
type="text"
|
||||
placeholder="搜索课程名称、ID 或地点..."
|
||||
class="search-input"
|
||||
/>
|
||||
<span class="count-badge">
|
||||
可取消: <strong>{{ cancellableCourses.length }}</strong> 个
|
||||
</span>
|
||||
</div>
|
||||
<div class="action-row">
|
||||
<span v-if="hasSelection" class="selection-info">
|
||||
已选择 <strong>{{ selectedIds.size }}</strong> 个团课
|
||||
</span>
|
||||
<button
|
||||
class="btn btn-danger"
|
||||
:disabled="!hasSelection || cancelLoading"
|
||||
@click="openCancelDialog"
|
||||
>
|
||||
<i v-if="cancelLoading" class="fas fa-spinner fa-spin"></i>
|
||||
<i v-else class="fas fa-ban"></i>
|
||||
{{ cancelLoading ? '取消中...' : `批量取消 (${selectedIds.size})` }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-wrapper">
|
||||
<div class="table-header">
|
||||
<h3>可取消团课列表</h3>
|
||||
</div>
|
||||
|
||||
<!-- skeleton -->
|
||||
<table v-if="loading">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:40px"></th>
|
||||
<th>ID</th><th>课程名称</th><th>开始时间</th><th>结束时间</th>
|
||||
<th>人数</th><th>地点</th><th>状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="i in 5" :key="'sk-' + i">
|
||||
<td v-for="j in 8" :key="j">
|
||||
<span class="sk-td skeleton"></span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- real table -->
|
||||
<table v-else>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:40px">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="selectAll"
|
||||
@change="toggleSelectAll"
|
||||
:disabled="cancellableCourses.length === 0"
|
||||
/>
|
||||
</th>
|
||||
<th>ID</th><th>课程名称</th><th>开始时间</th><th>结束时间</th>
|
||||
<th>人数</th><th>地点</th><th>状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="c in cancellableCourses"
|
||||
:key="c.id"
|
||||
:class="{ 'row-selected': selectedIds.has(c.id) }"
|
||||
@click="toggleSelect(c.id)"
|
||||
>
|
||||
<td @click.stop>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="selectedIds.has(c.id)"
|
||||
@change="toggleSelect(c.id)"
|
||||
/>
|
||||
</td>
|
||||
<td>{{ c.id }}</td>
|
||||
<td class="course-name">{{ c.courseName }}</td>
|
||||
<td>{{ formatTime(c.startTime) }}</td>
|
||||
<td>{{ formatTime(c.endTime) }}</td>
|
||||
<td>{{ c.currentMembers }}/{{ c.maxMembers }}</td>
|
||||
<td>{{ c.location }}</td>
|
||||
<td>
|
||||
<span class="status-badge green">正常</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="cancellableCourses.length === 0">
|
||||
<td colspan="8" class="empty">
|
||||
{{ keyword ? '未找到匹配的团课' : '暂无可取消的团课' }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 密码验证弹窗 -->
|
||||
<div v-if="showPasswordModal" class="modal-overlay" @click.self="showPasswordModal = false">
|
||||
<div class="modal modal-sm">
|
||||
<h3><i class="fas fa-shield-alt" style="margin-right:8px;color:#4f7cff"></i>管理员身份验证</h3>
|
||||
<p class="modal-desc">
|
||||
即将取消 <strong>{{ selectedIds.size }}</strong> 个团课,请输入管理员密码确认操作。
|
||||
</p>
|
||||
|
||||
<div class="form-group">
|
||||
<label>管理员账号</label>
|
||||
<input :value="authStore.username" type="text" disabled class="input-disabled" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>管理员密码 <span class="required">*</span></label>
|
||||
<div class="input-wrapper">
|
||||
<i class="fas fa-lock"></i>
|
||||
<input
|
||||
v-model="adminPassword"
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
@keyup.enter="handleVerify"
|
||||
ref="passwordInput"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="passwordError" class="field-error">{{ passwordError }}</p>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-outline" @click="showPasswordModal = false">取消</button>
|
||||
<button class="btn btn-danger" @click="handleVerify" :disabled="verifying">
|
||||
<i v-if="verifying" class="fas fa-spinner fa-spin"></i>
|
||||
{{ verifying ? '验证中...' : '确认取消' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.toolbar {
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.search-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
padding: 9px 16px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
font-size: 14px;
|
||||
width: 280px;
|
||||
outline: none;
|
||||
transition: 0.15s;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
border-color: #4f7cff;
|
||||
box-shadow: 0 0 0 3px rgba(79,124,255,0.12);
|
||||
}
|
||||
|
||||
.count-badge {
|
||||
font-size: 13px;
|
||||
color: #6b7d94;
|
||||
background: #f8fafd;
|
||||
padding: 6px 14px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid #edf2f9;
|
||||
}
|
||||
|
||||
.action-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.selection-info {
|
||||
font-size: 13px;
|
||||
color: #4f7cff;
|
||||
background: #eff6ff;
|
||||
padding: 6px 14px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid #bfdbfe;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 9px 20px;
|
||||
border-radius: 40px;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: white;
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
color: #1e293b;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
transition: 0.15s;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #ef4444;
|
||||
border-color: #ef4444;
|
||||
color: white;
|
||||
box-shadow: 0 4px 10px rgba(239,68,68,0.25);
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) { background: #dc2626; }
|
||||
.btn-danger:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.btn-outline:hover { background: #f0f5ff; }
|
||||
|
||||
.table-wrapper {
|
||||
background: white;
|
||||
border-radius: 20px;
|
||||
border: 1px solid #edf2f9;
|
||||
padding: 20px 24px 8px 24px;
|
||||
}
|
||||
|
||||
.table-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.table-header h3 {
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
color: #0b1a33;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
padding: 12px 8px 12px 0;
|
||||
font-weight: 600;
|
||||
color: #4e627c;
|
||||
border-bottom: 1px solid #eef2f8;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 14px 8px 14px 0;
|
||||
border-bottom: 1px solid #f1f5fa;
|
||||
color: #1e2f44;
|
||||
}
|
||||
|
||||
tr:last-child td { border-bottom: none; }
|
||||
|
||||
td.empty {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #8b9ab0;
|
||||
}
|
||||
|
||||
tr {
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
animation: fadeIn 0.35s ease;
|
||||
}
|
||||
|
||||
tr:hover {
|
||||
background: #f8fafd;
|
||||
}
|
||||
|
||||
tr.row-selected {
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
tr.row-selected:hover {
|
||||
background: #dbeafe;
|
||||
}
|
||||
|
||||
input[type='checkbox'] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
accent-color: #4f7cff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.course-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ---- skeleton ---- */
|
||||
.skeleton {
|
||||
background: linear-gradient(90deg, #eef2f8 25%, #e0e7f0 50%, #eef2f8 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.sk-td { display: block; height: 16px; width: 80%; }
|
||||
|
||||
/* ---- badge ---- */
|
||||
.status-badge {
|
||||
padding: 4px 12px;
|
||||
border-radius: 40px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
display: inline-block;
|
||||
background: #e0f5ec;
|
||||
color: #0b8b5e;
|
||||
}
|
||||
|
||||
/* ---- modal ---- */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 200;
|
||||
animation: fadeIn 0.15s ease;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: white;
|
||||
border-radius: 20px;
|
||||
padding: 28px;
|
||||
width: 560px;
|
||||
max-width: 92vw;
|
||||
box-shadow: 0 12px 40px rgba(0,0,0,0.12);
|
||||
animation: fadeIn 0.2s ease;
|
||||
}
|
||||
|
||||
.modal-sm {
|
||||
width: 440px;
|
||||
}
|
||||
|
||||
.modal h3 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #0b1a33;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.modal-desc {
|
||||
font-size: 14px;
|
||||
color: #6b7d94;
|
||||
margin-bottom: 20px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.modal-desc strong {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.form-group .required { color: #ef4444; }
|
||||
|
||||
.input-disabled {
|
||||
width: 100%;
|
||||
padding: 9px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #e2e8f0;
|
||||
font-size: 14px;
|
||||
background: #f1f5f9;
|
||||
color: #94a3b8;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: #f8fafd;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
padding: 8px 14px;
|
||||
transition: 0.15s;
|
||||
}
|
||||
|
||||
.input-wrapper:focus-within {
|
||||
border-color: #4f7cff;
|
||||
box-shadow: 0 0 0 3px rgba(79,124,255,0.12);
|
||||
}
|
||||
|
||||
.input-wrapper i {
|
||||
color: #8b9ab0;
|
||||
font-size: 14px;
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
.input-wrapper input {
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.field-error {
|
||||
color: #ef4444;
|
||||
font-size: 13px;
|
||||
margin-top: -6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
/* ---- animations ---- */
|
||||
@keyframes shimmer {
|
||||
0% { background-position: -200% 0; }
|
||||
100% { background-position: 200% 0; }
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(6px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,396 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import {
|
||||
getRecommendList,
|
||||
createRecommend,
|
||||
updateRecommend,
|
||||
deleteRecommend,
|
||||
enableRecommend,
|
||||
disableRecommend,
|
||||
type GroupCourseRecommend,
|
||||
} from '@/api/groupCourseRecommend'
|
||||
import { getCourseList, type GroupCourse } from '@/api/groupCourse'
|
||||
import AppLayout from '@/components/AppLayout.vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const toast = useToast()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const recommends = ref<GroupCourseRecommend[]>([])
|
||||
const courses = ref<GroupCourse[]>([])
|
||||
const loading = ref(false)
|
||||
const showModal = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const editId = ref<number | null>(null)
|
||||
const saveLoading = ref(false)
|
||||
|
||||
const form = ref({
|
||||
courseId: undefined as number | undefined,
|
||||
recommendTitle: '',
|
||||
recommendContent: '',
|
||||
recommendReason: '',
|
||||
priority: 0,
|
||||
isActive: true,
|
||||
})
|
||||
|
||||
function resetForm() {
|
||||
form.value = {
|
||||
courseId: undefined,
|
||||
recommendTitle: '',
|
||||
recommendContent: '',
|
||||
recommendReason: '',
|
||||
priority: 0,
|
||||
isActive: true,
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchRecommends() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await getRecommendList()
|
||||
recommends.value = data
|
||||
} catch {
|
||||
recommends.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchCourses() {
|
||||
try {
|
||||
const data = await getCourseList()
|
||||
courses.value = data
|
||||
} catch {
|
||||
courses.value = []
|
||||
}
|
||||
}
|
||||
|
||||
function handleCreate() {
|
||||
isEdit.value = false
|
||||
editId.value = null
|
||||
resetForm()
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function handleEdit(item: GroupCourseRecommend) {
|
||||
isEdit.value = true
|
||||
editId.value = item.id
|
||||
form.value = {
|
||||
courseId: item.courseId,
|
||||
recommendTitle: item.recommendTitle,
|
||||
recommendContent: item.recommendContent,
|
||||
recommendReason: item.recommendReason,
|
||||
priority: item.priority,
|
||||
isActive: item.isActive,
|
||||
}
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!form.value.courseId) {
|
||||
toast.error('请选择团课')
|
||||
return
|
||||
}
|
||||
saveLoading.value = true
|
||||
try {
|
||||
if (isEdit.value && editId.value) {
|
||||
await updateRecommend(editId.value, form.value)
|
||||
toast.success('更新成功')
|
||||
} else {
|
||||
await createRecommend(form.value)
|
||||
toast.success('创建成功')
|
||||
}
|
||||
showModal.value = false
|
||||
fetchRecommends()
|
||||
} catch (e: any) {
|
||||
toast.error(e?.response?.data?.message || '操作失败')
|
||||
} finally {
|
||||
saveLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id: number) {
|
||||
if (!confirm('确定要删除该推荐吗?')) return
|
||||
try {
|
||||
await deleteRecommend(id)
|
||||
toast.success('删除成功')
|
||||
fetchRecommends()
|
||||
} catch (e: any) {
|
||||
toast.error(e?.response?.data?.message || '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggle(item: GroupCourseRecommend) {
|
||||
try {
|
||||
if (item.isActive) {
|
||||
await disableRecommend(item.id)
|
||||
} else {
|
||||
await enableRecommend(item.id)
|
||||
}
|
||||
fetchRecommends()
|
||||
} catch (e: any) {
|
||||
toast.error(e?.response?.data?.message || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
function getCourseName(courseId: number) {
|
||||
const c = courses.value.find((x) => x.id === courseId)
|
||||
return c?.courseName || `课程ID: ${courseId}`
|
||||
}
|
||||
|
||||
function formatTime(t: string) {
|
||||
if (!t) return '--'
|
||||
return new Date(t).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchRecommends()
|
||||
fetchCourses()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="toolbar">
|
||||
<button class="btn btn-primary" v-if="authStore.hasPermission('business:groupCourseRecommend:create')" @click="handleCreate">
|
||||
<i class="fas fa-plus"></i> 新建推荐
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="table-wrapper">
|
||||
<div class="table-header">
|
||||
<h3>推荐团课列表 (共 {{ recommends.length }} 条)</h3>
|
||||
</div>
|
||||
|
||||
<!-- skeleton table -->
|
||||
<table v-if="loading">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th><th>团课</th><th>推荐标题</th><th>推荐内容</th>
|
||||
<th>推荐理由</th><th>优先级</th><th>状态</th><th>更新时间</th><th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="i in 5" :key="'sk-' + i">
|
||||
<td v-for="j in 9" :key="j">
|
||||
<span class="sk-td skeleton"></span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- real table -->
|
||||
<table v-else>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th><th>团课</th><th>推荐标题</th><th>推荐内容</th>
|
||||
<th>推荐理由</th><th>优先级</th><th>状态</th><th>更新时间</th><th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in recommends" :key="item.id">
|
||||
<td>{{ item.id }}</td>
|
||||
<td>{{ getCourseName(item.courseId) }}</td>
|
||||
<td>{{ item.recommendTitle }}</td>
|
||||
<td class="text-ellipsis">{{ item.recommendContent }}</td>
|
||||
<td>{{ item.recommendReason }}</td>
|
||||
<td>{{ item.priority }}</td>
|
||||
<td>
|
||||
<span
|
||||
class="status-badge"
|
||||
:class="item.isActive ? 'green' : ''"
|
||||
:style="{ cursor: authStore.hasPermission('business:groupCourseRecommend:edit') ? 'pointer' : 'default' }"
|
||||
@click="authStore.hasPermission('business:groupCourseRecommend:edit') && handleToggle(item)"
|
||||
>{{ item.isActive ? '启用' : '禁用' }}</span>
|
||||
</td>
|
||||
<td>{{ formatTime(item.updatedAt) }}</td>
|
||||
<td class="actions">
|
||||
<button class="btn-sm" v-if="authStore.hasPermission('business:groupCourseRecommend:edit')" @click="handleEdit(item)">编辑</button>
|
||||
<button class="btn-sm danger" v-if="authStore.hasPermission('business:groupCourseRecommend:delete')" @click="handleDelete(item.id)">删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="recommends.length === 0">
|
||||
<td colspan="9" class="empty">暂无数据</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- modal -->
|
||||
<div v-if="showModal" class="modal-overlay" @click.self="showModal = false">
|
||||
<div class="modal">
|
||||
<h3>{{ isEdit ? '编辑推荐' : '新建推荐' }}</h3>
|
||||
<div class="form-group">
|
||||
<label>选择团课 <span class="required">*</span></label>
|
||||
<select v-model.number="form.courseId">
|
||||
<option :value="undefined" disabled>请选择团课</option>
|
||||
<option v-for="c in courses" :key="c.id" :value="c.id">
|
||||
{{ c.courseName }} (ID: {{ c.id }})
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>推荐标题</label>
|
||||
<input v-model="form.recommendTitle" placeholder="请输入推荐标题" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>推荐内容</label>
|
||||
<textarea v-model="form.recommendContent" placeholder="请输入推荐内容" rows="3"></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>推荐理由</label>
|
||||
<input v-model="form.recommendReason" placeholder="请输入推荐理由" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>优先级</label>
|
||||
<input v-model.number="form.priority" type="number" placeholder="数字越大优先级越高" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>状态</label>
|
||||
<select v-model="form.isActive">
|
||||
<option :value="true">启用</option>
|
||||
<option :value="false">禁用</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-outline" @click="showModal = false">取消</button>
|
||||
<button class="btn btn-primary" @click="handleSave" :disabled="saveLoading">
|
||||
<i v-if="saveLoading" class="fas fa-spinner fa-spin"></i>
|
||||
{{ saveLoading ? '保存中...' : '保存' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.toolbar { margin-bottom: 20px; }
|
||||
|
||||
.btn {
|
||||
padding: 9px 20px; border-radius: 40px; border: 1px solid #e2e8f0;
|
||||
background: white; font-weight: 500; font-size: 13px; color: #1e293b;
|
||||
display: inline-flex; align-items: center; gap: 8px; cursor: pointer; transition: 0.15s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #4f7cff; border-color: #4f7cff; color: white;
|
||||
box-shadow: 0 4px 10px rgba(79,124,255,0.25);
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) { background: #3f6ae0; }
|
||||
.btn-primary:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
.btn-outline:hover { background: #f0f5ff; }
|
||||
|
||||
.table-wrapper {
|
||||
background: white; border-radius: 20px; border: 1px solid #edf2f9;
|
||||
padding: 20px 24px 8px 24px;
|
||||
}
|
||||
|
||||
.table-header {
|
||||
display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.table-header h3 { font-weight: 600; font-size: 16px; color: #0b1a33; }
|
||||
|
||||
table { width: 100%; border-collapse: collapse; font-size: 14px; }
|
||||
|
||||
th {
|
||||
text-align: left; padding: 12px 8px 12px 0;
|
||||
font-weight: 600; color: #4e627c; border-bottom: 1px solid #eef2f8;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 14px 8px 14px 0; border-bottom: 1px solid #f1f5fa; color: #1e2f44;
|
||||
}
|
||||
|
||||
tr:last-child td { border-bottom: none; }
|
||||
|
||||
td.empty { text-align: center; padding: 40px; color: #8b9ab0; }
|
||||
|
||||
.text-ellipsis { max-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
/* ---- skeleton ---- */
|
||||
.skeleton {
|
||||
background: linear-gradient(90deg, #eef2f8 25%, #e0e7f0 50%, #eef2f8 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.sk-td { display: block; height: 16px; width: 80%; }
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { background-position: -200% 0; }
|
||||
100% { background-position: 200% 0; }
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(6px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
tr { animation: fadeIn 0.35s ease; }
|
||||
|
||||
/* ---- badge ---- */
|
||||
.status-badge {
|
||||
padding: 4px 12px; border-radius: 40px; font-size: 12px;
|
||||
font-weight: 500; display: inline-block;
|
||||
background: #e6f0ff; color: #4f7cff;
|
||||
}
|
||||
|
||||
.status-badge.green { background: #e0f5ec; color: #0b8b5e; }
|
||||
|
||||
.actions { white-space: nowrap; }
|
||||
|
||||
.btn-sm {
|
||||
padding: 5px 12px; border-radius: 20px; border: 1px solid #e2e8f0;
|
||||
background: white; font-size: 12px; cursor: pointer; margin-right: 6px; transition: 0.15s;
|
||||
}
|
||||
|
||||
.btn-sm:hover { background: #f0f5ff; }
|
||||
.btn-sm.danger { color: #ef4444; border-color: #fecaca; }
|
||||
.btn-sm.danger:hover { background: #fef2f2; }
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed; inset: 0; background: rgba(0,0,0,0.3);
|
||||
display: flex; align-items: center; justify-content: center; z-index: 200;
|
||||
animation: fadeIn 0.15s ease;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: white; border-radius: 20px; padding: 28px;
|
||||
width: 560px; max-width: 92vw; max-height: 85vh; overflow-y: auto;
|
||||
box-shadow: 0 12px 40px rgba(0,0,0,0.12);
|
||||
animation: fadeIn 0.2s ease;
|
||||
}
|
||||
|
||||
.modal h3 { font-size: 18px; font-weight: 600; color: #0b1a33; margin-bottom: 20px; }
|
||||
|
||||
.form-group { margin-bottom: 14px; }
|
||||
.form-group label {
|
||||
display: block; font-size: 13px; font-weight: 600; color: #1e293b; margin-bottom: 4px;
|
||||
}
|
||||
.form-group .required { color: #ef4444; }
|
||||
|
||||
.form-group input,
|
||||
.form-group textarea,
|
||||
.form-group select {
|
||||
width: 100%; padding: 9px 12px; border-radius: 10px;
|
||||
border: 1px solid #e2e8f0; font-size: 14px; outline: none;
|
||||
transition: 0.15s; background: #f8fafd; font-family: inherit;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group textarea:focus,
|
||||
.form-group select:focus {
|
||||
border-color: #4f7cff; box-shadow: 0 0 0 3px rgba(79,124,255,0.12);
|
||||
}
|
||||
|
||||
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
|
||||
.modal-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 20px; }
|
||||
</style>
|
||||
@@ -0,0 +1,981 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import {
|
||||
getTypeList,
|
||||
updateType,
|
||||
deleteType,
|
||||
createType,
|
||||
searchCourses,
|
||||
getAllLabels,
|
||||
createLabel,
|
||||
getLabelsByTypeId,
|
||||
addLabelsToType,
|
||||
removeLabelFromType,
|
||||
removeLabelsFromType,
|
||||
type CourseType,
|
||||
type CourseLabel,
|
||||
type CourseSearchParams,
|
||||
type GroupCourse,
|
||||
} from '@/api/groupCourse'
|
||||
import AppLayout from '@/components/AppLayout.vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const toast = useToast()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// --- 类型列表 ---
|
||||
const types = ref<CourseType[]>([])
|
||||
const loadingTypes = ref(false)
|
||||
const selectedTypeId = ref<number | null>(null)
|
||||
|
||||
// --- 排序 ---
|
||||
const sortField = ref<string>('id')
|
||||
const sortDirection = ref<string>('asc')
|
||||
const sortOptions = [
|
||||
{ label: 'ID 升序', field: 'id', direction: 'asc' },
|
||||
{ label: 'ID 降序', field: 'id', direction: 'desc' },
|
||||
{ label: '创建时间 升序', field: 'createTime', direction: 'asc' },
|
||||
{ label: '创建时间 降序', field: 'createTime', direction: 'desc' },
|
||||
{ label: '修改时间 升序', field: 'updateTime', direction: 'asc' },
|
||||
{ label: '修改时间 降序', field: 'updateTime', direction: 'desc' },
|
||||
]
|
||||
|
||||
// --- 类型编辑 ---
|
||||
const editingType = ref(false)
|
||||
const typeForm = ref({ typeName: '', description: '', category: '' })
|
||||
const saveTypeLoading = ref(false)
|
||||
|
||||
// --- 类型创建 ---
|
||||
const showCreateModal = ref(false)
|
||||
const createForm = ref({ typeName: '', description: '', category: '', baseDifficulty: 1 })
|
||||
const createLoading = ref(false)
|
||||
|
||||
// --- 标签管理 ---
|
||||
const typeLabels = ref<CourseLabel[]>([])
|
||||
const allLabels = ref<CourseLabel[]>([])
|
||||
const loadingLabels = ref(false)
|
||||
const showLabelModal = ref(false)
|
||||
const addLabelIds = ref<number[]>([])
|
||||
const batchDeleteMode = ref(false)
|
||||
const batchSelectedLabelIds = ref<number[]>([])
|
||||
|
||||
// --- 新建标签 ---
|
||||
const newLabelForm = ref({ labelName: '', color: '#1890ff' })
|
||||
const createLabelLoading = ref(false)
|
||||
|
||||
// --- 删除确认弹窗 ---
|
||||
const showDeleteModal = ref(false)
|
||||
const deleteTargetId = ref<number | null>(null)
|
||||
const deleteTargetName = ref('')
|
||||
const deletePassword = ref('')
|
||||
const deleteLoading = ref(false)
|
||||
const deleteError = ref('')
|
||||
|
||||
// --- 该类型下的团课 ---
|
||||
const typeCourses = ref<GroupCourse[]>([])
|
||||
const loadingCourses = ref(false)
|
||||
|
||||
const selectedType = computed(() => types.value.find((t) => t.id === selectedTypeId.value))
|
||||
|
||||
async function fetchTypes() {
|
||||
loadingTypes.value = true
|
||||
try {
|
||||
types.value = await getTypeList(true, sortField.value, sortDirection.value)
|
||||
// 默认选中第一个
|
||||
const first = types.value[0]
|
||||
if (first && !selectedTypeId.value) {
|
||||
selectedTypeId.value = first.id
|
||||
await loadTypeDetail(first.id)
|
||||
}
|
||||
} catch {
|
||||
types.value = []
|
||||
} finally {
|
||||
loadingTypes.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSortChange(field: string, direction: string) {
|
||||
sortField.value = field
|
||||
sortDirection.value = direction
|
||||
fetchTypes()
|
||||
}
|
||||
|
||||
function onSortChange(value: string) {
|
||||
const parts = value.split('-')
|
||||
handleSortChange(parts[0]!, parts[1] || '')
|
||||
}
|
||||
|
||||
async function selectType(id: number) {
|
||||
selectedTypeId.value = id
|
||||
await loadTypeDetail(id)
|
||||
}
|
||||
|
||||
async function loadTypeDetail(id: number) {
|
||||
await Promise.all([fetchTypeLabels(id), fetchTypeCourses(id)])
|
||||
}
|
||||
|
||||
async function fetchTypeLabels(typeId: number) {
|
||||
loadingLabels.value = true
|
||||
try {
|
||||
typeLabels.value = await getLabelsByTypeId(typeId)
|
||||
} catch {
|
||||
typeLabels.value = []
|
||||
} finally {
|
||||
loadingLabels.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchTypeCourses(typeId: number) {
|
||||
loadingCourses.value = true
|
||||
try {
|
||||
const res = await searchCourses({ courseType: typeId, size: 100 })
|
||||
typeCourses.value = res.data?.content || []
|
||||
} catch {
|
||||
typeCourses.value = []
|
||||
} finally {
|
||||
loadingCourses.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function startEditType() {
|
||||
const t = selectedType.value
|
||||
if (!t) return
|
||||
typeForm.value = {
|
||||
typeName: t.typeName,
|
||||
description: t.description || '',
|
||||
category: t.category || '',
|
||||
}
|
||||
editingType.value = true
|
||||
}
|
||||
|
||||
function cancelEditType() {
|
||||
editingType.value = false
|
||||
}
|
||||
|
||||
async function handleSaveType() {
|
||||
if (!selectedTypeId.value) return
|
||||
saveTypeLoading.value = true
|
||||
try {
|
||||
await updateType(selectedTypeId.value, typeForm.value)
|
||||
toast.success('类型更新成功')
|
||||
editingType.value = false
|
||||
await fetchTypes()
|
||||
} catch (e: any) {
|
||||
toast.error(e?.response?.data?.message || '更新失败')
|
||||
} finally {
|
||||
saveTypeLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openDeleteModal(id: number, name: string) {
|
||||
deleteTargetId.value = id
|
||||
deleteTargetName.value = name
|
||||
deletePassword.value = ''
|
||||
deleteError.value = ''
|
||||
showDeleteModal.value = true
|
||||
}
|
||||
|
||||
async function handleDeleteType() {
|
||||
if (!deleteTargetId.value || !deletePassword.value.trim()) {
|
||||
deleteError.value = '请输入管理员密码'
|
||||
return
|
||||
}
|
||||
deleteLoading.value = true
|
||||
deleteError.value = ''
|
||||
try {
|
||||
await deleteType(deleteTargetId.value, deletePassword.value)
|
||||
toast.success('类型已删除')
|
||||
showDeleteModal.value = false
|
||||
if (selectedTypeId.value === deleteTargetId.value) {
|
||||
selectedTypeId.value = null
|
||||
}
|
||||
await fetchTypes()
|
||||
} catch (e: any) {
|
||||
deleteError.value = e?.response?.data?.message || '删除失败'
|
||||
} finally {
|
||||
deleteLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateType() {
|
||||
if (!createForm.value.typeName.trim()) {
|
||||
toast.error('类型名称不能为空')
|
||||
return
|
||||
}
|
||||
createLoading.value = true
|
||||
try {
|
||||
await createType(createForm.value)
|
||||
toast.success('类型创建成功')
|
||||
showCreateModal.value = false
|
||||
createForm.value = { typeName: '', description: '', category: '', baseDifficulty: 1 }
|
||||
await fetchTypes()
|
||||
} catch (e: any) {
|
||||
toast.error(e?.response?.data?.message || '创建失败')
|
||||
} finally {
|
||||
createLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function openLabelModal() {
|
||||
addLabelIds.value = []
|
||||
try {
|
||||
allLabels.value = await getAllLabels()
|
||||
} catch {
|
||||
allLabels.value = []
|
||||
}
|
||||
showLabelModal.value = true
|
||||
}
|
||||
|
||||
function toggleLabelSelection(id: number) {
|
||||
const idx = addLabelIds.value.indexOf(id)
|
||||
if (idx >= 0) {
|
||||
addLabelIds.value.splice(idx, 1)
|
||||
} else {
|
||||
addLabelIds.value.push(id)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddLabels() {
|
||||
if (!selectedTypeId.value || addLabelIds.value.length === 0) return
|
||||
try {
|
||||
await addLabelsToType(selectedTypeId.value, addLabelIds.value)
|
||||
toast.success('标签添加成功')
|
||||
showLabelModal.value = false
|
||||
await fetchTypeLabels(selectedTypeId.value)
|
||||
} catch (e: any) {
|
||||
toast.error(e?.response?.data?.message || '添加失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateNewLabel() {
|
||||
if (!newLabelForm.value.labelName.trim()) {
|
||||
toast.error('标签名称不能为空')
|
||||
return
|
||||
}
|
||||
createLabelLoading.value = true
|
||||
try {
|
||||
const res = await createLabel({
|
||||
labelName: newLabelForm.value.labelName.trim(),
|
||||
color: newLabelForm.value.color || '#1890ff',
|
||||
})
|
||||
toast.success('标签创建成功')
|
||||
// 刷新标签列表
|
||||
allLabels.value = await getAllLabels()
|
||||
// 自动选中新创建的标签
|
||||
if (res.data?.id) {
|
||||
addLabelIds.value.push(res.data.id)
|
||||
}
|
||||
newLabelForm.value = { labelName: '', color: '#1890ff' }
|
||||
} catch (e: any) {
|
||||
toast.error(e?.response?.data?.message || '创建失败')
|
||||
} finally {
|
||||
createLabelLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemoveLabel(labelId: number) {
|
||||
if (!selectedTypeId.value) return
|
||||
try {
|
||||
await removeLabelFromType(selectedTypeId.value, labelId)
|
||||
toast.success('标签已移除')
|
||||
await fetchTypeLabels(selectedTypeId.value)
|
||||
} catch (e: any) {
|
||||
toast.error(e?.response?.data?.message || '移除失败')
|
||||
}
|
||||
}
|
||||
|
||||
function enterBatchDeleteMode() {
|
||||
batchDeleteMode.value = true
|
||||
batchSelectedLabelIds.value = []
|
||||
}
|
||||
|
||||
function cancelBatchDelete() {
|
||||
batchDeleteMode.value = false
|
||||
batchSelectedLabelIds.value = []
|
||||
}
|
||||
|
||||
function toggleBatchLabelSelection(id: number) {
|
||||
const idx = batchSelectedLabelIds.value.indexOf(id)
|
||||
if (idx >= 0) {
|
||||
batchSelectedLabelIds.value.splice(idx, 1)
|
||||
} else {
|
||||
batchSelectedLabelIds.value.push(id)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBatchRemoveLabels() {
|
||||
if (!selectedTypeId.value || batchSelectedLabelIds.value.length === 0) return
|
||||
try {
|
||||
await removeLabelsFromType(selectedTypeId.value, batchSelectedLabelIds.value)
|
||||
toast.success(`已移除 ${batchSelectedLabelIds.value.length} 个标签`)
|
||||
batchDeleteMode.value = false
|
||||
batchSelectedLabelIds.value = []
|
||||
await fetchTypeLabels(selectedTypeId.value)
|
||||
} catch (e: any) {
|
||||
toast.error(e?.response?.data?.message || '批量移除失败')
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(t: string) {
|
||||
if (!t) return '--'
|
||||
return new Date(t).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
function getCourseStatusText(c: GroupCourse): string {
|
||||
if (c.deletedAt) return '已删除'
|
||||
const s = Number(c.status)
|
||||
if (s === 0) return '正常'
|
||||
if (s === 1) return '已取消'
|
||||
if (s === 2) return '已结束'
|
||||
return '未知'
|
||||
}
|
||||
|
||||
// 可用标签(排除已添加的)
|
||||
const availableLabels = computed(() =>
|
||||
allLabels.value.filter((l) => !typeLabels.value.some((tl) => tl.id === l.id)),
|
||||
)
|
||||
|
||||
onMounted(fetchTypes)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="type-page">
|
||||
<!-- 左侧:类型列表 -->
|
||||
<div class="type-sidebar">
|
||||
<div class="sidebar-header">
|
||||
<h3>课程类型</h3>
|
||||
<button class="btn-icon" v-if="authStore.hasPermission('business:groupCourseType:create')" @click="showCreateModal = true" title="新建类型">
|
||||
<i class="fas fa-plus"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="sidebar-sort">
|
||||
<select
|
||||
class="sort-select"
|
||||
:value="`${sortField}-${sortDirection}`"
|
||||
@change="(e) => onSortChange((e.target as HTMLSelectElement).value)"
|
||||
>
|
||||
<option
|
||||
v-for="opt in sortOptions"
|
||||
:key="opt.field + '-' + opt.direction"
|
||||
:value="opt.field + '-' + opt.direction"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="type-list">
|
||||
<div v-if="loadingTypes" class="type-list-loading">加载中...</div>
|
||||
<div v-else-if="types.length === 0" class="type-list-empty">暂无类型</div>
|
||||
<div
|
||||
v-for="t in types"
|
||||
:key="t.id"
|
||||
class="type-item"
|
||||
:class="{ active: selectedTypeId === t.id }"
|
||||
@click="selectType(t.id)"
|
||||
>
|
||||
<span class="type-name">{{ t.typeName }}</span>
|
||||
<span v-if="t.category" class="type-category">{{ t.category }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:详情 -->
|
||||
<div class="type-detail">
|
||||
<template v-if="selectedType">
|
||||
<!-- 类型信息卡片 -->
|
||||
<div class="detail-card">
|
||||
<div class="card-header">
|
||||
<h2>{{ selectedType.typeName }}</h2>
|
||||
<div class="card-actions">
|
||||
<button v-if="!editingType && authStore.hasPermission('business:groupCourseType:edit')" class="btn-sm" @click="startEditType">
|
||||
<i class="fas fa-edit"></i> 编辑
|
||||
</button>
|
||||
<button
|
||||
v-if="authStore.hasPermission('business:groupCourseType:delete')"
|
||||
class="btn-sm danger"
|
||||
@click="openDeleteModal(selectedType.id, selectedType.typeName)"
|
||||
>
|
||||
<i class="fas fa-trash"></i> 删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 编辑模式 -->
|
||||
<template v-if="editingType">
|
||||
<div class="edit-form">
|
||||
<div class="form-group">
|
||||
<label>类型名称</label>
|
||||
<input v-model="typeForm.typeName" placeholder="类型名称" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>分类</label>
|
||||
<input v-model="typeForm.category" placeholder="如:有氧、力量、柔韧" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>描述</label>
|
||||
<textarea v-model="typeForm.description" placeholder="类型描述" rows="2"></textarea>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button class="btn-sm" @click="cancelEditType">取消</button>
|
||||
<button class="btn-sm primary" :disabled="saveTypeLoading" @click="handleSaveType">
|
||||
<i v-if="saveTypeLoading" class="fas fa-spinner fa-spin"></i>
|
||||
{{ saveTypeLoading ? '保存中...' : '保存' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 查看模式 -->
|
||||
<template v-else>
|
||||
<div class="type-meta">
|
||||
<div v-if="selectedType.category" class="meta-item">
|
||||
<span class="meta-label">分类</span>
|
||||
<span class="meta-value">{{ selectedType.category }}</span>
|
||||
</div>
|
||||
<div v-if="selectedType.description" class="meta-item">
|
||||
<span class="meta-label">描述</span>
|
||||
<span class="meta-value">{{ selectedType.description }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 标签管理 -->
|
||||
<div class="detail-card">
|
||||
<div class="card-header">
|
||||
<h3>标签</h3>
|
||||
<div class="card-header-actions">
|
||||
<button v-if="!batchDeleteMode && authStore.hasPermission('business:groupCourseType:edit')" class="btn-sm" @click="openLabelModal">
|
||||
<i class="fas fa-plus"></i> 添加标签
|
||||
</button>
|
||||
<button
|
||||
v-if="!batchDeleteMode && typeLabels.length > 0 && authStore.hasPermission('business:groupCourseType:edit')"
|
||||
class="btn-sm danger-outline"
|
||||
@click="enterBatchDeleteMode"
|
||||
>
|
||||
<i class="fas fa-trash"></i> 批量删除
|
||||
</button>
|
||||
<template v-if="batchDeleteMode">
|
||||
<button
|
||||
class="btn-sm danger"
|
||||
:disabled="batchSelectedLabelIds.length === 0"
|
||||
@click="handleBatchRemoveLabels"
|
||||
>
|
||||
删除选中 ({{ batchSelectedLabelIds.length }})
|
||||
</button>
|
||||
<button class="btn-sm" @click="cancelBatchDelete">取消</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="loadingLabels" class="section-loading">加载中...</div>
|
||||
<div v-else-if="typeLabels.length === 0" class="section-empty">暂无标签</div>
|
||||
<div v-else class="label-cloud">
|
||||
<span
|
||||
v-for="label in typeLabels"
|
||||
:key="label.id"
|
||||
class="label-tag"
|
||||
:class="{ 'label-tag-selectable': batchDeleteMode, 'label-tag-selected': batchSelectedLabelIds.includes(label.id) }"
|
||||
:style="{
|
||||
background: batchSelectedLabelIds.includes(label.id) ? (label.color || '#e74c3c') + '22' : (label.color || '#e6f0ff') + '33',
|
||||
borderColor: batchSelectedLabelIds.includes(label.id) ? (label.color || '#e74c3c') : (label.color || '#4f7cff'),
|
||||
cursor: batchDeleteMode ? 'pointer' : 'default'
|
||||
}"
|
||||
@click="batchDeleteMode && toggleBatchLabelSelection(label.id)"
|
||||
>
|
||||
<template v-if="batchDeleteMode">
|
||||
<i
|
||||
class="fas"
|
||||
:class="batchSelectedLabelIds.includes(label.id) ? 'fa-check-square' : 'fa-square'"
|
||||
:style="{ color: label.color || '#4f7cff' }"
|
||||
></i>
|
||||
</template>
|
||||
<span :style="{ color: label.color || '#4f7cff' }">{{ label.labelName }}</span>
|
||||
<template v-if="!batchDeleteMode && authStore.hasPermission('business:groupCourseType:edit')">
|
||||
<i class="fas fa-times" @click="handleRemoveLabel(label.id)"></i>
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 该类型下的团课 -->
|
||||
<div class="detail-card courses-card">
|
||||
<div class="card-header">
|
||||
<h3>该类型团课 ({{ typeCourses.length }})</h3>
|
||||
</div>
|
||||
<div v-if="loadingCourses" class="section-loading">加载中...</div>
|
||||
<div v-else-if="typeCourses.length === 0" class="section-empty">暂无团课</div>
|
||||
<div v-else class="table-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>课程名称</th>
|
||||
<th>开始时间</th>
|
||||
<th>地点</th>
|
||||
<th>人数</th>
|
||||
<th>状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="c in typeCourses" :key="c.id">
|
||||
<td>{{ c.id }}</td>
|
||||
<td>{{ c.courseName }}</td>
|
||||
<td>{{ formatTime(c.startTime) }}</td>
|
||||
<td>{{ c.location || '--' }}</td>
|
||||
<td>{{ c.currentMembers }}/{{ c.maxMembers }}</td>
|
||||
<td>
|
||||
<span class="status-badge" :class="{
|
||||
green: Number(c.status) === 0 && !c.deletedAt,
|
||||
orange: Number(c.status) === 1,
|
||||
gray: !!c.deletedAt,
|
||||
}">{{ getCourseStatusText(c) }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 未选中任何类型 -->
|
||||
<div v-else class="no-selection">
|
||||
<i class="fas fa-tags"></i>
|
||||
<p>请从左侧选择一个课程类型</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 创建类型弹窗 -->
|
||||
<div v-if="showCreateModal" class="modal-overlay" @click.self="showCreateModal = false">
|
||||
<div class="modal">
|
||||
<h3>新建课程类型</h3>
|
||||
<div class="form-group">
|
||||
<label>类型名称 <span class="required">*</span></label>
|
||||
<input v-model="createForm.typeName" placeholder="请输入类型名称" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>分类</label>
|
||||
<input v-model="createForm.category" placeholder="如:有氧、力量、柔韧" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>基础难度 (1-10)</label>
|
||||
<input v-model.number="createForm.baseDifficulty" type="number" min="1" max="10" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>描述</label>
|
||||
<textarea v-model="createForm.description" placeholder="请输入类型描述" rows="2"></textarea>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn-sm" @click="showCreateModal = false">取消</button>
|
||||
<button class="btn-sm primary" :disabled="createLoading" @click="handleCreateType">
|
||||
<i v-if="createLoading" class="fas fa-spinner fa-spin"></i>
|
||||
{{ createLoading ? '创建中...' : '创建' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 添加标签弹窗 -->
|
||||
<div v-if="showLabelModal" class="modal-overlay" @click.self="showLabelModal = false">
|
||||
<div class="modal modal-sm">
|
||||
<h3>添加标签到「{{ selectedType?.typeName }}」</h3>
|
||||
<div v-if="availableLabels.length === 0" class="section-empty">
|
||||
没有可添加的标签
|
||||
</div>
|
||||
<div v-else class="label-select-list">
|
||||
<label
|
||||
v-for="label in availableLabels"
|
||||
:key="label.id"
|
||||
class="label-select-item"
|
||||
:class="{ selected: addLabelIds.includes(label.id) }"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="addLabelIds.includes(label.id)"
|
||||
@change="toggleLabelSelection(label.id)"
|
||||
/>
|
||||
<span
|
||||
class="label-dot"
|
||||
:style="{ background: label.color || '#4f7cff' }"
|
||||
></span>
|
||||
<span>{{ label.labelName }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="new-label-section">
|
||||
<div class="new-label-title">创建新标签</div>
|
||||
<div class="new-label-form">
|
||||
<input
|
||||
v-model="newLabelForm.labelName"
|
||||
placeholder="标签名称"
|
||||
class="new-label-input"
|
||||
maxlength="50"
|
||||
/>
|
||||
<input
|
||||
v-model="newLabelForm.color"
|
||||
type="color"
|
||||
class="new-label-color"
|
||||
/>
|
||||
<button
|
||||
class="btn-sm primary"
|
||||
:disabled="createLabelLoading || !newLabelForm.labelName.trim()"
|
||||
@click="handleCreateNewLabel"
|
||||
>
|
||||
<i v-if="createLabelLoading" class="fas fa-spinner fa-spin"></i>
|
||||
{{ createLabelLoading ? '创建中...' : '新建' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn-sm" @click="showLabelModal = false">取消</button>
|
||||
<button
|
||||
class="btn-sm primary"
|
||||
:disabled="addLabelIds.length === 0"
|
||||
@click="handleAddLabels"
|
||||
>
|
||||
确认添加 ({{ addLabelIds.length }})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 删除类型确认弹窗 -->
|
||||
<div v-if="showDeleteModal" class="modal-overlay" @click.self="showDeleteModal = false">
|
||||
<div class="modal modal-sm">
|
||||
<h3>删除课程类型</h3>
|
||||
<div class="delete-warning">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
<p>确定要删除类型「<strong>{{ deleteTargetName }}</strong>」吗?此操作不可恢复。</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>管理员密码 <span class="required">*</span></label>
|
||||
<input
|
||||
v-model="deletePassword"
|
||||
type="password"
|
||||
placeholder="请输入您的管理员密码"
|
||||
@keyup.enter="handleDeleteType"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="deleteError" class="delete-error">{{ deleteError }}</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn-sm" @click="showDeleteModal = false">取消</button>
|
||||
<button
|
||||
class="btn-sm danger"
|
||||
:disabled="deleteLoading"
|
||||
@click="handleDeleteType"
|
||||
>
|
||||
<i v-if="deleteLoading" class="fas fa-spinner fa-spin"></i>
|
||||
{{ deleteLoading ? '删除中...' : '确认删除' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.type-page {
|
||||
display: flex; gap: 20px; height: calc(100vh - 160px); overflow: hidden;
|
||||
}
|
||||
|
||||
/* ---- 左侧 ---- */
|
||||
.type-sidebar {
|
||||
width: 240px; flex-shrink: 0;
|
||||
background: white; border-radius: 16px; border: 1px solid #edf2f9;
|
||||
display: flex; flex-direction: column; overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 16px; border-bottom: 1px solid #f1f5fa;
|
||||
}
|
||||
|
||||
.sidebar-header h3 {
|
||||
font-size: 15px; font-weight: 600; color: #0b1a33; margin: 0;
|
||||
}
|
||||
|
||||
.sidebar-sort {
|
||||
padding: 8px 12px; border-bottom: 1px solid #f1f5fa;
|
||||
}
|
||||
|
||||
.sort-select {
|
||||
width: 100%; padding: 6px 10px; border-radius: 8px;
|
||||
border: 1px solid #e2e8f0; font-size: 13px; color: #1e293b;
|
||||
background: #f8fafd; outline: none; cursor: pointer;
|
||||
}
|
||||
|
||||
.sort-select:focus {
|
||||
border-color: #4f7cff; box-shadow: 0 0 0 3px rgba(79,124,255,0.12);
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
width: 32px; height: 32px; border-radius: 8px; border: 1px solid #e2e8f0;
|
||||
background: white; cursor: pointer; display: flex; align-items: center;
|
||||
justify-content: center; font-size: 14px; color: #4f7cff; transition: 0.15s;
|
||||
}
|
||||
|
||||
.btn-icon:hover { background: #f0f5ff; }
|
||||
|
||||
.type-list {
|
||||
flex: 1; overflow-y: auto; padding: 8px;
|
||||
}
|
||||
|
||||
.type-list-loading,
|
||||
.type-list-empty {
|
||||
text-align: center; padding: 24px; font-size: 13px; color: #94a3b8;
|
||||
}
|
||||
|
||||
.type-item {
|
||||
padding: 10px 12px; border-radius: 10px; cursor: pointer;
|
||||
display: flex; flex-direction: column; gap: 2px; transition: 0.15s;
|
||||
}
|
||||
|
||||
.type-item:hover { background: #f8fafd; }
|
||||
.type-item.active { background: #eff6ff; }
|
||||
|
||||
.type-name { font-size: 14px; font-weight: 500; color: #1e293b; }
|
||||
.type-item.active .type-name { color: #4f7cff; }
|
||||
.type-category { font-size: 12px; color: #94a3b8; }
|
||||
|
||||
/* ---- 右侧 ---- */
|
||||
.type-detail {
|
||||
flex: 1; display: flex; flex-direction: column; gap: 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.detail-card {
|
||||
background: white; border-radius: 16px; border: 1px solid #edf2f9;
|
||||
padding: 20px; flex-shrink: 0;
|
||||
}
|
||||
|
||||
.detail-card.courses-card {
|
||||
flex: 1; display: flex; flex-direction: column; overflow: hidden;
|
||||
}
|
||||
|
||||
.table-scroll {
|
||||
flex: 1; overflow-y: auto;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.card-header h2 {
|
||||
font-size: 18px; font-weight: 600; color: #0b1a33; margin: 0;
|
||||
}
|
||||
|
||||
.card-header h3 {
|
||||
font-size: 15px; font-weight: 600; color: #0b1a33; margin: 0;
|
||||
}
|
||||
|
||||
.card-actions { display: flex; gap: 8px; }
|
||||
|
||||
.type-meta {
|
||||
display: flex; flex-direction: column; gap: 10px;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
display: flex; gap: 8px; font-size: 14px;
|
||||
}
|
||||
|
||||
.meta-label {
|
||||
color: #94a3b8; font-weight: 500; min-width: 48px;
|
||||
}
|
||||
|
||||
.meta-value { color: #1e293b; }
|
||||
|
||||
/* ---- 编辑表单 ---- */
|
||||
.edit-form {
|
||||
display: flex; flex-direction: column; gap: 12px;
|
||||
}
|
||||
|
||||
.form-group { margin-bottom: 4px; }
|
||||
.form-group label {
|
||||
display: block; font-size: 13px; font-weight: 600; color: #1e293b; margin-bottom: 4px;
|
||||
}
|
||||
.form-group .required { color: #ef4444; }
|
||||
|
||||
.form-group input,
|
||||
.form-group textarea {
|
||||
width: 100%; padding: 8px 12px; border-radius: 10px;
|
||||
border: 1px solid #e2e8f0; font-size: 14px; outline: none;
|
||||
transition: 0.15s; background: #f8fafd; font-family: inherit;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group textarea:focus {
|
||||
border-color: #4f7cff; box-shadow: 0 0 0 3px rgba(79,124,255,0.12);
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex; justify-content: flex-end; gap: 8px; margin-top: 4px;
|
||||
}
|
||||
|
||||
/* ---- 标签 ---- */
|
||||
.label-cloud {
|
||||
display: flex; flex-wrap: wrap; gap: 8px;
|
||||
}
|
||||
|
||||
.label-tag {
|
||||
padding: 4px 12px; border-radius: 20px; font-size: 13px;
|
||||
border: 1px solid; font-weight: 500;
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
}
|
||||
|
||||
.label-tag i {
|
||||
cursor: pointer; font-size: 10px; opacity: 0.6;
|
||||
}
|
||||
|
||||
.label-tag i:hover { opacity: 1; }
|
||||
|
||||
/* ---- 标签选择列表 ---- */
|
||||
.label-select-list {
|
||||
display: flex; flex-direction: column; gap: 6px; max-height: 300px; overflow-y: auto;
|
||||
}
|
||||
|
||||
.label-select-item {
|
||||
display: flex; align-items: center; gap: 10px; padding: 8px 10px;
|
||||
border-radius: 10px; cursor: pointer; transition: 0.15s; font-size: 14px;
|
||||
}
|
||||
|
||||
.label-select-item:hover { background: #f8fafd; }
|
||||
.label-select-item.selected { background: #eff6ff; }
|
||||
|
||||
.label-select-item input { margin: 0; }
|
||||
|
||||
.label-dot {
|
||||
width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ---- 新建标签区域 ---- */
|
||||
.new-label-section {
|
||||
margin-top: 12px; padding-top: 12px; border-top: 1px solid #eef2f8;
|
||||
}
|
||||
|
||||
.new-label-title {
|
||||
font-size: 13px; font-weight: 600; color: #4e627c; margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.new-label-form {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
}
|
||||
|
||||
.new-label-input {
|
||||
flex: 1; padding: 6px 10px; border-radius: 8px;
|
||||
border: 1px solid #e2e8f0; font-size: 13px; outline: none;
|
||||
background: #f8fafd; min-width: 0;
|
||||
}
|
||||
|
||||
.new-label-input:focus {
|
||||
border-color: #4f7cff; box-shadow: 0 0 0 3px rgba(79,124,255,0.12);
|
||||
}
|
||||
|
||||
.new-label-color {
|
||||
width: 36px; height: 32px; border: 1px solid #e2e8f0; border-radius: 8px;
|
||||
padding: 2px; cursor: pointer; flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ---- 表格 ---- */
|
||||
table {
|
||||
width: 100%; border-collapse: collapse; font-size: 13px;
|
||||
}
|
||||
|
||||
th {
|
||||
text-align: left; padding: 10px 8px 10px 0;
|
||||
font-weight: 600; color: #4e627c; border-bottom: 1px solid #eef2f8;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 12px 8px 12px 0; border-bottom: 1px solid #f1f5fa; color: #1e2f44;
|
||||
}
|
||||
|
||||
tr:last-child td { border-bottom: none; }
|
||||
|
||||
.section-loading,
|
||||
.section-empty {
|
||||
text-align: center; padding: 24px; font-size: 13px; color: #94a3b8;
|
||||
}
|
||||
|
||||
.no-selection {
|
||||
flex: 1; display: flex; flex-direction: column; align-items: center;
|
||||
justify-content: center; color: #94a3b8; gap: 12px;
|
||||
}
|
||||
|
||||
.no-selection i { font-size: 48px; opacity: 0.3; }
|
||||
.no-selection p { font-size: 14px; margin: 0; }
|
||||
|
||||
/* ---- status ---- */
|
||||
.status-badge {
|
||||
padding: 3px 10px; border-radius: 20px; font-size: 12px;
|
||||
font-weight: 500; background: #e6f0ff; color: #4f7cff;
|
||||
}
|
||||
|
||||
.status-badge.green { background: #e0f5ec; color: #0b8b5e; }
|
||||
.status-badge.orange { background: #fff3e0; color: #b26e00; }
|
||||
.status-badge.gray { background: #f1f5f9; color: #64748b; }
|
||||
|
||||
/* ---- btn ---- */
|
||||
.btn-sm {
|
||||
padding: 6px 14px; border-radius: 20px; border: 1px solid #e2e8f0;
|
||||
background: white; font-size: 12px; cursor: pointer; transition: 0.15s;
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
}
|
||||
|
||||
.btn-sm:hover { background: #f0f5ff; }
|
||||
.btn-sm.primary {
|
||||
background: #4f7cff; border-color: #4f7cff; color: white;
|
||||
}
|
||||
.btn-sm.primary:hover:not(:disabled) { background: #3f6ae0; }
|
||||
.btn-sm.primary:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
.btn-sm.danger { color: #ef4444; border-color: #fecaca; }
|
||||
.btn-sm.danger:hover { background: #fef2f2; }
|
||||
.btn-sm.danger-outline { color: #ef4444; border-color: #fecaca; }
|
||||
.btn-sm.danger-outline:hover { background: #fef2f2; }
|
||||
|
||||
.card-header-actions { display: flex; gap: 8px; align-items: center; }
|
||||
|
||||
.label-tag-selectable { user-select: none; }
|
||||
.label-tag-selectable:hover { opacity: 0.85; }
|
||||
.label-tag-selected { }
|
||||
|
||||
/* ---- modal ---- */
|
||||
.modal-overlay {
|
||||
position: fixed; inset: 0; background: rgba(0,0,0,0.3);
|
||||
display: flex; align-items: center; justify-content: center; z-index: 200;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: white; border-radius: 20px; padding: 28px;
|
||||
width: 480px; max-width: 92vw; max-height: 85vh; overflow-y: auto;
|
||||
box-shadow: 0 12px 40px rgba(0,0,0,0.12);
|
||||
}
|
||||
|
||||
.modal-sm { width: 400px; }
|
||||
|
||||
.modal h3 {
|
||||
font-size: 17px; font-weight: 600; color: #0b1a33; margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.delete-warning {
|
||||
display: flex; align-items: flex-start; gap: 10px;
|
||||
padding: 12px 14px; background: #fff3e0; border-radius: 10px;
|
||||
font-size: 13px; color: #b26e00; margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.delete-warning i {
|
||||
font-size: 18px; flex-shrink: 0; margin-top: 1px;
|
||||
}
|
||||
|
||||
.delete-warning p { margin: 0; line-height: 1.5; }
|
||||
|
||||
.delete-error {
|
||||
padding: 8px 12px; background: #fef2f2; border-radius: 8px;
|
||||
font-size: 13px; color: #ef4444; margin-top: 8px;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,259 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
const loading = ref(false)
|
||||
const errorMsg = ref('')
|
||||
|
||||
async function handleLogin() {
|
||||
if (!username.value || !password.value) {
|
||||
errorMsg.value = '请输入用户名和密码'
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
errorMsg.value = ''
|
||||
try {
|
||||
await authStore.login(username.value, password.value)
|
||||
router.push('/dashboard')
|
||||
} catch (e: any) {
|
||||
console.error('登录失败:', e)
|
||||
errorMsg.value =
|
||||
e?.response?.data?.message || e?.message || '登录失败,请检查用户名和密码'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<div class="login-card">
|
||||
<div class="login-header">
|
||||
<div class="brand-icon">G</div>
|
||||
<h2>GymManage 后台管理</h2>
|
||||
<p>请输入您的账号信息登录</p>
|
||||
</div>
|
||||
<form class="login-form" @submit.prevent="handleLogin">
|
||||
<div class="form-group">
|
||||
<label>用户名</label>
|
||||
<div class="input-wrapper">
|
||||
<i class="fas fa-user"></i>
|
||||
<input
|
||||
v-model="username"
|
||||
type="text"
|
||||
placeholder="请输入用户名"
|
||||
autocomplete="username"
|
||||
:disabled="loading"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>密码</label>
|
||||
<div class="input-wrapper">
|
||||
<i class="fas fa-lock"></i>
|
||||
<input
|
||||
v-model="password"
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
autocomplete="current-password"
|
||||
:disabled="loading"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<transition name="fade">
|
||||
<p v-if="errorMsg" class="error-msg">{{ errorMsg }}</p>
|
||||
</transition>
|
||||
<button class="btn-login" :class="{ 'is-loading': loading }" :disabled="loading" type="submit">
|
||||
<span class="btn-spinner" v-if="loading">
|
||||
<i class="fas fa-circle-notch fa-spin"></i>
|
||||
</span>
|
||||
<span class="btn-text">{{ loading ? '登录中...' : '登录' }}</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #f6f8fc 0%, #eef2f8 100%);
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background: white;
|
||||
padding: 48px 40px;
|
||||
border-radius: 24px;
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.06);
|
||||
border: 1px solid #edf2f9;
|
||||
width: 400px;
|
||||
max-width: 90vw;
|
||||
animation: scaleIn 0.35s ease;
|
||||
}
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.brand-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: linear-gradient(145deg, #4f7cff, #3b5fd9);
|
||||
border-radius: 14px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 8px 16px rgba(79, 124, 255, 0.25);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.login-header h2 {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: #0b1a33;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.login-header p {
|
||||
font-size: 14px;
|
||||
color: #6b7d94;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: #f8fafd;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
padding: 10px 14px;
|
||||
transition: 0.15s;
|
||||
}
|
||||
|
||||
.input-wrapper:focus-within {
|
||||
border-color: #4f7cff;
|
||||
box-shadow: 0 0 0 3px rgba(79, 124, 255, 0.12);
|
||||
}
|
||||
|
||||
.input-wrapper i {
|
||||
color: #8b9ab0;
|
||||
font-size: 14px;
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
.input-wrapper input {
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.input-wrapper input:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.error-msg {
|
||||
color: #ef4444;
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
background: #fef2f2;
|
||||
padding: 10px 14px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
|
||||
.btn-login {
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
border: none;
|
||||
background: #4f7cff;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
box-shadow: 0 4px 12px rgba(79, 124, 255, 0.3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.btn-login:hover:not(:disabled) {
|
||||
background: #3f6ae0;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 6px 16px rgba(79, 124, 255, 0.35);
|
||||
}
|
||||
|
||||
.btn-login:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.btn-login:disabled {
|
||||
opacity: 0.8;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-login.is-loading {
|
||||
background: #4f7cff;
|
||||
}
|
||||
|
||||
.btn-spinner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* ---- animations ---- */
|
||||
@keyframes scaleIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.25s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,817 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import {
|
||||
listMemberCards,
|
||||
createMemberCard,
|
||||
updateMemberCard,
|
||||
deleteMemberCard,
|
||||
type MemberCard,
|
||||
type CreateMemberCardDto,
|
||||
type UpdateMemberCardDto,
|
||||
} from '@/api/memberCard'
|
||||
import { verifyPassword } from '@/api/auth'
|
||||
import AppLayout from '@/components/AppLayout.vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const toast = useToast()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// --- 数据 ---
|
||||
const cards = ref<MemberCard[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
// --- 筛选 ---
|
||||
const keyword = ref('')
|
||||
type TypeFilter = '' | 'TIME_CARD' | 'COUNT_CARD' | 'STORED_VALUE_CARD'
|
||||
type StatusFilter = '' | '1' | '0'
|
||||
const typeFilter = ref<TypeFilter>('')
|
||||
const statusFilter = ref<StatusFilter>('')
|
||||
|
||||
// --- 排序 ---
|
||||
type SortKey = 'id_desc' | 'id_asc' | 'price_desc' | 'price_asc'
|
||||
const sortKey = ref<SortKey>('id_desc')
|
||||
|
||||
// --- 分页 ---
|
||||
const currentPage = ref(0)
|
||||
const pageSize = ref(10)
|
||||
|
||||
// --- 弹窗 ---
|
||||
const showModal = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const editId = ref<number | null>(null)
|
||||
const saveLoading = ref(false)
|
||||
|
||||
// --- 密码验证弹窗 ---
|
||||
const showPasswordModal = ref(false)
|
||||
const pendingDeleteCardId = ref<number | null>(null)
|
||||
const adminPassword = ref('')
|
||||
const passwordError = ref('')
|
||||
const verifying = ref(false)
|
||||
|
||||
const cardTypeMap: Record<string, string> = {
|
||||
TIME_CARD: '时长卡',
|
||||
COUNT_CARD: '次卡',
|
||||
STORED_VALUE_CARD: '储值卡',
|
||||
}
|
||||
|
||||
function getTypeLabel(type: string): string {
|
||||
return cardTypeMap[type] || '未知'
|
||||
}
|
||||
|
||||
const form = ref<CreateMemberCardDto>({
|
||||
memberCardName: '',
|
||||
memberCardType: 'TIME_CARD',
|
||||
memberCardPrice: 0,
|
||||
memberCardValidityDays: undefined,
|
||||
memberCardTotalTimes: undefined,
|
||||
memberCardAmount: undefined,
|
||||
memberCardStatus: 1,
|
||||
})
|
||||
|
||||
function formatPrice(p: number): string {
|
||||
return '¥' + (p ?? 0).toFixed(2)
|
||||
}
|
||||
|
||||
function formatTime(t: string | null): string {
|
||||
if (!t) return '--'
|
||||
return new Date(t).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
// --- 数据过滤 ---
|
||||
const filteredCards = computed(() => {
|
||||
let list = cards.value
|
||||
|
||||
if (typeFilter.value) {
|
||||
list = list.filter((c) => c.memberCardType === typeFilter.value)
|
||||
}
|
||||
if (statusFilter.value) {
|
||||
list = list.filter((c) => c.memberCardStatus === Number(statusFilter.value))
|
||||
}
|
||||
if (keyword.value.trim()) {
|
||||
const kw = keyword.value.trim().toLowerCase()
|
||||
list = list.filter(
|
||||
(c) =>
|
||||
c.memberCardName.toLowerCase().includes(kw) ||
|
||||
String(c.memberCardId).includes(kw),
|
||||
)
|
||||
}
|
||||
|
||||
// 排序
|
||||
if (sortKey.value === 'id_desc') {
|
||||
list.sort((a, b) => b.memberCardId - a.memberCardId)
|
||||
} else if (sortKey.value === 'id_asc') {
|
||||
list.sort((a, b) => a.memberCardId - b.memberCardId)
|
||||
} else if (sortKey.value === 'price_desc') {
|
||||
list.sort((a, b) => (b.memberCardPrice ?? 0) - (a.memberCardPrice ?? 0))
|
||||
} else if (sortKey.value === 'price_asc') {
|
||||
list.sort((a, b) => (a.memberCardPrice ?? 0) - (b.memberCardPrice ?? 0))
|
||||
}
|
||||
|
||||
return list
|
||||
})
|
||||
|
||||
const totalElements = computed(() => filteredCards.value.length)
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(totalElements.value / pageSize.value)))
|
||||
|
||||
const pagedCards = computed(() => {
|
||||
const start = currentPage.value * pageSize.value
|
||||
return filteredCards.value.slice(start, start + pageSize.value)
|
||||
})
|
||||
|
||||
function changePage(page: number) {
|
||||
if (page >= 0 && page < totalPages.value) {
|
||||
currentPage.value = page
|
||||
}
|
||||
}
|
||||
|
||||
// --- 数据加载 ---
|
||||
async function fetchCards() {
|
||||
loading.value = true
|
||||
try {
|
||||
const list = await listMemberCards({ page: 0, size: 200 })
|
||||
cards.value = list
|
||||
} catch {
|
||||
cards.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
currentPage.value = 0
|
||||
}
|
||||
|
||||
function handleTypeChange() {
|
||||
currentPage.value = 0
|
||||
}
|
||||
|
||||
function handleStatusChange() {
|
||||
currentPage.value = 0
|
||||
}
|
||||
|
||||
// --- CRUD ---
|
||||
function resetForm() {
|
||||
form.value = {
|
||||
memberCardName: '',
|
||||
memberCardType: 'TIME_CARD',
|
||||
memberCardPrice: 0,
|
||||
memberCardValidityDays: undefined,
|
||||
memberCardTotalTimes: undefined,
|
||||
memberCardAmount: undefined,
|
||||
memberCardStatus: 1,
|
||||
}
|
||||
}
|
||||
|
||||
function handleCreate() {
|
||||
isEdit.value = false
|
||||
editId.value = null
|
||||
resetForm()
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function handleEdit(card: MemberCard) {
|
||||
isEdit.value = true
|
||||
editId.value = card.memberCardId
|
||||
form.value = {
|
||||
memberCardName: card.memberCardName,
|
||||
memberCardType: card.memberCardType,
|
||||
memberCardPrice: card.memberCardPrice,
|
||||
memberCardValidityDays: card.memberCardValidityDays ?? undefined,
|
||||
memberCardTotalTimes: card.memberCardTotalTimes ?? undefined,
|
||||
memberCardAmount: card.memberCardAmount ?? undefined,
|
||||
memberCardStatus: card.memberCardStatus,
|
||||
}
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!form.value.memberCardName.trim()) {
|
||||
toast.error('请输入会员卡名称')
|
||||
return
|
||||
}
|
||||
if (!(form.value.memberCardPrice >= 0)) {
|
||||
toast.error('请输入有效的价格')
|
||||
return
|
||||
}
|
||||
// 根据卡类型校验必填字段
|
||||
if (form.value.memberCardType === 'TIME_CARD') {
|
||||
if (!form.value.memberCardValidityDays || form.value.memberCardValidityDays <= 0) {
|
||||
toast.error('时长卡必须输入有效天数')
|
||||
return
|
||||
}
|
||||
} else if (form.value.memberCardType === 'COUNT_CARD') {
|
||||
if (!form.value.memberCardTotalTimes || form.value.memberCardTotalTimes <= 0) {
|
||||
toast.error('次卡必须输入有效次数')
|
||||
return
|
||||
}
|
||||
} else if (form.value.memberCardType === 'STORED_VALUE_CARD') {
|
||||
if (!(form.value.memberCardAmount && form.value.memberCardAmount > 0)) {
|
||||
toast.error('储值卡必须输入有效面额')
|
||||
return
|
||||
}
|
||||
}
|
||||
saveLoading.value = true
|
||||
try {
|
||||
if (isEdit.value && editId.value) {
|
||||
await updateMemberCard(editId.value, form.value as UpdateMemberCardDto)
|
||||
toast.success('会员卡更新成功')
|
||||
} else {
|
||||
await createMemberCard(form.value)
|
||||
toast.success('会员卡创建成功')
|
||||
}
|
||||
showModal.value = false
|
||||
fetchCards()
|
||||
} catch (e: any) {
|
||||
toast.error(e?.response?.data?.message || '操作失败')
|
||||
} finally {
|
||||
saveLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleDelete(card: MemberCard) {
|
||||
// 前置校验:会员卡必须已下架
|
||||
if (card.memberCardStatus !== 0) {
|
||||
toast.error('请先将会员卡下架后再删除')
|
||||
return
|
||||
}
|
||||
pendingDeleteCardId.value = card.memberCardId
|
||||
adminPassword.value = ''
|
||||
passwordError.value = ''
|
||||
showPasswordModal.value = true
|
||||
}
|
||||
|
||||
async function handleVerifyPassword() {
|
||||
if (!adminPassword.value) {
|
||||
passwordError.value = '请输入管理员密码'
|
||||
return
|
||||
}
|
||||
verifying.value = true
|
||||
passwordError.value = ''
|
||||
try {
|
||||
await verifyPassword({
|
||||
username: authStore.username,
|
||||
password: adminPassword.value,
|
||||
})
|
||||
showPasswordModal.value = false
|
||||
if (pendingDeleteCardId.value != null) {
|
||||
await executeDelete(pendingDeleteCardId.value)
|
||||
}
|
||||
} catch {
|
||||
passwordError.value = '密码验证失败,请重新输入'
|
||||
} finally {
|
||||
verifying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function executeDelete(id: number) {
|
||||
try {
|
||||
await deleteMemberCard(id)
|
||||
toast.success('删除成功')
|
||||
pendingDeleteCardId.value = null
|
||||
fetchCards()
|
||||
} catch (e: any) {
|
||||
const data = e?.response?.data
|
||||
const msg = typeof data === 'string' ? data : (data?.message || '删除失败')
|
||||
toast.error(msg)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchCards()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout>
|
||||
<!-- 工具栏 -->
|
||||
<div class="toolbar">
|
||||
<div class="toolbar-row">
|
||||
<input
|
||||
v-model="keyword"
|
||||
type="text"
|
||||
placeholder="搜索会员卡名称或编号..."
|
||||
class="search-input"
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
<select v-model="typeFilter" class="filter-select" @change="handleTypeChange">
|
||||
<option value="">全部类型</option>
|
||||
<option value="TIME_CARD">时长卡</option>
|
||||
<option value="COUNT_CARD">次卡</option>
|
||||
<option value="STORED_VALUE_CARD">储值卡</option>
|
||||
</select>
|
||||
<select v-model="statusFilter" class="filter-select" @change="handleStatusChange">
|
||||
<option value="">全部状态</option>
|
||||
<option value="1">已上架</option>
|
||||
<option value="0">已下架</option>
|
||||
</select>
|
||||
<select v-model="sortKey" class="filter-select" @change="handleSearch">
|
||||
<option value="id_desc">编号 ↓</option>
|
||||
<option value="id_asc">编号 ↑</option>
|
||||
<option value="price_desc">价格 ↓</option>
|
||||
<option value="price_asc">价格 ↑</option>
|
||||
</select>
|
||||
<button class="btn btn-primary" @click="handleCreate" v-if="authStore.hasPermission('business:memberCard:create')">
|
||||
<i class="fas fa-plus"></i> 新增会员卡
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 表格 -->
|
||||
<div class="table-wrapper">
|
||||
<div class="table-header">
|
||||
<h3>会员卡列表 ({{ totalElements }} 条)</h3>
|
||||
</div>
|
||||
|
||||
<!-- skeleton -->
|
||||
<table v-if="loading">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>编号</th><th>名称</th><th>类型</th><th>价格</th>
|
||||
<th>有效期/次数/面额</th><th>状态</th><th>创建时间</th><th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="i in 5" :key="'sk-' + i">
|
||||
<td v-for="j in 8" :key="j">
|
||||
<span class="sk-td skeleton"></span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- real table -->
|
||||
<table v-else>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>编号</th><th>名称</th><th>类型</th><th>价格</th>
|
||||
<th>有效期/次数/面额</th><th>状态</th><th>创建时间</th><th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="card in pagedCards" :key="card.memberCardId">
|
||||
<td>{{ card.memberCardId }}</td>
|
||||
<td>{{ card.memberCardName }}</td>
|
||||
<td>{{ getTypeLabel(card.memberCardType) }}</td>
|
||||
<td>{{ formatPrice(card.memberCardPrice) }}</td>
|
||||
<td>
|
||||
<span v-if="card.memberCardType === 'TIME_CARD'">{{ card.memberCardValidityDays ?? '--' }} 天</span>
|
||||
<span v-else-if="card.memberCardType === 'COUNT_CARD'">{{ card.memberCardTotalTimes ?? '--' }} 次</span>
|
||||
<span v-else-if="card.memberCardType === 'STORED_VALUE_CARD'">{{ formatPrice(card.memberCardAmount ?? 0) }}</span>
|
||||
<span v-else>--</span>
|
||||
</td>
|
||||
<td>
|
||||
<span :class="card.memberCardStatus === 1 ? 'badge-green' : 'badge-gray'">
|
||||
{{ card.memberCardStatus === 1 ? '已上架' : '已下架' }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ formatTime(card.createdAt) }}</td>
|
||||
<td class="actions">
|
||||
<button class="btn-sm" @click="handleEdit(card)" v-if="authStore.hasPermission('business:memberCard:edit')">编辑</button>
|
||||
<button class="btn-sm btn-sm-danger" @click="handleDelete(card)" v-if="authStore.hasPermission('business:memberCard:delete')">删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="pagedCards.length === 0">
|
||||
<td colspan="8" class="empty">暂无会员卡数据</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- pagination -->
|
||||
<div class="pagination" v-if="totalPages > 1">
|
||||
<button :disabled="currentPage === 0" @click="changePage(currentPage - 1)">上一页</button>
|
||||
<span>第 {{ currentPage + 1 }} / {{ totalPages }} 页</span>
|
||||
<button :disabled="currentPage >= totalPages - 1" @click="changePage(currentPage + 1)">下一页</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 新建/编辑弹窗 -->
|
||||
<div v-if="showModal" class="modal-overlay" @click.self="showModal = false">
|
||||
<div class="modal">
|
||||
<h3>{{ isEdit ? '编辑会员卡' : '新增会员卡' }}</h3>
|
||||
<div class="form-group">
|
||||
<label>会员卡名称 <span class="required">*</span></label>
|
||||
<input v-model="form.memberCardName" placeholder="请输入会员卡名称" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>会员卡类型 <span class="required">*</span></label>
|
||||
<select v-model="form.memberCardType" class="form-select">
|
||||
<option value="TIME_CARD">时长卡</option>
|
||||
<option value="COUNT_CARD">次卡</option>
|
||||
<option value="STORED_VALUE_CARD">储值卡</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>价格 (元) <span class="required">*</span></label>
|
||||
<input v-model.number="form.memberCardPrice" type="number" step="0.01" min="0" placeholder="请输入价格" />
|
||||
</div>
|
||||
|
||||
<!-- 时长卡字段 -->
|
||||
<div v-if="form.memberCardType === 'TIME_CARD'" class="form-group">
|
||||
<label>有效天数</label>
|
||||
<input v-model.number="form.memberCardValidityDays" type="number" min="1" placeholder="请输入有效天数" />
|
||||
</div>
|
||||
|
||||
<!-- 次卡字段 -->
|
||||
<div v-if="form.memberCardType === 'COUNT_CARD'" class="form-group">
|
||||
<label>总次数</label>
|
||||
<input v-model.number="form.memberCardTotalTimes" type="number" min="1" placeholder="请输入总次数" />
|
||||
</div>
|
||||
|
||||
<!-- 储值卡字段 -->
|
||||
<div v-if="form.memberCardType === 'STORED_VALUE_CARD'" class="form-group">
|
||||
<label>面额 (元)</label>
|
||||
<input v-model.number="form.memberCardAmount" type="number" step="0.01" min="0" placeholder="请输入面额" />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>状态</label>
|
||||
<select v-model.number="form.memberCardStatus" class="form-select">
|
||||
<option :value="1">已上架</option>
|
||||
<option :value="0">已下架</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-outline" @click="showModal = false">取消</button>
|
||||
<button class="btn btn-primary" @click="handleSave" :disabled="saveLoading">
|
||||
<i v-if="saveLoading" class="fas fa-spinner fa-spin"></i>
|
||||
{{ saveLoading ? '保存中...' : '保存' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 密码验证弹窗 -->
|
||||
<div v-if="showPasswordModal" class="modal-overlay" @click.self="showPasswordModal = false">
|
||||
<div class="modal modal-sm">
|
||||
<h3>
|
||||
<i class="fas fa-shield-alt" style="margin-right:8px;color:#4f7cff"></i>
|
||||
管理员身份验证
|
||||
</h3>
|
||||
<p class="modal-desc">
|
||||
即将删除会员卡,请输入管理员密码确认操作。
|
||||
</p>
|
||||
|
||||
<div class="form-group">
|
||||
<label>管理员账号</label>
|
||||
<input :value="authStore.username" type="text" disabled class="input-disabled" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>管理员密码 <span class="required">*</span></label>
|
||||
<input
|
||||
v-model="adminPassword"
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
@keyup.enter="handleVerifyPassword"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="passwordError" class="field-error">{{ passwordError }}</p>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-outline" @click="showPasswordModal = false">取消</button>
|
||||
<button
|
||||
class="btn"
|
||||
style="background:#ef4444;border-color:#ef4444;color:white"
|
||||
@click="handleVerifyPassword"
|
||||
:disabled="verifying"
|
||||
>
|
||||
<i v-if="verifying" class="fas fa-spinner fa-spin"></i>
|
||||
{{ verifying ? '验证中...' : '确认删除' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.toolbar {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.toolbar-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
padding: 9px 16px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
font-size: 14px;
|
||||
width: 260px;
|
||||
outline: none;
|
||||
transition: 0.15s;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
border-color: #4f7cff;
|
||||
box-shadow: 0 0 0 3px rgba(79, 124, 255, 0.12);
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
padding: 9px 14px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
font-size: 13px;
|
||||
color: #1e293b;
|
||||
background: white;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
transition: 0.15s;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 9px 20px;
|
||||
border-radius: 40px;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: white;
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
color: #1e293b;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
transition: 0.15s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #4f7cff;
|
||||
border-color: #4f7cff;
|
||||
color: white;
|
||||
box-shadow: 0 4px 10px rgba(79, 124, 255, 0.25);
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: #3f6ae0;
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-outline:hover {
|
||||
background: #f0f5ff;
|
||||
}
|
||||
|
||||
.table-wrapper {
|
||||
background: white;
|
||||
border-radius: 20px;
|
||||
border: 1px solid #edf2f9;
|
||||
padding: 20px 24px 8px 24px;
|
||||
}
|
||||
|
||||
.table-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.table-header h3 {
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
color: #0b1a33;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
padding: 12px 8px 12px 0;
|
||||
font-weight: 600;
|
||||
color: #4e627c;
|
||||
border-bottom: 1px solid #eef2f8;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 14px 8px 14px 0;
|
||||
border-bottom: 1px solid #f1f5fa;
|
||||
color: #1e2f44;
|
||||
}
|
||||
|
||||
tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
td.empty {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #8b9ab0;
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
background: linear-gradient(90deg, #eef2f8 25%, #e0e7f0 50%, #eef2f8 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.sk-td {
|
||||
display: block;
|
||||
height: 16px;
|
||||
width: 80%;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
tr {
|
||||
animation: fadeIn 0.35s ease;
|
||||
}
|
||||
|
||||
.actions {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 5px 12px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: white;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
margin-right: 6px;
|
||||
transition: 0.15s;
|
||||
}
|
||||
|
||||
.btn-sm:hover {
|
||||
background: #f0f5ff;
|
||||
}
|
||||
|
||||
.btn-sm-danger {
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.btn-sm-danger:hover {
|
||||
background: #fecaca;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 16px 0;
|
||||
font-size: 13px;
|
||||
color: #4e627c;
|
||||
}
|
||||
|
||||
.pagination button {
|
||||
padding: 6px 16px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: 0.15s;
|
||||
}
|
||||
|
||||
.pagination button:hover:not(:disabled) {
|
||||
background: #f0f5ff;
|
||||
}
|
||||
|
||||
.pagination button:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ---- modal ---- */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 200;
|
||||
animation: fadeIn 0.15s ease;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: white;
|
||||
border-radius: 20px;
|
||||
padding: 28px;
|
||||
width: 560px;
|
||||
max-width: 92vw;
|
||||
max-height: 85vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.12);
|
||||
animation: fadeIn 0.2s ease;
|
||||
}
|
||||
|
||||
.modal h3 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #0b1a33;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.modal-sm { width: 420px; }
|
||||
|
||||
.modal-desc {
|
||||
font-size: 14px; color: #4e627c; margin-bottom: 16px; line-height: 1.6;
|
||||
}
|
||||
|
||||
.input-disabled {
|
||||
background: #f1f5f9 !important; color: #64748b; cursor: not-allowed;
|
||||
}
|
||||
|
||||
.field-error {
|
||||
color: #ef4444; font-size: 13px; margin: -6px 0 8px 0;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select {
|
||||
width: 100%;
|
||||
padding: 9px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #e2e8f0;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: 0.15s;
|
||||
background: #f8fafd;
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus {
|
||||
border-color: #4f7cff;
|
||||
box-shadow: 0 0 0 3px rgba(79, 124, 255, 0.12);
|
||||
}
|
||||
|
||||
.form-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.badge-green {
|
||||
display: inline-block;
|
||||
padding: 2px 10px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
background: #e0f5ec;
|
||||
color: #0b8b5e;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.badge-gray {
|
||||
display: inline-block;
|
||||
padding: 2px 10px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
background: #f1f5f9;
|
||||
color: #64748b;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.required {
|
||||
color: #ef4444;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,887 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import {
|
||||
getMemberSignInDetails,
|
||||
getMemberMonthlySignIns,
|
||||
getMemberDailySignIns,
|
||||
type MemberSignInDetail,
|
||||
type MemberMonthlySignIn,
|
||||
type MemberDailySignIn,
|
||||
} from '@/api/datacount'
|
||||
import AppLayout from '@/components/AppLayout.vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const loading = ref(true)
|
||||
const periodType = ref('DAY')
|
||||
const showDatePicker = ref(false)
|
||||
const startDate = ref('')
|
||||
const endDate = ref('')
|
||||
|
||||
const total = ref(0)
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const list = ref<MemberSignInDetail[]>([])
|
||||
|
||||
// 月度弹窗
|
||||
const showMonthly = ref(false)
|
||||
const monthlyLoading = ref(false)
|
||||
const monthlyData = ref<MemberMonthlySignIn[]>([])
|
||||
const selectedMember = ref<MemberSignInDetail | null>(null)
|
||||
|
||||
// 月份展开状态:{ [month]: MemberDailySignIn[] | null }
|
||||
const expandedMonth = ref('')
|
||||
const dailyLoading = ref(false)
|
||||
const dailyData = ref<MemberDailySignIn[]>([])
|
||||
|
||||
const signInTypeLabel: Record<string, string> = {
|
||||
QR_CODE: '扫码签到',
|
||||
MANUAL: '手动签到',
|
||||
FACE: '人脸签到',
|
||||
}
|
||||
|
||||
const periodOptions = [
|
||||
{ label: '今日', value: 'DAY' },
|
||||
{ label: '本周', value: 'WEEK' },
|
||||
{ label: '本月', value: 'MONTH' },
|
||||
{ label: '自定义', value: 'CUSTOM' },
|
||||
]
|
||||
|
||||
function formatDateTime(dateStr: string, isEnd: boolean): string {
|
||||
if (!dateStr) return ''
|
||||
return isEnd ? dateStr + 'T23:59:59' : dateStr + 'T00:00:00'
|
||||
}
|
||||
|
||||
function today(): string {
|
||||
return new Date().toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
function totalPages(): number {
|
||||
return Math.max(1, Math.ceil(total.value / pageSize.value))
|
||||
}
|
||||
|
||||
async function fetchData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: Record<string, string | number> = {}
|
||||
if (periodType.value === 'CUSTOM') {
|
||||
if (startDate.value) params.startTime = formatDateTime(startDate.value, false)
|
||||
if (endDate.value) params.endTime = formatDateTime(endDate.value, true)
|
||||
} else {
|
||||
params.periodType = periodType.value
|
||||
}
|
||||
params.page = currentPage.value
|
||||
params.size = pageSize.value
|
||||
|
||||
const res = await getMemberSignInDetails(params)
|
||||
total.value = res.total
|
||||
list.value = res.list
|
||||
} catch (e: any) {
|
||||
toast.error(e?.response?.data?.error || e?.message || '获取签到明细失败')
|
||||
total.value = 0
|
||||
list.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function changePeriod(value: string) {
|
||||
periodType.value = value
|
||||
showDatePicker.value = value === 'CUSTOM'
|
||||
currentPage.value = 1
|
||||
if (value !== 'CUSTOM') {
|
||||
fetchData()
|
||||
}
|
||||
}
|
||||
|
||||
function handleCustomQuery() {
|
||||
if (!startDate.value && !endDate.value) return
|
||||
currentPage.value = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
async function viewMonthly(item: MemberSignInDetail) {
|
||||
selectedMember.value = item
|
||||
showMonthly.value = true
|
||||
monthlyLoading.value = true
|
||||
monthlyData.value = []
|
||||
try {
|
||||
monthlyData.value = await getMemberMonthlySignIns(item.memberId)
|
||||
} catch (e: any) {
|
||||
toast.error(e?.response?.data?.error || e?.message || '获取月度数据失败')
|
||||
} finally {
|
||||
monthlyLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function closeMonthly() {
|
||||
showMonthly.value = false
|
||||
selectedMember.value = null
|
||||
monthlyData.value = []
|
||||
expandedMonth.value = ''
|
||||
dailyData.value = []
|
||||
}
|
||||
|
||||
async function toggleMonth(month: string) {
|
||||
if (expandedMonth.value === month) {
|
||||
// 收起
|
||||
expandedMonth.value = ''
|
||||
dailyData.value = []
|
||||
} else {
|
||||
// 展开
|
||||
expandedMonth.value = month
|
||||
dailyLoading.value = true
|
||||
dailyData.value = []
|
||||
try {
|
||||
dailyData.value = await getMemberDailySignIns(selectedMember.value!.memberId, month)
|
||||
} catch (e: any) {
|
||||
toast.error(e?.response?.data?.error || e?.message || '获取每日数据失败')
|
||||
} finally {
|
||||
dailyLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function goPage(page: number) {
|
||||
if (page < 1 || page > totalPages()) return
|
||||
currentPage.value = page
|
||||
fetchData()
|
||||
}
|
||||
|
||||
onMounted(fetchData)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="period-bar">
|
||||
<div class="period-selector">
|
||||
<button
|
||||
v-for="opt in periodOptions"
|
||||
:key="opt.value"
|
||||
class="period-btn"
|
||||
:class="{ active: periodType === opt.value }"
|
||||
@click="changePeriod(opt.value)"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<transition name="fade">
|
||||
<div v-if="showDatePicker" class="date-range">
|
||||
<label>开始日期</label>
|
||||
<input v-model="startDate" type="date" :max="endDate || today()" />
|
||||
<label>结束日期</label>
|
||||
<input v-model="endDate" type="date" :min="startDate" :max="today()" />
|
||||
<button class="btn btn-primary" @click="handleCustomQuery">
|
||||
<i class="fas fa-search"></i> 查询
|
||||
</button>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
|
||||
<div class="page-header">
|
||||
<h2>会员签到明细</h2>
|
||||
<span class="total-info">共 {{ total }} 人</span>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<template v-if="loading">
|
||||
<div class="skeleton-table">
|
||||
<div v-for="i in 5" :key="'sk-' + i" class="sk-row">
|
||||
<div class="sk-cell skeleton" style="width:60px"></div>
|
||||
<div class="sk-cell skeleton" style="width:80px"></div>
|
||||
<div class="sk-cell skeleton" style="width:120px"></div>
|
||||
<div class="sk-cell skeleton" style="width:60px"></div>
|
||||
<div class="sk-cell skeleton" style="width:60px"></div>
|
||||
<div class="sk-cell skeleton" style="width:60px"></div>
|
||||
<div class="sk-cell skeleton" style="width:140px"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="list.length > 0">
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>会员ID</th>
|
||||
<th>昵称</th>
|
||||
<th>手机号</th>
|
||||
<th>签到总次数</th>
|
||||
<th>成功</th>
|
||||
<th>失败</th>
|
||||
<th>最近签到时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in list" :key="item.memberId" class="clickable-row" @click="viewMonthly(item)">
|
||||
<td>{{ item.memberId }}</td>
|
||||
<td>{{ item.memberName }}</td>
|
||||
<td>{{ item.memberPhone }}</td>
|
||||
<td>
|
||||
<span class="badge badge-blue">{{ item.totalSignIns }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge badge-green">{{ item.successSignIns }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge badge-red">{{ item.failedSignIns }}</span>
|
||||
</td>
|
||||
<td class="time-cell">{{ item.lastSignInTime }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="pagination" v-if="totalPages() > 1">
|
||||
<button class="page-btn" :disabled="currentPage <= 1" @click="goPage(currentPage - 1)">
|
||||
<i class="fas fa-chevron-left"></i>
|
||||
</button>
|
||||
<span class="page-info">{{ currentPage }} / {{ totalPages() }}</span>
|
||||
<button class="page-btn" :disabled="currentPage >= totalPages()" @click="goPage(currentPage + 1)">
|
||||
<i class="fas fa-chevron-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else class="empty-state">暂无签到数据</div>
|
||||
</div>
|
||||
|
||||
<!-- 月度签到弹窗 -->
|
||||
<Teleport to="body">
|
||||
<transition name="modal">
|
||||
<div v-if="showMonthly" class="modal-overlay" @click.self="closeMonthly">
|
||||
<div class="modal-panel">
|
||||
<div class="modal-header">
|
||||
<h3>
|
||||
<i class="fas fa-calendar-alt" style="margin-right: 8px; color: #ec4899"></i>
|
||||
{{ selectedMember?.memberName }} 的月度签到记录
|
||||
</h3>
|
||||
<button class="modal-close" @click="closeMonthly">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<template v-if="monthlyLoading">
|
||||
<div class="sk-row" v-for="i in 5" :key="'skm-' + i">
|
||||
<div class="sk-cell skeleton" style="width:80px"></div>
|
||||
<div class="sk-cell skeleton" style="width:60px"></div>
|
||||
<div class="sk-cell skeleton" style="width:60px"></div>
|
||||
<div class="sk-cell skeleton" style="width:60px"></div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="monthlyData.length > 0">
|
||||
<div class="monthly-list">
|
||||
<template v-for="m in monthlyData" :key="m.month">
|
||||
<div
|
||||
class="month-row"
|
||||
:class="{ expanded: expandedMonth === m.month }"
|
||||
@click="toggleMonth(m.month)"
|
||||
>
|
||||
<div class="month-main">
|
||||
<span class="month-badge">{{ m.month }}</span>
|
||||
<div class="month-stats">
|
||||
<span class="stat-item">
|
||||
<span class="stat-val">{{ m.totalSignIns }}</span>
|
||||
<span class="stat-sub">次</span>
|
||||
</span>
|
||||
<span class="stat-sep"></span>
|
||||
<span class="stat-item success">
|
||||
<span class="stat-val">{{ m.successSignIns }}</span>
|
||||
<span class="stat-sub">成功</span>
|
||||
</span>
|
||||
<span class="stat-sep"></span>
|
||||
<span class="stat-item fail">
|
||||
<span class="stat-val">{{ m.failedSignIns }}</span>
|
||||
<span class="stat-sub">失败</span>
|
||||
</span>
|
||||
</div>
|
||||
<i
|
||||
class="fas fa-chevron-down expand-icon"
|
||||
:class="{ rotated: expandedMonth === m.month }"
|
||||
></i>
|
||||
</div>
|
||||
|
||||
<!-- 展开:每日明细 -->
|
||||
<transition name="expand">
|
||||
<div v-if="expandedMonth === m.month" class="daily-panel">
|
||||
<div v-if="dailyLoading" class="daily-loading">
|
||||
<div class="sk-cell skeleton" style="width:100%;height:16px" v-for="i in 3" :key="'ds-' + i"></div>
|
||||
</div>
|
||||
<template v-else-if="dailyData.length > 0">
|
||||
<div
|
||||
v-for="d in dailyData"
|
||||
:key="d.signInTime"
|
||||
class="daily-row"
|
||||
:class="{ failed: d.signInStatus === 'FAILED' }"
|
||||
>
|
||||
<span class="daily-time">
|
||||
<i class="fas" :class="d.signInStatus === 'SUCCESS' ? 'fa-check-circle' : 'fa-times-circle'"></i>
|
||||
{{ d.signInTime }}
|
||||
</span>
|
||||
<span class="daily-type">{{ signInTypeLabel[d.signInType] || d.signInType }}</span>
|
||||
<span v-if="d.signInStatus === 'FAILED' && d.failReason" class="daily-reason">{{ d.failReason }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="daily-empty">该月无签到记录</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="empty-state">该会员暂无签到记录</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</Teleport>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.period-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.period-selector {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.period-btn {
|
||||
padding: 8px 20px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: white;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #3e4e62;
|
||||
cursor: pointer;
|
||||
transition: 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.period-btn.active {
|
||||
background: #4f7cff;
|
||||
border-color: #4f7cff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.period-btn:hover:not(.active) {
|
||||
background: #f0f5ff;
|
||||
}
|
||||
|
||||
.date-range {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: white;
|
||||
padding: 8px 14px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid #edf2f9;
|
||||
}
|
||||
|
||||
.date-range label {
|
||||
font-size: 13px;
|
||||
color: #6b7d94;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.date-range input {
|
||||
padding: 6px 10px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: 0.15s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #4f7cff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #3b5fd9;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.page-header h2 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #0b1a33;
|
||||
}
|
||||
|
||||
.total-info {
|
||||
font-size: 13px;
|
||||
color: #8b9ab0;
|
||||
background: #f0f3f8;
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #edf2f9;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
thead {
|
||||
background: #f8fafd;
|
||||
}
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
padding: 12px 16px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #6b7d94;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
border-bottom: 1px solid #edf2f9;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 12px 16px;
|
||||
font-size: 14px;
|
||||
color: #3e4e62;
|
||||
border-bottom: 1px solid #f0f3f8;
|
||||
}
|
||||
|
||||
tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
tr:hover td {
|
||||
background: #f8fafd;
|
||||
}
|
||||
|
||||
.clickable-row {
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.clickable-row:hover td {
|
||||
background: #fdf2f8;
|
||||
}
|
||||
|
||||
.time-cell {
|
||||
font-size: 13px;
|
||||
color: #8b9ab0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 2px 10px;
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge-blue {
|
||||
background: #f0f5ff;
|
||||
color: #4f7cff;
|
||||
}
|
||||
|
||||
.badge-green {
|
||||
background: #ecfdf5;
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.badge-red {
|
||||
background: #fef2f2;
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
border-top: 1px solid #edf2f9;
|
||||
}
|
||||
|
||||
.page-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: white;
|
||||
color: #6b7d94;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: 0.15s;
|
||||
}
|
||||
|
||||
.page-btn:hover:not(:disabled) {
|
||||
background: #f0f5ff;
|
||||
color: #4f7cff;
|
||||
border-color: #4f7cff;
|
||||
}
|
||||
|
||||
.page-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.page-info {
|
||||
font-size: 13px;
|
||||
color: #6b7d94;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
color: #8b9ab0;
|
||||
text-align: center;
|
||||
padding: 48px 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* ---- modal ---- */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(11, 26, 51, 0.45);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.modal-panel {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
width: 520px;
|
||||
max-width: 90vw;
|
||||
max-height: 80vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid #edf2f9;
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #0b1a33;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
background: #f0f3f8;
|
||||
color: #8b9ab0;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: 0.15s;
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
background: #fef2f2;
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 16px 24px 24px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* ---- monthly list ---- */
|
||||
.monthly-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.month-row {
|
||||
border: 1px solid #edf2f9;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.month-row:hover {
|
||||
border-color: #d0d8e8;
|
||||
}
|
||||
|
||||
.month-row.expanded {
|
||||
border-color: #c4d4f5;
|
||||
}
|
||||
|
||||
.month-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 12px 16px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.month-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
background: #fdf2f8;
|
||||
color: #ec4899;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.month-stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.stat-val {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #3e4e62;
|
||||
}
|
||||
|
||||
.stat-item.success .stat-val {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.stat-item.fail .stat-val {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.stat-sub {
|
||||
font-size: 11px;
|
||||
color: #8b9ab0;
|
||||
}
|
||||
|
||||
.stat-sep {
|
||||
width: 1px;
|
||||
height: 14px;
|
||||
background: #edf2f9;
|
||||
}
|
||||
|
||||
.expand-icon {
|
||||
color: #8b9ab0;
|
||||
font-size: 12px;
|
||||
transition: transform 0.25s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.expand-icon.rotated {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* ---- daily panel ---- */
|
||||
.daily-panel {
|
||||
border-top: 1px solid #edf2f9;
|
||||
background: #fafbfc;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.daily-loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.daily-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #f0f3f8;
|
||||
}
|
||||
|
||||
.daily-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.daily-row.failed {
|
||||
background: #fef2f2;
|
||||
margin: 0 -8px;
|
||||
padding: 8px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.daily-time {
|
||||
font-size: 13px;
|
||||
color: #3e4e62;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.daily-time .fa-check-circle {
|
||||
color: #10b981;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.daily-time .fa-times-circle {
|
||||
color: #ef4444;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.daily-type {
|
||||
font-size: 12px;
|
||||
color: #6b7d94;
|
||||
background: #f0f3f8;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.daily-reason {
|
||||
font-size: 12px;
|
||||
color: #ef4444;
|
||||
flex: 1;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.daily-empty {
|
||||
font-size: 13px;
|
||||
color: #8b9ab0;
|
||||
text-align: center;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
/* ---- expand transition ---- */
|
||||
.expand-enter-active,
|
||||
.expand-leave-active {
|
||||
transition: all 0.25s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.expand-enter-from,
|
||||
.expand-leave-to {
|
||||
opacity: 0;
|
||||
max-height: 0;
|
||||
}
|
||||
|
||||
.expand-enter-to,
|
||||
.expand-leave-from {
|
||||
opacity: 1;
|
||||
max-height: 600px;
|
||||
}
|
||||
|
||||
/* ---- modal animation ---- */
|
||||
.modal-enter-active {
|
||||
transition: opacity 0.25s ease;
|
||||
}
|
||||
|
||||
.modal-enter-active .modal-panel {
|
||||
transition: transform 0.25s ease;
|
||||
}
|
||||
|
||||
.modal-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.modal-leave-active .modal-panel {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.modal-enter-from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.modal-enter-from .modal-panel {
|
||||
transform: scale(0.95) translateY(10px);
|
||||
}
|
||||
|
||||
.modal-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.modal-leave-to .modal-panel {
|
||||
transform: scale(0.95) translateY(10px);
|
||||
}
|
||||
|
||||
.skeleton-table {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.sk-row {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid #f0f3f8;
|
||||
}
|
||||
|
||||
.sk-cell {
|
||||
height: 16px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
background: linear-gradient(90deg, #edf2f9 25%, #f8fafd 50%, #edf2f9 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,592 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import {
|
||||
getAllNotices,
|
||||
createNotice,
|
||||
updateNotice,
|
||||
deleteNotice,
|
||||
type SysNotice,
|
||||
type CreateNoticeDto,
|
||||
type UpdateNoticeDto,
|
||||
} from '@/api/notice'
|
||||
import AppLayout from '@/components/AppLayout.vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
// ==================== 列表数据 ====================
|
||||
const notices = ref<SysNotice[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
// ==================== 创建/编辑弹窗 ====================
|
||||
const showFormModal = ref(false)
|
||||
const formLoading = ref(false)
|
||||
const isEditing = ref(false)
|
||||
const editingId = ref<number | null>(null)
|
||||
const form = ref<CreateNoticeDto>({
|
||||
noticeTitle: '',
|
||||
noticeType: '1',
|
||||
noticeContent: '',
|
||||
status: '0',
|
||||
})
|
||||
const formErrors = ref<Record<string, string>>({})
|
||||
|
||||
// ==================== 删除确认弹窗 ====================
|
||||
const showDeleteModal = ref(false)
|
||||
const deleteTarget = ref<SysNotice | null>(null)
|
||||
const deleteLoading = ref(false)
|
||||
|
||||
// ==================== 类型/状态映射 ====================
|
||||
const noticeTypeMap: Record<string, string> = { '1': '通知', '2': '公告' }
|
||||
const statusMap: Record<string, string> = { '0': '正常', '1': '关闭' }
|
||||
|
||||
// ==================== 数据获取 ====================
|
||||
async function fetchNotices() {
|
||||
loading.value = true
|
||||
try {
|
||||
notices.value = await getAllNotices()
|
||||
} catch (e: any) {
|
||||
notices.value = []
|
||||
const msg = e?.response?.data?.message || e?.message || '获取公告列表失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 创建/编辑 ====================
|
||||
function openCreateModal() {
|
||||
isEditing.value = false
|
||||
editingId.value = null
|
||||
form.value = { noticeTitle: '', noticeType: '1', noticeContent: '', status: '0' }
|
||||
formErrors.value = {}
|
||||
showFormModal.value = true
|
||||
}
|
||||
|
||||
function openEditModal(notice: SysNotice) {
|
||||
isEditing.value = true
|
||||
editingId.value = notice.id
|
||||
form.value = {
|
||||
noticeTitle: notice.noticeTitle,
|
||||
noticeType: notice.noticeType,
|
||||
noticeContent: notice.noticeContent,
|
||||
status: notice.status,
|
||||
}
|
||||
formErrors.value = {}
|
||||
showFormModal.value = true
|
||||
}
|
||||
|
||||
function validateForm(): boolean {
|
||||
const errs: Record<string, string> = {}
|
||||
if (!form.value.noticeTitle.trim()) {
|
||||
errs.noticeTitle = '标题不能为空'
|
||||
}
|
||||
if (!form.value.noticeContent.trim()) {
|
||||
errs.noticeContent = '内容不能为空'
|
||||
}
|
||||
formErrors.value = errs
|
||||
return Object.keys(errs).length === 0
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!validateForm()) return
|
||||
formLoading.value = true
|
||||
try {
|
||||
if (isEditing.value && editingId.value != null) {
|
||||
const updateData: UpdateNoticeDto = { ...form.value }
|
||||
await updateNotice(editingId.value, updateData)
|
||||
toast.success('公告更新成功')
|
||||
} else {
|
||||
await createNotice(form.value)
|
||||
toast.success('公告创建成功')
|
||||
}
|
||||
showFormModal.value = false
|
||||
fetchNotices()
|
||||
} catch (e: any) {
|
||||
const msg = e?.response?.data?.message || e?.message || '操作失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
formLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 删除 ====================
|
||||
function openDeleteModal(notice: SysNotice) {
|
||||
deleteTarget.value = notice
|
||||
showDeleteModal.value = true
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!deleteTarget.value) return
|
||||
deleteLoading.value = true
|
||||
try {
|
||||
await deleteNotice(deleteTarget.value.id)
|
||||
toast.success('公告已删除')
|
||||
showDeleteModal.value = false
|
||||
fetchNotices()
|
||||
} catch (e: any) {
|
||||
const msg = e?.response?.data?.message || e?.message || '删除失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
deleteLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 工具函数 ====================
|
||||
function formatTime(t: string | null): string {
|
||||
if (!t) return '--'
|
||||
return new Date(t).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
function truncateContent(content: string, max = 50): string {
|
||||
if (!content) return '--'
|
||||
return content.length > max ? content.slice(0, max) + '...' : content
|
||||
}
|
||||
|
||||
// ==================== 初始化 ====================
|
||||
onMounted(fetchNotices)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout>
|
||||
<!-- ========== 操作栏 ========== -->
|
||||
<div class="filter-bar">
|
||||
<div class="filter-row">
|
||||
<div class="filter-spacer"></div>
|
||||
<button class="btn btn-primary" @click="openCreateModal">
|
||||
<i class="fas fa-plus"></i> 新增公告
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ========== 数据表格 ========== -->
|
||||
<div class="table-container">
|
||||
<table class="data-table" v-if="!loading">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 60px">ID</th>
|
||||
<th style="width: 200px">标题</th>
|
||||
<th style="width: 80px">类型</th>
|
||||
<th style="width: 260px">内容</th>
|
||||
<th style="width: 80px">状态</th>
|
||||
<th style="width: 150px">创建时间</th>
|
||||
<th style="width: 160px">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="notices.length === 0">
|
||||
<td colspan="7" class="empty-cell">暂无公告数据</td>
|
||||
</tr>
|
||||
<tr v-for="n in notices" :key="n.id">
|
||||
<td>{{ n.id }}</td>
|
||||
<td class="text-bold">{{ n.noticeTitle }}</td>
|
||||
<td>
|
||||
<span class="type-badge" :class="n.noticeType === '2' ? 'announce' : 'notice'">
|
||||
{{ noticeTypeMap[n.noticeType] || n.noticeType }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-ellipsis">{{ truncateContent(n.noticeContent) }}</td>
|
||||
<td>
|
||||
<span class="status-badge" :class="n.status === '0' ? 'active' : 'inactive'">
|
||||
{{ statusMap[n.status] || n.status }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ formatTime(n.createdAt) }}</td>
|
||||
<td class="action-cell">
|
||||
<button class="btn btn-sm btn-outline" @click="openEditModal(n)">
|
||||
<i class="fas fa-edit"></i> 编辑
|
||||
</button>
|
||||
<button class="btn btn-sm btn-danger" @click="openDeleteModal(n)">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div v-else class="loading-state">
|
||||
<i class="fas fa-spinner fa-spin"></i> 加载中...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ========== 创建/编辑弹窗 ========== -->
|
||||
<Teleport to="body">
|
||||
<div class="modal-overlay" v-if="showFormModal" @click.self="showFormModal = false">
|
||||
<div class="modal-box">
|
||||
<div class="modal-header">
|
||||
<h3>
|
||||
<i :class="isEditing ? 'fas fa-edit' : 'fas fa-plus'"></i>
|
||||
{{ isEditing ? '编辑公告' : '新增公告' }}
|
||||
</h3>
|
||||
<button class="modal-close" @click="showFormModal = false">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label>公告标题 <span class="required">*</span></label>
|
||||
<input v-model="form.noticeTitle" class="form-input" placeholder="请输入公告标题" />
|
||||
<span class="field-error" v-if="formErrors.noticeTitle">{{ formErrors.noticeTitle }}</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>公告类型</label>
|
||||
<select v-model="form.noticeType" class="form-input">
|
||||
<option value="1">通知</option>
|
||||
<option value="2">公告</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>状态</label>
|
||||
<select v-model="form.status" class="form-input">
|
||||
<option value="0">正常</option>
|
||||
<option value="1">关闭</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>公告内容 <span class="required">*</span></label>
|
||||
<textarea
|
||||
v-model="form.noticeContent"
|
||||
class="form-textarea"
|
||||
rows="6"
|
||||
placeholder="请输入公告内容"
|
||||
></textarea>
|
||||
<span class="field-error" v-if="formErrors.noticeContent">{{ formErrors.noticeContent }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-outline" @click="showFormModal = false">取消</button>
|
||||
<button class="btn btn-primary" :disabled="formLoading" @click="handleSubmit">
|
||||
<i class="fas fa-spinner fa-spin" v-if="formLoading"></i>
|
||||
{{ formLoading ? '保存中...' : '保存' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- ========== 删除确认弹窗 ========== -->
|
||||
<Teleport to="body">
|
||||
<div class="modal-overlay" v-if="showDeleteModal" @click.self="showDeleteModal = false">
|
||||
<div class="modal-box modal-sm">
|
||||
<div class="modal-header">
|
||||
<h3><i class="fas fa-exclamation-triangle" style="color: #e74c3c"></i> 确认删除</h3>
|
||||
<button class="modal-close" @click="showDeleteModal = false">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="modal-desc">
|
||||
确定要删除公告 <strong>{{ deleteTarget?.noticeTitle }}</strong> 吗?
|
||||
</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-outline" @click="showDeleteModal = false">取消</button>
|
||||
<button class="btn btn-danger" :disabled="deleteLoading" @click="handleDelete">
|
||||
<i class="fas fa-spinner fa-spin" v-if="deleteLoading"></i>
|
||||
{{ deleteLoading ? '删除中...' : '确认删除' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* ==================== 筛选栏 ==================== */
|
||||
.filter-bar {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.filter-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.filter-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* ==================== 表格 ==================== */
|
||||
.table-container {
|
||||
background: #ffffff;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e9edf4;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
background: #f8f9fc;
|
||||
color: #5a6a85;
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #e9edf4;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.data-table td {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #f0f3f8;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.data-table tbody tr:hover {
|
||||
background: #f8f9fc;
|
||||
}
|
||||
|
||||
.empty-cell {
|
||||
text-align: center;
|
||||
color: #8b9ab0;
|
||||
padding: 40px 16px !important;
|
||||
}
|
||||
|
||||
.text-bold {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.text-ellipsis {
|
||||
max-width: 240px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.action-cell {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.type-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.type-badge.notice {
|
||||
background: #e3f2fd;
|
||||
color: #1565c0;
|
||||
}
|
||||
|
||||
.type-badge.announce {
|
||||
background: #fff3e0;
|
||||
color: #e65100;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-badge.active {
|
||||
background: #e8f5e9;
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
.status-badge.inactive {
|
||||
background: #fce4e4;
|
||||
color: #c62828;
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
padding: 60px;
|
||||
text-align: center;
|
||||
color: #8b9ab0;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
/* ==================== 按钮 ==================== */
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #4f7cff;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: #3b5fd9;
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
background: #ffffff;
|
||||
color: #4a5568;
|
||||
border: 1px solid #dde2eb;
|
||||
}
|
||||
|
||||
.btn-outline:hover:not(:disabled) {
|
||||
background: #f0f3f8;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #e74c3c;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: #c0392b;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 5px 10px;
|
||||
font-size: 12px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
/* ==================== 弹窗 ==================== */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-box {
|
||||
background: #ffffff;
|
||||
border-radius: 14px;
|
||||
width: 560px;
|
||||
max-width: 90vw;
|
||||
max-height: 85vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.modal-box.modal-sm {
|
||||
width: 420px;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px 24px 16px;
|
||||
border-bottom: 1px solid #f0f3f8;
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
font-size: 18px;
|
||||
color: #1e293b;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 24px;
|
||||
color: #8b9ab0;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 20px 24px;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
padding: 16px 24px;
|
||||
border-top: 1px solid #f0f3f8;
|
||||
}
|
||||
|
||||
.modal-desc {
|
||||
color: #5a6a85;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* ==================== 表单 ==================== */
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #5a6a85;
|
||||
}
|
||||
|
||||
.required {
|
||||
color: #e74c3c;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 9px 14px;
|
||||
border: 1px solid #dde2eb;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
color: #1e293b;
|
||||
transition: border-color 0.2s;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
border-color: #4f7cff;
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
width: 100%;
|
||||
padding: 9px 14px;
|
||||
border: 1px solid #dde2eb;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
color: #1e293b;
|
||||
resize: vertical;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.2s;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.form-textarea:focus {
|
||||
border-color: #4f7cff;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.field-error {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: #e74c3c;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
|
||||
"exclude": ["src/**/__tests__/*"],
|
||||
"compilerOptions": {
|
||||
// Extra safety for array and object lookups, but may have false positives.
|
||||
"noUncheckedIndexedAccess": true,
|
||||
|
||||
// Path mapping for cleaner imports.
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
|
||||
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
|
||||
// Specified here to keep it out of the root directory.
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// TSConfig for modules that run in Node.js environment via either transpilation or type-stripping.
|
||||
{
|
||||
"extends": "@tsconfig/node24/tsconfig.json",
|
||||
"include": [
|
||||
"vite.config.*",
|
||||
"vitest.config.*",
|
||||
"cypress.config.*",
|
||||
"playwright.config.*",
|
||||
"eslint.config.*"
|
||||
],
|
||||
"compilerOptions": {
|
||||
// Most tools use transpilation instead of Node.js's native type-stripping.
|
||||
// Bundler mode provides a smoother developer experience.
|
||||
"module": "preserve",
|
||||
"moduleResolution": "bundler",
|
||||
|
||||
// Include Node.js types and avoid accidentally including other `@types/*` packages.
|
||||
"types": ["node"],
|
||||
|
||||
// Disable emitting output during `vue-tsc --build`, which is used for type-checking only.
|
||||
"noEmit": true,
|
||||
|
||||
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
|
||||
// Specified here to keep it out of the root directory.
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import vueDevTools from 'vite-plugin-vue-devtools'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
vueDevTools(),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
},
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8084',
|
||||
changeOrigin: true
|
||||
},
|
||||
},
|
||||
},
|
||||
// 🔑 新增:配置部署子路径
|
||||
base: '/cuit/',
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
应用异常: TypeError: can't access property "attendRate", $setup.summary.bookingStatistics is null
|
||||
default Dashboard.vue:422
|
||||
renderFnWithContext runtime-core.esm-bundler.js:702
|
||||
renderSlot runtime-core.esm-bundler.js:3277
|
||||
_sfc_render AppLayout.vue:252
|
||||
renderComponentRoot runtime-core.esm-bundler.js:4580
|
||||
componentUpdateFn runtime-core.esm-bundler.js:6223
|
||||
run reactivity.esm-bundler.js:260
|
||||
runIfDirty reactivity.esm-bundler.js:298
|
||||
callWithErrorHandling runtime-core.esm-bundler.js:199
|
||||
flushJobs runtime-core.esm-bundler.js:408
|
||||
promise callback*queueFlush runtime-core.esm-bundler.js:322
|
||||
queueJob runtime-core.esm-bundler.js:317
|
||||
scheduler runtime-core.esm-bundler.js:6274
|
||||
trigger reactivity.esm-bundler.js:288
|
||||
endBatch reactivity.esm-bundler.js:346
|
||||
notify reactivity.esm-bundler.js:637
|
||||
trigger reactivity.esm-bundler.js:611
|
||||
set value reactivity.esm-bundler.js:1542
|
||||
fetchData Dashboard.vue:128
|
||||
createHook runtime-core.esm-bundler.js:3104
|
||||
callWithErrorHandling runtime-core.esm-bundler.js:199
|
||||
callWithAsyncErrorHandling runtime-core.esm-bundler.js:206
|
||||
__weh runtime-core.esm-bundler.js:3084
|
||||
flushPostFlushCbs runtime-core.esm-bundler.js:385
|
||||
flushJobs runtime-core.esm-bundler.js:427
|
||||
promise callback*queueFlush runtime-core.esm-bundler.js:322
|
||||
queuePostFlushCb runtime-core.esm-bundler.js:336
|
||||
queueEffectWithSuspense runtime-core.esm-bundler.js:7574
|
||||
scheduler runtime-core.esm-bundler.js:891
|
||||
scheduler reactivity.esm-bundler.js:1946
|
||||
trigger reactivity.esm-bundler.js:288
|
||||
endBatch reactivity.esm-bundler.js:346
|
||||
notify reactivity.esm-bundler.js:637
|
||||
trigger reactivity.esm-bundler.js:611
|
||||
set value reactivity.esm-bundler.js:1542
|
||||
finalizeNavigation vue-router.js:1348
|
||||
pushWithRedirect vue-router.js:1276
|
||||
promise callback*pushWithRedirect vue-router.js:1264
|
||||
push vue-router.js:1217
|
||||
handleLogin Login.vue:23
|
||||
cacheKey runtime-dom.esm-bundler.js:1857
|
||||
callWithErrorHandling runtime-core.esm-bundler.js:199
|
||||
callWithAsyncErrorHandling runtime-core.esm-bundler.js:206
|
||||
invoker
|
||||
Reference in New Issue
Block a user