引入前端界面

This commit is contained in:
xxx
2024-06-05 16:15:38 +08:00
parent e97f0318e6
commit f5047edd91
367 changed files with 29746 additions and 0 deletions

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

202
playedu-admin/LICENSE Normal file
View File

@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2023 杭州白书科技有限公司
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

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:

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

View File

@@ -0,0 +1,250 @@
import { Spin, Tree, Checkbox } from "antd";
import { useState, useEffect } from "react";
import { department } from "../../api/index";
import type { TreeProps } from "antd/es/tree";
import type { CheckboxChangeEvent } from "antd/es/checkbox";
interface Option {
key: string | number;
title: string;
children?: Option[];
}
interface PropInterface {
type: string;
refresh: boolean;
showNum: boolean;
selected: any;
onUpdate: (keys: any, nodes: any) => void;
}
export const TreeDeps = (props: PropInterface) => {
const [treeData, setTreeData] = useState<any>([]);
const [loading, setLoading] = useState(true);
const [selectKeys, setSelectKeys] = useState<number[]>([]);
const [checkKeys, setCheckKeys] = useState<number[]>([]);
const [userTotal, setUserTotal] = useState(0);
const [showLeafIcon, setShowLeafIcon] = useState<boolean | React.ReactNode>(
true
);
const [expandKeys, setExpandKeys] = useState<number[]>([]);
const [totalKeys, setTotalKeys] = useState<number[]>([]);
useEffect(() => {
setSelectKeys(props.selected);
setCheckKeys(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);
const topLevelParentKeyList = new_arr.map((node: any) => node.key);
setTotalKeys(topLevelParentKeyList);
} else {
const new_arr: any[] = checkArr(departments, 0);
setTreeData(new_arr);
const topLevelParentKeyList = new_arr.map((node: any) => node.key);
setTotalKeys(topLevelParentKeyList);
}
} else {
const new_arr: Option[] = [];
setTreeData(new_arr);
setTotalKeys([]);
}
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: TreeProps["onSelect"] = (selectedKeys: any, info: any) => {
console.log("onCheck", selectedKeys, info);
let nodes = [];
if (info) {
nodes = info.selectedNodes;
}
props.onUpdate(selectedKeys, nodes);
setSelectKeys(selectedKeys);
};
const onExpand = (expandedKeys: any, info: any) => {
console.log("onExpand", expandedKeys, info);
if (checkKeys.includes(info.node.key)) {
// 关闭该节点的展开
return;
}
setExpandKeys(expandedKeys);
};
const onCheck: TreeProps["onCheck"] = (checkedKeys: any, info: any) => {
console.log("onCheck", checkedKeys, info);
let nodes = [];
if (info) {
nodes = info.checkedNodes;
}
props.onUpdate(checkedKeys.checked, nodes);
setCheckKeys(checkedKeys.checked);
if (info.checked && info.node.children) {
setExpandKeys(expandKeys.filter((key) => key !== info.node.key)); // 关闭父节点的展开
}
};
const onChange = (e: CheckboxChangeEvent) => {
console.log(`checked = ${e.target.checked}`);
if (e.target.checked) {
const topLevelParentKeyList = treeData.map((node: any) => node.key);
const topLevelParentNodes = treeData.map((node: any) => node);
// 设置最外层父级节点为勾选状态
if (props.type === "single") {
console.log("全选");
setSelectKeys([]);
setExpandKeys([]);
} else {
setCheckKeys(topLevelParentKeyList);
setExpandKeys([]);
}
props.onUpdate(topLevelParentKeyList, topLevelParentNodes);
} else {
setSelectKeys([]);
setExpandKeys([]);
setCheckKeys([]);
props.onUpdate([], []);
}
};
return (
<>
{loading && (
<div className="text-center mt-30">
<Spin></Spin>
</div>
)}
<div style={{ display: loading ? "none" : "block" }}>
{treeData.length > 0 && (
<>
{props.type === "single" ? (
<div className="playedu-old-tree">
<div style={{ height: 40 }} className="d-flex">
<Checkbox onChange={onChange}></Checkbox>
</div>
<Tree
onSelect={onSelect}
treeData={treeData}
onExpand={(expandedKeys: any, info: any) => {
setExpandKeys(expandedKeys);
}}
selectedKeys={selectKeys}
expandedKeys={expandKeys}
showLine={{ showLeafIcon }}
/>
</div>
) : (
<div className="playedu-old-tree">
<div style={{ height: 40 }} className="d-flex">
<Checkbox
checked={
checkKeys.toString() === totalKeys.toString()
? true
: false
}
onChange={onChange}
>
</Checkbox>
</div>
<Tree
selectable={false}
checkable
checkStrictly={true}
checkedKeys={checkKeys}
multiple={true}
onExpand={onExpand}
expandedKeys={expandKeys}
showLine={{ showLeafIcon }}
onCheck={onCheck}
treeData={treeData}
/>
</div>
)}
</>
)}
</div>
</>
);
};

