Compare commits

...

63 Commits
v1.4 ... main

Author SHA1 Message Date
xxx
09fbd8e3a5 light->2.0 2025-05-22 15:35:38 +08:00
xxx
022e230419 readme 2025-05-22 15:25:52 +08:00
白书科技
12daa31ab9 !14 存储桶改为private
* 后台 使用许可页面
* 优化:移除API访问地址配置
* 后台、pc、h5 删除无用配置
* docker部署优化
* 2.0 networkMode=bridge
* changelog
* 学员端权限为空报错
* h5 我的页面请求优化
* 缓存查询
* 后台 学员列表报错、线上课-上架时间字段优化
* 后台、pc、h5 使用签名地址
* 学员端接口修改
* 后台、pc 使用签名地址
* 后台 使用签名地址
* 上传接口
* 上传接口
* 系统配置
* 线上课封面
* bucket由public改为private
* 资源相关表实体及对象修改
* 统一数据库脚本
2025-05-22 07:23:06 +00:00
白书科技
c206fa4bf2 !13 LDAP优化增强
* cursorrules
* fixed: ldap同步的部门记录name记录错误
* 主题色一致
* changelog
* admin接入ldap同步数据统计
* ldap同步数据记录接口合并
* fixed: 已同步被禁用用户的DN更新
* 已经同步的LDAP用户被禁止可以继续更新
* 优化代码
* 新增LDAP同步的详细记录
* 新增LDAP禁止用户的数据量统计
* 优化LDAP拉取数据的重复使用
* 优化LDAP同步
* ldap同步记录
* cursor rules
2025-05-19 06:25:34 +00:00
白书科技
b9f600d3bc !12 使用内存缓存
* 后台管理员登录失败次数设置为10次
* 优化代码
* 后台缓存接口新增权限控制
* 移除redis关键字
* Merge branch 'dev' into dev-cache
* 优化静态资源访问不存在抛出异常
* 删除redis相关内容
* 移除compose.yml中redis和minio的配置
* changelog
* 后台 s3配置
* 后台 s3配置
* 云存储:移除minio;新增阿里云oss,腾讯云cos
* 内存缓存替换redis缓存
2025-05-10 02:13:24 +00:00
xxx
05bad03d69 Merge branch 'main' into dev 2025-05-09 15:20:07 +08:00
xxx
d25308beee readme 2024-10-15 15:47:02 +08:00
白书科技
590642320a !11 依赖更新
* fixed: cors跨域
* pom依赖更新
* 版本号
2024-10-12 05:41:42 +00:00
xxx
370cc361f6 版本号 2024-08-22 16:11:19 +08:00
xxx
b4647458ba 软件依赖走带国内下载渠道 2024-07-08 10:38:22 +08:00
xxx
1c2ed1e4df 优化日志输出 2024-07-05 14:21:45 +08:00
白书科技
5fa072592a !10 compose镜像加速 2024-07-05 05:36:41 +00:00
xxx
0a5286d026 license 2024-07-05 09:38:21 +08:00
xxx
bac04d164f fixed: LDAP部门同步的同层级的上级变动bug 2024-07-05 09:27:28 +08:00
白书科技
6541c3a191 !9 LDAP同步优化
- 优化:[API]LDAP的部门同步逻辑
- 优化:[API]LDAP的用户同步逻辑
2024-07-04 08:55:21 +00:00
xxx
92a9f85171 changelog 2024-06-06 10:19:29 +08:00
xxx
c9e1894f17 使用已编译好的镜像 2024-06-06 10:09:24 +08:00
xxx
fbc61f9eac github action 2024-06-06 10:07:07 +08:00
xxx
b5fe6ecd4d minio配置增加env的读取 2024-06-06 10:04:55 +08:00
xxx
fe47657213 resolve conflict 2024-06-05 17:29:07 +08:00
xxx
5eb3675921 readme 2024-06-05 17:27:27 +08:00
xxx
37fccc1f40 compose 2024-06-05 17:24:49 +08:00
xxx
f5047edd91 引入前端界面 2024-06-05 16:15:38 +08:00
xxx
e97f0318e6 移动到playedu-api目录 2024-06-05 16:13:27 +08:00
wsw
5b027dfa23 首页增加课件数 2024-06-04 15:25:22 +08:00
wsw
6776fecfe9 课件列表报错修复 2024-06-03 09:55:29 +08:00
wsw
e13ff1ae25 学员学习权限修改 2024-06-02 17:51:14 +08:00
wsw
7c84e185a6 学员部门包含子部门所有学员数量 2024-06-02 14:57:15 +08:00
wsw
4d527cff77 课程详情增加部门名称对象 2024-06-02 11:51:25 +08:00
wsw
5d1a6109c2 1.根据部门ID获取所有父级部门的课程
后管-学员--学习课程列表、学员-部门学习进度及导出
pc-课程首页
2.根据分类ID获取所有子分类的课程
pc-课程首页
2024-05-30 16:33:59 +08:00
wsw
eb02def070 后管-线上课、资源:选择分类、部门递归查询该子类、子部门数据 2024-05-30 14:44:03 +08:00
xxx
296aa71d51 readme 2024-04-02 09:07:01 +08:00
xxx
b96c609eef link change 2024-03-09 19:24:12 +08:00
xxx
8beae72101 erge branch 'main' into dev 2024-02-17 15:13:31 +08:00
xxx
269c366d6b 新增jar直接构建docker image 2024-02-17 15:13:17 +08:00
xxx
afc82856f6 fixed: 学员批量导入邮箱 2024-01-26 11:05:59 +08:00
xxx
e215fbb59c 移除身份证号查看权限 2024-01-22 10:29:49 +08:00
xxx
1e92f19923 禁用用户不同步 2023-12-26 15:34:56 +08:00
xxx
8d03678e71 LDAP用户分页读取 2023-12-26 14:57:15 +08:00
xxx
ff9e212366 fixed: 修复LDAP同步部门重复写入bug 2023-11-20 10:43:59 +08:00
xxx
c3b3e06a07 fixed: LDAP服务不存在属性读取报错的bug 2023-11-17 11:51:07 +08:00
xxx
8e60deb3bc readme 2023-11-16 09:25:25 +08:00
xxx
03403d2c33 fixed: github action 2023-11-15 15:00:35 +08:00
xxx
893ab33811 优化学员学习记录的错误提示 2023-11-15 14:00:34 +08:00
xxx
5e4c35f9bf fixed: userCourseRecord更新 2023-11-15 13:59:12 +08:00
xxx
877aec3b01 返回线上课学员的学习记录最早的学习时间 2023-11-15 10:07:51 +08:00
xxx
e8399362ea fixed: 定时任务的执行时机 2023-11-15 09:22:01 +08:00
xxx
5dcde4f911 readme 2023-11-14 16:14:04 +08:00
xxx
12c4b810c2 fixed: minio的domain为域名无法上传文件 2023-11-14 15:47:00 +08:00
xxx
3ea07739d1 优化github build 2023-11-14 14:10:22 +08:00
xxx
9f27aaac42 LDAP部门+学员一起同步 2023-11-14 13:51:48 +08:00
xxx
be6264dcd3 added: 定时同步LDAP 2023-11-14 13:50:33 +08:00
xxx
3c9b354aea 代码优化 2023-11-14 13:38:27 +08:00
xxx
040dcdfaed LDAP学员同步 2023-11-14 12:00:20 +08:00
xxx
8c905c6552 优化Ldap的部门同步 2023-11-14 11:07:56 +08:00
xxx
8f27bb9fda 权限优化 && 学员的线上课学习记录重置的进度更新 2023-11-13 15:24:54 +08:00
xxx
23ff7068f7 修复后台显示的学员线上课初次学习时间不准确bug 2023-11-13 13:58:12 +08:00
xxx
82c53ed87f 优化权限结构 2023-11-13 11:29:47 +08:00
xxx
acb8b79edd fixed: mysql8的链接 2023-11-13 10:57:52 +08:00
xxx
21c016af62 minio的sdk更换为s3 2023-11-13 10:54:56 +08:00
xxx
f0f316c504 更新依赖 2023-11-13 10:13:45 +08:00
xxx
ee1f009966 线上课增加admin_id字段 2023-11-13 10:08:52 +08:00
xxx
179a7d5f62 readme 2023-11-10 11:59:20 +08:00
780 changed files with 46728 additions and 4025 deletions

View File

@ -0,0 +1,31 @@
---
description:
globs:
alwaysApply: false
---
# PlayEdu API Project Overview
PlayEdu is an online training solution developed by Baishu Technology. The API is built with Java + Spring Boot 3, using a modular approach.
## Project Structure
- [playedu-api](mdc:playedu-api) - Java backend API project
- [playedu-admin](mdc:playedu-admin) - Admin frontend
- [playedu-pc](mdc:playedu-pc) - PC web interface
- [playedu-h5](mdc:playedu-h5) - Mobile web interface
## API Key Modules
- [playedu-api/PlayeduApiApplication.java](mdc:playedu-api/playedu-api/src/main/java/xyz/playedu/api/PlayeduApiApplication.java) - Main application entry point
- [playedu-api](mdc:playedu-api/playedu-api) - API module containing controllers and API-specific logic
- [playedu-common](mdc:playedu-api/playedu-common) - Common utilities and shared code
- [playedu-resource](mdc:playedu-api/playedu-resource) - Resource management module
- [playedu-course](mdc:playedu-api/playedu-course) - Course-related functionality
- [playedu-system](mdc:playedu-api/playedu-system) - System management functionality
## Backend vs Frontend Controllers
- [Backend Controllers](mdc:playedu-api/playedu-api/src/main/java/xyz/playedu/api/controller/backend) - Admin-facing API endpoints
- [Frontend Controllers](mdc:playedu-api/playedu-api/src/main/java/xyz/playedu/api/controller/frontend) - Student-facing API endpoints
## Development and Deployment
- [pom.xml](mdc:playedu-api/pom.xml) - Main Maven configuration file
- [Dockerfile](mdc:playedu-api/Dockerfile) - Docker build configuration
- [compose.yml](mdc:compose.yml) - Docker Compose configuration

View File

@ -0,0 +1,32 @@
---
description:
globs:
alwaysApply: false
---
# PlayEdu API Structure
The API module follows a standard Spring Boot structure with controllers, services, and supporting components.
## Controller Layout
The API endpoints are divided into backend (admin) and frontend (student) controllers:
### Backend Controllers
- [Backend Controllers](mdc:playedu-api/playedu-api/src/main/java/xyz/playedu/api/controller/backend) - Admin management interfaces
- [AdminUserController](mdc:playedu-api/playedu-api/src/main/java/xyz/playedu/api/controller/backend/AdminUserController.java) - Administrator management
- [CourseController](mdc:playedu-api/playedu-api/src/main/java/xyz/playedu/api/controller/backend/CourseController.java) - Course management
- [DepartmentController](mdc:playedu-api/playedu-api/src/main/java/xyz/playedu/api/controller/backend/DepartmentController.java) - Department management
- [ResourceController](mdc:playedu-api/playedu-api/src/main/java/xyz/playedu/api/controller/backend/ResourceController.java) - Resource management
- [UserController](mdc:playedu-api/playedu-api/src/main/java/xyz/playedu/api/controller/backend/UserController.java) - User management
### Frontend Controllers
- [Frontend Controllers](mdc:playedu-api/playedu-api/src/main/java/xyz/playedu/api/controller/frontend) - Student-facing endpoints
- [LoginController](mdc:playedu-api/playedu-api/src/main/java/xyz/playedu/api/controller/frontend/LoginController.java) - Student login
- [CourseController](mdc:playedu-api/playedu-api/src/main/java/xyz/playedu/api/controller/frontend/CourseController.java) - Course access
## API Application Components
- [PlayeduApiApplication](mdc:playedu-api/playedu-api/src/main/java/xyz/playedu/api/PlayeduApiApplication.java) - Main application entry point
- [Request DTOs](mdc:playedu-api/playedu-api/src/main/java/xyz/playedu/api/request) - Data transfer objects for API requests
- [Response Format](mdc:playedu-api/playedu-common/src/main/java/xyz/playedu/common/util/JsonResponse.java) - Standard JSON response format
- [Event Handlers](mdc:playedu-api/playedu-api/src/main/java/xyz/playedu/api/event) - Event-driven components
- [Scheduled Tasks](mdc:playedu-api/playedu-api/src/main/java/xyz/playedu/api/schedule) - Scheduled/recurring tasks
- [Interceptors](mdc:playedu-api/playedu-api/src/main/java/xyz/playedu/api/interceptor) - HTTP request interceptors

