Compare commits

...

18 Commits

Author SHA1 Message Date
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
728 changed files with 32214 additions and 485 deletions

18
.env.example Normal file
View File

@ -0,0 +1,18 @@
################# PlayEdu ###################
PLAYEDU_API_PORT=9700
PLAYEDU_PC_PORT=9800
PLAYEDU_H5_PORT=9801
PLAYEDU_ADMIN_PORT=9900
PLAYEDU_JWT_KEY=playeduxyz
################# MySQL ###################
MYSQL_PORT=3306
################# Redis ###################
REDIS_PORT=6379
################# MINIO ###################
MINIO_ROOT_USERNAME=username
MINIO_ROOT_PASSWORD=password
MINIO_PORT=9002
MINIO_CONSOLE_PORT=50002

View File

@ -2,16 +2,12 @@ name: EstablishDockerImage
on:
push:
branches:
- main
- dev
- 'feat/**'
tags:
- '*'
- "*"
env:
IMAGE_FQDN: registry.cn-hangzhou.aliyuncs.com/playedu/api
IMAGE_TAG: ''
IMAGE_FQDN: registry.cn-hangzhou.aliyuncs.com/playedu/light
IMAGE_TAG: ""
jobs:
build-and-push:

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

13
CHANGELOG.md Normal file
View File

@ -0,0 +1,13 @@
## v1.7 版本更新
- 新增:[API]MinIO配置信息增加环境变量的读取
- 新增:[API]学员学习权限优化
- 新增:[后台]后台首页增加课件数量的显示
- 新增:[后台]线上课列表增加创建人字段
- 优化:[后台]学员部门包含子部门所有学员数量
- 优化:[API]根据分类ID获取所有子分类的课程
- 优化:[API]根据部门ID获取所有父级部门的课程
- 优化:[后台]部门指派器
- 优化:[PC]视频播放器去除右键点击
- 优化:[PC]首页学习时长去掉秒
- 优化:[H5]首页tab切换优化

View File

@ -1,6 +1,27 @@
FROM eclipse-temurin:17 as builder
FROM node:20-alpine as NodeBuilder
COPY . /app
RUN yarn config set registry https://registry.npmmirror.com &&\
yarn global add pnpm &&\
pnpm config set registry https://registry.npmmirror.com
COPY playedu-admin /app/admin
COPY playedu-pc /app/pc
COPY playedu-h5 /app/h5
WORKDIR /app
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 eclipse-temurin:17 as javaBuilder
COPY playedu-api /app
WORKDIR /app
@ -8,18 +29,18 @@ RUN /app/mvnw -Dmaven.test.skip=true clean package
FROM eclipse-temurin:17
WORKDIR /app
LABEL maintainer="滕勇志 <tengyongzhi@meedu.vip>"
# 使用东八区时间环境
RUN echo "Asia/Shanghai" > /etc/timezone
# 将指定目录下的jar包复制到docker容器的/目录下
COPY --from=builder /app/playedu-api/target/playedu-api.jar /app/app.jar
RUN apt update && apt install -y nginx
RUN chmod +x /app/app.jar
COPY --from=javaBuilder /app/playedu-api/target/playedu-api.jar /app/api/app.jar
# 声明服务运行在8080端口
EXPOSE 9898
COPY --from=NodeBuilder /app/admin/dist /app/admin
COPY --from=NodeBuilder /app/pc/dist /app/pc
COPY --from=NodeBuilder /app/h5/dist /app/h5
# 指定docker容器启动时运行jar包
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
COPY docker/nginx/conf/nginx.conf /etc/nginx/sites-enabled/default
CMD nginx; echo "Waiting for MySQL/Redis/MinIO 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} --spring.data.redis.host=${REDIS_HOST} --spring.data.redis.port=${REDIS_PORT:-6379} --spring.data.redis.password=${REDIS_PASS} --spring.data.redis.database=${REDIS_DB:-0} --sa-token.is-concurrent=${SA_TOKEN_IS_CONCURRENT:-false} --sa-token.jwt-secret-key=${SA_TOKEN_JWT_SECRET_KEY}

View File