View File

@@ -0,0 +1,267 @@
import { InboxOutlined } from "@ant-design/icons";
import {
Button,
Col,
message,
Modal,
Progress,
Row,
Table,
Tag,
Upload,
} from "antd";
import Dragger from "antd/es/upload/Dragger";
import { useEffect, useRef, useState } from "react";
import { generateUUID } from "../../utils";
import { minioMergeVideo, minioUploadId } from "../../api/upload";
import { UploadChunk } from "../../js/minio-upload-chunk";
interface PropsInterface {
categoryIds: number[];
onUpdate: () => void;
}
export const UploadCoursewareButton = (props: PropsInterface) => {
const [showModal, setShowModal] = useState(false);
const localFileList = useRef<FileItem[]>([]);
const intervalId = useRef<number>();
const [fileList, setFileList] = useState<FileItem[]>([]);
const getMinioUploadId = async (extension: string) => {
let resp: any = await minioUploadId(extension);
return resp.data;
};
useEffect(() => {
if (showModal) {
intervalId.current = setInterval(() => {
if (localFileList.current.length === 0) {
return;
}
for (let i = 0; i < localFileList.current.length; i++) {
if (localFileList.current[i].upload.status === 0) {
localFileList.current[i].upload.handler.start();
break;
}
if (localFileList.current[i].upload.status === 3) {
break;
}
}
}, 1000);
console.log("定时器已创建", intervalId.current);
} else {
window.clearInterval(intervalId.current);
console.log("定时器已销毁");
}
}, [showModal]);
const uploadProps = {
multiple: true,
beforeUpload: async (file: File) => {
if (file.size === 0) {
message.error(`文件 ${file.name} 为空文件`);
return Upload.LIST_IGNORE;
}
let extension: any = file.name.split(".");
extension = extension[extension.length - 1];
if (
extension === "rar" ||
file.type ===
"application/vnd.openxmlformats-officedocument.wordprocessingml.document" ||
file.type === "application/msword" ||
file.type === "application/vnd.ms-word.document.macroEnabled.12" ||
file.type === "application/vnd.ms-word.template.macroEnabled.12" ||
file.type === "text/plain" ||
file.type === "application/pdf" ||
file.type === "application/x-zip-compressed" ||
file.type === "application/octet-stream" ||
file.type === "application/zip" ||
file.type === "application/x-rar" ||
file.type ===
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" ||
file.type === "application/vnd.ms-excel" ||
file.type ===
"application/vnd.openxmlformats-officedocument.spreadsheetml.template" ||
file.type === "application/vnd.ms-excel.sheet.macroEnabled.12" ||
file.type === "application/vnd.ms-excel.template.macroEnabled.12" ||
file.type === "application/vnd.ms-excel.addin.macroEnabled.12" ||
file.type === "application/vnd.ms-excel.sheet.binary.macroEnabled.12" ||
file.type === "application/vnd.ms-powerpoint" ||
file.type ===
"application/vnd.openxmlformats-officedocument.presentationml.presentation" ||
file.type ===
"application/vnd.openxmlformats-officedocument.presentationml.template" ||
file.type ===
"application/vnd.openxmlformats-officedocument.presentationml.slideshow" ||
file.type === "application/vnd.ms-powerpoint.addin.macroEnabled.12" ||
file.type ===
"application/vnd.ms-powerpoint.presentation.macroEnabled.12" ||
file.type === "application/vnd.ms-powerpoint.slideshow.macroEnabled.12"
) {
// 添加到本地待上传
let data = await getMinioUploadId(extension);
let run = new UploadChunk(file, data["upload_id"], data["filename"]);
let item: FileItem = {
id: generateUUID(),
file: file,
upload: {
handler: run,
progress: 0,
status: 0,
remark: "",
},
};
item.upload.handler.on("success", () => {
minioMergeVideo(
data["filename"],
data["upload_id"],
props.categoryIds.join(","),
item.file.name,
extension,
item.file.size,
0,
""
).then(() => {
item.upload.progress = 100;
item.upload.status = item.upload.handler.getUploadStatus();
setFileList([...localFileList.current]);
});
});
item.upload.handler.on("progress", (p: number) => {
item.upload.status = item.upload.handler.getUploadStatus();
item.upload.progress = p >= 100 ? 99 : p;
setFileList([...localFileList.current]);
});
item.upload.handler.on("error", (msg: string) => {
item.upload.status = item.upload.handler.getUploadStatus();
item.upload.remark = msg;
setFileList([...localFileList.current]);
});
// 先插入到ref
localFileList.current.push(item);
// 再更新list
setFileList([...localFileList.current]);
} else {
message.error(`${file.name} 并不是可上传文件`);
}
return Upload.LIST_IGNORE;
},
};
const closeWin = () => {
if (fileList.length > 0) {
fileList.forEach((item) => {
if (item.upload.status !== 5 && item.upload.status !== 7) {
item.upload.handler.cancel();
}
});
}
props.onUpdate();
localFileList.current = [];
setFileList([]);
setShowModal(false);
};
return (
<>
<Button
type="primary"
onClick={() => {
setShowModal(true);
}}
>
</Button>
{showModal ? (
<Modal
width={800}
title="上传课件"
open={true}
onCancel={() => {
closeWin();
}}
maskClosable={false}
closable={false}
onOk={() => {
closeWin();
}}
okText="完成"
>
<Row gutter={[0, 10]}>
<Col span={24}>
<Dragger {...uploadProps}>
<p className="ant-upload-drag-icon">
<InboxOutlined />
</p>
<p className="ant-upload-text"></p>
<p className="ant-upload-hint">
/
wordexcelpptpdfziprartxt格式文件
</p>
</Dragger>
</Col>
<Col span={24}>
<Table
pagination={false}
rowKey="id"
columns={[
{
title: "课件",
dataIndex: "name",
key: "name",
render: (_, record) => <span>{record.file.name}</span>,
},
{
title: "大小",
dataIndex: "size",
key: "size",
render: (_, record) => (
<span>
{(record.file.size / 1024 / 1024).toFixed(2)}M
</span>
),
},
{
title: "进度",
dataIndex: "progress",
key: "progress",
render: (_, record: FileItem) => (
<>
{record.upload.status === 0 ? (
"等待上传"
) : (
<Progress
size="small"
steps={20}
percent={record.upload.progress}
/>
)}
</>
),
},
{
title: "操作",
key: "action",
render: (_, record) => (
<>
{record.upload.status === 5 ? (
<Tag color="red">{record.upload.remark}</Tag>
) : null}
{record.upload.status === 7 ? (
<Tag color="success"></Tag>
) : null}
</>
),
},
]}
dataSource={fileList}
/>
</Col>
</Row>
</Modal>
) : null}
</>
);
};