View File

@ -0,0 +1,32 @@
---
description:
globs:
alwaysApply: false
---
# PlayEdu Configuration Guide
The PlayEdu application uses standard Spring Boot configuration with YAML files.
## Application Configuration
- [application.yml](mdc:playedu-api/playedu-api/src/main/resources/application.yml) - Main application configuration
- [application-dev.yml](mdc:playedu-api/playedu-api/src/main/resources/application-dev.yml) - Development environment overrides
## Key Configuration Properties
- Database connection settings
- Redis cache configuration
- File storage configuration
- Security settings
- Cors configuration
## Build Configuration
- [pom.xml](mdc:playedu-api/pom.xml) - Main project Maven POM file
- [playedu-api/pom.xml](mdc:playedu-api/playedu-api/pom.xml) - API module POM file
- [playedu-common/pom.xml](mdc:playedu-api/playedu-common/pom.xml) - Common module POM file
- [playedu-course/pom.xml](mdc:playedu-api/playedu-course/pom.xml) - Course module POM file
- [playedu-resource/pom.xml](mdc:playedu-api/playedu-resource/pom.xml) - Resource module POM file
- [playedu-system/pom.xml](mdc:playedu-api/playedu-system/pom.xml) - System module POM file
## Docker Configuration
- [Dockerfile](mdc:playedu-api/Dockerfile) - Docker image definition
- [Dockerfile.local](mdc:playedu-api/Dockerfile.local) - Local development Docker configuration
- [compose.yml](mdc:compose.yml) - Docker Compose service definitions

View File

@ -0,0 +1,46 @@
---
description:
globs:
alwaysApply: false
---
# PlayEdu Module Structure
PlayEdu follows a modular architecture with separate modules for different concerns:
## Module Organization
Each module follows a similar structure with domain models, services, and mappers:
- **playedu-api**: Main API controllers and application entry point
- [Controllers](mdc:playedu-api/playedu-api/src/main/java/xyz/playedu/api/controller) - API endpoints
- [Request DTOs](mdc:playedu-api/playedu-api/src/main/java/xyz/playedu/api/request) - Request data objects
- [Configuration](mdc:playedu-api/playedu-api/src/main/java/xyz/playedu/api/config) - Module-specific configuration
- **playedu-common**: Shared utilities, base classes, and common functionality
- [Constants](mdc:playedu-api/playedu-common/src/main/java/xyz/playedu/common/constant) - System constants
- [Exceptions](mdc:playedu-api/playedu-common/src/main/java/xyz/playedu/common/exception) - Custom exceptions
- [Utilities](mdc:playedu-api/playedu-common/src/main/java/xyz/playedu/common/util) - Common utility classes
- [Base Models](mdc:playedu-api/playedu-common/src/main/java/xyz/playedu/common/bus) - Base model classes
- **playedu-resource**: Resource management (files, media, etc.)
- [Domain Models](mdc:playedu-api/playedu-resource/src/main/java/xyz/playedu/resource/domain) - Entity classes
- [Services](mdc:playedu-api/playedu-resource/src/main/java/xyz/playedu/resource/service) - Business logic
- [Mappers](mdc:playedu-api/playedu-resource/src/main/java/xyz/playedu/resource/mapper) - Database access layer
- **playedu-course**: Course management functionality
- [Domain Models](mdc:playedu-api/playedu-course/src/main/java/xyz/playedu/course/domain) - Course entities
- [Services](mdc:playedu-api/playedu-course/src/main/java/xyz/playedu/course/service) - Course business logic
- [Mappers](mdc:playedu-api/playedu-course/src/main/java/xyz/playedu/course/mapper) - Course data access
- **playedu-system**: System administration functionality
- [Domain Models](mdc:playedu-api/playedu-system/src/main/java/xyz/playedu/system/domain) - System entities
- [Services](mdc:playedu-api/playedu-system/src/main/java/xyz/playedu/system/service) - System business logic
- [Mappers](mdc:playedu-api/playedu-system/src/main/java/xyz/playedu/system/mapper) - System data access
## Domain-Driven Design
The codebase follows a layered architecture with:
- Controllers: Handle API requests and responses
- Services: Implement business logic
- Mappers: Data access layer (using MyBatis)
- Domain models: Entity classes representing business objects
This modular approach allows for separation of concerns and easier maintainability.

View File

@ -0,0 +1,45 @@
---
description:
globs:
alwaysApply: false
---
# PlayEdu Development Workflow
This guide outlines the workflow for developing and running the PlayEdu API.
## Local Development Setup
1. Clone the repository
2. Use Docker Compose to run the application: `docker-compose up -d`
3. Access points:
- API: `http://localhost:9700`
- Admin backend: `http://localhost:9900` (default credentials: `admin@playedu.xyz / playedu`)
- PC web interface: `http://localhost:9800`
- H5 mobile interface: `http://localhost:9801`
## Main Entry Points
- [PlayeduApiApplication.java](mdc:playedu-api/playedu-api/src/main/java/xyz/playedu/api/PlayeduApiApplication.java) - Main application class
- [application.yml](mdc:playedu-api/playedu-api/src/main/resources/application.yml) - Configuration
## Tech Stack
- Java with Spring Boot 3
- MySQL database
- Redis for caching
- MyBatis for data access
- Docker for containerization
## Development Best Practices
- Follow existing code structure when adding new features
- Add unit tests for new functionality
- Maintain module separation of concerns
- Use existing utility classes from `playedu-common`
## Build Process
To build the application:
1. Use Maven: `mvn clean package`
2. Build Docker image: `docker build -t playedu-api .`
3. Run in development mode: `docker-compose up -d`
## Version Control
- Follow standard Git workflow with feature branches
- Create pull requests for significant changes
- Update CHANGELOG.md for version releases

View File

@ -0,0 +1,33 @@
---
description:
globs:
alwaysApply: false
---
# PlayEdu Security Model
This guide outlines the security model of the PlayEdu application.
## Authentication
- [BackendAuthInterceptor](mdc:playedu-api/playedu-api/src/main/java/xyz/playedu/api/interceptor/BackendAuthInterceptor.java) - Backend authentication interceptor
- [FrontendAuthInterceptor](mdc:playedu-api/playedu-api/src/main/java/xyz/playedu/api/interceptor/FrontendAuthInterceptor.java) - Frontend authentication interceptor
- JWT-based authentication for both frontend and backend users
## Authorization
- Role-based access control for backend users
- Department-based content access for frontend users
- Course permission enforcement
## Security Configuration
- CORS configuration to prevent cross-site request forgery
- Password encryption using BCrypt
- Input validation and sanitization
## Resource Security
- Private video storage and delivery
- URL-based token authentication for media access
- Anti-leech protection for media files
## Sensitive Data Protection
- PII (Personally Identifiable Information) protection
- Logging sanitization for sensitive data
- Database encryption for critical fields

View File

@ -0,0 +1,40 @@
---
description:
globs:
alwaysApply: false
---
# PlayEdu Database Structure
This guide outlines the database structure of the PlayEdu application.
## Database Technology
- MySQL database for persistent storage
- Redis for caching and session management
- MyBatis as the ORM framework
## Core Tables
- **admin_users** - Administrator user accounts
- **admin_roles** - Administrator roles for RBAC
- **departments** - Organizational departments
- **users** - Student/learner accounts
- **courses** - Course information
- **resources** - Media and document resources
- **course_chapters** - Course chapter organization
- **course_hour_records** - Learning progress tracking
## Entity Relationships
- Departments have many Users (many-to-many)
- Courses have many Chapters (one-to-many)
- Courses have many Resources (many-to-many)
- Users have progress records for Courses (many-to-many)
## Database Access
- Data access through MyBatis Mappers
- [Example Mapper](mdc:playedu-api/playedu-course/src/main/java/xyz/playedu/course/mapper/CourseMapper.java)
- XML query definitions in resource XML files
- [Example XML](mdc:playedu-api/playedu-course/src/main/resources/mapper/CourseMapper.xml)
## Data Migration
- Managed through SQL scripts
- Version controlled database changes
- Backup procedures for data safety

20
.dockerignore Normal file
View File

@ -0,0 +1,20 @@
/playedu-pc/node_modules/
/playedu-pc/dist/
/playedu-pc/.env.local/
/playedu-pc/.env.development/
/playedu-pc/.env.production/
/playedu-pc/build/
/playedu-h5/node_modules/
/playedu-h5/dist/
/playedu-h5/.env.local/
/playedu-h5/.env.development/
/playedu-h5/.env.production/
/playedu-h5/build/
/playedu-admin/node_modules/
/playedu-admin/dist/
/playedu-admin/.env.local/
/playedu-admin/.env.development/
/playedu-admin/.env.production/
/playedu-admin/build/

9
.env.example Normal file
View File

@ -0,0 +1,9 @@
################# PlayEdu ###################
PLAYEDU_API_PORT=9700
PLAYEDU_PC_PORT=9800
PLAYEDU_H5_PORT=9801
PLAYEDU_ADMIN_PORT=9900
PLAYEDU_JWT_KEY=playeduxyz
################# MySQL ###################
MYSQL_PORT=23307

View File

@ -1,36 +0,0 @@
name: EstablishDockerImage
on:
push:
branches:
- main
- dev
- 'feat/**'
env:
IMAGE_FQDN: registry.cn-hangzhou.aliyuncs.com/playedu/api
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Login to GHCR
uses: docker/login-action@v2
with:
registry: registry.cn-hangzhou.aliyuncs.com
username: ${{ secrets.ALI_REGISTRY_EMAIL }}
password: ${{ secrets.ALI_REGISTRY_PASS }}
- name: Build
uses: docker/build-push-action@v3
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: |
${{ env.IMAGE_FQDN }}:1.4

40
.gitignore vendored
View File

@ -1,37 +1,3 @@
HELP.md
target/
!.mvn/wrapper/maven-wrapper.jar
!**/src/main/**/target/
!**/src/test/**/target/
### STS ###
.apt_generated
.classpath
.factorypath
.project
.settings
.springBeans
.sts4-cache
### IntelliJ IDEA ###
.idea
*.iws
*.iml
*.ipr
### NetBeans ###
/nbproject/private/
/nbbuild/
/dist/
/nbdist/
/.nb-gradle/
build/
!**/src/main/**/build/
!**/src/test/**/build/
### VS Code ###
.vscode/
/src/main/resources/application-dev.yml
/playedu-api/src/main/resources/application-dev.yml
/logs
.DS_Store
*.log
.env

View File

@ -1,2 +0,0 @@
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.8.6/apache-maven-3.8.6-bin.zip
wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar

168
CHANGELOG.md Normal file
View File