@ -2,36 +2,50 @@
<img src="https://meedu.cloud.oss.meedu.vip/playedu/%E5%A4%B4%E5%9B%BE.jpg"/>
</p>
## 系统介绍
<h4 align="center">
<a href="http://www.playeduos.com">官网</a> |
<a href="https://www.playeduos.com/function.html">商业版</a> |
<a href="https://faq.playeduos.com/">部署文档</a> |
<a href="https://www.playeduos.com/demo.html">演示站</a> |
<a href="https://faq.playeduos.com/qa">开源社区</a>
</h4>
**PlayEdu** 是由白书科技团队经营多年线上教育系统打造出的一款全新的企业培训解决方案,致力于为更多企业机构搭建私有化内部培训平台。**PlayEdu** 基于 Java + MySQL 开发;采用前后端分离模式;前端采用 React18 为核心框架,后端采用 SpringBoot3 为核心框架。提供部门管理、学员管理、在线视频学习、学员进度追踪、视频私有化存储等培训主要功能。**与此同时,我们在开源版本的基础上还提供了功能更加丰富的企业版本。企业版本在开源功能的基础上提供了包括视频云端存储、视频加密、音频学习、文档(PDF|WORD|PPT)在线学习、在线考试、学习计划培训等功能。企业版本更多信息请点击下方的企业版链接查看。**
PlayEdu 是由白书科技团队经营多年线上教培领域打造出的一款业内领先的线上培训解决方案。PlayEdu 基于 Java + MySQL 开发,采用前后端分离模式,前端核心框架为 React18后端核心框架为 SpringBoot3。开源版本提供部门管理、学员管理、在线视频学习、学员进度追踪、视频私有化存储等基础培训功能。
**针对企业级培训场景,我们精心打造了“功能更多、响应更快、并发更强”的企业版本,满足企业多样化的培训需求。企业版本支持音视频学习、文档在线预览、线上考试、学习任务等多种学习方式,并提供多重安全防护,如视频转码加密、防盗链、学习防快进、防挂机等。同时,我们集成了企业微信、钉钉、飞书等主流办公系统,帮助企业快速部署专属培训平台!**
## 常用链接
## 🚀 快速上手
| 站点 | 链接 |
| ---------- | ---------------------------------------------------------------------------- |
| 官网 | [http://www.playedu.xyz](http://www.playedu.xyz) |
| **企业版** | [https://www.playedu.xyz/commercial](https://www.playedu.xyz/commercial) |
| 部署文档 | [https://www.playedu.xyz/book](https://www.playedu.xyz/book) |
| 系统演示 | [https://www.playedu.xyz/demo](https://www.playedu.xyz/demo) |
| 问答社区 | [https://www.playedu.xyz/qa](https://www.playedu.xyz/qa) |
拉取代码:
## 依赖前端项目
```
git clone --branch main https://gitee.com/playeduxyz/playedu.git playedu
```
- [后台界面程序](https://github.com/PlayEdu/backend)
- [PC 界面程序](https://github.com/PlayEdu/frontend)
- [H5 界面程序](https://github.com/PlayEdu/h5)
构建镜像:
## 界面预览
```
cd playedu && docker-compose up -d
```
命令执行完成以后,打开您的浏览器,输入 `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.版权保护** 严禁删除、修改或篡改源代码中的版权信息及开源说明文件。
- **4.商业化行为** 在遵循开源协议的前提下,可以进行商用。但请注意,任何商业化行为都应明确标注 PlayEdu 开源版的版权信息。

91
compose.yml Normal file
View File

@ -0,0 +1,91 @@
x-logging: &default-logging
driver: "json-file"
options:
max-size: "10m"
max-file: "10"
networks:
playedu:
driver: bridge
ipam:
driver: default
config:
- subnet: 172.10.10.0/24
volumes:
mysql-data:
mysql-conf:
redis-data:
minio-data:
services:
playedu:
image: registry.cn-hangzhou.aliyuncs.com/playedu/light:1.7
restart: always
environment:
- DB_HOST=mysql
- DB_PORT=3306
- DB_NAME=playedu
- DB_USER=root
- DB_PASS=playeduxyz
- REDIS_HOST=redis
- REDIS_PASS=playeduxyz
- REDIS_PORT=6379
- REDIS_DB=2
- SA_TOKEN_IS_CONCURRENT=false
- SA_TOKEN_JWT_SECRET_KEY=${PLAYEDU_JWT_KEY:-playeduxyz}
ports:
- "${PLAYEDU_API_PORT:-9700}:9700"
- "${PLAYEDU_PC_PORT:-9800}:9800"
- "${PLAYEDU_H5_PORT:-9801}:9801"
- "${PLAYEDU_ADMIN_PORT:-9900}:9900"
networks:
- playedu
depends_on:
- mysql
- redis
- minio
logging: *default-logging
redis:
build: ./docker/redis
restart: always
volumes:
- redis-data:/data
# ports:
# - "${REDIS_PORT:-6379}:6379"
networks:
- playedu
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
- mysql-conf:/etc/mysql/conf.d
# ports:
# - "${MYSQL_PORT:-3306}:3306"
networks:
- playedu
logging: *default-logging
minio:
image: bitnami/minio:latest
restart: always
environment:
- MINIO_ROOT_USER=${MINIO_ROOT_USERNAME:-username}
- MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD:-password}
- MINIO_DEFAULT_BUCKETS=playedu:public
volumes:
- minio-data:/bitnami/minio/data
ports:
- "${MINIO_PORT:-9002}:9000"
- "${MINIO_CONSOLE_PORT:-50002}:9001"
networks:
- playedu
logging: *default-logging

18
docker/mysql/Dockerfile Normal file
View File

@ -0,0 +1,18 @@
FROM mysql:8.1
# 时区配置
ARG TZ=UTC
ENV TZ ${TIMEZONE}
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ >/etc/timezone && chown -R mysql:root /var/lib/mysql/
# 复制自定义的my.cnf
COPY my.cnf /etc/mysql/conf.d/my.cnf
# my.cnf只读权限
RUN chmod 0444 /etc/mysql/conf.d/my.cnf
# 暴露3306端口
EXPOSE 3306
# 启动mysql
CMD ["mysqld"]

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,131 @@
client_max_body_size 50m;
server {
# 监听端口号9800
listen 9800;
# 禁用绝对重定向
# 如果没有设置该配置项当Nginx重定向时它将使用绝对路径这意味着当用户访问一个虚拟主机时
# Nginx可能会将他们重定向到另一个主机的URL这可能会导致安全问题和用户体验问题。因此通过
# 设置 absolute_redirect off Nginx将使用相对路径进行重定向以确保用户在同一主机上进
# 行重定向,而不会遇到上述问题。
absolute_redirect off;
# 匹配任何主机名
server_name _;
# 指定Web应用程序的根目录
root /app/pc;
# 指定默认的索引文件为index.html
index index.html;
# 启用gzip压缩
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;
# 将所有以/api/开头的请求代理到本地地址为127.0.0.1:9898的Web应用程序后端服务器
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/;
}
# 处理所有不以/api/开头的请求尝试查找匹配的文件如果没有找到则返回index.html
location ~* ^/(?![api].*) {
try_files $uri /index.html;
}
}
server {
# 监听端口号9801
listen 9801;
# 禁用绝对重定向
# 如果没有设置该配置项当Nginx重定向时它将使用绝对路径这意味着当用户访问一个虚拟主机时
# Nginx可能会将他们重定向到另一个主机的URL这可能会导致安全问题和用户体验问题。因此通过
# 设置 absolute_redirect off Nginx将使用相对路径进行重定向以确保用户在同一主机上进
# 行重定向,而不会遇到上述问题。
absolute_redirect off;
# 匹配任何主机名
server_name _;
# 指定Web应用程序的根目录
root /app/h5;
# 指定默认的索引文件为index.html
index index.html;
# 启用gzip压缩
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;
# 将所有以/api/开头的请求代理到本地地址为127.0.0.1:9898的Web应用程序后端服务器
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/;
}
# 处理所有不以/api/开头的请求尝试查找匹配的文件如果没有找到则返回index.html
location ~* ^/(?![api].*) {
try_files $uri /index.html;
}
}
server {
# 监听端口号9900
listen 9900;
# 禁用绝对重定向
# 如果没有设置该配置项当Nginx重定向时它将使用绝对路径这意味着当用户访问一个虚拟主机时
# Nginx可能会将他们重定向到另一个主机的URL这可能会导致安全问题和用户体验问题。因此通过
# 设置 absolute_redirect off Nginx将使用相对路径进行重定向以确保用户在同一主机上进
# 行重定向,而不会遇到上述问题。
absolute_redirect off;
# 匹配任何主机名
server_name _;
# 指定Web应用程序的根目录
root /app/admin;
# 指定默认的索引文件为index.html
index index.html;
# 启用gzip压缩
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;
# 将所有以/api/开头的请求代理到本地地址为127.0.0.1:9898的Web应用程序后端服务器
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/;
}
# 处理所有不以/api/开头的请求尝试查找匹配的文件如果没有找到则返回index.html
location ~* ^/(?![api].*) {
try_files $uri /index.html;
}
}
server {
# 监听端口号80
listen 9700;
# 匹配任何主机名
server_name _;
# 指定Web应用程序的根目录
root /app/pc;
# 指定默认的索引文件为index.html
index index.html;
# 将所有请求代理到本地地址为127.0.0.1:9898的Web应用程序后端服务器
location / {
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/;
}
}

9
docker/redis/Dockerfile Normal file
View File

@ -0,0 +1,9 @@
FROM redis:7.0.12
COPY redis.conf /usr/local/etc/redis/redis.conf
VOLUME /data
EXPOSE 6379
CMD ["redis-server", "/usr/local/etc/redis/redis.conf"]

1377
docker/redis/redis.conf Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,3 @@
/node_modules
/build
/dist

View File

@ -0,0 +1 @@
VITE_APP_URL=

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

@ -0,0 +1,37 @@
# 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/
pnpm-lock.yaml

13
playedu-admin/Dockerfile Normal file
View File

@ -0,0 +1,13 @@
FROM node:lts-slim as builder
WORKDIR /app
COPY . /app
RUN yarn config set registry https://registry.npm.taobao.org && yarn && yarn build
FROM nginx:1.23.4-alpine-slim
COPY --from=builder /app/dist /usr/share/nginx/html
COPY --from=builder /app/docker/nginx.conf /etc/nginx/nginx.conf

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 放置在本官网展示。

View File

@ -0,0 +1,83 @@
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log notice;
pid /var/run/nginx.pid;
events {
worker_connections 32000;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
sendfile on;
#tcp_nopush on;
keepalive_timeout 65;
#开启gzip
gzip on;
#低于1kb的资源不压缩
gzip_min_length 1k;
#压缩级别1-9越大压缩率越高同时消耗cpu资源也越多建议设置在5左右。
gzip_comp_level 5;
#需要压缩哪些响应类型的资源,多个空格隔开。不建议压缩图片.
gzip_types text/plain application/javascript application/x-javascript text/javascript text/xml text/css;
#配置禁用gzip条件支持正则。此处表示ie6及以下不启用gzip因为ie低版本不支持
gzip_disable "MSIE [1-6]\.";
#是否添加“Vary: Accept-Encoding”响应头
gzip_vary on;
server {
listen 80;
listen [::]:80;
server_name localhost;
root /usr/share/nginx/html;
index index.html index.htm;
location / {
try_files $uri $uri/ /index.html;
}
#禁止访问的文件或目录
location ~ ^/(\.user.ini|\.htaccess|\.git|\.env|\.svn|\.project|LICENSE|README.md)
{
return 404;
}
#一键申请SSL证书验证目录相关设置
location ~ \.well-known{
allow all;
}
#禁止在证书验证目录放入敏感文件
if ( $uri ~ "^/\.well-known/.*\.(php|jsp|py|js|css|lua|ts|go|zip|tar\.gz|rar|7z|sql|bak)$" ) {
return 403;
}
location ~ .*\.(gif|jpg|jpeg|png|bmp|swf)$
{
expires 30d;
error_log /dev/null;
access_log /dev/null;
}
location ~ .*\.(js|css)?$
{
expires 12h;
error_log /dev/null;
access_log /dev/null;
}
}
}

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"
}
}

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,
published_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,17 @@
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";

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,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}`
);
}

View File

@ -0,0 +1,143 @@
@font-face {
font-family: "iconfont"; /* Project id 3943555 */
src: url('iconfont.woff2?t=1690600882833') format('woff2'),
url('iconfont.woff?t=1690600882833') format('woff'),
url('iconfont.ttf?t=1690600882833') format('truetype');
}
.iconfont {
font-family: "iconfont" !important;
font-size: 16px;
font-style: normal;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.icon-playedu:before {
content: "\e756";
}
.icon-icon-xuexi:before {
content: "\e753";
}
.icon-icon-wode:before {
content: "\e754";
}
.icon-icon-shouye:before {
content: "\e755";
}
.icon-icon-xiala:before {
content: "\e752";
}
.icon-close:before {
content: "\e751";
}
.icon-fullscreen:before {
content: "\e74b";
}
.icon-speed:before {
content: "\e74c";
}
.icon-mute:before {
content: "\e74d";
}
.icon-play:before {
content: "\e74e";
}
.icon-pause:before {
content: "\e74f";
}
.icon-unmute:before {
content: "\e750";
}
.icon-icon-tips:before {
content: "\e74a";
}
.icon-icon-fold:before {
content: "\e749";
}
.icon-icon-12:before {
content: "\e748";
}
.icon-waterprint:before {
content: "\e747";
}
.icon-adduser:before {
content: "\e743";
}
.icon-upvideo:before {
content: "\e744";
}
.icon-onlinelesson:before {
content: "\e745";
}
.icon-department:before {
content: "\e746";
}
.icon-icon-drag:before {
content: "\e740";
}
.icon-icon-edit:before {
content: "\e741";
}
.icon-icon-delete:before {
content: "\e742";
}
.icon-icon-video:before {
content: "\e73f";
}
.icon-icon-home:before {
content: "\e737";
}
.icon-icon-category:before {
content: "\e738";
}
.icon-icon-file:before {
content: "\e739";
}
.icon-icon-study:before {
content: "\e73a";
}
.icon-icon-user:before {
content: "\e73b";
}
.icon-icon-setting:before {
content: "\e73c";
}
.icon-icon-password:before {
content: "\e73d";
}
.icon-a-icon-logout:before {
content: "\e73e";
}

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: 243 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

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 1.7
</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,253 @@
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
),
];
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>
)}
</>
);
};

View File

@ -0,0 +1,12 @@
import React from "react";
import { getToken } from "../../utils/index";
import { Navigate } from "react-router-dom";
interface PropInterface {
Component: any;
}
const PrivateRoute: React.FC<PropInterface> = ({ Component }) => {
return getToken() ? Component : <Navigate to="/login" replace={true} />;
};
export default PrivateRoute;

View File

@ -0,0 +1,81 @@
import { useEffect, useState } from "react";
import { Row, Modal, Tabs } from "antd";
import styles from "./index.module.less";
import { UploadCoursewareSub } from "../../compenents";
import type { TabsProps } from "antd";
interface PropsInterface {
defaultKeys: any[];
open: boolean;
onSelected: (arr: any[], videos: any[]) => void;
onCancel: () => void;
}
type selAttachmentModel = {
name: string;
rid: number;
type: string;
};
export const SelectAttachment = (props: PropsInterface) => {
const [refresh, setRefresh] = useState(true);
const [tabKey, setTabKey] = useState(1);
const [selectKeys, setSelectKeys] = useState<number[]>([]);
const [selectVideos, setSelectVideos] = useState<selAttachmentModel[]>([]);
useEffect(() => {
setRefresh(!refresh);
}, [props.open]);
const items: TabsProps["items"] = [
{
key: "1",
label: `课件`,
children: (
<UploadCoursewareSub
label="课件"
defaultCheckedList={props.defaultKeys}
open={refresh}
onSelected={(arr: any[], videos: any[]) => {
setSelectKeys(arr);
setSelectVideos(videos);
}}
/>
),
},
];
const onChange = (key: string) => {
setTabKey(Number(key));
};
return (
<>
{props.open ? (
<Modal
title="资源素材库"
centered
closable={false}
onCancel={() => {
setSelectKeys([]);
setSelectVideos([]);
props.onCancel();
}}
open={true}
width={800}
maskClosable={false}
onOk={() => {
props.onSelected(selectKeys, selectVideos);
setSelectKeys([]);
setSelectVideos([]);
}}
>
<Row>
<Tabs defaultActiveKey="1" items={items} onChange={onChange} />
</Row>
</Modal>
) : null}
</>
);
};

View File

@ -0,0 +1,54 @@
.user-content {
margin-left: 24px;
margin-bottom: 12px;
width: 200px;
height: 514px;
background: #fafafa;
border-radius: 4px;
overflow-x: hidden;
overflow-y: auto;
box-sizing: border-box;
padding: 0px 16px;
.title {
width: 100%;
height: 40px;
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 22px;
.tit {
font-size: 14px;
font-weight: 400;
color: rgba(0, 0, 0, 0.88);
line-height: 40px;
}
.link {
font-size: 14px;
font-weight: 400;
color: rgba(0, 0, 0, 0.45);
cursor: pointer;
&:hover {
opacity: 0.8;
}
}
}
.user-item {
width: 100%;
height: 40px;
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 8px;
.user-name {
width: 140px;
height: 40px;
font-size: 14px;
font-weight: 600;
color: rgba(0, 0, 0, 0.88);
line-height: 40px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
}

View File

@ -0,0 +1,113 @@
import { useEffect, useState } from "react";
import { Modal, Tabs, message } from "antd";
import {} from "../../compenents";
import styles from "./index.module.less";
import type { TabsProps } from "antd";
import { SelectDepsSub } from "./select-deps-sub";
import { CloseOutlined } from "@ant-design/icons";
interface PropsInterface {
defaultDepIds: any[];
defaultDeps: any[];
open: boolean;
onSelected: (selDepIds: any[], selDeps: any[]) => void;
onCancel: () => void;
}
type selVideosModel = {
name: string;
id: number;
};
export const SelectRange = (props: PropsInterface) => {
const [refresh, setRefresh] = useState(true);
const [selectKeys, setSelectKeys] = useState<number[]>([]);
const [selectVideos, setSelectVideos] = useState<selVideosModel[]>([]);
useEffect(() => {
setRefresh(!refresh);
}, [props.open]);
useEffect(() => {
setSelectKeys(props.defaultDepIds);
setSelectVideos(props.defaultDeps);
}, [props.defaultDepIds, props.defaultDeps, refresh]);
return (
<>
{props.open ? (
<Modal
title="选择部门"
centered
closable={false}
onCancel={() => {
props.onCancel();
}}
okText="确定"
open={true}
width={800}
maskClosable={false}
onOk={() => {
if (selectKeys.length === 0) {
message.error("请选择至少一个部门对象");
return;
}
props.onSelected(selectKeys, selectVideos);
}}
>
<div style={{ width: "100%", display: "flex" }}>
<div style={{ width: 528 }} className="select-range-modal">
<SelectDepsSub
defaultkeys={selectKeys}
open={refresh}
onSelected={(arr: any[], videos: any[]) => {
if (arr && videos) {
setSelectKeys(arr);
setSelectVideos(videos);
}
}}
></SelectDepsSub>
</div>
<div className={styles["user-content"]}>
<div className={styles["title"]}>
<div className={styles["tit"]}></div>
<div
className={styles["link"]}
onClick={() => {
setSelectKeys([]);
setSelectVideos([]);
}}
>
</div>
</div>
{selectVideos.length > 0 &&
selectVideos.map((item: any, index: number) => (
<div key={"dep" + index} className={styles["user-item"]}>
<div className={styles["user-name"]}>
{item.title.props.children}
</div>
<CloseOutlined
style={{
fontSize: 10,
color: "rgba(0,0,0,0.45)",
cursor: "pointer",
}}
onClick={() => {
let arr = [...selectKeys];
let arr2 = [...selectVideos];
arr.splice(index, 1);
arr2.splice(index, 1);
setSelectKeys(arr);
setSelectVideos(arr2);
}}
/>
</div>
))}
</div>
</div>
</Modal>
) : null}
</>
);
};

View File

@ -0,0 +1,33 @@
import { useState } from "react";
import { TreeDeps } from "../../tree-deps";
interface PropsInterface {
defaultkeys: any[];
open: boolean;
onSelected: (arr: any[], videos: any[]) => void;
}
export const SelectDepsSub = (props: PropsInterface) => {
const [init, setInit] = useState(true);
return (
<div
style={{
width: 528,
height: 458,
overflowY: "auto",
overflowX: "hidden",
}}
>
<TreeDeps
selected={props.defaultkeys}
refresh={props.open}
showNum={true}
type=""
onUpdate={(keys: any, nodes: any) => {
props.onSelected(keys, nodes);
}}
></TreeDeps>
</div>
);
};

View File

@ -0,0 +1,67 @@
import { useEffect, useState } from "react";
import { Row, Modal, } from "antd";
import styles from "./index.module.less";
import { UploadVideoSub } from "../../compenents";
interface PropsInterface {
defaultKeys: any[];
open: boolean;
onSelected: (arr: any[], videos: any[]) => void;
onCancel: () => void;
}
type selVideosModel = {
name: string;
rid: number;
type: string;
duration: number;
};
export const SelectResource = (props: PropsInterface) => {
const [refresh, setRefresh] = useState(true);
const [selectKeys, setSelectKeys] = useState<number[]>([]);
const [selectVideos, setSelectVideos] = useState<selVideosModel[]>([]);
useEffect(() => {
setRefresh(!refresh);
}, [props.open]);
return (
<>
{props.open ? (
<Modal
title="视频库"
centered
closable={false}
onCancel={() => {
setSelectKeys([]);
setSelectVideos([]);
props.onCancel();
}}
open={true}
width={800}
maskClosable={false}
onOk={() => {
props.onSelected(selectKeys, selectVideos);
setSelectKeys([]);
setSelectVideos([]);
}}
>
<Row>
<div className="float-left mt-24">
<UploadVideoSub
label="视频"
defaultCheckedList={props.defaultKeys}
open={refresh}
onSelected={(arr: any[], videos: any[]) => {
setSelectKeys(arr);
setSelectVideos(videos);
}}
/>
</div>
</Row>
</Modal>
) : null}
</>
);
};

View File

@ -0,0 +1,88 @@
import { Tree } from "antd";
import { useState, useEffect } from "react";
import { adminRole } from "../../api/index";
interface Option {
key: string | number;
title: any;
children: any[];
}
interface PropInterface {
refresh: boolean;
roleDelSuccess: boolean;
type: string;
text: string;
onUpdate: (keys: any, title: any, isSuper: boolean) => void;
}
export const TreeAdminroles = (props: PropInterface) => {
const [treeData, setTreeData] = useState<any>([]);
const [loading, setLoading] = useState<boolean>(true);
const [selectKey, setSelectKey] = useState<any>([]);
const [superId, setSuperId] = useState(0);
useEffect(() => {
onSelect([], "");
}, [props.roleDelSuccess]);
useEffect(() => {
adminRole.adminRoleList().then((res: any) => {
let adminrole = res.data;
let superId = 0;
if (adminrole.length > 0) {
const new_arr: Option[] = [];
for (let i = 0; i < adminrole.length; i++) {
new_arr.push({
title: adminrole[i].name,
key: adminrole[i].id,
children: [],
});
if (adminrole[i].slug === "super-role") {
superId = adminrole[i].id;
}
}
setTreeData(new_arr);
}
setSuperId(superId);
});
}, [props.refresh]);
const onSelect = (selectedKeys: any, info: any) => {
let label = "全部" + props.text;
if (info) {
label = info.node.title;
}
let isSuper = false;
if (selectedKeys[0] === superId && superId !== 0) {
isSuper = true;
}
props.onUpdate(selectedKeys, label, isSuper);
setSelectKey(selectedKeys);
};
return (
<div className="playedu-tree">
<div
className={
selectKey.length === 0
? "mb-8 category-label active"
: "mb-8 category-label"
}
onClick={() => {
onSelect([], "");
}}
>
<div className="j-b-flex">
<span>{props.text}</span>
</div>
</div>
<Tree
onSelect={onSelect}
selectedKeys={selectKey}
treeData={treeData}
switcherIcon={<i className="iconfont icon-icon-fold c-gray" />}
/>
</div>
);
};

View File

@ -0,0 +1,109 @@
import { Tree } from "antd";
import { useState, useEffect } from "react";
import { useSelector } from "react-redux";
interface Option {
key: string | number;
title: any;
children?: Option[];
}
interface PropInterface {
type: string;
text: string;
selected: any;
onUpdate: (keys: any, title: any) => void;
}
export const TreeCategory = (props: PropInterface) => {
const [treeData, setTreeData] = useState<any>([]);
const [selectKey, setSelectKey] = useState<number[]>([]);
const resourceCategories = useSelector(
(state: any) => state.systemConfig.value.resourceCategories
);
useEffect(() => {
if (props.selected && props.selected.length > 0) {
setSelectKey(props.selected);
}
}, [props.selected]);
useEffect(() => {
if (JSON.stringify(resourceCategories) !== "{}") {
const new_arr: Option[] = checkArr(resourceCategories, 0);
if (props.type === "no-cate") {
new_arr.unshift({
key: 0,
title: <span className="tree-title-elli"></span>,
});
}
setTreeData(new_arr);
}
}, [resourceCategories]);
const checkArr = (categories: CategoriesBoxModel, id: number) => {
const arr = [];
for (let i = 0; i < categories[id].length; i++) {
if (!categories[categories[id][i].id]) {
let name = (
<span className="tree-title-elli">{categories[id][i].name}</span>
);
arr.push({
title: name,
key: categories[id][i].id,
});
} else {
let name = (
<span className="tree-title-elli">{categories[id][i].name}</span>
);
const new_arr: Option[] = checkArr(categories, categories[id][i].id);
arr.push({
title: name,
key: categories[id][i].id,
children: new_arr,
});
}
}
return arr;
};
const onSelect = (selectedKeys: any, info: any) => {
let label = "全部" + props.text;
if (info) {
label = info.node.title.props.children;
}
props.onUpdate(selectedKeys, label);
setSelectKey(selectedKeys);
};
const onExpand = (selectedKeys: any, info: any) => {};
return (
<div className="playedu-tree">
<div
className={
selectKey.length === 0
? "mb-8 category-label active"
: "mb-8 category-label"
}
onClick={() => {
onSelect([], "");
}}
>
<div className="j-b-flex">
<span>{props.text}</span>
</div>
</div>
{treeData.length > 0 && (
<Tree
onSelect={onSelect}
selectedKeys={selectKey}
onExpand={onExpand}
treeData={treeData}
// defaultExpandAll={true}
switcherIcon={<i className="iconfont icon-icon-fold c-gray" />}
/>
)}
</div>
);
};

View File

@ -0,0 +1,177 @@
import { Tree } from "antd";
import { useState, useEffect } from "react";
import { department } from "../../api/index";
interface Option {
key: string | number;
title: string;
children?: Option[];
}
interface PropInterface {
text: string;
showNum: boolean;
selected: any;
refresh: boolean;
onUpdate: (keys: any, title: any) => void;
}
export const TreeDepartment = (props: PropInterface) => {
const [treeData, setTreeData] = useState<any>([]);
const [loading, setLoading] = useState<boolean>(true);
const [selectKey, setSelectKey] = useState<number[]>([]);
const [userTotal, setUserTotal] = useState(0);
useEffect(() => {
if (props.selected && props.selected.length > 0) {
setSelectKey(props.selected);
}
}, [props.selected]);
useEffect(() => {
setLoading(true);
department.departmentList({}).then((res: any) => {
const departments: DepartmentsBoxModel = res.data.departments;
const departCount: DepIdsModel = res.data.dep_user_count;
setUserTotal(res.data.user_total);
if (JSON.stringify(departments) !== "{}") {
if (props.showNum) {
const new_arr: any[] = checkNewArr(departments, 0, departCount);
setTreeData(new_arr);
} else {
const new_arr: any[] = checkArr(departments, 0);
setTreeData(new_arr);
}
} else {
const new_arr: Option[] = [
{
key: "",
title: "全部",
children: [],
},
];
setTreeData(new_arr);
}
setLoading(false);
});
}, [props.refresh]);
const checkNewArr = (
departments: DepartmentsBoxModel,
id: number,
counts: any
) => {
const arr = [];
for (let i = 0; i < departments[id].length; i++) {
if (!departments[departments[id][i].id]) {
arr.push({
title: getNewTitle(
departments[id][i].name,
departments[id][i].id,
counts
),
key: departments[id][i].id,
});
} else {
const new_arr: any = checkNewArr(
departments,
departments[id][i].id,
counts
);
arr.push({
title: getNewTitle(
departments[id][i].name,
departments[id][i].id,
counts
),
key: departments[id][i].id,
children: new_arr,
});
}
}
return arr;
};
const checkArr = (departments: DepartmentsBoxModel, id: number) => {
const arr = [];
for (let i = 0; i < departments[id].length; i++) {
if (!departments[departments[id][i].id]) {
arr.push({
title: (
<span className="tree-title-elli">{departments[id][i].name}</span>
),
key: departments[id][i].id,
});
} else {
const new_arr: any[] = checkArr(departments, departments[id][i].id);
arr.push({
title: (
<span className="tree-title-elli">{departments[id][i].name}</span>
),
key: departments[id][i].id,
children: new_arr,
});
}
}
return arr;
};
const getNewTitle = (title: any, id: number, counts: any) => {
if (counts) {
let value = counts[id] || 0;
return (
<span className="tree-title-elli">{title + "(" + value + ")"}</span>
);
} else {
return <span className="tree-title-elli">{title}</span>;
}
};
const onSelect = (selectedKeys: any, info: any) => {
let label = "全部" + props.text;
if (info) {
label = info.node.title.props.children;
}
if (selectedKeys.length <= 1) {
props.onUpdate(selectedKeys, label);
setSelectKey(selectedKeys);
}
};
const onExpand = (selectedKeys: any, info: any) => {
let label = "全部" + props.text;
if (info) {
label = info.node.title.props.children;
}
if (selectedKeys.length <= 1) {
props.onUpdate(selectedKeys, label);
setSelectKey(selectedKeys);
}
};
return (
<div className="playedu-tree">
<div
className={
selectKey.length === 0
? "mb-8 category-label active"
: "mb-8 category-label"
}
onClick={() => onSelect([], "")}
>
{props.text}
{props.showNum && userTotal ? "(" + userTotal + ")" : ""}
</div>
{treeData.length > 0 && (
<Tree
selectedKeys={selectKey}
onSelect={onSelect}
onExpand={onExpand}
treeData={treeData}
// defaultExpandAll={true}
switcherIcon={<i className="iconfont icon-icon-fold c-gray" />}
/>
)}
</div>
);
};

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