View File

@@ -0,0 +1,249 @@
import { useEffect, useState } from "react";
import { Row, Col, Empty, Table, Spin, Typography, Input, Button } from "antd";
import type { ColumnsType } from "antd/es/table";
import { resource } from "../../api";
import styles from "./index.module.less";
import { TreeCategory } from "../../compenents";
interface VideoItem {
id: number;
name: string;
created_at: string;
type: string;
url: string;
path: string;
size: number;
extension: string;
admin_id: number;
}
interface DataType {
id: React.Key;
name: string;
created_at: string;
type: string;
url: string;
path: string;
size: number;
extension: string;
admin_id: number;
}
interface PropsInterface {
defaultCheckedList: any[];
label: string;
open: boolean;
onSelected: (arr: any[], videos: []) => void;
}
export const UploadCoursewareSub = (props: PropsInterface) => {
const [init, setInit] = useState(true);
const [category_ids, setCategoryIds] = useState<any>([]);
const [loading, setLoading] = useState<boolean>(false);
const [videoList, setVideoList] = useState<VideoItem[]>([]);
const [existingTypes, setExistingTypes] = useState<any>([]);
const [refresh, setRefresh] = useState(false);
const [page, setPage] = useState(1);
const [size, setSize] = useState(10);
const [total, setTotal] = useState(0);
const [selectedRowKeys, setSelectedRowKeys] = useState<any>([]);
const [title, setTitle] = useState("");
// 加载列表
useEffect(() => {
setInit(true);
getvideoList();
}, [props.open, category_ids, refresh, page, size]);
useEffect(() => {
if (props.defaultCheckedList.length > 0) {
setSelectedRowKeys(props.defaultCheckedList);
}
}, [props.defaultCheckedList]);
// 获取列表
const getvideoList = () => {
setLoading(true);
let categoryIds = category_ids.join(",");
resource
.resourceList(
page,
size,
"",
"",
title,
"WORD,EXCEL,PPT,PDF,TXT,RAR,ZIP",
categoryIds
)
.then((res: any) => {
setTotal(res.data.result.total);
setExistingTypes(res.data.existing_types);
setVideoList(res.data.result.data);
setLoading(false);
setInit(false);
})
.catch((err) => {
setLoading(false);
setInit(false);
console.log("错误,", err);
});
};
// 重置列表
const resetVideoList = () => {
setPage(1);
setVideoList([]);
setTitle("");
setRefresh(!refresh);
};
const paginationProps = {
current: page, //当前页码
pageSize: size,
total: total, // 总条数
onChange: (page: number, pageSize: number) =>
handlePageChange(page, pageSize), //改变页码的函数
showSizeChanger: true,
};
const handlePageChange = (page: number, pageSize: number) => {
setPage(page);
setSize(pageSize);
};
const columns: ColumnsType<DataType> = [
{
title: "课件",
render: (_, record: any) => (
<div className="d-flex">
<i
className="iconfont icon-icon-file"
style={{
fontSize: 14,
color: "rgba(0,0,0,0.3)",
}}
/>
<div className="video-title ml-8">{record.name}</div>
</div>
),
},
{
title: "类型",
render: (_, record: any) => <span>{record.type}</span>,
},
];
const rowSelection = {
selectedRowKeys: selectedRowKeys,
onChange: (selectedRowKeys: React.Key[], selectedRows: DataType[]) => {
let row: any = selectedRows;
let arrVideos: any = [];
if (row) {
for (var i = 0; i < row.length; i++) {
if (props.defaultCheckedList.indexOf(row[i].id) === -1) {
arrVideos.push({
name: row[i].name,
type: row[i].type,
rid: row[i].id,
});
}
}
props.onSelected(selectedRowKeys, arrVideos);
}
setSelectedRowKeys(selectedRowKeys);
},
getCheckboxProps: (record: any) => ({
disabled: props.defaultCheckedList.indexOf(record.id) !== -1, //禁用的条件
}),
};
return (
<>
<Row style={{ width: 752, minHeight: 520 }}>
<Col span={7}>
{init && (
<div className="float-left text-center mt-30">
<Spin></Spin>
</div>
)}
<div
className="float-left"
style={{ display: init ? "none" : "block" }}
>
<TreeCategory
selected={[]}
type="no-cate"
text={props.label}
onUpdate={(keys: any) => setCategoryIds(keys)}
/>
</div>
</Col>
<Col span={17}>
<Row style={{ marginBottom: 24, paddingLeft: 10 }}>
<div className="float-left j-b-flex">
<div className="d-flex"></div>
<div className="d-flex">
<div className="d-flex mr-24">
<Typography.Text></Typography.Text>
<Input
value={title}
onChange={(e) => {
setTitle(e.target.value);
}}
allowClear
style={{ width: 160 }}
placeholder="请输入名称关键字"
/>
</div>
<div className="d-flex">
<Button className="mr-16" onClick={resetVideoList}>
</Button>
<Button
type="primary"
onClick={() => {
setPage(1);
setRefresh(!refresh);
}}
>
</Button>
</div>
</div>
</div>
</Row>
{init && (
<div className="float-left text-center mt-30">
<Spin></Spin>
</div>
)}
<div
className={styles["video-list"]}
style={{ display: init ? "none" : "block" }}
>
{videoList.length === 0 && (
<Col span={24} style={{ marginTop: 150 }}>
<Empty description="暂无课件" />
</Col>
)}
{videoList.length > 0 && (
<div className="list-select-column-box c-flex">
<Table
rowSelection={{
type: "checkbox",
...rowSelection,
}}
columns={columns}
dataSource={videoList}
loading={loading}
pagination={paginationProps}
rowKey={(record) => record.id}
/>
</div>
)}
</div>
</Col>
</Row>
</>
);
};