@ -0,0 +1,168 @@
## 2.0
- 新增:`LDAP`同步数据统计
- 新增:`LDAP`同步的详细记录
- 新增:`LDAP`同步的数据下载
- 修复:学员详情接口报错的Bug
- 修复:限流缓存无过期时间的Bug
- 优化:存储桶由`public`改为`private`
- 优化:资源访问URL动态生成
- 优化:数据表结构[注意:1.x版本无法直接升级2.x版本]
- 优化:移除`Redis`运行依赖改为使用内存缓存
- 优化:移除本地存储方案`MinIO`的支持改为支持阿里云OSS和腾讯云COS
## 1.8
- 优化:[API]日志输出
- 优化:[API]LDAP的部门同步逻辑
- 优化:[API]LDAP的用户同步逻辑
- 优化:[其它]`docker`镜像更换为阿里云加速
## 1.7
- 新增:[API]MinIO配置信息增加环境变量的读取
- 新增:[API]学员学习权限优化
- 新增:[后台]后台首页增加课件数量的显示
- 新增:[后台]线上课列表增加创建人字段
- 优化:[后台]学员部门包含子部门所有学员数量
- 优化:[API]根据分类ID获取所有子分类的课程
- 优化:[API]根据部门ID获取所有父级部门的课程
- 优化:[后台]部门指派器
- 优化:[PC]视频播放器去除右键点击
- 优化:[PC]首页学习时长去掉秒
- 优化:[H5]首页tab切换优化
## 1.6
- 优化:删除学员身份证号字段并移除身份证号查看权限
- 修复LDAP用户读取。现已支持分页读取数据
- 修复:学员批量导入
- 优化:[PC]线上课课程详情tab交互
- 优化:[PC]首页学习时间不显示天数
- 优化:[PC]删除无用的js依赖
- 优化:[PC]安全退出登录
- 优化:[PC]系统错误提示
- 优化:[PC]部门切换缓存
- 修复:[PC]pnpm build 和 dev 报错
- 优化:[后台]视频上传
- 优化:[后台]删除无用的依赖
- 优化:[后台]部门页面组件交互增加loading
- 优化:[后台]删除资源选择上传资源的按钮
- 优化:[后台]学员添加编辑
- 修复:[后台]pnpm build 和 dev 报错
- 优化:[H5]删除无用依赖
- 优化:[H5]下拉刷新
- 修复:[H5]pnpm build 和 dev 报错
- 修复:[H5]底部导航栏悬浮bug
## 1.5
- 新增:线上课新增创建人字段
- 新增:视频资源增加视频名搜索
- 新增LDAP部门+学员自动同步(每小时同步一次)
- 优化:前台学习总时长统计最大单位由“天”改为“小时”
- 优化:更换 minio 底层的SDK
- 修复修复角色权限配置参数错误的bug
- 修复修复学员开始学习时间错误bug
- 修复线上课编辑章节名排序bug
- 修复:修复课时拖拽排序父子集交互
- 修复MySQL8版本无法连接bug
## 1.4
- 新增Window AD域登录支持
- 新增一键同步 LDAP 的部门组织架构
- 优化docker镜像内的时区
- 优化后台的全局配置接口
- 新增后台资源菜单权限
- 新增后台分类菜单权限
- 新增文件上传权限
## 1.3
- 新增 `LDAP` 登录
- 数据库表在运行时自动同步到数据库,以后安装/升级程序无需手动将 `sql` 文件导入到数据库
- 线上课新增 `published_at` 字段,用于控制课程的排序
- `API` 程序模块化
- 移除 `minio` 上传的限流控制
- 图片上传最大尺寸调整为 `10mb`
- 优化跨域配置
- 新增视频上传失败的重新上传
## 1.2
- 课程附件
- 管理员日志
## 1.1
+ 移除图形验证码
+ 优化dockerfile
+ 修复前后台的账号冲突
+ 新增API限流
+ 新增账户限流
## 1.0-beta7
+ 新增:后台|系统配置增加 API 地址配置
+ 新增:后台|已上传视频预览播放
+ 新增:后台|视频批量删除
+ 新增:后台|视频、图片的分类修改
+ 优化:后台|系统配置部分敏感配置 * 号代替显示
+ 优化:后台|分类删除交互优化
+ 优化:后台|跑马灯交互优化
+ 优化:后台|超级管理员不显示权限配置按钮
+ 修复:后台|未分类下上传资源的资源分类关联 bug
+ 优化API|重构用户的 JWT 底层服务
+ 优化PC学员端|全屏播放播放结束的下节课时显示
## 1.0-beta6
+ 优化MinIO配置
+ 优化图形验证码
+ 优化学员删除的关联数据删除
+ 优化dockerfile
+ 新增:禁止拖拽播放配置
## 1.0-beta5
+ 学员注册增加课程分类筛选
+ 线上课-学员列表增加学员所属部门、邮箱字段的显示
+ 线上课-学员列表显示可以观看该线上课的所有学员
+ 修复学员的多部门查询bug
+ 优化学员学习进度的展示(前后台保持统一)
+ 修复后台部门的文案显示溢出
+ 优化视频播放页面样式和交互
## 1.0-beta4
+ 新增学员线上课详细学习进度api
+ 新增课时详情api
+ 后台部门学员的学习进度api增加更多过滤参数
+ 修复图形验证码的大写校验bug
+ 提升系统稳定性
## 1.0-beta3
+ 后台的学员列表左侧部门列表显示每个部门的学员人数
+ 后台学员列表在选择部门的情况下可直接查看该部门下的学员学习进度
+ 后台学员列表学员增加『学习』按钮点击可查看该学员的最近30天每日学习时长、该学员的所有课时、线上课的学习进度
+ 优化 Docker 编译。现在可直接编译镜像无需手动执行命令安装依赖
+ 修复后台学员默认头像的bug
+ 修复部门的排序和父子级变更的权限控制Bug
+ 优化已知的null错误提升系统稳定性
+ 修复PC端口的部门切换无法持久化保存的bug
+ 修复PC端口的页面切换滚动条位置bug
+ 优化PC端口的学员退出的逻辑
## 1.0-beta2
+ 后台线上课部门选择增加学员数量的显示
+ 优化学员的默认头像
+ 优化线上课的默认三张封面
+ 优化学员导入
+ 修复已知bug
## 1.0-beta1
- 一款开源的培训系统,您可以使用它快速搭建私有化内部培训平台

View File

@ -1,25 +1,39 @@
FROM eclipse-temurin:17 as builder
FROM registry.cn-hangzhou.aliyuncs.com/hzbs/node:20-alpine AS node-builder
COPY . /app
COPY playedu-admin /app/admin
COPY playedu-pc /app/pc
COPY playedu-h5 /app/h5
WORKDIR /app/admin
RUN pnpm i && VITE_APP_URL=/api/ pnpm build
WORKDIR /app/pc
RUN pnpm i && VITE_APP_URL=/api/ pnpm build
WORKDIR /app/h5
RUN pnpm i && VITE_APP_URL=/api/ pnpm build
FROM registry.cn-hangzhou.aliyuncs.com/hzbs/eclipse-temurin:17 AS java-builder
COPY playedu-api /app
WORKDIR /app
RUN /app/mvnw -Dmaven.test.skip=true clean package
FROM eclipse-temurin:17
FROM registry.cn-hangzhou.aliyuncs.com/hzbs/eclipse-temurin:17 AS base
WORKDIR /app
COPY --from=java-builder /app/playedu-api/target/playedu-api.jar /app/api/app.jar
# 使用东八区时间环境
RUN echo "Asia/Shanghai" > /etc/timezone
COPY --from=node-builder /app/admin/dist /app/admin
COPY --from=node-builder /app/pc/dist /app/pc
COPY --from=node-builder /app/h5/dist /app/h5
# 将指定目录下的jar包复制到docker容器的/目录下
COPY --from=builder /app/playedu-api/target/playedu-api.jar /app/app.jar
COPY docker/nginx/conf/nginx.conf /etc/nginx/sites-enabled/default
RUN chmod +x /app/app.jar
# 声明服务运行在8080端口
EXPOSE 9898
EXPOSE 9800
EXPOSE 9801
EXPOSE 9900
# 指定docker容器启动时运行jar包
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
CMD nginx; echo "Waiting for MySQL to start..."; sleep 15; java -jar /app/api/app.jar --spring.profiles.active=prod --spring.datasource.url="jdbc:mysql://${DB_HOST}:${DB_PORT:-3306}/${DB_NAME}?useUnicode=true&characterEncoding=UTF-8&autoReconnect=true&useSSL=false&allowPublicKeyRetrieval=true" --spring.datasource.username=${DB_USER} --spring.datasource.password=${DB_PASS} --sa-token.is-concurrent=${SA_TOKEN_IS_CONCURRENT:-false} --sa-token.jwt-secret-key=${SA_TOKEN_JWT_SECRET_KEY}

View File