View File

@@ -0,0 +1,37 @@
.categoryItem {
width: 100%;
height: 50px;
line-height: 50px;
float: left;
cursor: pointer;
display: flex;
&.active {
background-color: red;
color: white;
}
}
.categoryTitle {
width: 100%;
height: 30px;
line-height: 30px;
display: flex;
}
.checked {
width: 16px;
height: 16px;
background: #ff4d4f;
border-radius: 3px;
border: 2px solid #ff4d4f;
position: absolute;
left: 5px;
top: 5px;
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
color: #ffffff;
cursor: pointer;
}

View File

@@ -0,0 +1,184 @@
import { useEffect, useState } from "react";
import {
Button,
Row,
Col,
Modal,
Image,
Empty,
message,
Pagination,
} from "antd";
import { resource, resourceCategory } from "../../api";
import styles from "./index.module.less";
import { CreateResourceCategory } from "../create-rs-category";
import { CloseOutlined, CheckOutlined } from "@ant-design/icons";
import { UploadImageSub } from "./upload-image-sub";
import { TreeCategory } from "../../compenents";
interface Option {
id: string | number;
name: string;
children?: Option[];
}
interface ImageItem {
id: number;
category_id: number;
name: string;
extension: string;
size: number;
disk: string;
file_id: string;
path: string;
url: string;
created_at: string;
}
interface PropsInterface {
text: any;
onSelected: (url: string) => void;
}
export const UploadImageButton = (props: PropsInterface) => {
const [showModal, setShowModal] = useState(false);
const [category_ids, setCategoryIds] = useState<any>([]);
const [imageList, setImageList] = useState<ImageItem[]>([]);
const [refresh, setRefresh] = useState(false);
const [page, setPage] = useState(1);
const [size, setSize] = useState(15);
const [total, setTotal] = useState(0);
const [selected, setSelected] = useState<string>("");
// 获取图片列表
const getImageList = () => {
let categoryIds = category_ids.join(",");
resource
.resourceList(page, size, "", "", "", "IMAGE", categoryIds)
.then((res: any) => {
setTotal(res.data.result.total);
setImageList(res.data.result.data);
})
.catch((err) => {
console.log("错误,", err);
});
};
// 重置列表
const resetImageList = () => {
setPage(1);
setImageList([]);
setRefresh(!refresh);
};
// 加载图片列表
useEffect(() => {
if (showModal) {
getImageList();
}
}, [category_ids, refresh, page, size, showModal]);
return (
<>
<Button
onClick={() => {
setShowModal(true);
}}
>
{props.text ? props.text : "上传图片"}
</Button>
{showModal && (
<Modal
title="图片素材库"
closable={false}
onCancel={() => {
setShowModal(false);
}}
open={true}
width={820}
maskClosable={false}
onOk={() => {
if (!selected) {
message.error("请选择图片后确定");
return;
}
props.onSelected(selected);
setShowModal(false);
}}
>
<Row style={{ width: 752, minHeight: 520, marginTop: 24 }}>
<Col span={7}>
<TreeCategory
selected={category_ids}
type="no-cate"
text={"图片"}
onUpdate={(keys: any) => {
setSelected("");
setCategoryIds(keys);
}}
/>
</Col>
<Col span={17}>
<Row style={{ marginBottom: 24, paddingLeft: 10 }}>
<Col span={24}>
<UploadImageSub
categoryIds={category_ids}
onUpdate={() => {
resetImageList();
}}
></UploadImageSub>
</Col>
</Row>
{imageList.length === 0 && (
<Col span={24}>
<Empty description="暂无图片" />
</Col>
)}
<div className="image-list-box">
{imageList.map((item) => (
<div
key={item.id}
className="image-item"
style={{ backgroundImage: `url(${item.url})` }}
onClick={() => {
setSelected(item.url);
}}
>
{selected.indexOf(item.url) !== -1 && (
<i
className={styles.checked}
onClick={(e) => {
e.stopPropagation();
setSelected("");
}}
>
<CheckOutlined />
</i>
)}
</div>
))}
</div>
{imageList.length > 0 && (
<Col
span={24}
style={{ display: "flex", flexDirection: "row-reverse" }}
>
<Pagination
showSizeChanger
onChange={(currentPage, currentSize) => {
setPage(currentPage);
setSize(currentSize);
}}
defaultCurrent={page}
total={total}
/>
</Col>
)}
</Col>
</Row>
</Modal>
)}
</>
);
};