@ -1,42 +1,50 @@
<p align="center">
<img src="https://meedu.cloud.oss.meedu.vip/playedu/%E5%A4%B4%E5%9B%BE.jpg"/>
</p>
<p align="center">
<a href="https://playedu.xyz">官网</a> | <a href="https://playedu.xyz/docs/docs/intro/">文档</a> | <a href="https://playedu.xyz/docs/docs/function">功能列表</a> | <a href="https://playedu.xyz/docs/docs/install/quick">快速上手</a>
</p>
### 系统介绍
<h4 align="center">
<a href="http://www.playeduos.com">PlayEdu官网</a> |
<a href="https://www.playeduos.com/function.html">PlayEdu商业版</a> |
<a href="https://faq.playeduos.com/opensource-maintenance-handbook/article/t08o2iHfLR">部署文档</a> |
<a href="https://www.playeduos.com/demo.html">开源版演示站</a>
</h4>
PlayEdu 是由白书科技团队经营多年线上教育系统打造出的一款全新的企业培训方案致力于为更多企业机构搭建私有化内部培训平台。PlayEdu 基于 Java + MySQL 开发,采用前后端分离模式,前台采用 React18 为核心框架,后台采用 SpringBoot3 为核心框架。
PlayEdu 是由白书科技团队经营多年线上教培领域打造出的一款业内领先的线上培训解决方案。PlayEdu 基于 Java + MySQL 开发,采用前后端分离模式,前端核心框架为 React18后端核心框架为 SpringBoot3。开源版本提供部门管理、学员管理、在线视频学习、学员进度追踪、视频私有化存储等基础培训功能。
**针对企业级培训场景,我们精心打造了“功能更多、响应更快、并发更强”的企业版本,满足企业多样化的培训需求。企业版本支持音视频学习、文档在线预览、线上考试、学习任务等多种学习方式,并提供多重安全防护,如视频转码加密、防盗链、学习防快进、防挂机等。同时,我们集成了企业微信、钉钉、飞书等主流办公系统,帮助企业快速部署专属培训平台!**
### 系统演示
## 🚀 快速上手
| - | 站点 | 账号 | 密码 |
| ------------ | ------------------------------------------------------ | ------------------- | -------- |
| 学员端口 | [https://demo.playedu.xyz](https://demo.playedu.xyz) | `1@playedu.xyz` | `123123` |
| 后台管理端口 | [https://admin.playedu.xyz](https://admin.playedu.xyz) | `admin@playedu.xyz` | `123123` |
拉取代码:
### 依赖项目
```
git clone --branch main https://gitee.com/playeduxyz/playedu.git playedu
```
- [PC 界面程序](https://github.com/PlayEdu/frontend)
- [后台界面程序](https://github.com/PlayEdu/backend)
构建镜像:
### 官方交流群
```
cd playedu && docker-compose up -d
```
<p><img src="https://meedu.cloud.oss.meedu.vip/playedu/PlayEduk%E5%AE%A2%E6%9C%8D-zhu.png" width="200" /></p>
命令执行完成以后,打开您的浏览器,输入 `http://localhost:9900` 即可访问后台管理界面,默认管理员账号和密码 `admin@playedu.xyz / playedu`
### 界面预览
- PC 端口 `http://localhost:9800`
- H5 端口 `http://localhost:9801`
- API 端口 `http://localhost:9700`
## 🔰️ 软件安全
安全问题应该通过邮件私下报告给 tengyongzhi@meedu.vip。 您将在 24 小时内收到回复,如果因为某些原因您没有收到回复,请通过回复原始邮件的方式跟进,以确保我们收到了您的原始邮件。
## 👁 界面预览
![学员端口界面预览](https://meedu.cloud.oss.meedu.vip/playedu/%E5%89%8D%E5%8F%B0%E9%A1%B5%E9%9D%A2.jpg)
![管理后台界面预览](https://meedu.cloud.oss.meedu.vip/playedu/%E5%90%8E%E5%8F%B0%E9%A1%B5%E9%9D%A2.jpg)
### 使用协议
## 📃 使用须知
● 要求
- 保留页脚处版权信息。
- 保留源代码中的协议。
- 如果修改了代码,则必须在文件中进行说明。
● 允许
- 私用、商用、修改。
- **1.版权归属** 杭州白书科技有限公司对 PlayEdu 开源版拥有完整版权,所有使用权保留。
- **2.代码修改** 在遵守相关开源协议的严格前提下,允许对 PlayEdu 开源版代码进行修改。修改时,必须在代码中加入明确备注,详细记录每一处修改的具体内容。
- **3.版权保护** 严令禁止删除、修改或篡改源代码中的版权信息及开源说明文件,侵犯版权的行为将面临法律追究。
- 在任何使用场景下,必须严格保留 PlayEdu 开源版页面及代码中的原有版权信息,包括不限于 “Designed By PlayEdu” 页面版权标识、官网链接以及代码中的开源说明等,一旦出现侵犯版权的行为,将承担相应法律责任。

50
compose.yml Normal file
View File

@ -0,0 +1,50 @@
x-logging: &default-logging
driver: "json-file"
options:
max-size: "10m"
max-file: "10"
networks:
playedu:
driver: bridge
volumes:
mysql-data:
services:
playedu:
image: registry.cn-hangzhou.aliyuncs.com/playedu/light:2.0
restart: always
environment:
- DB_HOST=mysql
- DB_PORT=3306
- DB_NAME=playedu
- DB_USER=root
- DB_PASS=playeduxyz
- SA_TOKEN_IS_CONCURRENT=false
- SA_TOKEN_JWT_SECRET_KEY=${PLAYEDU_JWT_KEY:-playeduxyz}
ports:
- "${PLAYEDU_API_PORT:-9700}:9898"
- "${PLAYEDU_PC_PORT:-9800}:9800"
- "${PLAYEDU_H5_PORT:-9801}:9801"
- "${PLAYEDU_ADMIN_PORT:-9900}:9900"
networks:
- playedu
depends_on:
- mysql
logging: *default-logging
mysql:
build: ./docker/mysql
restart: always
environment:
- MYSQL_DATABASE=playedu
- MYSQL_ROOT_PASSWORD=playeduxyz
- TZ=UTC
volumes:
- mysql-data:/var/lib/mysql
ports:
- "${MYSQL_PORT:-23307}:3306"
networks:
- playedu
logging: *default-logging

2
docker/data/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
*
!.gitignore

13
docker/maven/settings.xml Normal file
View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<settings xmlns="http://maven.apache.org/SETTINGS/1.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.0.0 http://maven.apache.org/xsd/settings-1.0.0.xsd">
<mirrors>
<mirror>
<id>nexus-aliyun</id>
<mirrorOf>*</mirrorOf>
<name>Nexus aliyun</name>
<url>http://maven.aliyun.com/nexus/content/groups/public</url>
</mirror>
</mirrors>
</settings>

5
docker/mysql/Dockerfile Normal file
View File

@ -0,0 +1,5 @@
FROM registry.cn-hangzhou.aliyuncs.com/hzbs/mysql:8.1
COPY my.cnf /etc/mysql/conf.d/my.cnf
RUN chmod 0444 /etc/mysql/conf.d/my.cnf

12
docker/mysql/my.cnf Normal file
View File

@ -0,0 +1,12 @@
# The MySQL Client configuration file.
#
# For explanations see
# http://dev.mysql.com/doc/mysql/en/server-system-variables.html
[mysql]
default-character-set=utf8mb4
[mysqld]
sql-mode="STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION"
character-set-server=utf8mb4
default-authentication-plugin=mysql_native_password

View File

@ -0,0 +1,76 @@
client_max_body_size 500m;
server {
listen 9800;
server_name _;
root /app/pc;
index index.html;
gzip on;
gzip_static on;
gzip_buffers 4 16k;
gzip_http_version 1.1;
gzip_comp_level 5;
gzip_types text/plain application/javascript text/css application/xml text/javascript;
gzip_vary on;
location /api/ {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass http://127.0.0.1:9898/;
}
location ~* ^/(?![api].*) {
try_files $uri /index.html;
}
}
server {
listen 9801;
server_name _;
root /app/h5;
index index.html;
gzip on;
gzip_static on;
gzip_buffers 4 16k;
gzip_http_version 1.1;
gzip_comp_level 5;
gzip_types text/plain application/javascript text/css application/xml text/javascript;
gzip_vary on;
location /api/ {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass http://127.0.0.1:9898/;
}
location ~* ^/(?![api].*) {
try_files $uri /index.html;
}
}
server {
listen 9900;
server_name _;
root /app/admin;
index index.html;
gzip on;
gzip_static on;
gzip_buffers 4 16k;
gzip_http_version 1.1;
gzip_comp_level 5;
gzip_types text/plain application/javascript text/css application/xml text/javascript;
gzip_vary on;
location /api/ {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass http://127.0.0.1:9898/;
}
location ~* ^/(?![api].*) {
try_files $uri /index.html;
}
}

View File

@ -0,0 +1 @@
VITE_APP_URL=

35
playedu-admin/.gitignore vendored Normal file
View File

@ -0,0 +1,35 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
.env
.env.production
.env.development
npm-debug.log*
yarn-debug.log*
yarn-error.log*
yarn.lock
package-lock.json
deploy-test.sh
deploy-prod.sh
deploy-demo.sh
dist/

22
playedu-admin/README.md Normal file
View File

@ -0,0 +1,22 @@
<p align="center">
<img src="https://playedu.xyz/images/index/logo-big.png?v=2023032901" width="150"/>
</p>
<h1 align="center">后台界面程序 - PlayEdu开源培训系统</h1>
<p align="center">一款开源的培训系统,您可以使用它快速搭建私有化内部培训平台</p>
### 常用链接
+ [官网](https://playedu.xyz)
+ [快速上手](https://playedu.xyz/docs/docs/category/%E5%90%8E%E5%8F%B0%E7%95%8C%E9%9D%A2%E7%A8%8B%E5%BA%8F%E5%AE%89%E8%A3%85)
### 开发团队
杭州白书科技有限公司
### 使用协议
欢迎使用杭州白书科技有限公司提供的开源培训解决方案!请您仔细阅读以下条款。通过使用 PlayEdu ,您表示同意接受以下所有条款。
+ 本开源项目中所有代码基于 Apache-2.0 许可协议,您默认遵守许可协议中约定的义务。
+ 您默认授权我们将您使用 PlayEdu 所在业务的 Logo 放置在本官网展示。

17
playedu-admin/index.html Normal file
View File

@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0"
/>
<title>管理后台</title>
<script src="/js/DPlayer.min.js"></script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@ -0,0 +1,39 @@
{
"name": "playedu-admin-interface",
"private": false,
"version": "1.6.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@ant-design/icons": "^5.2.3",
"@reduxjs/toolkit": "^1.9.3",
"ahooks": "^3.7.6",
"antd": "^5.3.2",
"axios": "^1.3.4",
"dayjs": "^1.11.10",
"echarts": "^5.4.2",
"localforage": "^1.10.0",
"match-sorter": "^6.3.1",
"moment": "^2.29.4",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-redux": "^8.0.5",
"react-router-dom": "^6.9.0",
"redux": "^4.2.1",
"sort-by": "^1.2.0",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@vitejs/plugin-react-swc": "^3.0.0",
"less": "^4.1.3",
"rollup-plugin-gzip": "^3.1.0",
"typescript": "^4.9.3",
"vite": "^4.2.0"
}
}

2126
playedu-admin/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

View File

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

View File

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

View File

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

View File

@ -0,0 +1,217 @@
'use strict';
// Do this as the first thing so that any code reading it knows the right env.
process.env.BABEL_ENV = 'production';
process.env.NODE_ENV = 'production';
// Makes the script crash on unhandled rejections instead of silently
// ignoring them. In the future, promise rejections that are not handled will
// terminate the Node.js process with a non-zero exit code.
process.on('unhandledRejection', err => {
throw err;
});
// Ensure environment variables are read.
require('../config/env');
const path = require('path');
const chalk = require('react-dev-utils/chalk');
const fs = require('fs-extra');
const bfj = require('bfj');
const webpack = require('webpack');
const configFactory = require('../config/webpack.config');
const paths = require('../config/paths');
const checkRequiredFiles = require('react-dev-utils/checkRequiredFiles');
const formatWebpackMessages = require('react-dev-utils/formatWebpackMessages');
const printHostingInstructions = require('react-dev-utils/printHostingInstructions');
const FileSizeReporter = require('react-dev-utils/FileSizeReporter');
const printBuildError = require('react-dev-utils/printBuildError');
const measureFileSizesBeforeBuild =
FileSizeReporter.measureFileSizesBeforeBuild;
const printFileSizesAfterBuild = FileSizeReporter.printFileSizesAfterBuild;
const useYarn = fs.existsSync(paths.yarnLockFile);
// These sizes are pretty large. We'll warn for bundles exceeding them.
const WARN_AFTER_BUNDLE_GZIP_SIZE = 512 * 1024;
const WARN_AFTER_CHUNK_GZIP_SIZE = 1024 * 1024;
const isInteractive = process.stdout.isTTY;
// Warn and crash if required files are missing
if (!checkRequiredFiles([paths.appHtml, paths.appIndexJs])) {
process.exit(1);
}
const argv = process.argv.slice(2);
const writeStatsJson = argv.indexOf('--stats') !== -1;
// Generate configuration
const config = configFactory('production');
// We require that you explicitly set browsers and do not fall back to
// browserslist defaults.
const { checkBrowsers } = require('react-dev-utils/browsersHelper');
checkBrowsers(paths.appPath, isInteractive)
.then(() => {
// First, read the current file sizes in build directory.
// This lets us display how much they changed later.
return measureFileSizesBeforeBuild(paths.appBuild);
})
.then(previousFileSizes => {
// Remove all content but keep the directory so that
// if you're in it, you don't end up in Trash
fs.emptyDirSync(paths.appBuild);
// Merge with the public folder
copyPublicFolder();
// Start the webpack build
return build(previousFileSizes);
})
.then(
({ stats, previousFileSizes, warnings }) => {
if (warnings.length) {
console.log(chalk.yellow('Compiled with warnings.\n'));
console.log(warnings.join('\n\n'));
console.log(
'\nSearch for the ' +
chalk.underline(chalk.yellow('keywords')) +
' to learn more about each warning.'
);
console.log(
'To ignore, add ' +
chalk.cyan('// eslint-disable-next-line') +
' to the line before.\n'
);
} else {
console.log(chalk.green('Compiled successfully.\n'));
}
console.log('File sizes after gzip:\n');
printFileSizesAfterBuild(
stats,
previousFileSizes,
paths.appBuild,
WARN_AFTER_BUNDLE_GZIP_SIZE,
WARN_AFTER_CHUNK_GZIP_SIZE
);
console.log();
const appPackage = require(paths.appPackageJson);
const publicUrl = paths.publicUrlOrPath;
const publicPath = config.output.publicPath;
const buildFolder = path.relative(process.cwd(), paths.appBuild);
printHostingInstructions(
appPackage,
publicUrl,
publicPath,
buildFolder,
useYarn
);
},
err => {
const tscCompileOnError = process.env.TSC_COMPILE_ON_ERROR === 'true';
if (tscCompileOnError) {
console.log(
chalk.yellow(
'Compiled with the following type errors (you may want to check these before deploying your app):\n'
)
);
printBuildError(err);
} else {
console.log(chalk.red('Failed to compile.\n'));
printBuildError(err);
process.exit(1);
}
}
)
.catch(err => {
if (err && err.message) {
console.log(err.message);
}
process.exit(1);
});
// Create the production build and print the deployment instructions.
function build(previousFileSizes) {
console.log('Creating an optimized production build...');
const compiler = webpack(config);
return new Promise((resolve, reject) => {
compiler.run((err, stats) => {
let messages;
if (err) {
if (!err.message) {
return reject(err);
}
let errMessage = err.message;
// Add additional information for postcss errors
if (Object.prototype.hasOwnProperty.call(err, 'postcssNode')) {
errMessage +=
'\nCompileError: Begins at CSS selector ' +
err['postcssNode'].selector;
}
messages = formatWebpackMessages({
errors: [errMessage],
warnings: [],
});
} else {
messages = formatWebpackMessages(
stats.toJson({ all: false, warnings: true, errors: true })
);
}
if (messages.errors.length) {
// Only keep the first error. Others are often indicative
// of the same problem, but confuse the reader with noise.
if (messages.errors.length > 1) {
messages.errors.length = 1;
}
return reject(new Error(messages.errors.join('\n\n')));
}
if (
process.env.CI &&
(typeof process.env.CI !== 'string' ||
process.env.CI.toLowerCase() !== 'false') &&
messages.warnings.length
) {
// Ignore sourcemap warnings in CI builds. See #8227 for more info.
const filteredWarnings = messages.warnings.filter(
w => !/Failed to parse source map/.test(w)
);
if (filteredWarnings.length) {
console.log(
chalk.yellow(
'\nTreating warnings as errors because process.env.CI = true.\n' +
'Most CI servers set it automatically.\n'
)
);
return reject(new Error(filteredWarnings.join('\n\n')));
}
}
const resolveArgs = {
stats,
previousFileSizes,
warnings: messages.warnings,
};
if (writeStatsJson) {
return bfj
.write(paths.appBuild + '/bundle-stats.json', stats.toJson())
.then(() => resolve(resolveArgs))
.catch(error => reject(new Error(error)));
}
return resolve(resolveArgs);
});
});
}
function copyPublicFolder() {
fs.copySync(paths.appPublic, paths.appBuild, {
dereference: true,
filter: file => file !== paths.appHtml,
});
}

View File

@ -0,0 +1,154 @@
'use strict';
// Do this as the first thing so that any code reading it knows the right env.
process.env.BABEL_ENV = 'development';
process.env.NODE_ENV = 'development';
// Makes the script crash on unhandled rejections instead of silently
// ignoring them. In the future, promise rejections that are not handled will
// terminate the Node.js process with a non-zero exit code.
process.on('unhandledRejection', err => {
throw err;
});
// Ensure environment variables are read.
require('../config/env');
const fs = require('fs');
const chalk = require('react-dev-utils/chalk');
const webpack = require('webpack');
const WebpackDevServer = require('webpack-dev-server');
const clearConsole = require('react-dev-utils/clearConsole');
const checkRequiredFiles = require('react-dev-utils/checkRequiredFiles');
const {
choosePort,
createCompiler,
prepareProxy,
prepareUrls,
} = require('react-dev-utils/WebpackDevServerUtils');
const openBrowser = require('react-dev-utils/openBrowser');
const semver = require('semver');
const paths = require('../config/paths');
const configFactory = require('../config/webpack.config');
const createDevServerConfig = require('../config/webpackDevServer.config');
const getClientEnvironment = require('../config/env');
const react = require(require.resolve('react', { paths: [paths.appPath] }));
const env = getClientEnvironment(paths.publicUrlOrPath.slice(0, -1));
const useYarn = fs.existsSync(paths.yarnLockFile);
const isInteractive = process.stdout.isTTY;
// Warn and crash if required files are missing
if (!checkRequiredFiles([paths.appHtml, paths.appIndexJs])) {
process.exit(1);
}
// Tools like Cloud9 rely on this.
const DEFAULT_PORT = parseInt(process.env.PORT, 10) || 3000;
const HOST = process.env.HOST || '0.0.0.0';
if (process.env.HOST) {
console.log(
chalk.cyan(
`Attempting to bind to HOST environment variable: ${chalk.yellow(
chalk.bold(process.env.HOST)
)}`
)
);
console.log(
`If this was unintentional, check that you haven't mistakenly set it in your shell.`
);
console.log(
`Learn more here: ${chalk.yellow('https://cra.link/advanced-config')}`
);
console.log();
}
// We require that you explicitly set browsers and do not fall back to
// browserslist defaults.
const { checkBrowsers } = require('react-dev-utils/browsersHelper');
checkBrowsers(paths.appPath, isInteractive)
.then(() => {
// We attempt to use the default port but if it is busy, we offer the user to
// run on a different port. `choosePort()` Promise resolves to the next free port.
return choosePort(HOST, DEFAULT_PORT);
})
.then(port => {
if (port == null) {
// We have not found a port.
return;
}
const config = configFactory('development');
const protocol = process.env.HTTPS === 'true' ? 'https' : 'http';
const appName = require(paths.appPackageJson).name;
const useTypeScript = fs.existsSync(paths.appTsConfig);
const urls = prepareUrls(
protocol,
HOST,
port,
paths.publicUrlOrPath.slice(0, -1)
);
// Create a webpack compiler that is configured with custom messages.
const compiler = createCompiler({
appName,
config,
urls,
useYarn,
useTypeScript,
webpack,
});
// Load proxy config
const proxySetting = require(paths.appPackageJson).proxy;
const proxyConfig = prepareProxy(
proxySetting,
paths.appPublic,
paths.publicUrlOrPath
);
// Serve webpack assets generated by the compiler over a web server.
const serverConfig = {
...createDevServerConfig(proxyConfig, urls.lanUrlForConfig),
host: HOST,
port,
};
const devServer = new WebpackDevServer(serverConfig, compiler);
// Launch WebpackDevServer.
devServer.startCallback(() => {
if (isInteractive) {
clearConsole();
}
if (env.raw.FAST_REFRESH && semver.lt(react.version, '16.10.0')) {
console.log(
chalk.yellow(
`Fast Refresh requires React 16.10 or higher. You are using React ${react.version}.`
)
);
}
console.log(chalk.cyan('Starting the development server...\n'));
openBrowser(urls.localUrlForBrowser);
});
['SIGINT', 'SIGTERM'].forEach(function (sig) {
process.on(sig, function () {
devServer.close();
process.exit();
});
});
if (process.env.CI !== 'true') {
// Gracefully exit when stdin ends
process.stdin.on('end', function () {
devServer.close();
process.exit();
});
}
})
.catch(err => {
if (err && err.message) {
console.log(err.message);
}
process.exit(1);
});

View File

@ -0,0 +1,52 @@
'use strict';
// Do this as the first thing so that any code reading it knows the right env.
process.env.BABEL_ENV = 'test';
process.env.NODE_ENV = 'test';
process.env.PUBLIC_URL = '';
// Makes the script crash on unhandled rejections instead of silently
// ignoring them. In the future, promise rejections that are not handled will
// terminate the Node.js process with a non-zero exit code.
process.on('unhandledRejection', err => {
throw err;
});
// Ensure environment variables are read.
require('../config/env');
const jest = require('jest');
const execSync = require('child_process').execSync;
let argv = process.argv.slice(2);
function isInGitRepository() {
try {
execSync('git rev-parse --is-inside-work-tree', { stdio: 'ignore' });
return true;
} catch (e) {
return false;
}
}
function isInMercurialRepository() {
try {
execSync('hg --cwd . root', { stdio: 'ignore' });
return true;
} catch (e) {
return false;
}
}
// Watch unless on CI or explicitly running all tests
if (
!process.env.CI &&
argv.indexOf('--watchAll') === -1 &&
argv.indexOf('--watchAll=false') === -1
) {
// https://github.com/facebook/create-react-app/issues/5210
const hasSourceControl = isInGitRepository() || isInMercurialRepository();
argv.push(hasSourceControl ? '--watch' : '--watchAll');
}
jest.run(argv);

View File

@ -0,0 +1,3 @@
.App {
background-color: #f6f6f6;
}

19
playedu-admin/src/App.tsx Normal file
View File

@ -0,0 +1,19 @@
import { Suspense } from "react";
import styles from "./App.module.less";
import { useRoutes } from "react-router-dom";
import routes from "./routes";
import LoadingPage from "./pages/loading";
function App() {
const Views = () => useRoutes(routes);
return (
<Suspense fallback={<LoadingPage />}>
<div className={styles.App}>
<Views />
</div>
</Suspense>
);
}
export default App;

View File

@ -0,0 +1,13 @@
import React from "react";
import { useLayoutEffect } from "react";
import { useLocation } from "react-router-dom";
const AutoScorllTop: React.FC<{ children: any }> = ({ children }) => {
const location = useLocation();
useLayoutEffect(() => {
document.documentElement.scrollTo(0, 0);
}, [location.pathname]);
return children;
};
export default AutoScorllTop;

View File

@ -0,0 +1,25 @@
import client from "./internal/httpClient";
export function adminLogList(
page: number,
size: number,
admin_name: string,
title: string,
opt: string,
start_time: string,
end_time: string
) {
return client.get("/backend/v1/admin/log/index", {
page: page,
size: size,
admin_name: admin_name,
title: title,
opt: opt,
start_time: start_time,
end_time: end_time,
});
}
export function adminLogDetail(id: number) {
return client.get(`/backend/v1/admin/log/detail/${id}`, {});
}

View File

@ -0,0 +1,35 @@
import client from "./internal/httpClient";
export function adminRoleList() {
return client.get("/backend/v1/admin-role/index", {});
}
export function createAdminRole() {
return client.get("/backend/v1/admin-role/create", {});
}
export function storeAdminRole(name: string, permissionIds: number[]) {
return client.post("/backend/v1/admin-role/create", {
name: name,
permission_ids: permissionIds,
});
}
export function adminRole(id: number) {
return client.get(`/backend/v1/admin-role/${id}`, {});
}
export function updateAdminRole(
id: number,
name: string,
permissionIds: number[]
) {
return client.put(`/backend/v1/admin-role/${id}`, {
name: name,
permission_ids: permissionIds,
});
}
export function destroyAdminRole(id: number) {
return client.destroy(`/backend/v1/admin-role/${id}`);
}

View File

@ -0,0 +1,60 @@
import client from "./internal/httpClient";
export function adminUserList(
page: number,
size: number,
name: string,
roleId: number
) {
return client.get("/backend/v1/admin-user/index", {
page: page,
size: size,
name: name,
role_id: roleId,
});
}
export function createAdminUser() {
return client.get("/backend/v1/admin-user/create", {});
}
export function storeAdminUser(
name: string,
email: string,
password: string,
isBanLogin: number,
roleIds: number[]
) {
return client.post("/backend/v1/admin-user/create", {
name: name,
email: email,
password: password,
is_ban_login: isBanLogin,
role_ids: roleIds,
});
}
export function AdminUser(id: number) {
return client.get(`/backend/v1/admin-user/${id}`, {});
}
export function updateAdminUser(
id: number,
name: string,
email: string,
password: string,
isBanLogin: number,
roleIds: number[]
) {
return client.put(`/backend/v1/admin-user/${id}`, {
name: name,
email: email,
password: password,
is_ban_login: isBanLogin,
role_ids: roleIds,
});
}
export function destroyAdminUser(id: number) {
return client.destroy(`/backend/v1/admin-user/${id}`);
}

View File

@ -0,0 +1,9 @@
import client from "./internal/httpClient";
export function appConfig() {
return client.get("/backend/v1/app-config", {});
}
export function saveAppConfig(data: any) {
return client.put(`/backend/v1/app-config`, { data: data });
}

View File

@ -0,0 +1,20 @@
import client from "./internal/httpClient";
export function storeCourseAttachmentMulti(
courseId: number,
attachments: number[]
) {
return client.post(`/backend/v1/course/${courseId}/attachment/create-batch`, {
attachments: attachments,
});
}
export function destroyAttachment(courseId: number, id: number) {
return client.destroy(`/backend/v1/course/${courseId}/attachment/${id}`);
}
export function transCourseAttachment(courseId: number, ids: number[]) {
return client.put(`/backend/v1/course/${courseId}/attachment/update/sort`, {
ids: ids,
});
}

View File

@ -0,0 +1,42 @@
import client from "./internal/httpClient";
export function courseCategoryList() {
return client.get("/backend/v1/course-category/index", {});
}
export function createCourseCategory() {
return client.get("/backend/v1/course-category/create", {});
}
export function storeCourseCategory(
name: string,
parentId: number,
sort: number
) {
return client.post("/backend/v1/course-category/create", {
name: name,
parent_id: parentId,
sort: sort,
});
}
export function courseCategory(id: number) {
return client.get(`/backend/v1/course-category/${id}`, {});
}
export function updateCourseCategory(
id: number,
name: string,
parentId: number,
sort: number
) {
return client.post(`/backend/v1/course-category/${id}`, {
name: name,
parent_id: parentId,
sort: sort,
});
}
export function destroyCourseCategory(id: number) {
return client.destroy(`/backend/v1/course-category/${id}`);
}

View File

@ -0,0 +1,46 @@
import client from "./internal/httpClient";
export function courseChapterList(courseId: number) {
return client.get(`/backend/v1/course/${courseId}/chapter/index`, {});
}
export function createCourseChapter(courseId: number) {
return client.get(`/backend/v1/course/${courseId}/chapter/create`, {});
}
export function storeCourseChapter(
courseId: number,
name: string,
sort: number
) {
return client.post(`/backend/v1/course/${courseId}/chapter/create`, {
name: name,
sort: sort,
});
}
export function courseChapter(courseId: number, id: number) {
return client.get(`/backend/v1/course/${courseId}/course-chapter/${id}`, {});
}
export function updateCourseChapter(
courseId: number,
id: number,
name: string,
sort: number
) {
return client.put(`/backend/v1/course/${courseId}/chapter/${id}`, {
name: name,
sort: sort,
});
}
export function destroyCourseChapter(courseId: number, id: number) {
return client.destroy(`/backend/v1/course/${courseId}/chapter/${id}`);
}
export function transCourseChapter(courseId: number, ids: number[]) {
return client.put(`/backend/v1/course/${courseId}/chapter/update/sort`, {
ids: ids,
});
}

View File

@ -0,0 +1,66 @@
import client from "./internal/httpClient";
export function courseHourList(courseId: number) {
return client.get(`/backend/v1/course/${courseId}/hour/index`, {});
}
export function createCourseHour(courseId: number) {
return client.get(`/backend/v1/course/${courseId}/hour/create`, {});
}
export function storeCourseHour(
courseId: number,
chapterId: number,
title: string,
type: string,
druation: number,
rid: number
) {
return client.post(`/backend/v1/course/${courseId}/hour/create`, {
chapter_id: chapterId,
title,
type,
druation,
sort: 0,
rid,
});
}
export function storeCourseHourMulti(courseId: number, hours: number[]) {
return client.post(`/backend/v1/course/${courseId}/hour/create-batch`, {
hours: hours,
});
}
export function courseHour(courseId: number, id: number) {
return client.get(`/backend/v1/course/${courseId}/hour/${id}`, {});
}
export function updateCourseHour(
courseId: number,
id: number,
chapterId: number,
title: string,
type: string,
druation: number,
rid: number
) {
return client.put(`/backend/v1/course/${courseId}/hour/${id}`, {
chapter_id: chapterId,
title,
type,
druation,
sort: 0,
rid,
});
}
export function destroyCourseHour(courseId: number, id: number) {
return client.destroy(`/backend/v1/course/${courseId}/hour/${id}`);
}
export function transCourseHour(courseId: number, ids: number[]) {
return client.put(`/backend/v1/course/${courseId}/hour/update/sort`, {
ids: ids,
});
}

View File

@ -0,0 +1,117 @@
import client from "./internal/httpClient";
export function courseList(
page: number,
size: number,
sortField: string,
sortAlgo: string,
title: string,
depIds: string,
categoryIds: string
) {
return client.get("/backend/v1/course/index", {
page: page,
size: size,
sort_field: sortField,
sort_algo: sortAlgo,
title: title,
dep_ids: depIds,
category_ids: categoryIds,
});
}
export function createCourse() {
return client.get("/backend/v1/course/create", {});
}
// depIds => 部门id数组请用英文逗号连接
// categoryIds => 所属分类数组,请用英文逗号连接
export function storeCourse(
title: string,
thumb: string,
shortDesc: string,
isShow: number,
isRequired: number,
depIds: number[],
categoryIds: number[],
chapters: any[],
hours: any[],
attachments: any[]
) {
return client.post("/backend/v1/course/create", {
title: title,
thumb: thumb,
short_desc: shortDesc,
is_show: isShow,
is_required: isRequired,
dep_ids: depIds,
category_ids: categoryIds,
chapters: chapters,
hours: hours,
attachments: attachments,
});
}
export function course(id: number) {
return client.get(`/backend/v1/course/${id}`, {});
}
export function updateCourse(
id: number,
title: string,
thumb: string,
shortDesc: string,
isShow: number,
isRequired: number,
depIds: number[],
categoryIds: number[],
chapters: number[],
hours: number[],
publishedAt: string
) {
return client.put(`/backend/v1/course/${id}`, {
title: title,
thumb: thumb,
short_desc: shortDesc,
is_show: isShow,
is_required: isRequired,
dep_ids: depIds,
category_ids: categoryIds,
chapters: chapters,
hours: hours,
sort_at: publishedAt,
});
}
export function destroyCourse(id: number) {
return client.destroy(`/backend/v1/course/${id}`);
}
//学员列表
export function courseUser(
courseId: number,
page: number,
size: number,
sortField: string,
sortAlgo: string,
name: string,
email: string,
idCard: string
) {
return client.get(`/backend/v1/course/${courseId}/user/index`, {
page: page,
size: size,
sort_field: sortField,
sort_algo: sortAlgo,
name: name,
email: email,
id_card: idCard,
});
}
//删除学员
export function destroyCourseUser(courseId: number, ids: number[]) {
return client.post(`/backend/v1/course/${courseId}/user/destroy`, {
ids: ids,
});
}

View File

@ -0,0 +1,5 @@
import client from "./internal/httpClient";
export function dashboardList() {
return client.get("/backend/v1/dashboard/index", {});
}

View File

@ -0,0 +1,60 @@
import client from "./internal/httpClient";
export function departmentList(params: any) {
return client.get("/backend/v1/department/index", params);
}
export function createDepartment() {
return client.get("/backend/v1/department/create", {});
}
export function storeDepartment(name: string, parentId: number, sort: number) {
return client.post("/backend/v1/department/create", {
name,
parent_id: parentId,
sort,
});
}
export function department(id: number) {
return client.get(`/backend/v1/department/${id}`, {});
}
export function updateDepartment(
id: number,
name: string,
parentId: number,
sort: number
) {
return client.put(`/backend/v1/department/${id}`, {
name,
parent_id: parentId,
sort,
});
}
export function destroyDepartment(id: number) {
return client.destroy(`/backend/v1/department/${id}`);
}
export function dropSameClass(ids: number[]) {
return client.put(`/backend/v1/department/update/sort`, {
ids: ids,
});
}
export function dropDiffClass(id: number, parent_id: number, ids: number[]) {
return client.put(`/backend/v1/department/update/parent`, {
id: id,
parent_id: parent_id,
ids: ids,
});
}
export function checkDestroy(id: number) {
return client.get(`/backend/v1/department/${id}/destroy`, {});
}
export function ldapSync() {
return client.post(`/backend/v1/department/ldap-sync`, {});
}

View File

@ -0,0 +1,18 @@
export * as login from "./login";
export * as system from "./system";
export * as adminRole from "./admin-role";
export * as adminUser from "./admin-user";
export * as courseCategory from "./course-category";
export * as courseChapter from "./course-chapter";
export * as course from "./course";
export * as courseHour from "./course-hour";
export * as courseAttachment from "./course-attachment";
export * as department from "./department";
export * as resourceCategory from "./resource-category";
export * as resource from "./resource";
export * as upload from "./upload";
export * as user from "./user";
export * as appConfig from "./app-config";
export * as dashboard from "./dashboard";
export * as adminLog from "./admin-log";
export * as ldap from "./ldap";

View File

@ -0,0 +1,166 @@
import axios, { Axios, AxiosResponse } from "axios";
import { message } from "antd";
import { getToken, clearToken } from "../../utils/index";
const GoLogin = () => {
clearToken();
window.location.href = "/login";
};
const GoError = (code: number) => {
// window.location.href = "/error?code=" + code;
};
export class HttpClient {
axios: Axios;
constructor(url: string) {
this.axios = axios.create({
baseURL: url,
timeout: 15000,
withCredentials: false,
headers: {
Accept: "application/json",
},
});
//拦截器注册
this.axios.interceptors.request.use(
(config) => {
const token = getToken();
token && (config.headers.Authorization = "Bearer " + token);
return config;
},
(err) => {
return Promise.reject(err);
}
);
this.axios.interceptors.response.use(
(response: AxiosResponse) => {
let code = response.data.code; //业务返回代码
let msg = response.data.msg; //错误消息
if (code === 0) {
return Promise.resolve(response);
} else if (code === 404) {
message.error(msg);
// 跳转到404页面
GoError(404);
} else if (code === 403) {
message.error(msg);
// 跳转到无权限页面
GoError(403);
} else if (code === 429) {
message.error(msg);
// 跳转到429页面
GoError(429);
} else if (code === 500) {
message.error(msg);
// 跳转到500异常页面
GoError(500);
} else {
GoError(code);
message.error(msg);
}
return Promise.reject(response);
},
// 当http的状态码非0
(error) => {
let status = error.response.status;
if (status === 401) {
message.error("请重新登录");
GoLogin();
} else if (status === 404) {
// 跳转到404页面
GoError(404);
} else if (status === 403) {
// 跳转到无权限页面
GoError(403);
} else if (status === 429) {
// 跳转到429页面
GoError(429);
} else if (status === 500) {
// 跳转到500异常页面
GoError(500);
} else {
GoError(status);
}
return Promise.reject(error.response);
}
);
}
get(url: string, params: object) {
return new Promise((resolve, reject) => {
this.axios
.get(url, {
params: params,
})
.then((res) => {
resolve(res.data);
})
.catch((err) => {
reject(err.data);
});
});
}
destroy(url: string) {
return new Promise((resolve, reject) => {
this.axios
.delete(url)
.then((res) => {
resolve(res.data);
})
.catch((err) => {
reject(err.data);
});
});
}
post(url: string, params: object) {
return new Promise((resolve, reject) => {
this.axios
.post(url, params)
.then((res) => {
resolve(res.data);
})
.catch((err) => {
reject(err.data);
});
});
}
put(url: string, params: object) {
return new Promise((resolve, reject) => {
this.axios
.put(url, params)
.then((res) => {
resolve(res.data);
})
.catch((err) => {
reject(err.data);
});
});
}
request(config: object) {
return new Promise((resolve, reject) => {
this.axios
.request(config)
.then((res) => {
resolve(res.data);
})
.catch((err) => {
reject(err.data);
});
});
}
}
const APP_URL = import.meta.env.VITE_APP_URL || "";
const client = new HttpClient(APP_URL);
export default client;

View File

@ -0,0 +1,26 @@
import client from "./internal/httpClient";
// 获取同步记录列表
export function getSyncRecords(params: { page?: number; size?: number }) {
return client.get("/backend/v1/ldap/sync-records", params);
}
// 获取单条同步记录详情
export function getSyncRecordDetail(id: number) {
return client.get(`/backend/v1/ldap/sync-records/${id}`, {});
}
// 获取同步记录的详细项目
export function getSyncRecordDetails(id: number, params: {
type: 'department' | 'user';
action?: number;
page?: number;
size?: number
}) {
return client.get(`/backend/v1/ldap/sync-records/${id}/details`, params);
}
// 下载同步记录数据
export function downloadSyncRecord(id: number) {
return client.get(`/backend/v1/ldap/sync-records/${id}/download`, {});
}

View File

@ -0,0 +1,23 @@
import client from "./internal/httpClient";
export function login(email: string, password: string) {
return client.post("/backend/v1/auth/login", {
email: email,
password: password,
});
}
export function logout() {
return client.post("/backend/v1/auth/logout", {});
}
export function getUser() {
return client.get("/backend/v1/auth/detail", {});
}
export function passwordChange(oldPassword: string, newPassword: string) {
return client.put("/backend/v1/auth/password", {
old_password: oldPassword,
new_password: newPassword,
});
}

View File

@ -0,0 +1,60 @@
import client from "./internal/httpClient";
export function resourceCategoryList() {
return client.get("/backend/v1/resource-category/index", {});
}
export function createResourceCategory() {
return client.get("/backend/v1/resource-category/create", {});
}
export function storeResourceCategory(
name: string,
parentId: number,
sort: number
) {
return client.post("/backend/v1/resource-category/create", {
name,
parent_id: parentId,
sort,
});
}
export function resourceCategory(id: number) {
return client.get(`/backend/v1/resource-category/${id}`, {});
}
export function updateResourceCategory(
id: number,
name: string,
parentId: number,
sort: number
) {
return client.put(`/backend/v1/resource-category/${id}`, {
name,
parent_id: parentId,
sort,
});
}
export function destroyResourceCategory(id: number) {
return client.destroy(`/backend/v1/resource-category/${id}`);
}
export function dropSameClass(ids: number[]) {
return client.put(`/backend/v1/resource-category/update/sort`, {
ids: ids,
});
}
export function dropDiffClass(id: number, parent_id: number, ids: number[]) {
return client.put(`/backend/v1/resource-category/update/parent`, {
id: id,
parent_id: parent_id,
ids: ids,
});
}
export function checkDestroy(id: number) {
return client.get(`/backend/v1/resource-category/${id}/destroy`, {});
}

View File

@ -0,0 +1,71 @@
import client from "./internal/httpClient";
export function resourceList(
page: number,
size: number,
sortField: string,
sortAlgo: string,
name: string,
type: string,
categoryIds: string
) {
return client.get("/backend/v1/resource/index", {
page,
size,
sort_field: sortField,
sort_algo: sortAlgo,
name,
type,
category_ids: categoryIds,
});
}
export function createResource(type: string) {
return client.get("/backend/v1/resource/create", { type });
}
export function storeResource(
categoryId: number,
name: string,
extension: string,
size: number,
disk: string,
fileId: string,
path: string,
url: string,
extra: object
) {
let data = Object.assign(
{},
{
category_id: categoryId,
name,
extension,
size,
disk,
file_id: fileId,
path,
url,
},
extra
);
return client.post("/backend/v1/resource/create", data);
}
export function destroyResource(id: number) {
return client.destroy(`/backend/v1/resource/${id}`);
}
export function destroyResourceMulti(ids: number[]) {
return client.post(`/backend/v1/resource/destroy-multi`, {
ids: ids,
});
}
export function videoDetail(id: number) {
return client.get(`/backend/v1/resource/${id}`, {});
}
export function videoUpdate(id: number, params: any) {
return client.put(`/backend/v1/resource/${id}`, params);
}

View File

@ -0,0 +1,9 @@
import client from "./internal/httpClient";
export function getImageCaptcha() {
return client.get("/backend/v1/system/image-captcha", {});
}
export function getSystemConfig() {
return client.get("/backend/v1/system/config", {});
}

View File

@ -0,0 +1,40 @@
import client from "./internal/httpClient";
export function minioUploadId(extension: string) {
return client.get("/backend/v1/upload/minio/upload-id", {
extension,
});
}
export function minioPreSignUrl(
uploadId: string,
filename: string,
partNumber: number
) {
return client.get("/backend/v1/upload/minio/pre-sign-url", {
upload_id: uploadId,
filename,
part_number: partNumber,
});
}
export function minioMergeVideo(
filename: string,
uploadId: string,
categoryIds: string,
originalFilename: string,
extension: string,
size: number,
duration: number,
poster: string
) {
return client.post("/backend/v1/upload/minio/merge-file", {
filename,
upload_id: uploadId,
original_filename: originalFilename,
category_ids: categoryIds,
size,
duration,
extension,
poster,
});
}

View File

@ -0,0 +1,149 @@
import client from "./internal/httpClient";
//params可选值如下
// name - 姓名
// nickname - 昵称
// email - 邮箱
// id_card - 身份证号
// is_active - 是否激活[1:是,0:否]
// is_lock - 是否锁定[1:是,0:否]
// is_verify - 是否完成实名认证[1:是,0:否]
// is_set_password - 是否设置密码[1:是,0:否]
// created_at - 注册时间区间过滤 - 格式(字符串): "开始时间,结束时间"
// dep_ids - 部门id字符串 - 格式(字符串): 1,2,3
// sort_field - 排序字段(默认值:id) 可选值id,created_at
// sort_algo - 排序算法(默认值:desc) 可选值asc,desc
export function userList(page: number, size: number, params: object) {
return client.get("/backend/v1/user/index", {
page,
size,
...params,
});
}
export function createUser() {
return client.get("/backend/v1/user/create", {});
}
export function storeUser(
email: string,
name: string,
avatar: string,
password: string,
idCard: string,
depIds: number[]
) {
return client.post("/backend/v1/user/create", {
email,
name,
avatar,
password,
id_card: idCard,
dep_ids: depIds,
});
}
export function user(id: number) {
return client.get(`/backend/v1/user/${id}`, {});
}
export function updateUser(
id: number,
email: string,
name: string,
avatar: string,
password: string,
idCard: string,
depIds: number[]
) {
return client.put(`/backend/v1/user/${id}`, {
email,
name,
avatar,
password,
id_card: idCard,
dep_ids: depIds,
});
}
export function destroyUser(id: number) {
return client.destroy(`/backend/v1/user/${id}`);
}
//startline是表格真是数据的起始行号-用于提示哪一行数据存在问题
//users是一个二维字符串数组每个数组的元素如下[部门ids字符串,邮箱,昵称,密码,姓名,身份证]
export function storeBatch(startLine: number, users: string[][]) {
return client.post("/backend/v1/user/store-batch", {
start_line: startLine,
users: users,
});
}
export function learnStats(id: number) {
return client.get(`/backend/v1/user/${id}/learn-stats`, {});
}
export function learnHours(
id: number,
page: number,
size: number,
params: object
) {
return client.get(`/backend/v1/user/${id}/learn-hours`, {
page,
size,
...params,
});
}
export function learnCourses(
id: number,
page: number,
size: number,
params: object
) {
return client.get(`/backend/v1/user/${id}/learn-courses`, {
page,
size,
...params,
});
}
export function learnAllCourses(id: number) {
return client.get(`/backend/v1/user/${id}/all-courses`, {});
}
export function departmentProgress(
id: number,
page: number,
size: number,
params: object
) {
return client.get(`/backend/v1/department/${id}/users`, {
page,
size,
...params,
});
}
export function learnCoursesProgress(
id: number,
courseId: number,
params: any
) {
return client.get(`/backend/v1/user/${id}/learn-course/${courseId} `, params);
}
export function destroyAllUserLearned(id: number, courseId: number) {
return client.destroy(`/backend/v1/user/${id}/learn-course/${courseId}`);
}
export function destroyUserLearned(
id: number,
courseId: number,
hourId: number
) {
return client.destroy(
`/backend/v1/user/${id}/learn-course/${courseId}/hour/${hourId}`
);
}

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 836 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 736 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 243 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 706 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 716 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 919 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 527 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@ -0,0 +1,20 @@
.back-bar-box {
width: 100%;
height: auto;
float: left;
display: flex;
align-items: center;
.line {
width: 1px;
height: 14px;
background-color: #d8d8d8;
margin-right: 15px;
}
.name {
font-size: 14px;
font-weight: 600;
color: #333333;
}
}

View File

@ -0,0 +1,29 @@
import { Button } from "antd";
import { useState } from "react";
import styles from "./index.module.less";
import { useNavigate } from "react-router-dom";
import { LeftOutlined } from "@ant-design/icons";
interface PropInterface {
title: string;
}
export const BackBartment = (props: PropInterface) => {
const [loading, setLoading] = useState<boolean>(true);
const navigate = useNavigate();
return (
<div className={styles["back-bar-box"]}>
<Button
style={{ paddingLeft: 0 }}
icon={<LeftOutlined />}
type="link"
danger
onClick={() => navigate(-1)}
>
</Button>
<div className={styles["line"]}></div>
<div className={styles["name"]}>{props.title}</div>
</div>
);
};

View File

@ -0,0 +1,64 @@
import { Button, Input, message, Modal } from "antd";
import { useState } from "react";
import { resourceCategory } from "../../api";
import { PlusOutlined } from "@ant-design/icons";
interface PropInterface {
type: string;
onUpdate: () => void;
}
export const CreateResourceCategory = (props: PropInterface) => {
const [showModal, setShowModal] = useState(false);
const [name, setName] = useState<string>("");
const confirm = () => {
if (name.length == 0) {
message.error("请输入分类名");
return;
}
resourceCategory
.storeResourceCategory(name, 0, 0)
.then(() => {
setName("");
message.success("分类添加成功");
setShowModal(false);
props.onUpdate();
})
.catch((err) => {
console.log("错误", err);
});
};
return (
<>
<Button
type="primary"
onClick={() => {
setShowModal(true);
}}
shape="circle"
icon={<PlusOutlined />}
/>
{showModal ? (
<Modal
onCancel={() => {
setShowModal(false);
}}
onOk={confirm}
open={true}
title="创建分类"
>
<Input
placeholder="请输入分类名"
value={name}
onChange={(e) => {
setName(e.target.value);
}}
allowClear
/>
</Modal>
) : null}
</>
);
};

View File

@ -0,0 +1,32 @@
import { useEffect, useState } from "react";
interface PropInterface {
duration: number;
}
export const DurationText = (props: PropInterface) => {
const [hour, setHour] = useState(0);
const [minute, setMinute] = useState(0);
const [second, setSecond] = useState(0);
const duration = props.duration;
useEffect(() => {
let h = Math.trunc(duration / 3600);
let m = Math.trunc((duration % 3600) / 60);
let s = Math.trunc((duration % 3600) % 60);
setHour(h);
setMinute(m);
setSecond(s);
}, []);
return (
<>
<span>
{hour === 0 ? null : hour + ":"}
{minute >= 10 ? minute : "0" + minute}:
{second >= 10 ? second : "0" + second}
</span>
</>
);
};

View File

@ -0,0 +1,44 @@
import React from "react";
import { Layout } from "antd";
import { Link } from "react-router-dom";
interface PropInterface {
type?: string;
}
export const Footer: React.FC<PropInterface> = ({ type }) => {
return (
<Layout.Footer
style={{
width: "100%",
background: type === "none" ? "none" : "#F6F6F6",
height: 166,
paddingTop: 80,
textAlign: "center",
}}
>
<Link
to="https://www.playeduos.com/"
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
target="blank"
>
{/* 此处为版权标识,严禁删改 */}
<i
style={{ fontSize: 30, color: "#cccccc" }}
className="iconfont icon-waterprint footer-icon"
onClick={() => {}}
></i>
<span
className="ml-5"
style={{ color: "#D7D7D7", fontSize: 12, marginTop: -5 }}
>
Version 2.0
</span>
</Link>
</Layout.Footer>
);
};

View File

@ -0,0 +1,31 @@
.app-header {
width: 100%;
background-color: white !important;
box-sizing: border-box;
-moz-box-sizing: border-box;
/* Firefox */
-webkit-box-sizing: border-box;
/* Safari */
padding: 0px 24px;
}
.main-header {
width: 100%;
background-color: white !important;
height: 48px;
line-height: 48px;
display: flex;
align-items: center;
justify-content: space-between;
}
.App-logo {
width: 151px;
height: 40px;
float: left;
}
.top-main {
display: flex;
align-items: center;
}

View File

@ -0,0 +1,64 @@
import React from "react";
import styles from "./index.module.less";
import { Button, Dropdown, MenuProps } from "antd";
import { useDispatch, useSelector } from "react-redux";
import { useNavigate } from "react-router-dom";
import avatar from "../../assets/images/commen/avatar.png";
import { logoutAction } from "../../store/user/loginUserSlice";
import { clearToken } from "../../utils/index";
export const Header: React.FC = () => {
const dispatch = useDispatch();
const navigate = useNavigate();
const user = useSelector((state: any) => state.loginUser.value.user);
const onClick: MenuProps["onClick"] = ({ key }) => {
if (key === "login_out") {
clearToken();
dispatch(logoutAction());
navigate("/login");
} else if (key === "change_password") {
navigate("/change-password");
}
};
const items: MenuProps["items"] = [
{
label: "修改密码",
key: "change_password",
icon: (
<i
className="iconfont icon-icon-password c-red"
style={{ fontSize: 16 }}
/>
),
},
{
label: "退出登录",
key: "login_out",
icon: (
<i
className="iconfont icon-a-icon-logout c-red"
style={{ fontSize: 16 }}
/>
),
},
];
return (
<div className={styles["app-header"]}>
<div className={styles["main-header"]}>
<div></div>
<Button.Group className={styles["button-group"]}>
<Dropdown menu={{ items, onClick }} placement="bottomRight">
<div className="d-flex">
{user.name && (
<img style={{ width: 30, height: 30 }} src={avatar} />
)}
<span className="ml-8 c-admin">{user.name}</span>
</div>
</Dropdown>
</Button.Group>
</div>
</div>
);
};

View File

@ -0,0 +1,16 @@
export * from "./footer";
export * from "./header";
export * from "./left-menu";
export * from "./upload-image-button";
export * from "./tree-department";
export * from "./back-bar";
export * from "./permission-button";
export * from "./tree-category";
export * from ".//tree-adminroles";
export * from "./duration-text";
export * from "./upload-video-sub";
export * from "./select-resource";
export * from "./upload-courseware-button";
export * from "./upload-courseware-sub";
export * from "./select-attachment";
export * from "./select-range";

View File

@ -0,0 +1,29 @@
import { useUpdate } from "ahooks";
import { useEffect, useRef } from "react";
import { useLocation, useOutlet } from "react-router-dom";
function KeepAlive() {
const componentList = useRef(new Map());
const outLet = useOutlet();
const { pathname } = useLocation();
const forceUpdate = useUpdate();
useEffect(() => {
if (!componentList.current.has(pathname)) {
componentList.current.set(pathname, outLet);
}
forceUpdate();
}, [pathname]);
return (
<div>
{Array.from(componentList.current).map(([key, component]) => (
<div key={key} style={{ display: pathname === key ? "block" : "none" }}>
{component}
</div>
))}
</div>
);
}
export default KeepAlive;

View File

@ -0,0 +1,20 @@
.left-menu {
width: 200px;
height: 100%;
background-color: #fff;
.App-logo {
width: 124px;
height: 40px;
margin-top: 16px;
margin-left: 38px;
margin-bottom: 14px;
}
.menu-box {
width: 200px;
height: calc(100% - 74px);
overflow-y: auto;
overflow-x: hidden;
}
}

View File

@ -0,0 +1,261 @@
import React, { useEffect, useState } from "react";
import { Menu } from "antd";
import { useSelector } from "react-redux";
import { useNavigate, useLocation } from "react-router-dom";
import styles from "./index.module.less";
import logo from "../../assets/logo.png";
function getItem(
label: any,
key: any,
icon: any,
children: any,
type: any,
permission: any
) {
return {
key,
icon,
children,
label,
type,
permission,
};
}
const items = [
getItem(
"首页概览",
"/",
<i className={`iconfont icon-icon-home`} />,
null,
null,
null
),
getItem(
"分类管理",
"/resource-category",
<i className="iconfont icon-icon-category" />,
null,
null,
"resource-category-menu"
),
getItem(
"资源管理",
"resource",
<i className="iconfont icon-icon-file" />,
[
getItem("视频", "/videos", null, null, null, "resource-menu"),
getItem("图片", "/images", null, null, null, "resource-menu"),
getItem("课件", "/courseware", null, null, null, "resource-menu"),
],
null,
null
),
getItem(
"课程中心",
"courses",
<i className="iconfont icon-icon-study" />,
[getItem("线上课", "/course", null, null, null, "course")],
null,
null
),
getItem(
"学员管理",
"user",
<i className="iconfont icon-icon-user" />,
[
getItem("学员", "/member/index", null, null, null, "user-index"),
getItem("部门", "/department", null, null, null, "department-cud"),
],
null,
null
),
getItem(
"系统设置",
"system",
<i className="iconfont icon-icon-setting" />,
[
getItem(
"系统配置",
"/system/config/index",
null,
null,
null,
"system-config"
),
getItem(
"管理人员",
"/system/administrator",
null,
null,
null,
"admin-user-index"
),
getItem("管理日志", "/system/adminlog", null, null, null, "admin-log"),
],
null,
null
),
getItem(
"使用许可",
"/licensing",
<i className="iconfont icon-xuke" />,
null,
null,
null,
),
];
export const LeftMenu: React.FC = () => {
const location = useLocation();
const navigate = useNavigate();
const children2Parent: any = {
"^/video": ["resource"],
"^/image": ["resource"],
"^/courseware": ["resource"],
"^/member": ["user"],
"^/department": ["user"],
"^/course": ["courses"],
"^/system": ["system"],
};
const hit = (pathname: string): string[] => {
for (let p in children2Parent) {
if (pathname.search(p) >= 0) {
return children2Parent[p];
}
}
return [];
};
const openKeyMerge = (pathname: string): string[] => {
let newOpenKeys = hit(pathname);
for (let i = 0; i < openKeys.length; i++) {
let isIn = false;
for (let j = 0; j < newOpenKeys.length; j++) {
if (newOpenKeys[j] === openKeys[i]) {
isIn = true;
break;
}
}
if (isIn) {
continue;
}
newOpenKeys.push(openKeys[i]);
}
return newOpenKeys;
};
// 选中的菜单
const [selectedKeys, setSelectedKeys] = useState<string[]>([
location.pathname,
]);
// 展开菜单
const [openKeys, setOpenKeys] = useState<string[]>(hit(location.pathname));
const permissions = useSelector(
(state: any) => state.loginUser.value.permissions
);
const [activeMenus, setActiveMenus] = useState<any>([]);
const onClick = (e: any) => {
navigate(e.key);
};
useEffect(() => {
checkMenuPermissions(items, permissions);
}, [items, permissions]);
const checkMenuPermissions = (items: any, permissions: any) => {
let menus: any = [];
if (permissions.length === 0) {
setActiveMenus(menus);
return;
}
for (let i in items) {
let menuItem = items[i];
// 一级菜单=>没有子菜单&配置了权限
if (menuItem.children === null) {
if (
menuItem.permission !== null &&
typeof permissions[menuItem.permission] === "undefined"
) {
continue;
}
menus.push(menuItem);
continue;
}
let children = [];
for (let j in menuItem.children) {
let childrenItem = menuItem.children[j];
if (
typeof permissions[childrenItem.permission] !== "undefined" ||
!childrenItem.permission
) {
// 存在权限
children.push(childrenItem);
}
}
if (children.length > 0) {
menus.push(Object.assign({}, menuItem, { children: children }));
}
}
setActiveMenus(menus);
};
useEffect(() => {
if (location.pathname.indexOf("/course/user") !== -1) {
setSelectedKeys(["/course"]);
setOpenKeys(openKeyMerge("/course"));
} else if (location.pathname.indexOf("/member/learn") !== -1) {
setSelectedKeys(["/member/index"]);
setOpenKeys(openKeyMerge("/member/index"));
} else {
setSelectedKeys([location.pathname]);
setOpenKeys(openKeyMerge(location.pathname));
}
}, [location.pathname]);
return (
<div className={styles["left-menu"]}>
<div
style={{
textDecoration: "none",
cursor: "pointer",
position: "sticky",
top: 0,
zIndex: 10,
background: "#fff",
}}
onClick={() => {
window.location.href = "/";
}}
>
{/* 此处为版权标识,严禁删改 */}
<img src={logo} className={styles["App-logo"]} />
</div>
<div className={styles["menu-box"]}>
<Menu
onClick={onClick}
style={{
width: 200,
background: "#ffffff",
}}
selectedKeys={selectedKeys}
openKeys={openKeys}
mode="inline"
items={activeMenus}
onSelect={(data: any) => {
setSelectedKeys(data.selectedKeys);
}}
onOpenChange={(keys: any) => {
setOpenKeys(keys);
}}
/>
</div>
</div>
);
};

View File

@ -0,0 +1,55 @@
import { Button } from "antd";
import { useSelector } from "react-redux";
interface PropInterface {
type: "link" | "text" | "primary" | "default";
text: string;
p: string;
class: string;
icon?: any;
onClick?: () => void;
disabled: any;
}
export const PerButton = (props: PropInterface) => {
const permissions = useSelector(
(state: any) => state.loginUser.value.permissions
);
const isThrough = () => {
if (!permissions) {
return false;
}
return typeof permissions[props.p] !== "undefined";
};
return (
<>
{isThrough() && props.type === "link" && (
<Button
className={props.class}
type="link"
danger
icon={props.icon}
onClick={() => {
props.onClick && props.onClick();
}}
disabled={props.disabled}
>
{props.text}
</Button>
)}
{isThrough() && props.type !== "link" && (
<Button
className={props.class}
type={props.type}
icon={props.icon}
onClick={() => {
props.onClick && props.onClick();
}}
disabled={props.disabled}
>
{props.text}
</Button>
)}
</>
);
};

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