View File

@@ -0,0 +1,81 @@
import { Button, message, Modal } from "antd";
import Dragger from "antd/es/upload/Dragger";
import { useState } from "react";
import config from "../../../js/config";
import { getToken, checkUrl } from "../../../utils";
import { InboxOutlined } from "@ant-design/icons";
interface PropsInterface {
categoryIds: number[];
onUpdate: () => void;
}
export const UploadImageSub = (props: PropsInterface) => {
const [showModal, setShowModal] = useState(false);
const uploadProps = {
name: "file",
multiple: true,
action:
checkUrl(config.app_url) +
"backend/v1/upload/minio?category_ids=" +
props.categoryIds.join(","),
headers: {
authorization: "Bearer " + getToken(),
},
onChange(info: any) {
const { status, response } = info.file;
if (status === "done") {
if (response.code === 0) {
message.success(`${info.file.name} 上传成功`);
} else {
message.error(response.msg);
}
} else if (status === "error") {
message.error(`${info.file.name} 上传失败`);
}
},
showUploadList: {
showRemoveIcon: false,
showDownloadIcon: false,
},
};
return (
<>
<Button
type="primary"
onClick={() => {
setShowModal(true);
}}
>
</Button>
{showModal && (
<Modal
open={true}
closable={false}
onCancel={() => {
setShowModal(false);
}}
onOk={() => {
props.onUpdate();
setShowModal(false);
}}
maskClosable={false}
>
<Dragger {...uploadProps}>
<p className="ant-upload-drag-icon">
<InboxOutlined />
</p>
<p className="ant-upload-text"></p>
<p className="ant-upload-hint">
/ png,jpg,jpeg,gif
</p>
</Dragger>
</Modal>
)}
</>
);
};

View File

@@ -0,0 +1,41 @@
import { useEffect, useRef, useState } from "react";
import { useSelector } from "react-redux";
import { Button } from "antd";
import { useDispatch } from "react-redux";
import { uploadAction } from "../../store/user/loginUserSlice";
interface PropsInterface {
categoryIds: number[];
onUpdate: () => void;
}
export const UploadVideoButton = (props: PropsInterface) => {
const dispatch = useDispatch();
const uploadStatus = useSelector(
(state: any) => state.loginUser.value.uploadStatus
);
useEffect(() => {
if (!uploadStatus) {
props.onUpdate();
}
}, [uploadStatus]);
return (
<>
<Button
type="primary"
onClick={() => {
dispatch(
uploadAction({
uploadStatus: true,
uploadCateIds: props.categoryIds,
})
);
}}
>
</Button>
</>
);
};

View File

@@ -0,0 +1,26 @@
.float-button {
width: auto;
height: 32px;
background: #ffffff;
box-shadow: 0px 0px 16px 0px rgba(0, 0, 0, 0.1);
border-radius: 16px;
box-sizing: border-box;
padding: 6px 8px;
display: flex;
align-items: center;
position: fixed;
right: 24px;
bottom: 20px;
z-index: 50;
cursor: pointer;
img {
width: 20px;
height: 20px;
}
span {
margin-left: 6px;
font-size: 12px;
font-weight: 400;
color: #ff4d4f;
}
}

View File

@@ -0,0 +1,289 @@
import { InboxOutlined } from "@ant-design/icons";
import {
Button,
Col,
message,
Modal,
Progress,
Row,
Table,
Tag,
Upload,
} from "antd";
import Dragger from "antd/es/upload/Dragger";
import { useEffect, useRef, useState } from "react";
import { generateUUID, parseVideo } from "../../utils";
import styles from "./index.module.less";
import { minioMergeVideo, minioUploadId } from "../../api/upload";
import { UploadChunk } from "../../js/minio-upload-chunk";
import { useDispatch } from "react-redux";
import { useSelector } from "react-redux";
import { uploadAction } from "../../store/user/loginUserSlice";
import upIcon from "../../assets/images/commen/upload.png";
export const UploadVideoFloatButton = () => {
const dispatch = useDispatch();
const [showModal, setShowModal] = useState(false);
const localFileList = useRef<FileItem[]>([]);
const intervalId = useRef<number>();
const intervalId2 = useRef<number>();
const [successNum, setSuccessNum] = useState(0);
const [fileList, setFileList] = useState<FileItem[]>([]);
const uploadStatus = useSelector(
(state: any) => state.loginUser.value.uploadStatus
);
const categoryIds = useSelector(
(state: any) => state.loginUser.value.uploadCateIds
);
const getMinioUploadId = async () => {
let resp: any = await minioUploadId("mp4");
return resp.data;
};
useEffect(() => {
if (uploadStatus) {
setShowModal(true);
intervalId.current = setInterval(() => {
let num = localFileList.current.filter(
(it) => it.upload.status === 7
).length;
setSuccessNum(num);
}, 5000);
let timeDiv = document.createElement("div");
document.body.appendChild(timeDiv);
intervalId2.current = setInterval(() => {
timeDiv && timeDiv.click();
}, 10000);
} else {
window.clearInterval(intervalId.current);
window.clearInterval(intervalId2.current);
console.log("定时器已销毁");
}
}, [uploadStatus]);
const uploadProps = {
multiple: true,
beforeUpload: async (file: File, fileList: any) => {
if (file.size > 2 * 1024 * 1024 * 1024) {
message.error(`${file.name} 大小超过2G`);
return Upload.LIST_IGNORE;
}
if (fileList.length > 10) {
message.config({ maxCount: 1 });
message.error("单次最多上传10个视频");
return Upload.LIST_IGNORE;
} else {
message.config({ maxCount: 10 });
}
if (file.type === "video/mp4") {
// 视频封面解析 || 视频时长解析
let videoInfo = await parseVideo(file);
// 添加到本地待上传
let data = await getMinioUploadId();
let run = new UploadChunk(file, data["upload_id"], data["filename"]);
let item: FileItem = {
id: generateUUID(),
file: file,
upload: {
handler: run,
progress: 0,
status: 0,
remark: "",
},
video: {
duration: videoInfo.duration,
poster: videoInfo.poster,
},
};
item.upload.handler.on("success", () => {
minioMergeVideo(
data["filename"],
data["upload_id"],
categoryIds.join(","),
item.file.name,
"mp4",
item.file.size,
item.video?.duration || 0,
item.video?.poster || ""
).then(() => {
item.upload.progress = 100;
item.upload.status = item.upload.handler.getUploadStatus();
setFileList([...localFileList.current]);
});
});
item.upload.handler.on("progress", (p: number) => {
item.upload.status = item.upload.handler.getUploadStatus();
item.upload.progress = p >= 100 ? 99 : p;
setFileList([...localFileList.current]);
});
item.upload.handler.on("error", (msg: string) => {
item.upload.status = item.upload.handler.getUploadStatus();
item.upload.remark = msg;
setFileList([...localFileList.current]);
});
item.upload.handler.start();
// 先插入到ref
localFileList.current.push(item);
// 再更新list
setFileList([...localFileList.current]);
} else {
message.error(`${file.name} 并不是 mp4 视频文件`);
}
return Upload.LIST_IGNORE;
},
};
const closeWin = () => {
if (fileList.length > 0) {
fileList.forEach((item) => {
if (item.upload.status !== 5 && item.upload.status !== 7) {
item.upload.handler.cancel();
}
});
}
localFileList.current = [];
setFileList([]);
setSuccessNum(0);
setShowModal(false);
dispatch(
uploadAction({
uploadStatus: false,
uploadCateIds: [],
})
);
};
return (
<>
{uploadStatus ? (
<>
<div
style={{ display: showModal ? "none" : "flex" }}
className={styles["float-button"]}
onClick={() => setShowModal(true)}
>
<img src={upIcon} />
<span>
({successNum}/{fileList.length})
</span>
</div>
<Modal
width={800}
title="上传视频"
open={showModal}
maskClosable={false}
footer={null}
onCancel={() => {
closeWin();
}}
>
<Row gutter={[0, 10]}>
<Col span={24}>
<Dragger {...uploadProps}>
<p className="ant-upload-drag-icon">
<InboxOutlined />
</p>
<p className="ant-upload-text"></p>
<p className="ant-upload-hint">
/ 2G以内的mp4文件
</p>
</Dragger>
</Col>
<Col span={24}>
<Table
pagination={false}
rowKey="id"
columns={[
{
title: "视频",
dataIndex: "name",
key: "name",
render: (_, record) => <span>{record.file.name}</span>,
},
{
title: "大小",
dataIndex: "size",
key: "size",
render: (_, record) => (
<span>
{(record.file.size / 1024 / 1024).toFixed(2)}M
</span>
),
},
{
title: "进度",
dataIndex: "progress",
key: "progress",
render: (_, record: FileItem) => (
<>
{record.upload.status === 0 ? (
"等待上传"
) : (
<Progress
size="small"
steps={20}
percent={record.upload.progress}
/>
)}
</>
),
},
{
title: "操作",
key: "action",
render: (_, record) => (
<>
{record.upload.status === 5 ? (
<>
<Button
type="link"
size="small"
className="b-n-link c-red"
onClick={() => {
record.upload.handler.retry();
}}
>
.
</Button>
</>
) : null}
{record.upload.status === 7 ? (
<Tag color="success"></Tag>
) : null}
</>
),
},
]}
dataSource={fileList}
/>
</Col>
<Col span={24}>
<div className="r-r-flex">
<Button
type="primary"
onClick={() => {
closeWin();
}}
>
</Button>
<Button
type="default"
className="mr-16"
onClick={() => {
setShowModal(false);
}}
>
</Button>
</div>
</Col>
</Row>
</Modal>
</>
) : null}
</>
);
};

View File

@@ -0,0 +1,240 @@
import { useEffect, useState } from "react";
import { Row, Col, Empty, Table, Spin, Typography, Input, Button } from "antd";
import type { ColumnsType } from "antd/es/table";
import { resource } from "../../api";
import styles from "./index.module.less";
import { DurationText, TreeCategory } from "../../compenents";
interface VideoItem {
id: number;
name: string;
created_at: string;
type: string;
url: string;
path: string;
size: number;
extension: string;
admin_id: number;
}
interface DataType {
id: React.Key;
name: string;
created_at: string;
type: string;
url: string;
path: string;
size: number;
extension: string;
admin_id: number;
}
interface PropsInterface {
defaultCheckedList: any[];
label: string;
open: boolean;
onSelected: (arr: any[], videos: []) => void;
}
export const UploadVideoSub = (props: PropsInterface) => {
const [init, setInit] = useState(true);
const [category_ids, setCategoryIds] = useState<any>([]);
const [loading, setLoading] = useState<boolean>(false);
const [videoList, setVideoList] = useState<VideoItem[]>([]);
const [videosExtra, setVideoExtra] = useState<any>([]);
const [refresh, setRefresh] = useState(false);
const [page, setPage] = useState(1);
const [size, setSize] = useState(10);
const [total, setTotal] = useState(0);
const [selectedRowKeys, setSelectedRowKeys] = useState<any>([]);
const [title, setTitle] = useState("");
// 加载列表
useEffect(() => {
setInit(true);
getvideoList();
}, [props.open, category_ids, refresh, page, size]);
useEffect(() => {
if (props.defaultCheckedList.length > 0) {
setSelectedRowKeys(props.defaultCheckedList);
}
}, [props.defaultCheckedList]);
// 获取列表
const getvideoList = () => {
setLoading(true);
let categoryIds = category_ids.join(",");
resource
.resourceList(page, size, "", "", title, "VIDEO", categoryIds)
.then((res: any) => {
setTotal(res.data.result.total);
setVideoExtra(res.data.videos_extra);
setVideoList(res.data.result.data);
setLoading(false);
setInit(false);
})
.catch((err) => {
setLoading(false);
setInit(false);
console.log("错误,", err);
});
};
// 重置列表
const resetVideoList = () => {
setPage(1);
setVideoList([]);
setTitle("");
setRefresh(!refresh);
};
const paginationProps = {
current: page, //当前页码
pageSize: size,
total: total, // 总条数
onChange: (page: number, pageSize: number) =>
handlePageChange(page, pageSize), //改变页码的函数
showSizeChanger: true,
};
const handlePageChange = (page: number, pageSize: number) => {
setPage(page);
setSize(pageSize);
};
const columns: ColumnsType<DataType> = [
{
title: "视频",
render: (_, record: any) => (
<div className="d-flex">
<i
className="iconfont icon-icon-video"
style={{
fontSize: 14,
color: "rgba(0,0,0,0.3)",
}}
/>
<div className="video-title ml-8">{record.name}</div>
</div>
),
},
{
title: "时长",
render: (_, record: any) => (
<div>
<DurationText
duration={videosExtra[record.id].duration}
></DurationText>
</div>
),
},
];
const rowSelection = {
selectedRowKeys: selectedRowKeys,
onChange: (selectedRowKeys: React.Key[], selectedRows: DataType[]) => {
let row: any = selectedRows;
let arrVideos: any = [];
if (row) {
for (var i = 0; i < row.length; i++) {
if (props.defaultCheckedList.indexOf(row[i].id) === -1) {
arrVideos.push({
name: row[i].name,
type: row[i].type,
rid: row[i].id,
duration: videosExtra[row[i].id].duration,
});
}
}
props.onSelected(selectedRowKeys, arrVideos);
}
setSelectedRowKeys(selectedRowKeys);
},
getCheckboxProps: (record: any) => ({
disabled: props.defaultCheckedList.indexOf(record.id) !== -1, //禁用的条件
}),
};
return (
<>
<Row style={{ width: 752, minHeight: 520 }}>
<Col span={7}>
<div className="float-left">
<TreeCategory
selected={[]}
type="no-cate"
text={props.label}
onUpdate={(keys: any) => setCategoryIds(keys)}
/>
</div>
</Col>
<Col span={17}>
<Row style={{ marginBottom: 24, paddingLeft: 10 }}>
<div className="float-left j-b-flex">
<div className="d-flex"></div>
<div className="d-flex">
<div className="d-flex mr-24">
<Typography.Text></Typography.Text>
<Input
value={title}
onChange={(e) => {
setTitle(e.target.value);
}}
allowClear
style={{ width: 160 }}
placeholder="请输入名称关键字"
/>
</div>
<div className="d-flex">
<Button className="mr-16" onClick={resetVideoList}>
</Button>
<Button
type="primary"
onClick={() => {
setPage(1);
setRefresh(!refresh);
}}
>
</Button>
</div>
</div>
</div>
</Row>
{init && (
<div className="float-left text-center mt-30">
<Spin></Spin>
</div>
)}
<div
className={styles["video-list"]}
style={{ display: init ? "none" : "block" }}
>
{videoList.length === 0 && (
<Col span={24} style={{ marginTop: 150 }}>
<Empty description="暂无视频" />
</Col>
)}
{videoList.length > 0 && (
<div className="list-select-column-box c-flex">
<Table
rowSelection={{
type: "checkbox",
...rowSelection,
}}
columns={columns}
dataSource={videoList}
loading={loading}
pagination={paginationProps}
rowKey={(record) => record.id}
/>
</div>
)}
</div>
</Col>
</Row>
</>
);
};

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