Compare commits

...

196 Commits

Author SHA1 Message Date
mozzie
575a0c4fa2 feat: 响应式 2023-03-28 14:59:56 +08:00
mozzie
3754811e60 feat: 响应式 2023-03-28 13:52:48 +08:00
mozzie
d87cd736d8 feat: 响应式 2023-03-28 13:52:34 +08:00
mozzie
17c04dc616 fix: sms bug 2023-03-25 20:41:00 +08:00
mozzie
7ea972bd06 feat: 老用户 2023-03-24 16:03:10 +08:00
mozzie
dd1387a528 feat: guide 引入github markdown樣式 2023-03-24 15:12:06 +08:00
mozzie
58d578fc16 feat: ui抖动 2023-03-24 11:24:05 +08:00
mozzie
a3c17101d6 feat: sub页 bg 2023-03-24 10:51:37 +08:00
mozzie
a153137604 refactor: docker ci 2023-03-24 09:42:27 +08:00
mozzie
03804b8364 Merge branch 'feat/backset.cn-dev-2.1' of ssh://git.mozzie.cn:10022/mozzie/pnpm-backset.cn into feat/backset.cn-dev-2.1 2023-03-24 09:34:53 +08:00
mozzie
f750d2c16d refactor: docker ci 2023-03-24 09:34:44 +08:00
mozzie
fe14dda10c fix: timeline bug 2023-03-24 08:04:11 +08:00
mozzie
5b398e9c7e refactor: course detail 2023-03-23 18:00:49 +08:00
mozzie
630c8ca1b5 Merge branch 'feat/backset.cn-dev-2.1' into release/1.0 2023-03-23 15:45:46 +08:00
mozzie
912f667e6d feat: 课程界面: featoc ui 2023-03-23 15:45:32 +08:00
mozzie
e1c4d01a31 Merge branch 'feat/backset.cn-dev-2.1' into release/1.0 2023-03-23 14:00:22 +08:00
mozzie
cca91ce48e cd: 修改drone build配置 2023-03-23 14:00:03 +08:00
mozzie
1d1574edcc cd: 修改drone build配置 2023-03-23 13:58:55 +08:00
mozzie
98ace645f5 cd: 修改drone build配置 2023-03-23 13:56:08 +08:00
mozzie
c2b54c15e6 cd: 修改drone build配置 2023-03-23 13:54:35 +08:00
mozzie
6b71962f6d cd: 修改drone build配置 2023-03-23 13:51:58 +08:00
mozzie
50dfe31d32 feat: 用户管理&订阅国企 2023-03-23 12:17:05 +08:00
mozzie
aab88ebc0f feat: admin course list 2023-03-22 17:05:08 +08:00
mozzie
9ffd6abac7 feat: ui 2023-03-22 10:15:49 +08:00
mozzie
f48a4498cb Merge branch 'feat/backset.cn-dev-2.1' of ssh://git.mozzie.cn:10022/mozzie/pnpm-backset.cn into feat/backset.cn-dev-2.1 2023-03-22 10:09:28 +08:00
mozzie
c54e03144e feat: ui 2023-03-22 10:09:21 +08:00
mozzie
728c7d6ee5 feat: docker compsose mysql chinese gibberish 2023-03-21 23:02:27 +08:00
mozzie
48b5c7df4b feat: course entity修改 2023-03-21 22:39:47 +08:00
mozzie
d3e58cd82f feat: course entity修改 2023-03-21 22:35:04 +08:00
mozzie
25a07b9f0e feat: token续签 2023-03-21 21:47:03 +08:00
mozzie
6919433b39 reafctor: 拆包 2023-03-21 15:05:14 +08:00
mozzie
c3aba1d4ee reafctor: 拆包 2023-03-21 15:01:44 +08:00
mozzie
1da8634408 reafctor: pnpm-lock 2023-03-21 14:46:20 +08:00
mozzie
6684477f47 reafctor: crypto.js 2023-03-21 14:44:36 +08:00
mozzie
ed52174b25 reafctor: sms接口加密 2023-03-21 14:40:59 +08:00
mozzie
46c4be225a reafctor: 分包 2023-03-21 13:50:20 +08:00
mozzie
f6dfae4585 reafctor: vite.config.js 2023-03-21 11:22:25 +08:00
mozzie
1f72cd8da3 feat: drone yml 2023-03-21 11:13:08 +08:00
mozzie
b7f29c2c20 feat: drone yml 2023-03-21 11:10:56 +08:00
mozzie
129267dd52 feat: login page 2023-03-21 11:08:16 +08:00
mozzie
c8288dbe61 feat: pnpm-lock 2023-03-21 10:40:43 +08:00
mozzie
660a65aa86 Merge branch 'feat/backset.cn-dev-2.1' of ssh://git.mozzie.cn:10022/mozzie/pnpm-backset.cn into feat/backset.cn-dev-2.1 2023-03-21 10:39:41 +08:00
mozzie
6933154692 feat: web highlight.js remove 2023-03-21 10:39:35 +08:00
mozzie
165e64b100 feat: docker compose 2023-03-20 21:42:43 +08:00
mozzie
9ba73f3285 feat: docker compose 2023-03-20 21:35:57 +08:00
mozzie
30d3f1fc1b feat: drone.ci 2023-03-20 17:56:27 +08:00
mozzie
5b2c557752 feat: drone.ci 2023-03-20 17:41:08 +08:00
mozzie
07c81289bc feat: drone.ci 2023-03-20 17:34:48 +08:00
mozzie
8acbdc69c5 feat: drone.ci 2023-03-20 17:31:08 +08:00
mozzie
f20f626555 feat: drone.ci 2023-03-20 17:30:17 +08:00
mozzie
43ca77cae8 feat: drone.ci 2023-03-20 17:23:47 +08:00
mozzie
8cfd1d6e30 feat: drone.ci 2023-03-20 17:23:13 +08:00
mozzie
cecaa95bd0 feat: drone.ci 2023-03-20 17:21:49 +08:00
mozzie
92e5097539 feat: drone.ci 2023-03-20 17:17:07 +08:00
mozzie
ec02561f6a feat: drone.ci 2023-03-20 17:09:36 +08:00
mozzie
109c80e4a7 feat: drone.ci 2023-03-20 17:08:15 +08:00
mozzie
7d0770135f feat: drone.ci 2023-03-20 17:07:00 +08:00
mozzie
875fb8aa6a feat: drone.ci 2023-03-20 16:59:42 +08:00
mozzie
7402a35f48 feat: drone.ci 2023-03-20 16:57:03 +08:00
mozzie
1fa0123021 feat: drone.ci 2023-03-20 16:29:31 +08:00
mozzie
9bec6220ab feat: drone.ci 2023-03-20 15:58:32 +08:00
mozzie
d2749d8f60 feat: drone.ci 2023-03-20 15:51:41 +08:00
mozzie
717d2d3247 feat: drone.ci 2023-03-20 15:49:42 +08:00
mozzie
c944b18786 feat: drone.ci 2023-03-20 15:46:26 +08:00
mozzie
311bc7bdf6 feat: drone.ci 2023-03-20 15:42:25 +08:00
mozzie
2e6e213768 feat: drone.ci 2023-03-20 15:39:02 +08:00
mozzie
195901fb5f feat: drone.ci 2023-03-20 15:35:58 +08:00
mozzie
2bf5c4bb39 feat: drone.ci 2023-03-20 15:35:01 +08:00
mozzie
a8ae53c509 feat: drone.ci 2023-03-20 15:34:31 +08:00
mozzie
e18c55a1a2 feat: drone.ci 2023-03-20 15:30:17 +08:00
mozzie
f8c523b921 feat: drone.ci 2023-03-20 15:13:52 +08:00
mozzie
1415d5e3b5 feat: drone.ci 2023-03-20 15:12:30 +08:00
mozzie
4841c20053 feat: drone.ci 2023-03-20 15:05:47 +08:00
mozzie
215d40ac9d feat: drone.ci 2023-03-20 14:59:52 +08:00
mozzie
4da8db7190 feat: drone.ci 2023-03-20 14:58:13 +08:00
mozzie
892039e622 feat: drone.ci 2023-03-20 14:56:17 +08:00
mozzie
4a128fa79e feat: drone.ci 2023-03-20 14:53:08 +08:00
mozzie
c1c585c209 feat: drone.ci 2023-03-20 14:51:45 +08:00
mozzie
0327666a44 feat: drone.ci 2023-03-20 14:50:15 +08:00
mozzie
abff347a97 feat: drone.ci 2023-03-20 14:48:41 +08:00
mozzie
b97454c001 feat: drone.ci 2023-03-20 14:46:37 +08:00
mozzie
7d7294a56e feat: drone.ci 2023-03-20 14:42:11 +08:00
mozzie
e182051dbe feat: drone.ci 2023-03-20 14:36:50 +08:00
mozzie
57d4963b59 feat: drone.ci 2023-03-20 14:31:02 +08:00
mozzie
7893c2cd01 feat: drone.ci 2023-03-20 14:25:04 +08:00
mozzie
7d26835299 feat: drone.ci 2023-03-20 14:24:43 +08:00
mozzie
70b60e35f8 feat: drone.ci 2023-03-20 14:17:32 +08:00
mozzie
0566389f87 feat: drone.ci 2023-03-20 14:13:12 +08:00
mozzie
cb37303e82 feat: drone.ci 2023-03-20 14:02:13 +08:00
mozzie
adff9e179c feat: drone.ci 2023-03-20 13:59:28 +08:00
mozzie
8e7d237651 feat: drone.ci 2023-03-20 13:57:42 +08:00
mozzie
a4e389e5d5 feat: drone.ci 2023-03-20 13:47:24 +08:00
mozzie
2342a88f79 feat: drone.ci 2023-03-20 13:46:41 +08:00
mozzie
aede19c86c feat: drone.ci 2023-03-20 13:42:52 +08:00
mozzie
9561a3e90f feat: drone.ci 2023-03-20 13:38:26 +08:00
mozzie
a7d32e332c feat: drone.ci 2023-03-20 13:37:42 +08:00
mozzie
c30e86ae78 feat: drone.ci 2023-03-20 13:34:49 +08:00
mozzie
3d64b6857a feat: drone.ci 2023-03-20 13:31:51 +08:00
mozzie
f04c3a62d0 feat: drone.ci 2023-03-20 13:16:18 +08:00
mozzie
6c292febb3 feat: drone.ci 2023-03-20 13:13:18 +08:00
mozzie
665dd6644b feat: drone.ci 2023-03-20 13:10:17 +08:00
mozzie
02aac0840d feat: drone.ci 2023-03-20 13:07:58 +08:00
mozzie
077380d4d6 feat: drone.ci 2023-03-20 13:05:51 +08:00
mozzie
bf30b19151 feat: drone.ci 2023-03-20 11:29:37 +08:00
mozzie
a38ba51948 feat: drone.ci 2023-03-20 11:24:47 +08:00
mozzie
5a62e30259 feat: drone.ci 2023-03-20 11:00:59 +08:00
mozzie
a838e61a65 feat: drone.ci 2023-03-20 10:59:12 +08:00
mozzie
d1eb76d957 feat: drone.ci 2023-03-20 10:47:53 +08:00
mozzie
b22a3b88b9 feat: drone.ci 2023-03-20 10:43:40 +08:00
mozzie
e91332e8f1 feat: drone.ci 2023-03-20 10:36:24 +08:00
mozzie
6cf7cfe412 feat: drone.ci 2023-03-20 10:33:08 +08:00
mozzie
1a95f07df8 feat: drone.ci 2023-03-20 10:29:53 +08:00
mozzie
3f496b5295 feat: drone.ci 2023-03-20 10:27:47 +08:00
mozzie
a3ad45fed8 feat: drone.ci 2023-03-20 10:21:21 +08:00
mozzie
103ca5fd53 feat: drone.ci 2023-03-20 10:12:37 +08:00
mozzie
6114d35d47 feat: drone.ci 2023-03-20 10:10:17 +08:00
mozzie
b806b0cc10 feat: drone.ci 2023-03-20 09:52:19 +08:00
mozzie
100dbe4ca7 feat: drone.ci 2023-03-20 09:49:00 +08:00
mozzie
dd29122199 feat: docker compose 2023-03-19 23:36:08 +08:00
mozzie
faa7ed18db feat: admin bug 2023-03-19 01:45:34 +08:00
mozzie
9115293095 feat: build admin 2023-03-19 01:43:03 +08:00
mozzie
a807f2c557 feat: message loadin 2023-03-18 22:03:17 +08:00
mozzie
3514c1e395 feat: alot 2023-03-17 17:58:37 +08:00
mozzie
ca8111dab0 feat: 登录验证码 2023-03-16 16:50:22 +08:00
mozzie
a298594cf8 feat: 登录状态 2023-03-15 22:22:16 +08:00
mozzie
c0d9192030 feat: 登录 2023-03-15 17:17:34 +08:00
mozzie
c9db6741f2 feat: login 2023-03-14 21:13:02 +08:00
mozzie
a3635ffafb feat: 接口鉴权 2023-03-14 17:41:11 +08:00
mozzie
6dcc4753ae feat: login 2023-03-13 22:25:43 +08:00
mozzie
24b8b4c91d feat: index 2023-03-13 16:52:27 +08:00
mozzie
0a7b4046a7 feat: 课程 2023-03-12 22:30:09 +08:00
mozzie
475b2932ef feat: 创建课程 2023-03-10 17:55:01 +08:00
mozzie
12be9a9180 feat: 课程创建 2023-03-09 22:28:16 +08:00
mozzie
3f9ad4b2df feat: vr图比例调整 2023-03-08 18:00:09 +08:00
mozzie
ddc823cf9e feat: 课程创建 2023-03-07 22:08:00 +08:00
mozzie
15537d5c13 feat: 课程创建 2023-03-07 17:44:37 +08:00
mozzie
220b46091d feat: 课程创建 2023-03-07 17:44:18 +08:00
mozzie
d649a14244 feat: 调用腾讯vod api 2023-03-06 21:56:04 +08:00
mozzie
1b4175c8d0 feat: 视频库 2023-03-06 17:57:41 +08:00
mozzie
b6f239a38e feat: 管理后台 2023-03-06 16:41:47 +08:00
mozzie
031be2925f feat: 管理后台 2023-03-06 16:41:00 +08:00
mozzie
3c1e8c2247 ci 2023-03-06 13:33:38 +08:00
mozzie
5c9c45b7a3 ci 2023-03-06 13:31:33 +08:00
mozzie
20b8cdaaba ci 2023-03-06 13:30:38 +08:00
mozzie
e73ee01cb2 ci 2023-03-06 13:23:40 +08:00
mozzie
99fa742660 ci 2023-03-06 13:20:35 +08:00
mozzie
602a8c58e7 ci 2023-03-06 13:18:17 +08:00
mozzie
fed3d952ce ci 2023-03-06 13:15:34 +08:00
mozzie
ff300648bd ci 2023-03-06 13:13:02 +08:00
mozzie
d8e6a8d55f ci 2023-03-06 13:12:33 +08:00
mozzie
3bb5d23f70 ci 2023-03-06 13:01:56 +08:00
mozzie
df8a6f6a7d ci 2023-03-06 11:31:15 +08:00
mozzie
04b2a2df4c ci 2023-03-06 11:29:12 +08:00
mozzie
ed2718c447 ci 2023-03-06 11:25:06 +08:00
mozzie
f815937a12 ci 2023-03-06 11:02:26 +08:00
mozzie
0032bd680a ci 2023-03-06 11:01:56 +08:00
mozzie
b9e421cb2e feat: 懒加载拆分 2023-03-06 10:54:33 +08:00
mozzie
657bc8f69c ci 2023-03-06 10:13:26 +08:00
mozzie
b3a739fd0c ci 2023-03-06 10:12:10 +08:00
mozzie
d61165981f ci 2023-03-06 10:11:36 +08:00
mozzie
5bbe1fc204 c 2023-03-06 10:10:53 +08:00
mozzie
9c57403f36 ci 2023-03-06 10:10:33 +08:00
mozzie
66bb7a4402 ci 2023-03-06 10:09:24 +08:00
mozzie
aa7ec61d3d ci 2023-03-06 10:08:12 +08:00
mozzie
9c47f3b616 ci 2023-03-06 10:06:47 +08:00
mozzie
4c37f8443b 1 2023-03-06 10:06:11 +08:00
mozzie
c178112274 ci 2023-03-06 10:05:46 +08:00
mozzie
406257a510 ci 2023-03-06 10:00:18 +08:00
mozzie
dd4c8e7fb0 ci 2023-03-06 09:53:10 +08:00
mozzie
7ef176d6fb ci: update 2023-03-05 17:42:12 +08:00
mozzie
e3d052774a ci: ci 更新 2023-03-05 17:31:09 +08:00
mozzie
c018d3f416 ci: ci update 2023-03-05 17:30:29 +08:00
mozzie
5fd9a9ed35 feat: ci 2023-03-05 17:20:11 +08:00
mozzie
6ca624f6d2 feat: backset.cn web ci 2023-03-05 17:17:04 +08:00
mozzie
e8da3e4c5a feat: 登录 2023-03-03 16:21:45 +08:00
mozzie
166986e31f feat: 订阅 2023-03-02 17:58:59 +08:00
mozzie
8cd3f5b6d2 feat: dplayer 2023-03-02 16:45:05 +08:00
mozzie
c215c6fa37 feat: dplayer 2023-03-02 16:40:25 +08:00
mozzie
d4a845c59c feat: 播放期 2023-03-02 15:28:02 +08:00
mozzie
57ff9c0356 feat: 播放期 2023-03-02 11:30:38 +08:00
mozzie
f8be92ddde feat: 滚动 2023-03-01 23:59:13 +08:00
mozzie
d66a606053 feat: 课程详情页 2023-03-01 17:05:06 +08:00
mozzie
1f84e3a682 feat: 课程详情页 2023-03-01 15:24:27 +08:00
mozzie
eb593ef590 feat: 时间线 2023-03-01 11:18:24 +08:00
mozzie
5b3f929c39 feat: 滚动 2023-02-28 23:58:50 +08:00
mozzie
fcc2086f12 feat: 滚动 2023-02-28 23:33:16 +08:00
mozzie
d074849556 feat: 时间线 2023-02-28 17:58:30 +08:00
mozzie
198c7132d1 feat: 2.0 2023-02-27 23:38:41 +08:00
mozzie
cdd17b3bd6 feat: react-alipalyer对接 2023-02-27 18:01:37 +08:00
mozzie
a5dc0e4531 feat: react-alipalyer对接 2023-02-27 15:41:19 +08:00
mozzie
2edb15d128 feat: react 2023-02-27 11:23:36 +08:00
mozzie
b1ece07f36 feat: 2.0 2023-02-27 00:18:17 +08:00
mozzie
be340499da feat: 2.0 2023-02-26 22:58:49 +08:00
mozzie
782697d2f5 feat: 2.0 2023-02-26 20:50:50 +08:00
mozzie
16f602a40f feat: 2.0 2023-02-26 16:45:43 +08:00
mozzie
c1deafb521 feat: nav css 2023-02-25 23:43:44 +08:00
181 changed files with 21771 additions and 1297 deletions

108
.drone.yml Normal file
View File

@ -0,0 +1,108 @@
---
kind: pipeline
type: docker
name: admin 资源发布
volumes:
- name: node_modules
host:
path: /home/drone/cache/node_modules
when:
branch:
- release/**
event:
- push
steps:
# admin管理
- name: build-admin
image: node:16.19-alpine
volumes:
- name: node_modules
path: /drone/src/node_modules
commands:
- pwd
- node -v
- npm -v
- npm install -g pnpm
- pnpm i
- pnpm build:admin
- name: deploy-admin
image: appleboy/drone-scp:1.6
settings:
host:
- backset.cn
username: root
password: cr654654.
port: 22
overwrite: true
command_timeout: 2m
target: /www/wwwroot/nginx/html/backset.cn/
source: ./apps/admin/dist/*
trigger:
branch:
- release/**
---
kind: pipeline
type: docker
name: web 资源发布
volumes:
- name: node_modules
host:
path: /home/drone/cache/node_modules
steps:
# web端
- name: build-web
image: node:16.19-alpine
# 容器内挂载点
volumes:
- name: node_modules
path: /drone/src/node_modules
commands:
- pwd
- node -v
- npm -v
- npm install -g pnpm
- pnpm i
- pnpm build:web
- name: deploy-web
image: appleboy/drone-scp:1.6
settings:
host:
- backset.cn
username: root
password: cr654654.
port: 22
overwrite: true
command_timeout: 2m
target: /www/wwwroot/nginx/html/backset.cn/
source: ./apps/web/dist/*
trigger:
branch:
- release/**
---
kind: pipeline
type: exec
name: server 镜像生产&容器发布
steps:
- name: docker image
commands:
- docker-compose build
- name: docker container
commands:
- docker-compose up -d
trigger:
branch:
- release/**

40
Dockerfile.release Normal file
View File

@ -0,0 +1,40 @@
FROM node:16.19 AS build
# 创建一个项目文件夹,可自定义
WORKDIR /app
# 将本地文件复制到项目文件夹下
COPY ./apps/server/ .
RUN ls -a
RUN npm set registry https://registry.npm.taobao.org
RUN npm install -g pnpm
RUN pnpm i
RUN pnpm build
# 切换镜像文件, alpine镜像打包后更小
FROM node:16.19-alpine
# 切换工作目录
WORKDIR /app
# 将打包后的的文件复制到docker镜像里
COPY --from=build /app/dist ./dist
RUN ls -a
# 把源代码复制过去, 以便报错能报对行
COPY --from=build /app/src ./src
COPY --from=build /app/bootstrap.js ./
COPY --from=build /app/package.json ./
COPY --from=build /app/public ./public
COPY --from=build /app/.env ./
ENV TZ="Asia/Shanghai"
RUN npm set registry https://registry.npm.taobao.org
# 安装工程依赖
RUN npm install --production
# 设置暴露端口
EXPOSE 7001
# 启动
CMD ["npm", "run", "start"]

View File

@ -14,7 +14,9 @@
"less": "^4.1.3",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "6.8.0"
"react-router-dom": "6.8.0",
"@ant-design/icons": "5.0.1",
"nanoid": "4.0.1"
},
"devDependencies": {
"@types/react": "^18.0.27",

View File

@ -1,52 +1,19 @@
import { Route, Routes } from "react-router-dom";
import "./assets/less/common.less";
import { Route, Routes, useNavigate } from "react-router-dom";
import User from "./view/User";
import Home from "./view/Home";
import { Guard } from "./router/Guard";
import Layout from "./layout";
import Login from "./view/Login";
import { ConfigProvider as AntDesignConfigProvider } from "antd";
import zhCN from "antd/locale/zh_CN";
function App() {
const navigate = useNavigate();
const routerList = [
{
path: "/",
element: <Home />,
name: "首页",
},
{
path: "user",
element: <User />,
name: "用户",
},
];
return (
<>
<header>header</header>
<div>
<aside>
<ul>
{routerList.map((router) => (
<li key={router.path}>
<a onClick={() => navigate(router.path)}>{router.name}</a>
</li>
))}
</ul>
</aside>
<main>
<Routes>
{routerList.map((router) => (
<Route
key={router.path}
path={router.path}
element={<Guard>{router.element}</Guard>}
/>
))}
<Route path="*" element={<span>404</span>} />
</Routes>
</main>
</div>
</>
<AntDesignConfigProvider locale={zhCN}>
<Routes>
<Route index key={"login"} path={"/"} element={<Login />} />
<Route key={"dash"} path={"/*"} element={<Layout />} />
<Route path="*" element={<span>404</span>} />
</Routes>
</AntDesignConfigProvider>
);
}

49
apps/admin/src/api/dto.ts Normal file
View File

@ -0,0 +1,49 @@
export interface IgetVodRequest {
offset: number;
limit: number;
}
export interface IGetVodeResponse {
MediaInfoSet: any[];
TotalCount: number;
}
export interface ICourseBasic {
course_id?: string;
course_title?: string;
course_cover_url?: string;
course_summary?: string;
course_createtime?: string;
valid?: boolean;
}
export interface ICreateCourseRequest extends ICourseBasic {
course_chapterList: [];
course_guide: {};
}
export interface IAdminLogin {
username: string;
password: string;
}
export interface IXcode {
expiretime: string;
code: string;
}
export interface ISelectCourse {
all?: boolean;
}
export interface IChapter {
chapter_course_id?: string;
chapter_file_id?: string;
chapter_id?: string;
chapter_level?: string;
chapter_title?: string;
media_cover_url?: string;
media_time?: string;
media_url?: string;
order?: number;
}

View File

@ -0,0 +1,61 @@
import R from "./request";
import P from "./process";
import {
IAdminLogin,
IChapter,
ICourseBasic,
ICreateCourseRequest,
IgetVodRequest,
ISelectCourse,
IXcode,
} from "./dto";
/**
* vod媒资
*/
export const getVod = (p: IgetVodRequest) =>
R.post("/api/vod/media/select", { ...p }).then((d: any) => P.getVod(d.data));
export const createCourse = (p: ICreateCourseRequest) =>
R.post("/api/course/create", { ...p });
export const adminLogin = (p: IAdminLogin) =>
R.post("/api/user/admin/auth", { ...p });
export const createXCode = (codeList: IXcode[]) =>
R.post("/api/xcode/admin/create", codeList);
export const selectXCodeList = () => R.post("/api/xcode/admin/select/all");
export const selectCourseList = (p: ISelectCourse) =>
R.post("/api/course/select/all", { ...p });
export const updateCourse = (course: ICourseBasic) =>
R.post("/api/course/update", course);
export const selectChapterList = ({
chapter_course_id,
}: {
chapter_course_id: string;
}) => R.post("/api/course/chapter/select", { chapter_course_id });
export const updateChapter = (chapter: IChapter) =>
R.post("/api/course/chapter/update", chapter);
export const removeCourse = (course: ICourseBasic) =>
R.post("/api/course/remove", course);
export const createChapter = (chapterList: IChapter[]) =>
R.post("/api/course/chapter/create", chapterList);
export const removeChapter = (chapter: IChapter) =>
R.post("/api/course/chapter/remove", chapter);
export const selectUserList = () => R.post("/api/user/admin/select/all");
export const updateUser = (user: any) => R.post("/api/user/admin/update", user);
export const selectGuide = (p: { guide_course_id: string }) =>
R.post("/api/course/guide/select", p);
export const updateGuide = (p: any) => R.post("/api/course/guide/update", p);

View File

@ -0,0 +1,23 @@
import { IGetVodeResponse } from "./dto";
/**
*
*/
export default {
/**
* vod媒资
*/
getVod: (p: IGetVodeResponse) => {
const { TotalCount: total, MediaInfoSet } = p;
const mediaList = MediaInfoSet.map((item) => {
return {
FileId: item.FileId,
AdaptStream:
item.AdaptiveDynamicStreamingInfo.AdaptiveDynamicStreamingSet[0],
BasicInfo: item.BasicInfo,
MetaData: item.MetaData,
};
});
return { total, mediaList };
},
};

View File

@ -0,0 +1,47 @@
import { message } from "antd";
import axios from "axios";
const config = {
baseURL: "",
timeout: 1000 * 15,
headers: {},
};
const instance = axios.create(config);
instance.interceptors.request.use(
(config) => {
return config;
},
(error) => {
return Promise.reject(error);
}
);
// Add a response interceptor
instance.interceptors.response.use(
(response) => {
const { msg, code } = response.data;
switch (code) {
case 10000:
message.success(`接口: ${response.config.url}, 请求成功`);
break;
case 20000:
message.error(`接口: ${response.config.url}, 遇到错误`);
break;
case 40000:
message.error(msg);
// window.location.href = "/";
break;
default:
// TODO ...
break;
}
return response?.data;
},
(error) => {
return Promise.reject(error);
}
);
export default instance;

View File

@ -3,6 +3,7 @@ html {
margin: 0;
padding: 0;
font-size: 14px;
height: 100%;
}
* {
@ -14,3 +15,25 @@ ul {
margin: 0;
padding: 0;
}
#root {
height: 100%;
}
@font-face {
font-family: "bs";
src: url("./backset.woff");
}
.bs-scrollbar {
&::-webkit-scrollbar {
width: 14px;
height: 4px;
}
&::-webkit-scrollbar-thumb {
border: 4px solid transparent;
background-clip: padding-box;
border-radius: 7px;
background-color: #d2d2d2;
}
}

View File

@ -0,0 +1,52 @@
.container {
height: 100%;
background: #f1f1f1;
> header {
padding: 0 24px;
display: flex;
align-items: center;
position: absolute;
top: 0;
left: 0;
right: 0;
height: 46px;
background: #001529;
z-index: 19;
.logo {
width: 176px;
display: flex;
align-items: center;
color: #fff;
svg {
width: 22px;
}
span {
padding-left: 10px;
font-family: "bs";
font-size: 16px;
}
}
}
> aside {
position: absolute;
left: 0;
top: 46px;
bottom: 0;
width: 200px;
> aside {
height: 100%;
}
}
> main {
position: absolute;
left: 200px;
right: 0;
top: 46px;
bottom: 0;
overflow-y: auto;
.view {
padding: 0 24px;
}
}
}

View File

@ -0,0 +1,123 @@
import React, { Suspense } from "react";
import "./index.less";
import {
VideoCameraAddOutlined,
UserSwitchOutlined,
AuditOutlined,
} from "@ant-design/icons";
import { Layout, Menu, MenuProps, Spin, theme } from "antd";
import { Route, Routes, useNavigate } from "react-router-dom";
import { Guard } from "../router/Guard";
import { navMenuList, navRoutes, sideMenuRoutes } from "../router";
const navMenus: MenuProps["items"] = navMenuList;
const sideMenus: MenuProps["items"] = [
{
key: "course",
icon: <VideoCameraAddOutlined />,
label: "课程",
children: [
{
key: "create",
label: "创建",
},
{
key: "list",
label: "课程列表",
},
{
key: "library",
label: "视频库",
},
],
},
{
key: "user",
icon: <UserSwitchOutlined />,
label: "用户",
},
{
key: "xcode",
icon: <AuditOutlined />,
label: "神秘代码",
},
];
const { Header, Sider, Content } = Layout;
const Index: React.FC = () => {
const {
token: { colorBgContainer },
} = theme.useToken();
const navigate = useNavigate();
const onClickNavMenuItem = (p: any) => {
navigate(p.key);
};
const onClickSideMenuItem = (p: any) => {
const path = p.keyPath.reverse().join("/");
navigate(path.startsWith("/") ? path : "/" + path);
};
return (
<div className="container">
<header>
<div className="logo">
<svg
fill="currentColor"
className="icon"
viewBox="0 0 1024 1024"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M158.165333 499.498667A42.496 42.496 0 0 0 170.666667 469.333333V256a42.666667 42.666667 0 0 1 42.666666-42.666667 42.666667 42.666667 0 0 0 0-85.333333C142.762667 128 85.333333 185.429333 85.333333 256v195.669333l-30.165333 30.165334a42.666667 42.666667 0 0 0 0 60.330666l30.165333 30.165334V768c0 70.570667 57.429333 128 128 128a42.666667 42.666667 0 0 0 0-85.333333 42.666667 42.666667 0 0 1-42.666666-42.666667v-213.333333a42.496 42.496 0 0 0-12.501334-30.165334L145.664 512l12.501333-12.501333zM978.090667 495.658667a42.709333 42.709333 0 0 0-9.258667-13.824L938.666667 451.669333V256c0-70.570667-57.429333-128-128-128a42.666667 42.666667 0 1 0 0 85.333333 42.666667 42.666667 0 0 1 42.666666 42.666667v213.333333a42.581333 42.581333 0 0 0 12.501334 30.165334l12.501333 12.501333-12.501333 12.501333A42.496 42.496 0 0 0 853.333333 554.666667v213.333333a42.666667 42.666667 0 0 1-42.666666 42.666667 42.666667 42.666667 0 1 0 0 85.333333c70.570667 0 128-57.429333 128-128v-195.669333l30.165333-30.165334a42.709333 42.709333 0 0 0 9.258667-46.506666zM669.738667 225.450667a42.752 42.752 0 0 0-69.546667 14.762666l-255.829333 512a42.624 42.624 0 0 0 23.893333 55.424 42.922667 42.922667 0 0 0 55.552-23.765333l255.786667-512a42.538667 42.538667 0 0 0-9.813334-46.421333z"></path>
</svg>
<span>Backset</span>
</div>
<Menu
theme="dark"
mode="horizontal"
defaultSelectedKeys={["/"]}
style={{ flex: 1 }}
items={navMenus}
onClick={onClickNavMenuItem}
/>
</header>
<aside>
<Sider width={200} style={{ background: colorBgContainer }}>
<Menu
mode="inline"
defaultSelectedKeys={["1"]}
defaultOpenKeys={["course"]}
style={{ height: "100%", borderRight: 0 }}
items={sideMenus}
onClick={onClickSideMenuItem}
/>
</Sider>
</aside>
<main className="bs-scrollbar">
<div className="view">
<Routes>
{[...navRoutes, ...sideMenuRoutes].map((router) => (
<Route
key={router.path}
path={router.path}
element={
<Suspense fallback={<Spin />}>
<Guard>{<router.element />}</Guard>
</Suspense>
}
/>
))}
<Route path="*" element={<span>404</span>} />
</Routes>
</div>
</main>
</div>
);
};
export default Index;

View File

@ -1,5 +1,6 @@
import React, { useEffect } from "react";
import { useLocation } from "react-router-dom";
import { useEffect } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import Cookie from "js-cookie";
interface IGuardProps {
children: JSX.Element;
@ -7,9 +8,11 @@ interface IGuardProps {
export const Guard = (props: IGuardProps) => {
const location = useLocation();
const navigate = useNavigate();
useEffect(() => {
console.log("location.pathname changed 拦截", location.pathname);
const sign = Cookie.get("_sign_admin");
if (!sign) navigate("/");
}, [location.pathname]);
return props.children;

View File

@ -0,0 +1,56 @@
import { lazy } from "react";
export interface IRouteMenuItem {
path: string;
name: string;
}
interface IRoute extends IRouteMenuItem {
element: React.LazyExoticComponent<() => JSX.Element>;
}
export const navRoutes: IRoute[] = [
{
path: "/overview",
element: lazy(() => import("../view/Overview")),
name: "总览",
},
{
path: "/payment",
element: lazy(() => import("../view/Payment")),
name: "支付",
},
];
export const sideMenuRoutes: IRoute[] = [
{
path: "/course/create",
element: lazy(() => import("../view/Course/Create")),
name: "创建课程",
},
{
path: "/course/list",
element: lazy(() => import("../view/Course/List")),
name: "课程列表",
},
{
path: "/course/library",
element: lazy(() => import("../view/Course/Library")),
name: "视频库",
},
{
path: "/user",
element: lazy(() => import("../view/User")),
name: "用户",
},
{
path: "/xcode",
element: lazy(() => import("../view/XCode")),
name: "邀请码",
},
];
export const navMenuList = navRoutes.map((route) => {
const { path: key, name: label } = route;
return { key, label };
});

View File

@ -0,0 +1,17 @@
import { create } from "zustand";
import { getVod } from "../api";
export const useMediaStore = create((set) => ({
list: [],
listFilter: [],
setList: (newState: any) =>
set(() => ({ list: newState, listFilter: newState })),
useFilter: (keyword: string) =>
set((state: any) => ({
listFilter: !keyword
? state.list
: state.list.filter(
(i: any) => i.name.toUpperCase().indexOf(keyword.toUpperCase()) > -1
),
})),
}));

View File

@ -1,3 +0,0 @@
body {
background: grey;
}

View File

@ -0,0 +1,32 @@
.preview-course {
position: relative;
background-size: cover;
background-position: center;
border-radius: 6px;
height: 360px;
.mask {
position: absolute;
left: 50%;
top: 50%;
width: 60%;
transform: translate(-50%, -50%);
p {
color: #fff;
text-align: center;
margin: 0;
&.title {
background: rgba(0, 0, 0, 0.7);
font-size: 22px;
padding: 10px;
line-height: 1.5;
border-radius: 6px 6px 0 0;
}
&.summary {
background: rgba(0, 0, 0, 0.3);
padding: 6px;
line-height: 1.4;
border-radius: 0 0 6px 6px;
}
}
}
}

View File

@ -0,0 +1,110 @@
import { InboxOutlined } from "@ant-design/icons";
import { Col, Form, Input, message, Row, Upload, UploadProps } from "antd";
import { useEffect, useState } from "react";
import "./index.less";
const { Dragger } = Upload;
interface IProps {
onChange: Function;
styles?: React.CSSProperties;
}
const BasicForm = (props: IProps) => {
const [preview, setPreivew] = useState({
course_cover_url: "",
course_title: "",
course_summary: "",
});
const [form] = Form.useForm();
const onValuesChange = (_: any, all: any) =>
setPreivew((p) => ({ ...p, ...all }));
const coverDragger: UploadProps = {
name: "file",
multiple: true,
action: "/api/vod/course/cover/upload",
onChange(info) {
const { status } = info.file;
if (status !== "uploading") console.log(info.file, info.fileList);
if (status === "done") {
const { code, data } = info.file.response;
if (code === 10000) {
message.success(`${info.file.name} 文件上传成功`);
const { MediaUrl } = data;
setPreivew((p) => ({ ...p, course_cover_url: MediaUrl }));
}
} else if (status === "error") {
message.error(`${info.file.name} 文件上传失败`);
}
},
onDrop(e) {
console.log("Dropped files", e.dataTransfer.files);
},
};
useEffect(() => props.onChange(preview), [preview]);
return (
<div style={{ ...props.styles }}>
<Row style={{ marginBottom: 24 }}>
<Col span={24}>
<div
className="preview-course"
style={{
backgroundImage: !preview.course_cover_url
? `linear-gradient(
to right,
#e95659,
#e15084,
#c55aaa,
#976bc4,
#5678ce
)`
: `url(${preview.course_cover_url})`,
}}
>
<div className="mask">
<p className="title">
{!preview.course_title ? "标题" : preview.course_title}
</p>
<p className="summary">
{!preview.course_summary ? "摘要" : preview.course_summary}{" "}
</p>
</div>
</div>
</Col>
</Row>
<Row gutter={24}>
<Col span={12}>
<Dragger {...coverDragger} maxCount={1}>
<p className="ant-upload-drag-icon">
<InboxOutlined />
</p>
<p className="ant-upload-text"></p>
<p className="ant-upload-hint"></p>
</Dragger>
</Col>
<Col span={12}>
<Form form={form} onValuesChange={onValuesChange}>
<Form.Item name="course_title" rules={[{ required: true }]}>
<Input size="large" type="text" placeholder="标题" />
</Form.Item>
<Form.Item
name="course_summary"
rules={[{ required: true }]}
style={{ marginBottom: 0 }}
>
<Input.TextArea
placeholder="摘要"
style={{ height: 130, resize: "none" }}
/>
</Form.Item>
</Form>
</Col>
</Row>
</div>
);
};
export default BasicForm;

View File

@ -0,0 +1,12 @@
.chapter-list {
li {
display: flex;
justify-content: space-between;
&.l1 {
margin-top: 20px;
&:first-of-type {
margin-top: 0;
}
}
}
}

View File

@ -0,0 +1,87 @@
import { MinusCircleOutlined, PlusOutlined } from "@ant-design/icons";
import {
Button,
Checkbox,
Col,
Form,
Input,
Radio,
Row,
Space,
Typography,
} from "antd";
const { Text } = Typography;
import {} from "antd";
import { useEffect, useState } from "react";
import "./index.less";
interface IProps {
onChange?: Function;
styles?: React.CSSProperties;
}
interface IChapter {
chapter_level: string;
chapter_title: string;
chapter_file_id?: string;
}
const Chatpter = (props: IProps) => {
const [form] = Form.useForm();
const [chapterList, setChapterList] = useState<IChapter[]>([]);
const onTocChange = () => {
const { toc } = form.getFieldsValue();
const process = toc
.split("\n")
.filter((i: string) => i.replace(/\s/, "").length > 0)
.map((row: string, index: number) => {
const [chapter_level, chapter_title, chapter_file_id] = row.split("|");
return !chapter_file_id
? { order: index, chapter_level, chapter_title }
: { order: index, chapter_level, chapter_title, chapter_file_id };
});
setChapterList(process);
};
useEffect(() => {
if (props.onChange) props.onChange(chapterList);
}, [chapterList]);
return (
<div style={{ ...props.styles }}>
<Row gutter={24}>
<Col span={12}>
<Form form={form} onChange={onTocChange}>
<Form.Item name="toc">
<Input.TextArea
placeholder="级别 | 标题 | FileId"
style={{ minHeight: 600, lineHeight: 2, fontSize: 16 }}
></Input.TextArea>
</Form.Item>
</Form>
</Col>
<Col span={12}>
<ul className="chapter-list">
{chapterList.map((c, index) => (
<li
key={index}
className={+c.chapter_level === 1 ? "l1" : ""}
style={{ paddingLeft: +c.chapter_level === 1 ? 0 : 20 }}
>
<Text style={{ fontSize: +c.chapter_level === 1 ? 18 : 16 }}>
{c.chapter_title}
</Text>
{c.chapter_file_id && (
<Text type="secondary">{c.chapter_file_id}</Text>
)}
</li>
))}
</ul>
</Col>
</Row>
</div>
);
};
export default Chatpter;

View File

@ -0,0 +1,2 @@
.vditor {
}

View File

@ -0,0 +1,137 @@
import "vditor/dist/index.css";
import Vditor from "vditor";
import { useEffect, useRef } from "react";
import "./index.less";
import { message } from "antd";
interface IProps {
onChange?: Function;
styles?: React.CSSProperties;
id: string;
defaultValue?: string;
}
const emoji = {
"+1": "👍",
"-1": "👎",
confused: "😕",
eyes: "👀️",
heart: "❤️",
rocket: "🚀️",
smile: "😄",
tada: "🎉️",
};
const toolbar = [
"emoji",
"headings",
"bold",
"italic",
"strike",
"link",
"|",
"list",
"ordered-list",
"check",
"outdent",
"indent",
"|",
"quote",
"line",
"code",
"inline-code",
"insert-before",
"insert-after",
"|",
"upload",
// "record",
"table",
"|",
"undo",
"redo",
"|",
"fullscreen",
"edit-mode",
{
name: "more",
toolbar: [
"both",
"code-theme",
"content-theme",
"export",
"outline",
"preview",
"devtools",
"info",
"help",
],
},
];
export default function Guide(props: IProps) {
const vditorRef = useRef<Vditor | null>(null);
const submitTool = {
name: "submit",
tipPosition: "s",
tip: "提交",
className: "right",
icon: '<svg viewBox="0 0 1024 1024"><path d="M385 840.5c-20.8 0-41.7-7.9-57.6-23.8L87.6 576.9c-31.8-31.8-31.8-83.3 0-115.1s83.3-31.8 115.1 0l239.8 239.8c31.8 31.8 31.8 83.3 0 115.1-15.9 15.9-36.7 23.8-57.5 23.8z" fill="#1296db" p-id="25510"></path><path d="M384.6 840.5c-20.8 0-41.7-7.9-57.6-23.8-31.8-31.8-31.8-83.3 0-115.1l494.2-494.2c31.8-31.8 83.3-31.8 115.1 0s31.8 83.3 0 115.1L442.2 816.7c-15.9 15.9-36.8 23.8-57.6 23.8z" fill="#1296db"></path></svg>',
click() {
const value = vditorRef.current?.getValue();
const html = vditorRef.current?.getHTML();
if (props.onChange) props.onChange({ value, html });
},
};
const renderEditor = () => {
vditorRef.current = new Vditor("vditor", {
height: "100%",
cache: {
id: props.id,
enable: false,
},
toolbar: [...toolbar, "|", submitTool],
value: props.defaultValue ?? "",
hint: { delay: 200, emoji },
counter: { enable: true },
preview: { actions: ["desktop", "mobile"] },
after: () => console.log("[info] vditor init success..."),
upload: {
accept: "image/*",
url: "/api/vod/oss/image/upload",
multiple: false,
success(_, res) {
const { code, data, msg } = JSON.parse(res);
if (code === 10000) {
message.success("上传成功");
const { name, url } = data;
vditorRef.current?.insertValue(`![${name}](${url})`);
} else {
message.error(msg);
}
},
},
});
};
const destroyEditor = () => {
vditorRef.current = null;
};
useEffect(() => {
renderEditor();
return () => destroyEditor();
}, []);
useEffect(() => {
renderEditor();
return () => destroyEditor();
}, [props.defaultValue]);
return (
<div style={{ ...props.styles }}>
<div id="vditor" className="vditor" />
</div>
);
}

View File

@ -0,0 +1,15 @@
.create-course {
width: 1120px;
margin: 0 auto;
padding: 24px 0;
.content {
padding: 20px 0;
flex: 1;
}
}
.drawer-media-item {
border-bottom: 1px solid rgba(5, 5, 5, 0.06);
padding: 8px;
margin-bottom: 8px;
}

View File

@ -0,0 +1,160 @@
import { Button, Card, Drawer, Input, message, Steps, Typography } from "antd";
import { useEffect, useState } from "react";
import Guide from "./Guide";
import BasicForm from "./BasicForm";
import Chatpter from "./Chatpter";
import "./index.less";
import { useMediaStore } from "../../../store/media";
import { createCourse } from "../../../api";
import { ICourseBasic } from "../../../api/dto";
const { Text } = Typography;
interface ICourse {
basicInfo: ICourseBasic;
course_chapterList: [];
course_guide: {
guide_value: string;
guide_html: string;
};
}
const CourseCreate = () => {
const [current, setCurrent] = useState(0);
const [course, setCourse] = useState<ICourse>({
basicInfo: {
course_cover_url: "",
course_title: "",
course_summary: "",
},
course_chapterList: [],
course_guide: {
guide_value: "",
guide_html: "",
},
});
const onBasicFormChange = (form: any) =>
setCourse((p) => ({ ...p, basicInfo: form }));
const steps = [
{
title: "基本信息",
},
{
title: "章节",
},
{
title: "导读",
},
];
const items = steps.map((item) => ({ key: item.title, title: item.title }));
const onChapterChange = (chapters: any) =>
setCourse((p) => ({ ...p, course_chapterList: chapters }));
const onGuideChange = ({ value, html }: { value: string; html: string }) => {
setCourse((p) => ({
...p,
course_guide: { guide_value: value, guide_html: html },
}));
};
/**
*
*/
const onClickCreate = () => {
const { basicInfo, ...rest } = course;
createCourse({ ...basicInfo, ...rest }).then((res) => {
console.log(res);
});
};
const mediaList = useMediaStore((s: any) => s.listFilter);
const useFilter = useMediaStore((s: any) => s.useFilter);
const [open, setOpen] = useState(false);
const showDrawer = () => {
setOpen(true);
};
const onClose = () => {
setOpen(false);
};
const onSearchChange = (e: any) => {
useFilter(e.target.value);
};
return (
<div className="create-course">
<Card>
<Steps current={current} items={items} />
<div className="content">
<BasicForm
onChange={onBasicFormChange}
styles={{ display: current === 0 ? "block" : "none" }}
/>
<Chatpter
onChange={onChapterChange}
styles={{ display: current === 1 ? "block" : "none" }}
/>
<Guide
id="createCourseEditor"
onChange={onGuideChange}
styles={{
display: current === 2 ? "block" : "none",
height: "600px",
}}
/>
</div>
<div style={{ textAlign: "right", marginTop: "40px" }}>
{current === 1 && (
<Button
type="primary"
onClick={showDrawer}
style={{ marginRight: "12px" }}
>
</Button>
)}
{current > 0 && (
<Button
style={{ marginRight: "12px" }}
onClick={() => setCurrent(current - 1)}
>
</Button>
)}
{current < steps.length - 1 && (
<Button type="primary" onClick={() => setCurrent(current + 1)}>
</Button>
)}
{current === steps.length - 1 && (
<Button type="primary" onClick={onClickCreate}>
</Button>
)}
</div>
</Card>
<Drawer title="媒体库" placement="right" onClose={onClose} open={open}>
<Input placeholder={`根据名称搜索`} onChange={onSearchChange} />
<section style={{ marginTop: 24 }}>
{mediaList.map((media: any) => {
return (
<div key={media.key} className="drawer-media-item">
<div>{media.name}</div>
<Text type="secondary">{media.key}</Text>
</div>
);
})}
</section>
</Drawer>
</div>
);
};
export default CourseCreate;

View File

@ -0,0 +1,200 @@
import { CloudSyncOutlined } from "@ant-design/icons";
import {
Button,
Card,
Col,
Row,
Space,
Table,
Input,
Tooltip,
Typography,
Tag,
} from "antd";
import dayjs from "dayjs";
import { useContext, useState } from "react";
import { getVod } from "../../../api";
import { useMount } from "../../../hooks";
import { useMediaStore } from "../../../store/media";
const { Paragraph, Text } = Typography;
const { Search } = Input;
const Library = () => {
const [dataSource, setDataSource] = useState<any>([]);
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
const colors = [
"magenta",
"red",
"volcano",
"orange",
"gold",
"green",
"cyan",
].reverse();
const columns = [
{
title: "媒体文件名称",
dataIndex: "name",
key: "name",
render: (_: any, record: any) => {
return (
<div>
<Row>{record.name}</Row>
<Row style={{ paddingTop: "5px" }}>
{record.m3u8SubStreamList.map((item: any, index: number) => (
<Tag key={item} color={colors[index]}>
{item}
</Tag>
))}
</Row>
<Row style={{ paddingTop: "5px" }}>
<Text type="secondary">: {record.duration}s</Text>
</Row>
</div>
);
},
},
{
title: "FileID",
dataIndex: "key",
key: "key",
},
{
title: "hls",
dataIndex: "m3u8Size",
key: "m3u8Size",
},
{
title: "状态",
dataIndex: "status",
key: "status",
},
{
title: "创建时间",
dataIndex: "createtime",
key: "createtime",
},
{
title: "m3u8",
dataIndex: "m3u8",
key: "m3u8",
},
{
title: "封面图",
dataIndex: "cover",
key: "cover",
},
{
title: "操作",
dataIndex: "operation",
key: "operation",
render: (_: any, record: any) => {
return (
<Space>
<a href={record.mp4} target="_blank">
</a>
</Space>
);
},
},
];
const onSelectChange = (newSelectedRowKeys: React.Key[]) => {
console.log("selectedRowKeys changed: ", newSelectedRowKeys);
setSelectedRowKeys(newSelectedRowKeys);
};
const rowSelection = { selectedRowKeys, onChange: onSelectChange };
const onSearch = (value: string) => console.log(value);
const computeMediaList = (mediaList: any) => {
return mediaList.map((m: any) => ({
key: m.FileId,
mp4: m.BasicInfo.MediaUrl,
m3u8: (
<Paragraph style={{ margin: 0 }} copyable={{ text: m.AdaptStream.Url }}>
</Paragraph>
),
m3u8Size: (m.AdaptStream.Size / 1024 / 1024).toFixed(2) + " MB",
m3u8SubStreamList: m.AdaptStream.SubStreamSet.map(
(i: any) => i.Height + "p"
),
name: m.BasicInfo.Name,
duration: m.MetaData.Duration,
cover: (
<Paragraph
style={{ margin: 0 }}
copyable={{ text: m.BasicInfo.CoverUrl }}
>
</Paragraph>
),
status: m.BasicInfo.Status,
createtime: dayjs(m.BasicInfo.CreateTime).format("YYYY-MM-DD HH:mm:ss"),
}));
};
const setMediaList = useMediaStore((s: any) => s.setList);
/**
* 5000
*/
const fetchVod = () => {
getVod({ offset: 0, limit: 5000 }).then((process: any) => {
const { mediaList, total } = process;
setDataSource(computeMediaList(mediaList));
setMediaList(computeMediaList(mediaList));
});
};
useMount(() => {
fetchVod();
});
return (
<Card style={{ marginTop: 24 }}>
<Row>
<Col span={14}>
<Space>
<Search
placeholder="根据名称搜索视频"
onSearch={onSearch}
style={{ width: 200 }}
/>
</Space>
</Col>
<Col span={10} style={{ textAlign: "right" }}>
<Space>
<Text type="secondary"> {dataSource.length} </Text>
<Tooltip title="从腾讯云VOD同步全部视频" placement="left">
<Button
onClick={() => fetchVod()}
type="primary"
icon={<CloudSyncOutlined />}
>
</Button>
</Tooltip>
</Space>
</Col>
</Row>
<Row style={{ marginTop: "16px" }}>
<Col span={24}>
<Table
rowSelection={rowSelection}
dataSource={dataSource}
columns={columns}
/>
</Col>
</Row>
</Card>
);
};
export default Library;

View File

@ -0,0 +1,3 @@
.list {
padding: 24px 0;
}

View File

@ -0,0 +1,570 @@
import {
CompassOutlined,
DeleteOutlined,
EditFilled,
EditOutlined,
FieldTimeOutlined,
FileAddOutlined,
InfoCircleOutlined,
} from "@ant-design/icons";
import {
Button,
Card,
DatePicker,
Image,
Input,
Popconfirm,
Space,
Switch,
Table,
Modal,
Tooltip,
Form,
Drawer,
} from "antd";
import dayjs from "dayjs";
import { useEffect, useState } from "react";
import {
removeCourse,
selectChapterList,
selectCourseList,
updateChapter,
updateCourse,
createChapter,
removeChapter,
selectGuide,
updateGuide,
} from "../../../api";
import Guide from "../Create/Guide";
import { useMount } from "../../../hooks";
import "./index.less";
interface IEditItem {
key: string;
value: string | boolean | number;
}
const defaultEditItem = { key: "", value: "" };
const defaultEditGuide = {
drawerVisible: false,
course_title: "",
guide_id: "",
guide_value: "",
guide_html: "",
};
export default function List() {
const [courseList, setCourseList] = useState<any>([]);
const [chapterList, setChapterList] = useState([]);
const [editChapterItem, setEditChapterItem] =
useState<IEditItem>(defaultEditItem);
const [editCourseItem, setEditCourseItem] =
useState<IEditItem>(defaultEditItem);
const [addChapterForm] = Form.useForm();
const [editGuide, setEditGuide] = useState(defaultEditGuide);
const onConfirmEditCourseItem = (record: any) => {
const { key, value } = editCourseItem;
updateCourse({ ...record, [key]: value }).then((res: any) => {
if (res?.code === 10000) {
renderCourseTable();
setEditCourseItem(defaultEditItem);
}
});
};
const onConfirmEditChapterItem = (record: any) => {
const { key, value } = editChapterItem;
updateChapter({ ...record, [key]: value }).then((res: any) => {
if (res?.code === 10000) {
const { chapter_course_id } = record;
renderChapterList(chapter_course_id);
setEditChapterItem(defaultEditItem);
}
});
};
const onConfirmDeleteCourseItem = (record: any) => {
const { course_id } = record;
removeCourse({ course_id }).then((res: any) => {
if (res?.code === 10000) renderCourseTable();
});
};
const onAddChapter = (record: any) => {
const { course_id: chapter_course_id } = record;
Modal.info({
title: "添加章节",
icon: <InfoCircleOutlined />,
content: (
<Form form={addChapterForm}>
<Form.Item name="chapterList">
<Input.TextArea
placeholder="级别 | 章节名 | 文件FileID"
autoSize={{ minRows: 12, maxRows: 20 }}
/>
</Form.Item>
</Form>
),
onOk: async () => {
const origin = addChapterForm.getFieldValue("chapterList");
const chapterList = origin
.split("\n")
.filter((i: string) => i.replace(/\s/, "").length > 0)
.map((row: string) => {
const [chapter_level, chapter_title, chapter_file_id] =
row.split("|");
return !chapter_file_id
? { chapter_level, chapter_title, chapter_course_id }
: {
chapter_level,
chapter_title,
chapter_file_id,
chapter_course_id,
};
});
createChapter(chapterList).then((res: any) => {
if (res?.code === 10000) renderChapterList(chapter_course_id);
});
},
okText: "确认",
cancelText: "取消",
});
};
const onConfirmDeleteChapterItem = (record: any) => {
const { chapter_id, chapter_course_id } = record;
removeChapter({ chapter_id }).then((res: any) => {
if (res?.code === 10000) renderChapterList(chapter_course_id);
});
};
const onEditGuide = (record: any) => {
const { course_title, course_id: guide_course_id } = record;
selectGuide({ guide_course_id }).then((res: any) => {
const { data } = res;
const { guide_value = "", guide_html = "", guide_id } = data;
setEditGuide({
drawerVisible: true,
course_title,
guide_id,
guide_value,
guide_html,
});
});
};
const onGuideChange = ({ value, html }: { value: string; html: string }) => {
setEditGuide((p) => ({ ...p, guide_value: value, guide_html: html }));
};
const onConfirmEditGuide = () => {
const { course_title, drawerVisible, ...rest } = editGuide;
console.log(editGuide);
updateGuide(rest).then((res: any) => {
if (res?.code === 10000) {
setEditGuide(defaultEditGuide);
renderCourseTable();
}
});
};
const columns = [
{
title: "课程",
dataIndex: "course_title",
key: "course_title",
render: (_: any, record: any) => {
return (
<Space>
<span>{record.course_title}</span>
<Popconfirm
placement="top"
title="修改课程标题"
description={
<Input
defaultValue={record.course_title}
onChange={(e: any) =>
setEditCourseItem({
key: "course_title",
value: e.target.value,
})
}
/>
}
onConfirm={() => onConfirmEditCourseItem(record)}
okText="确定"
cancelText="取消"
>
<Button type="text" icon={<EditFilled />} />
</Popconfirm>
</Space>
);
},
},
{
title: "摘要",
dataIndex: "course_summary",
key: "course_summary",
render: (_: any, record: any) => {
return (
<Space>
<span>{record.course_summary}</span>
<Popconfirm
placement="top"
title="修改摘要"
description={
<Input
defaultValue={record.course_summary}
onChange={(e: any) =>
setEditCourseItem({
key: "course_summary",
value: e.target.value,
})
}
/>
}
onConfirm={() => onConfirmEditCourseItem(record)}
okText="确定"
cancelText="取消"
>
<Button type="text" icon={<EditFilled />} />
</Popconfirm>
</Space>
);
},
},
{
title: "创建时间",
dataIndex: "course_createtime",
key: "course_createtime",
render: (_: any, record: any) => {
return (
<Space>
<span>
{dayjs(+record.course_createtime).format("YYYY-MM-DD HH:mm:ss")}
</span>
<Popconfirm
placement="top"
title="时间"
description={
<DatePicker
onChange={(date: any) => {
setEditCourseItem({
key: "course_createtime",
value: "" + new Date(date).getTime(),
});
}}
format="YYYY-MM-DD HH:mm:ss"
showTime={{ defaultValue: dayjs("00:00:00", "HH:mm:ss") }}
/>
}
onConfirm={() => onConfirmEditCourseItem(record)}
okText="确定"
cancelText="取消"
>
<Button type="text" icon={<FieldTimeOutlined />} />
</Popconfirm>
</Space>
);
},
},
{
title: "封面",
dataIndex: "course_cover_url",
key: "course_cover_url",
render: (_: any, record: any) => {
return <Image width={120} src={record.course_cover_url} />;
},
},
{
title: "可见",
dataIndex: "visible",
key: "visible",
render: (_: any, record: any) => {
return (
<Switch
checkedChildren="开启"
unCheckedChildren="关闭"
defaultChecked={record.valid}
onChange={(value: boolean) =>
updateCourse({ ...record, valid: value }).then((res: any) => {
if (res?.code === 10000) {
renderCourseTable();
setEditCourseItem(defaultEditItem);
}
})
}
/>
);
},
},
{
title: "操作",
dataIndex: "operation",
key: "operation",
render: (_: any, record: any) => {
return (
<Space>
<Popconfirm
placement="top"
title="确定删除课程嘛"
onConfirm={() => onConfirmDeleteCourseItem(record)}
okText="确定"
cancelText="取消"
>
<Button type="dashed" danger icon={<DeleteOutlined />}></Button>
</Popconfirm>
<Tooltip title="添加章节">
<Button
type="default"
onClick={() => onAddChapter(record)}
icon={<FileAddOutlined />}
></Button>
</Tooltip>
<Tooltip title="修改导读">
<Button
type="default"
onClick={() => onEditGuide(record)}
icon={<CompassOutlined />}
></Button>
</Tooltip>
</Space>
);
},
},
];
useEffect(() => {}, [courseList]);
const renderCourseTable = () => {
selectCourseList({ all: true }).then((res) => {
const { data } = res;
setCourseList(data.map((i: any) => ({ ...i, key: i.course_id })));
});
};
useMount(() => {
renderCourseTable();
});
const expandedRowRender = () => {
const col: any = [
{
title: "级别",
dataIndex: "chapter_level",
key: "chapter_level",
render: (_: any, record: any) => {
return (
<Space>
<span>{record.chapter_level}</span>
<Popconfirm
placement="top"
title="修改级别"
description={
<Input
defaultValue={record.chapter_level}
onChange={(e: any) =>
setEditChapterItem({
key: "chapter_level",
value: e.target.value,
})
}
/>
}
onConfirm={() => onConfirmEditChapterItem(record)}
okText="确定"
cancelText="取消"
>
<Button type="text" icon={<EditOutlined />} />
</Popconfirm>
</Space>
);
},
},
{
title: "章节",
dataIndex: "chapter_title",
key: "chapter_title",
render: (_: any, record: any) => {
return (
<Space>
<span>{record.chapter_title}</span>
<Popconfirm
placement="top"
title="修改章节标题名"
description={
<Input
defaultValue={record.chapter_title}
onChange={(e: any) =>
setEditChapterItem({
key: "chapter_title",
value: e.target.value,
})
}
/>
}
onConfirm={() => onConfirmEditChapterItem(record)}
okText="确定"
cancelText="取消"
>
<Button type="text" icon={<EditOutlined />} />
</Popconfirm>
</Space>
);
},
},
{
title: "FileID",
dataIndex: "chapter_file_id",
key: "chapter_file_id",
render: (_: any, record: any) => {
return +record.chapter_level === 2 ? (
<Space>
<span>{record.chapter_file_id}</span>
<Popconfirm
placement="top"
title="修改FileID"
description={
<Input
defaultValue={record.chapter_file_id}
onChange={(e: any) =>
setEditChapterItem({
key: "chapter_file_id",
value: e.target.value,
})
}
/>
}
onConfirm={() => onConfirmEditChapterItem(record)}
okText="确定"
cancelText="取消"
>
<Button type="text" icon={<EditOutlined />} />
</Popconfirm>
</Space>
) : (
"-"
);
},
},
{
title: "顺序",
dataIndex: "order",
key: "order",
render: (_: any, record: any) => {
return (
<Space>
<span>{record.order}</span>
<Popconfirm
placement="top"
title="修改顺序"
description={
<Input
defaultValue={record.order}
onChange={(e: any) =>
setEditChapterItem({
key: "order",
value: +e.target.value,
})
}
/>
}
onConfirm={() => onConfirmEditChapterItem(record)}
okText="确定"
cancelText="取消"
>
<Button type="text" icon={<EditOutlined />} />
</Popconfirm>
</Space>
);
},
},
{
title: "时长",
dataIndex: "media_time",
key: "media_time",
render: (_: any, record: any) => {
return +record.chapter_level === 2 ? (
<Space>
<span>{record.media_time}</span>
</Space>
) : (
"-"
);
},
},
{
title: "操作",
dataIndex: "operation_chapter",
key: "operation_chapter",
render: (_: any, record: any) => {
return (
<Space>
<Popconfirm
placement="top"
title="确定删除章节嘛"
onConfirm={() => onConfirmDeleteChapterItem(record)}
okText="确定"
cancelText="取消"
>
<Button type="dashed" danger icon={<DeleteOutlined />}></Button>
</Popconfirm>
</Space>
);
},
},
];
return <Table columns={col} dataSource={chapterList} pagination={false} />;
};
const renderChapterList = (chapter_course_id: string) => {
selectChapterList({ chapter_course_id }).then((res: any) => {
const { data = [] } = res;
setChapterList(data.map((i: any) => ({ ...i, key: i.chapter_id })));
});
};
const onExpand = (expand: boolean, record: any) => {
if (expand) {
const { course_id: chapter_course_id } = record;
renderChapterList(chapter_course_id);
}
};
return (
<div className="list">
<Card>
<Table
dataSource={courseList}
columns={columns}
expandable={{ expandedRowRender }}
onExpand={onExpand}
/>
</Card>
<Drawer
title={editGuide.course_title}
placement="bottom"
height="95%"
open={editGuide.drawerVisible}
onClose={() => setEditGuide((p) => ({ ...p, drawerVisible: false }))}
extra={
<Space>
<Button type="primary" onClick={onConfirmEditGuide}>
</Button>
</Space>
}
>
<Guide
onChange={onGuideChange}
styles={{ display: "block", height: "100%" }}
id="editGuideEditor"
defaultValue={editGuide.guide_value}
/>
</Drawer>
</div>
);
}

View File

@ -0,0 +1,127 @@
#root {
background: #f5f5f5;
height: 100vh;
}
.login {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
}
.frosted {
display: flex;
align-items: center;
height: 290px;
border-radius: 4px;
background-color: rgba(255, 255, 255, 1);
.bird-one {
width: 250px;
height: 290px;
position: relative;
background-color: #b4dfff;
overflow: hidden;
.beak {
width: 90px;
height: 90px;
position: absolute;
top: 65px;
left: 85px;
transform: rotate(55deg);
background-color: #ff4658;
}
.neck {
width: 200px;
height: 260px;
border-top-left-radius: 15% 10%;
position: absolute;
bottom: 0;
right: -30px;
transform: skewX(-20deg);
background: linear-gradient(to top, #4c77d3 86%, #ff4658 16%);
overflow: hidden;
}
.neck::before {
content: "";
width: 200px;
height: 20px;
border-top-left-radius: 18% 80%;
position: absolute;
top: 17px;
background: linear-gradient(to top, #102f97, #194fe6);
box-shadow: 0px -1px 0px 0.8px #417ef8, 0px -5px 8px rgba(0, 0, 0, 0.3);
}
.cir-blue {
width: 200px;
height: 200px;
border: 20px solid #2d4899;
border-top-left-radius: 65% 50%;
position: absolute;
top: 120px;
left: -20px;
background-color: #ff4658;
}
.feathers {
width: 200px;
height: 200px;
border-radius: 50%;
position: absolute;
top: 170px;
left: 135px;
background: linear-gradient(to left, #ccedff 75%, #4dabfd);
box-shadow: -6px 5px 8px rgba(0, 0, 0, 0.5);
}
.feather {
width: 30px;
height: 150px;
position: absolute;
border-top-left-radius: 100% 50%;
border-bottom-left-radius: 100% 50%;
}
.fe1 {
top: 12px;
left: 25px;
background-color: #84c7fc;
box-shadow: -8px 12px 8px rgba(0, 0, 0, 0.5), -1.5px 0px 0 #76c1ff;
}
.fe2 {
top: 5px;
left: 45px;
background-color: #99d7fe;
box-shadow: -5px 8px 8px rgba(0, 0, 0, 0.5), -1.5px 0 0 #52bafa;
}
.fe3 {
left: 65px;
background-color: #c4e9fb;
box-shadow: -5px 5px 8px rgba(0, 0, 0, 0.5), -0.8px 0 0 #60a2fd;
}
.eye {
width: 55px;
height: 55px;
border-radius: 50%;
position: absolute;
top: 80px;
right: 75px;
background-color: white;
}
.eye::before {
content: "";
width: 16px;
height: 25px;
border-radius: 50%;
position: absolute;
top: 15px;
left: 20px;
background: linear-gradient(to left, #2f4ba1 50%, #4b71d6 50%);
box-shadow: -0.6px 0px 0px 0.7px #364e9e, -4px 0px 4px #737e92,
2px 0px 3px #96a6bd;
}
}
.content {
padding: 30px;
}
}

View File

@ -0,0 +1,74 @@
import { Button, Checkbox, Form, Input, message } from "antd";
import { useNavigate } from "react-router-dom";
import { adminLogin } from "../../api";
import "./index.less";
const Login = () => {
const navigate = useNavigate();
const onFinish = (values: any) => {
adminLogin(values).then((res: any) => {
const { code } = res;
if (code == 10000) {
message.success("登录成功");
navigate("/overview");
} else {
message.error(res.msg);
}
});
};
return (
<div className="login">
<div className="frosted">
<div className="bird-one">
<div className="beak"></div>
<div className="neck">
<div className="cir-blue"></div>
</div>
<div className="feathers">
<div className="feather fe1"></div>
<div className="feather fe2"></div>
<div className="feather fe3"></div>
</div>
<div className="eye"></div>
</div>
<div className="content">
<Form
name="basic"
labelCol={{ span: 8 }}
wrapperCol={{ span: 16 }}
style={{ maxWidth: 600 }}
initialValues={{ remember: true }}
onFinish={onFinish}
autoComplete="off"
>
<Form.Item
name="username"
wrapperCol={{ span: 24 }}
rules={[{ required: true, message: "请输入用户名" }]}
>
<Input placeholder="用户名" />
</Form.Item>
<Form.Item
name="password"
rules={[{ required: true, message: "请输入密码" }]}
wrapperCol={{ span: 24 }}
>
<Input.Password placeholder="密码" />
</Form.Item>
<Form.Item wrapperCol={{ span: 24 }}>
<Button type="primary" htmlType="submit" block>
</Button>
</Form.Item>
</Form>
</div>
</div>
</div>
);
};
export default Login;

View File

@ -1,11 +1,8 @@
import { Button, message } from "antd";
import { useArticle } from "@backset/ui";
import { useMount } from "../../hooks";
export default function Index() {
useMount(() => {
console.log(useArticle());
});
useMount(() => {});
const onClick = () => {
message.info(`hi, 很惆怅啊, vite 哪里又有坑哦`);

View File

@ -0,0 +1,5 @@
import React from "react";
export default function Index() {
return <div>UserIndex</div>;
}

View File

@ -1,5 +1,192 @@
import React from "react";
import {
CheckOutlined,
EditOutlined,
SketchOutlined,
StopOutlined,
UserSwitchOutlined,
} from "@ant-design/icons";
import {
Button,
Card,
DatePicker,
Popconfirm,
Space,
Table,
Tooltip,
} from "antd";
import { RangePickerProps } from "antd/es/date-picker";
import dayjs from "dayjs";
import { useState } from "react";
import { selectUserList, updateUser } from "../../api";
import { useMount } from "../../hooks";
export default function Index() {
return <div>UserIndex</div>;
interface IEditUser {
key: string;
value: any;
}
const defaultEditUser: IEditUser = {
key: "",
value: "",
};
const User = () => {
const [userList, setUserList] = useState([]);
const [editUser, setEditUser] = useState<IEditUser>(defaultEditUser);
const onConfirmEditUser = (user: any) => {
updateUser(user).then((res: any) => {
if (res?.code === 10000) renderUserList();
});
};
const disabledDate: RangePickerProps["disabledDate"] = (current) => {
return current && current < dayjs().endOf("day");
};
const columns = [
{
title: "id",
dataIndex: "id",
key: "id",
},
{
title: "用户",
dataIndex: "user_login",
key: "user_login",
},
{
title: "创建时间",
dataIndex: "user_create_time",
key: "user_create_time",
render: (_: any, record: any) => {
return (
<span>
{dayjs(+record.user_create_time).format("YYYY-MM-DD HH:mm:ss")}
</span>
);
},
},
{
title: "状态",
dataIndex: "user_status",
key: "user_status",
render: (_: any, record: any) => {
return record.user_status ? (
<CheckOutlined style={{ color: "#52c41a" }} />
) : (
"-"
);
},
},
{
title: "订阅",
dataIndex: "user_sub",
key: "user_sub",
render: (_: any, record: any) => {
return record.user_sub ? (
<CheckOutlined style={{ color: "#52c41a" }} />
) : (
"-"
);
},
},
{
title: "订阅到期",
dataIndex: "user_sub_expired",
key: "user_sub_expired",
render: (_: any, record: any) => {
return record.user_sub ? (
<Space>
<span>
{dayjs(+record.user_sub_expired).format("YYYY-MM-DD HH:mm:ss")}
</span>
<Popconfirm
placement="top"
title="订阅到期时间"
description={
<DatePicker
onChange={(date: any) => {
setEditUser({
key: "user_sub_expired",
value: "" + new Date(date).getTime(),
});
}}
presets={[
{ label: "订阅 - 年", value: dayjs().add(1, "year") },
{ label: "订阅 - 季度", value: dayjs().add(3, "month") },
{ label: "订阅 - 月", value: dayjs().add(1, "month") },
]}
disabledDate={disabledDate}
format="YYYY-MM-DD HH:mm:ss"
showTime={{ defaultValue: dayjs("00:00:00", "HH:mm:ss") }}
/>
}
onConfirm={() =>
onConfirmEditUser({ ...record, [editUser.key]: editUser.value })
}
okText="确定"
cancelText="取消"
>
<Button type="text" icon={<EditOutlined />} />
</Popconfirm>
</Space>
) : (
"-"
);
},
},
{
title: "操作",
dataIndex: "operation",
key: "operation",
render: (_: any, record: any) => {
return (
<Space>
<Tooltip title="封号">
<Button
type="dashed"
danger
icon={<StopOutlined />}
onClick={() =>
onConfirmEditUser({
...record,
user_status: !record.user_status,
})
}
/>
</Tooltip>
<Tooltip title="订阅">
<Button
type="default"
icon={<UserSwitchOutlined />}
onClick={() =>
onConfirmEditUser({ ...record, user_sub: !record.user_sub })
}
/>
</Tooltip>
</Space>
);
},
},
];
const renderUserList = () => {
selectUserList().then((res: any) => {
const { data = [] } = res;
setUserList(data.map((u: any) => ({ ...u, key: u.id })));
});
};
useMount(() => renderUserList());
return (
<div style={{ marginTop: 24 }}>
<Card>
<Table dataSource={userList} columns={columns} />
</Card>
</div>
);
};
export default User;

View File

@ -0,0 +1,3 @@
.generate-invite-code {
padding: 24px 0;
}

View File

@ -0,0 +1,169 @@
import {
Button,
Card,
Col,
Input,
message,
Row,
Segmented,
Space,
Table,
} from "antd";
import "./index.less";
import { nanoid } from "nanoid";
import { useEffect, useState } from "react";
import { createXCode, selectXCodeList } from "../../api";
import { useMount } from "../../hooks";
import dayjs from "dayjs";
export default function InviteCode() {
const [dataSource, setDataSource] = useState([]);
const defaultColumns = [
{
title: "神秘代码",
dataIndex: "code",
key: "code",
filters: [],
filterSearch: true,
onFilter: (value: string, record: any) => record.code.startsWith(value),
},
{
title: "生成时间",
dataIndex: "createtime",
key: "createtime",
defaultSortOrder: "descend",
sorter: (a: any, b: any) => a.createtime - b.createtime,
render: (_: any, record: any) => {
return dayjs(+record.createtime).format("YYYY-MM-DD hh:mm:ss");
},
},
{
title: "失效时间",
dataIndex: "expiretime",
key: "expiretime",
defaultSortOrder: "descend",
sorter: (a: any, b: any) => a.expiretime - b.expiretime,
render: (_: any, record: any) => {
return dayjs(+record.expiretime).format("YYYY-MM-DD hh:mm:ss");
},
},
{
title: "绑定时间",
dataIndex: "user_usetime",
key: "user_usetime",
render: (_: any, record: any) => {
if (!!record.user_usetime)
return dayjs(+record.user_usetime).format("YYYY-MM-DD hh:mm:ss");
},
},
{
title: "用户",
dataIndex: "user_id",
key: "user_id",
render: (_: any, record: any) => {
return record.user_id > 0
? `${record.user_login ?? ""}(${record.user_id})`
: "";
},
},
{
title: "操作",
dataIndex: "operation",
key: "operation",
render: () => {
return <span>123</span>;
},
},
];
const [columns, setColumns] = useState<any>(defaultColumns);
useEffect(() => {
if (dataSource) {
defaultColumns[0].filters = dataSource.map((i: any) => ({
text: i.code.substring(0, 6) + "...",
value: i.code,
})) as any;
setColumns(defaultColumns);
}
}, [dataSource]);
const onClickGen = () => {
if (!genRule.amount || !genRule.day) return message.error("生成规则错误");
const params = [];
for (let i = 0; i < genRule.amount; i++)
params.push({
code: nanoid(),
expiretime: "" + (Date.now() + +genRule.day * 24 * 60 * 60 * 1000),
});
createXCode(params).then((res) => {
console.log(res);
setGenRule(defaultGenRule);
});
};
const defaultGenRule = { day: 30, amount: 10 };
const [genRule, setGenRule] = useState(defaultGenRule);
const onSearch = (value: string) => console.log(value);
useMount(() => {
selectXCodeList().then((res: any) => {
const { code, data } = res;
if (code === 10000)
setDataSource(data.map((i: any) => ({ ...i, key: i.code })));
});
});
return (
<div className="generate-invite-code">
<Card>
<Row>
<Col span={12}>
<Space>
<Segmented options={["生", "熟"]} />
</Space>
</Col>
<Col span={12} style={{ textAlign: "right" }}>
<Space>
<Input
style={{ width: 80 }}
placeholder="数量"
suffix="个"
value={genRule.amount}
onChange={(e) =>
setGenRule((p: any) => ({
...p,
amount: +e.target.value.replace(/[^\-?\d.]/g, ""),
}))
}
/>
<Input
style={{ width: 100 }}
placeholder="有效天数"
suffix="天"
value={genRule.day}
onChange={(e) =>
setGenRule((p: any) => ({
...p,
day: +e.target.value.replace(/[^\-?\d.]/g, ""),
}))
}
/>
<Button type="primary" onClick={onClickGen}>
</Button>
</Space>
</Col>
</Row>
<Table
style={{ marginTop: 24 }}
dataSource={dataSource}
columns={columns}
/>
</Card>
</div>
);
}

View File

@ -5,4 +5,14 @@ import tsconfigPaths from "vite-tsconfig-paths";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react(), tsconfigPaths()],
server: {
port: 5174,
proxy: {
"/api": {
rewrite: (path) => path.replace(/^\/api/, ""),
target: "http://127.0.0.1:7001/api/v1",
changeOrigin: true,
},
},
},
});

View File

@ -1,2 +1,12 @@
# 代码中使用 process.env.OSS_SECRET
OSS_SECRET=12345
OSS_SECRET=12345
# 腾讯vod
SECRET_ID=AKID534tZ7OvYzb2KQMwLYaVEl5FBwUtQWbU
SECRET_KEY=q9HD6lQimeLp9IH5h7NRJzUpNjwxmPq5
SUBAPPID=1500018521
SUBAPPID_OSS=1500018944
# 阿里sms
ACCESSKEY_ID=LTAIlHbKxMELdAM0
ACCESSKEY_SECRET=xLG8tBAMdUG8hVITgZEgl6lAgKamzC

View File

@ -12,5 +12,4 @@ run/
*.sw*
*.un~
.tsbuildinfo
.tsbuildinfo.*
public
.tsbuildinfo.*

View File

@ -4,8 +4,6 @@
"private": true,
"sideEffects": false,
"dependencies": {
"@backset/ui": "workspace:^1.0.0",
"@backset/util": "workspace:^1.0.0",
"@midwayjs/bootstrap": "^3.0.0",
"@midwayjs/core": "^3.0.0",
"@midwayjs/decorator": "^3.0.0",
@ -13,10 +11,10 @@
"@midwayjs/koa": "^3.0.0",
"@midwayjs/logger": "^2.14.0",
"@midwayjs/validate": "^3.0.0",
"@midwayjs/view-ejs": "^3.0.0",
"@midwayjs/static-file": "^3.0.0",
"@midwayjs/redis": "^3.0.0",
"@midwayjs/redis": "3.10.13",
"@midwayjs/typeorm": "^3.0.0",
"@midwayjs/upload": "3.10.14",
"mongoose": "^6.0.7",
"@midwayjs/typegoose": "3.0.0",
"@typegoose/typegoose": "10.1.1",
@ -24,7 +22,15 @@
"mysql2": "3.0.1",
"dotenv": "16.0.3",
"jsonwebtoken": "9.0.0",
"jquery": "3.6.3"
"tencentcloud-sdk-nodejs": "4.0.552",
"vod-node-sdk": "1.1.0",
"@alicloud/dysmsapi20170525": "2.0.23",
"@alicloud/openapi-client": "0.4.5",
"@alicloud/tea-util": "1.4.5",
"@alicloud/tea-typescript": "1.8.0",
"object-hash": "3.0.0",
"nanoid": "3.3.4",
"crypto-js": "4.1.1"
},
"devDependencies": {
"@midwayjs/cli": "^2.0.0",
@ -37,16 +43,14 @@
"@typescript-eslint/eslint-plugin": "^5.0.0",
"@typescript-eslint/parser": "^5.0.0",
"@types/jsonwebtoken": "9.0.1",
"@types/jquery": "3.5.16"
"@types/crypto-js": "4.1.1"
},
"engines": {
"node": ">=12.0.0"
},
"scripts": {
"start": "NODE_ENV=production node ./bootstrap.js",
"dev:be": "cross-env NODE_ENV=local midway-bin dev --ts",
"dev:fe": "cross-env RUNNING_ENV=dev webpack -w",
"build:fe": "cross-env RUNNING_ENV=prod webpack build",
"start": "NODE_ENV=production node ./bootstrap.js",
"cov": "midway-bin cov --ts",
"lint": "mwts check",
"lint:fix": "mwts fix",

View File

@ -0,0 +1,9 @@
/**
*
*/
export enum BizCode {
OK = 10000,
ERROR = 20000,
AUTH = 40000,
FORBID = 50000,
}

View File

@ -0,0 +1,18 @@
export const globalPrefix = '/api/v1';
const hour = 60 * 60 * 1000;
export const ADMIN = {
SIGN: '_sign_admin',
EXPIRED: 24 * hour,
};
export const WEB = {
SIGN: '_sign_web',
EXPIRED: 72 * hour,
};
/**
* 1
*/
export const SIGN_DEADLINE = 1 * hour;

View File

@ -1,16 +1,29 @@
import { MidwayAppInfo, MidwayConfig } from '@midwayjs/core';
import { uploadWhiteList } from '@midwayjs/upload';
import { tmpdir } from 'os';
import { join } from 'path';
import { globalPrefix } from './base.config';
export default (appInfo: MidwayAppInfo): MidwayConfig => {
return {
keys: '1676532942172_2248',
koa: {
port: 7001,
globalPrefix,
},
// ...
view: {
mapping: {
'.ejs': 'ejs',
},
upload: {
// mode: UploadMode, 默认为file即上传到服务器临时目录可以配置为 stream
mode: 'file',
// fileSize: string, 最大上传文件大小,默认为 10mb
fileSize: '10mb',
// whitelist: string[],文件扩展名白名单
whitelist: uploadWhiteList,
// tmpdir: string上传的文件临时存储路径
tmpdir: join(tmpdir(), 'midway-upload-files'),
// cleanTimeout: number上传的文件在临时目录中多久之后自动删除默认为 5 分钟
cleanTimeout: 5 * 60 * 1000,
// base64: boolean设置原始body是否是base64格式默认为false一般用于腾讯云的兼容
base64: false,
},
};
};

View File

@ -16,6 +16,7 @@ export default (appInfo: MidwayAppInfo): MidwayConfig => {
database: 'backset',
synchronize: true, // 如果第一次使用,不存在表,有同步的需求可以写 true注意会丢数据
logging: false,
connectTimeout: 15 * 1000,
// 扫描形式, 配置实体模型 entities: [Photo]
entities: ['**/entity/*.entity{.ts,.js}'],

View File

@ -0,0 +1,35 @@
import { MidwayAppInfo, MidwayConfig } from '@midwayjs/core';
export default (appInfo: MidwayAppInfo): MidwayConfig => {
return {
typeorm: {
dataSource: {
default: {
/**
*
*/
type: 'mysql',
host: 'backset-mysql',
port: 3307,
username: 'root',
password: 'root',
database: 'backset',
synchronize: true, // 如果第一次使用,不存在表,有同步的需求可以写 true注意会丢数据
logging: false,
connectTimeout: 15 * 1000,
// 扫描形式, 配置实体模型 entities: [Photo]
entities: ['**/entity/*.entity{.ts,.js}'],
},
},
},
redis: {
client: {
host: 'backset-redis', // Redis host
port: 26379, // Redis port
password: 'cr654654.',
db: 0,
},
},
} as MidwayConfig;
};

View File

@ -0,0 +1,8 @@
import { globalPrefix } from './base.config';
export const whiteApis = [
'/user/admin/auth',
'/user/web/auth',
'/user/web/sms',
'/course/select/all',
].map(api => globalPrefix + api);

View File

@ -1,28 +1,32 @@
import { Configuration, App } from '@midwayjs/core';
import {
Configuration,
App,
Inject,
MidwayDecoratorService,
} from '@midwayjs/core';
import * as koa from '@midwayjs/koa';
import * as validate from '@midwayjs/validate';
import * as info from '@midwayjs/info';
import * as view from '@midwayjs/view-ejs';
import * as staticFile from '@midwayjs/static-file';
import * as orm from '@midwayjs/typeorm';
import * as dotenv from 'dotenv';
import * as redis from '@midwayjs/redis';
import * as upload from '@midwayjs/upload';
import { join } from 'path';
import { DefaultErrorFilter } from './filter/default.filter';
import { NotFoundFilter } from './filter/notfound.filter';
import { ReportMiddleware } from './middleware/report.middleware';
import { LocalMiddleware } from './middleware/local.middleware';
import { AuthMiddleware } from './middleware/auth.middleware';
dotenv.config();
@Configuration({
imports: [
koa,
validate,
staticFile,
view,
orm,
redis,
upload,
{
component: info,
enabledEnvironment: ['local'],
@ -34,9 +38,12 @@ export class ContainerLifeCycle {
@App()
app: koa.Application;
@Inject()
decoratorService: MidwayDecoratorService;
async onReady() {
// add middleware
this.app.useMiddleware([ReportMiddleware, LocalMiddleware]);
this.app.useMiddleware([ReportMiddleware, AuthMiddleware]);
// add filter
this.app.useFilter([NotFoundFilter, DefaultErrorFilter]);
}

View File

@ -1,18 +0,0 @@
import { Inject, Controller, Post, Body } from '@midwayjs/core';
import { Context } from '@midwayjs/koa';
import { Validate } from '@midwayjs/validate';
import { UserDTO } from '../dto/user.dto';
@Controller('/api')
export class APIController {
@Inject()
ctx: Context;
@Post('/get_user')
@Validate({
errorStatus: 422,
})
async getUser(@Body() user: UserDTO) {
return { success: true, message: 'OK', data: user };
}
}

View File

@ -0,0 +1,170 @@
import { Body, Controller, Inject, Post } from '@midwayjs/core';
import { Context } from '@midwayjs/koa';
import { BizCode } from '../biz/code';
import { WEB } from '../config/base.config';
import { CourseCreateDTO } from '../dto/course.dto';
import { Chapter } from '../entity/chapter.entity';
import { Course } from '../entity/course.entity';
import { Guide } from '../entity/guide.entity';
import { ChapterService } from '../service/chapter.service';
import { CourseService } from '../service/course.service';
import { GuideService } from '../service/guide.service';
import { UserService } from '../service/user.service';
import { decodeToken } from '../util/encrypt';
@Controller('/course')
export class CourseController {
@Inject()
ctx: Context;
@Inject()
courseService: CourseService;
@Inject()
chapterService: ChapterService;
@Inject()
guideService: GuideService;
@Inject()
userService: UserService;
@Post('/create')
async create(@Body() param: CourseCreateDTO) {
try {
const { course_chapterList, course_guide, ...rest } = param;
const courseId = await this.courseService.create({ ...rest });
await this.chapterService.create(
course_chapterList.map((i: any) => ({
...i,
chapter_course_id: courseId,
}))
);
await this.guideService.create({
...course_guide,
guide_course_id: courseId,
});
return { code: BizCode.OK };
} catch (error) {
this.ctx.logger.error(error);
return {
code: BizCode.ERROR,
msg: `[error] course/create error happened`,
};
}
}
@Post('/select/all')
async selectAll(@Body() params) {
const { all = false } = params;
const courseList = await this.courseService.selectAll(all);
return { code: BizCode.OK, data: courseList };
}
@Post('/detail/select')
async selectDetailByCourseId(@Body() params) {
const { course_id } = params;
try {
const token = this.ctx.cookies.get(WEB.SIGN);
const { user_login } = decodeToken(token);
const user = await this.userService.select({ user_login });
// 用户订阅鉴权
if (!user.user_sub)
return { code: BizCode.AUTH, msg: '无权访问订阅课程' };
if (+user.user_sub_expired < Date.now())
return { code: BizCode.AUTH, msg: '订阅已过期' };
const course = await this.courseService.select({ course_id });
const chapterList = await this.chapterService.select(course_id);
const guide = await this.guideService.select(course_id);
return { code: BizCode.OK, data: { chapterList, guide, course } };
} catch (error) {
this.ctx.logger.error(error);
return { code: BizCode.ERROR, msg: '[error] /chapter/select error' };
}
}
@Post('/update')
async updateCourse(@Body() course: Course) {
try {
await this.courseService.update(course);
return { code: BizCode.OK };
} catch (error) {
this.ctx.logger.error(error);
return { code: BizCode.ERROR, msg: error };
}
}
@Post('/chapter/select')
async selectChapterList(@Body() chapter: Chapter) {
const { chapter_course_id } = chapter;
const chapterList = await this.chapterService.select(chapter_course_id);
return { code: BizCode.OK, data: chapterList };
}
@Post('/chapter/update')
async updateChapter(@Body() chapter: Chapter) {
try {
await this.chapterService.update(chapter);
return { code: BizCode.OK };
} catch (error) {
this.ctx.logger.error(error);
return { code: BizCode.ERROR, msg: error };
}
}
@Post('/remove')
async remove(@Body() course: Course) {
const { course_id } = course;
try {
await this.courseService.remove(course_id);
await this.chapterService.removeByCourseId(course_id);
await this.guideService.removeByCourseId(course_id);
return { code: BizCode.OK };
} catch (error) {
this.ctx.logger.error(error);
return { code: BizCode.ERROR, msg: error };
}
}
@Post('/chapter/create')
async createChapter(@Body() chapterList: Chapter[]) {
try {
// order默认为-1
await this.chapterService.create(chapterList);
return { code: BizCode.OK };
} catch (error) {
this.ctx.logger.error(error);
return { code: BizCode.ERROR, msg: error };
}
}
@Post('/chapter/remove')
async removeChapter(@Body() chapter: Chapter) {
try {
const { chapter_id } = chapter;
await this.chapterService.remove(chapter_id);
return { code: BizCode.OK };
} catch (error) {
this.ctx.logger.error(error);
return { code: BizCode.ERROR, msg: error };
}
}
@Post('/guide/select')
async selectGuide(@Body() guide: Guide) {
const { guide_course_id: course_id } = guide;
const data = await this.guideService.select(course_id);
return { code: BizCode.OK, data };
}
@Post('/guide/update')
async updateGuide(@Body() guide: Guide) {
try {
await this.guideService.update(guide);
return { code: BizCode.OK };
} catch (error) {
this.ctx.logger.error(error);
return { code: BizCode.ERROR, msg: error };
}
}
}

View File

@ -1,47 +0,0 @@
import { Controller, Get, Inject } from '@midwayjs/core';
import { InjectEntityModel } from '@midwayjs/typeorm';
import { Context } from '@midwayjs/koa';
import { Photo } from '../entity/photo.entity';
import { Repository } from 'typeorm';
@Controller('/')
export class HomeController {
@Inject()
ctx: Context;
@InjectEntityModel(Photo)
photoModel: Repository<Photo>;
@Get('/')
async home() {
await this.ctx.render('page/home/index.ejs', {
name: 'home',
});
}
@Get('/signup')
async signup() {
await this.ctx.render('page/signup/index.ejs', {
name: 'signup',
});
}
@Get('/testMysql')
async testMysql() {
// create a entity object
let photo = new Photo();
photo.name = 'Me and Bears';
photo.description = 'I am near polar bears';
photo.filename = 'photo-with-bears.jpg';
photo.views = 1;
photo.isPublished = true;
// save entity
const photoResult = await this.photoModel.save(photo);
// save success
console.log('photo id = ', photoResult.id);
this.ctx.body = photoResult.id;
}
}

View File

@ -0,0 +1,154 @@
import { Body, Controller, Get, Inject, Post } from '@midwayjs/core';
import { Context } from '@midwayjs/koa';
import { BizCode } from '../biz/code';
import { UserAdminAuthDTO, UserWebAuthDTO } from '../dto/user.dto';
import { XCodeService } from '../service/xcode.service';
import { UserService } from '../service/user.service';
import { createToken, decodeToken } from '../util/encrypt';
import { SmsService } from '../service/sms.service';
import { SmsDTO } from '../dto/sms.dto';
import { RedisService } from '@midwayjs/redis';
import * as CryptoJS from 'crypto-js';
import { ADMIN, WEB } from '../config/base.config';
import { User } from '../entity/user.entity';
@Controller('/user')
export class UserController {
@Inject()
ctx: Context;
@Inject()
userService: UserService;
@Inject()
xcodeService: XCodeService;
@Inject()
smsService: SmsService;
@Inject()
redisService: RedisService;
/**
*
*/
@Post('/web/auth')
async webAuth(@Body() params: UserWebAuthDTO) {
try {
const verifyCode = await this.redisService.get('' + params.user_login);
if (!verifyCode) return { code: BizCode.ERROR, msg: '验证码无效' };
// 查询用户是否存在
const userExist = await this.userService.select(params);
// 用户是否被封号
if (!userExist?.user_status)
return { code: BizCode.FORBID, msg: '您的账号被封禁' };
const payload = userExist?.id
? userExist
: await this.userService.createUser(params);
const expiredIn = new Date(Date.now() + WEB.EXPIRED);
const token = createToken({
...payload,
hasLogin: true,
expiredIn,
platform: 'web',
});
this.ctx.cookies.set(WEB.SIGN, token, {
expires: expiredIn,
httpOnly: false,
});
await this.redisService.del('' + params.user_login);
return {
code: BizCode.OK,
msg: '欢迎来到 backset.cn',
data: { ...payload },
};
} catch (error) {
this.ctx.logger.error(error);
return { code: BizCode.ERROR, msg: '[error] web/auth error' };
}
}
/**
*
*/
@Post('/admin/auth')
async AdminAuth(@Body() params: UserAdminAuthDTO) {
try {
const { username, password } = params;
const expiredIn = new Date(Date.now() + ADMIN.EXPIRED);
const token = createToken({
hasLogin: true,
expiredIn,
platform: 'admin',
});
if (username === 'admin' && password === '123123') {
this.ctx.cookies.set(ADMIN.SIGN, token, {
expires: expiredIn,
httpOnly: false,
});
return { code: BizCode.OK };
} else {
return { code: BizCode.ERROR, msg: '用户名密码错误' };
}
} catch (error) {
this.ctx.logger.error(error);
return { code: BizCode.ERROR, msg: error };
}
}
@Get('/web/state')
async state() {
try {
const token = this.ctx.cookies.get(WEB.SIGN);
const user = decodeToken(token);
const { user_login } = user;
// 查询用户是否被封号
const user_current = await this.userService.select({ user_login });
const { user_status } = user_current;
if (!user_status) return { code: BizCode.FORBID, msg: '您的账号被封禁' };
return { code: BizCode.OK, data: user };
} catch (error) {
this.ctx.logger.error(error);
return { code: BizCode.ERROR, msg: '[error] /web/state error' };
}
}
@Post('/web/sms')
async verifyCode(@Body() params: SmsDTO) {
try {
const { phoneNumber: phoneNumbers, sign } = params;
// 查询手机号是否被封禁
const user = await this.userService.select({ user_login: phoneNumbers });
if (user && !user.user_status)
return { code: BizCode.FORBID, msg: '您的账号被封禁' };
// 防止接口调用 start
const decrypted = CryptoJS.AES.decrypt(sign, phoneNumbers);
const hackAction = decrypted.toString(CryptoJS.enc.Utf8) !== phoneNumbers;
if (hackAction) return { code: BizCode.ERROR, msg: 'fuck u' };
// 防止接口调用 end
const code = Math.floor(Math.random() * 9000 + 1000);
await this.redisService.set('' + phoneNumbers, code, 'EX', 60);
await this.smsService.send({ code, phoneNumbers });
return { code: BizCode.OK };
} catch (error) {
this.ctx.logger.error(error);
return { code: BizCode.ERROR, msg: '[error] /web/sms error' };
}
}
@Post('/admin/select/all')
async selectUser() {
const data = await this.userService.selectAll();
return { code: BizCode.OK, data };
}
@Post('/admin/update')
async updateUser(@Body() user: User) {
try {
await this.userService.update(user);
return { code: BizCode.OK };
} catch (error) {
this.ctx.logger.error(error);
return { code: BizCode.ERROR, msg: error };
}
}
}

View File

@ -0,0 +1,68 @@
import { Inject, Post, Body, Files, Controller, Context } from '@midwayjs/core';
import { BizCode } from '../biz/code';
import { VodSearchDTO } from '../dto/vod.dto';
import { uploadImagePromise } from '../util/vod';
import { IVodResponse } from '../interface';
import { VodService } from '../service/vod.service';
@Controller('/vod')
export class VodController {
@Inject()
ctx: Context;
@Inject()
vodService: VodService;
/**
*
* API调用demo: https://console.cloud.tencent.com/api/explorer?Product=vod&Version=2018-07-17&Action=SearchMedia
* 5000
*/
@Post('/media/select')
async getCourseMediaList(@Body() param: VodSearchDTO) {
const { offset: Offset = 0, limit: Limit = 5000, fileId } = param;
try {
// 单个媒体查询
if (fileId) {
const { data } = await this.vodService.selectMediaByFileId(fileId);
if (data) return { code: BizCode.OK, data };
}
const { data } = await this.vodService.selectMediaList({ Offset, Limit });
if (data) return { code: BizCode.OK, data };
} catch (err) {
this.ctx.logger.error(err);
return { code: BizCode.ERROR, msg: '[error] vod create error' };
}
}
/**
*
*/
@Post('/course/cover/upload')
async uploadCourseCoverImage(@Files() files) {
const tmpPath = files[0].data;
return await uploadImagePromise(tmpPath, +process.env.SUBAPPID)
.then((data: IVodResponse) => ({ code: BizCode.OK, data }))
.catch(err => {
this.ctx.logger.error(err);
throw new Error(err);
});
}
/**
*
*/
@Post('/oss/image/upload')
async ossUploadImage(@Files() files) {
const tmpPath = files[0].data;
return await uploadImagePromise(tmpPath, +process.env.SUBAPPID_OSS)
.then((data: IVodResponse) => ({
code: BizCode.OK,
data: { name: data.FileId, url: data.MediaUrl },
}))
.catch(err => {
this.ctx.logger.error(err);
return { code: BizCode.ERROR, msg: err };
});
}
}

View File

@ -0,0 +1,29 @@
import { Body, Context, Controller, Inject, Post } from '@midwayjs/core';
import { BizCode } from '../biz/code';
import { XCodeService } from '../service/xcode.service';
@Controller('/xcode')
export class XCodeController {
@Inject()
xcodeService: XCodeService;
@Inject()
ctx: Context;
@Post('/admin/create')
async create(@Body() codeList) {
try {
await this.xcodeService.create(codeList);
return { code: BizCode.OK };
} catch (error) {
this.ctx.logger.error(error);
return { code: BizCode.ERROR, msg: '[error] xcode/create error' };
}
}
@Post('/admin/select/all')
async selectAll() {
const data = await this.xcodeService.selectAll();
return { code: BizCode.OK, data };
}
}

View File

@ -0,0 +1,18 @@
import { Rule, RuleType } from '@midwayjs/validate';
export class CourseCreateDTO {
@Rule(RuleType.string().required())
course_title: string;
@Rule(RuleType.string().required())
course_cover_url: string;
@Rule(RuleType.string().required())
course_summary: string;
@Rule(RuleType.required())
course_chapterList: [];
@Rule(RuleType.required())
course_guide: { guide_value: string; guide_html: string };
}

View File

@ -0,0 +1,10 @@
import { Rule, RuleType } from '@midwayjs/validate';
export class SmsDTO {
@Rule(
RuleType.string().required().length(11).error(new Error('手机号格式错误'))
)
phoneNumber?: string;
@Rule(RuleType.string().required())
sign?: string;
}

View File

@ -1,16 +1,23 @@
// src/dto/user.ts
import { Rule, RuleType } from '@midwayjs/validate';
export class UserDTO {
@Rule(RuleType.number().required())
id: number;
export class UserWebAuthDTO {
@Rule(
RuleType.string().required().length(11).error(new Error('手机号为11位数字'))
)
user_login: string;
@Rule(RuleType.string().required().min(4).error(new Error('验证码4位数字')))
user_pass: string;
@Rule(RuleType.string().allow(''))
xcode: string;
}
export class UserAdminAuthDTO {
@Rule(RuleType.string().required())
username: string;
@Rule(RuleType.string().required())
firstName: string;
@Rule(RuleType.string().max(10))
lastName: string;
@Rule(RuleType.number().max(60))
age: number;
password: string;
}

View File

@ -0,0 +1,13 @@
// src/dto/user.ts
import { Rule, RuleType } from '@midwayjs/validate';
export class VodSearchDTO {
@Rule(RuleType.number().required())
offset: number;
@Rule(RuleType.number().required())
limit: number;
@Rule(RuleType.string())
fileId?: string;
}

View File

@ -0,0 +1,31 @@
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
@Entity('chapter')
export class Chapter {
@PrimaryGeneratedColumn('uuid')
chapter_id?: string;
@Column({ type: 'text' })
chapter_title?: string;
@Column()
chapter_level?: '1' | '2';
@Column({ default: '' })
chapter_file_id?: string;
@Column()
chapter_course_id?: string;
@Column({ default: -1 })
order?: number;
@Column({ default: '' })
media_time?: string;
@Column({ default: '' })
media_url?: string;
@Column({ default: '' })
media_cover_url?: string;
}

View File

@ -0,0 +1,22 @@
import { Column, Entity, PrimaryColumn } from 'typeorm';
@Entity('course')
export class Course {
@PrimaryColumn()
course_id?: string;
@Column({ type: 'varchar' })
course_title?: string;
@Column({ type: 'text' })
course_summary?: string;
@Column()
course_cover_url?: string;
@Column({ default: 1 })
valid?: boolean;
@Column({ default: Date.now() })
course_createtime?: string;
}

View File

@ -0,0 +1,16 @@
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
@Entity('guide')
export class Guide {
@PrimaryGeneratedColumn('uuid')
guide_id?: string;
@Column({ type: 'text' })
guide_value: string;
@Column({ type: 'text' })
guide_html: string;
@Column()
guide_course_id: string;
}

View File

@ -1,3 +1,31 @@
export class User{
}
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
@Entity('user')
export class User {
@PrimaryGeneratedColumn('increment')
id?: number;
@Column({ unique: true })
user_login?: string;
@Column({ default: '' })
user_email?: string;
@Column({ default: Date.now() })
user_create_time?: string;
@Column({ default: true })
user_status?: boolean;
@Column()
display_name?: string;
@Column({ default: '' })
user_avatar?: string;
@Column({ default: false })
user_sub?: boolean;
@Column()
user_sub_expired?: string;
}

View File

@ -0,0 +1,19 @@
import { Column, Entity, PrimaryColumn } from 'typeorm';
@Entity('xcode')
export class XCode {
@PrimaryColumn()
code?: string;
@Column({ default: Date.now() })
createtime?: string;
@Column({ default: '' })
expiretime?: string;
@Column({ default: -1 })
user_id?: number;
@Column({ default: '' })
user_usetime?: string;
}

View File

@ -1,11 +1,12 @@
import { Catch, httpError, MidwayHttpError } from '@midwayjs/core';
import { Context } from '@midwayjs/koa';
import { BizCode } from '../biz/code';
@Catch(httpError.NotFoundError)
export class NotFoundFilter {
async catch(err: MidwayHttpError, ctx: Context) {
// 404 错误会到这里
// ctx.redirect('/404.html');
ctx.body = '迷路了'
ctx.body = { code: BizCode.ERROR, msg: err };
}
}

View File

@ -4,3 +4,10 @@
export interface IUserOptions {
uid: number;
}
export interface IVodResponse {
CoverUrl: string;
FileId: string;
MediaUrl: string;
RequestId: string;
}

View File

@ -0,0 +1,58 @@
import {
Middleware,
IMiddleware,
App,
IMidwayApplication,
} from '@midwayjs/core';
import { NextFunction, Context } from '@midwayjs/koa';
import { BizCode } from '../biz/code';
import { ADMIN, SIGN_DEADLINE, WEB } from '../config/base.config';
import { whiteApis } from '../config/white.api';
import { createToken, decodeToken } from '../util/encrypt';
@Middleware()
export class AuthMiddleware implements IMiddleware<Context, NextFunction> {
@App()
app: IMidwayApplication;
resolve() {
return async (ctx: Context, next: NextFunction) => {
const isWhiteApi = whiteApis.some(api => ctx.url.indexOf(api) > -1);
if (!isWhiteApi) {
const token = ctx.cookies.get(ADMIN.SIGN) ?? ctx.cookies.get(WEB.SIGN);
try {
const { hasLogin, expiredIn, platform, ...rest } = decodeToken(token);
// token缺少hasLogin
if (!hasLogin) return { code: BizCode.AUTH, msg: '身份验证错误' };
// 续签
const sign = platform === 'web' ? WEB.SIGN : ADMIN.SIGN;
const signExpired = platform === 'web' ? WEB.EXPIRED : ADMIN.EXPIRED;
const timeLeft = new Date(expiredIn).getTime() - Date.now();
if (timeLeft < SIGN_DEADLINE) {
const expiredIn = new Date(Date.now() + signExpired);
const token = createToken({
hasLogin: true,
platform,
expiredIn,
...rest,
});
ctx.cookies.set(sign, token, {
expires: expiredIn,
httpOnly: false,
});
}
await next();
} catch (error) {
ctx.logger.error(error);
return { code: BizCode.AUTH, msg: '身份验证错误' };
}
} else {
await next();
}
};
}
static getName(): string {
return 'auth';
}
}

View File

@ -1,21 +0,0 @@
import { Middleware, IMiddleware } from '@midwayjs/core';
import { NextFunction, Context } from '@midwayjs/koa';
const locals = {
assets: 'public/',
version: Date.now(),
};
@Middleware()
export class LocalMiddleware implements IMiddleware<Context, NextFunction> {
resolve() {
return async (ctx: Context, next: NextFunction) => {
ctx.locals = locals;
await next();
};
}
static getName(): string {
return 'local';
}
}

View File

@ -0,0 +1,51 @@
import { Context, Inject, Provide } from '@midwayjs/core';
import { InjectEntityModel } from '@midwayjs/typeorm';
import { Repository } from 'typeorm';
import { Chapter } from '../entity/chapter.entity';
import { VodService } from './vod.service';
@Provide()
export class ChapterService {
@Inject()
ctx: Context;
@Inject()
vodService: VodService;
@InjectEntityModel(Chapter)
chapterModel: Repository<Chapter>;
async create(chapterList: Chapter[]) {
for (const chapter of chapterList) {
const { chapter_file_id: fileID } = chapter;
if (fileID) {
const { data } = await this.vodService.selectMediaByFileId(fileID);
chapter.media_cover_url = data.MediaInfoSet[0].BasicInfo.CoverUrl;
chapter.media_time = '' + data.MediaInfoSet[0].MetaData.Duration;
chapter.media_url =
data.MediaInfoSet[0].AdaptiveDynamicStreamingInfo.AdaptiveDynamicStreamingSet[0].Url;
}
await this.chapterModel.save(chapter);
}
}
async select(course_id: string) {
const result = await this.chapterModel.find({
where: { chapter_course_id: course_id },
order: { order: 'asc' },
});
return result;
}
async update(chapter: Chapter) {
await this.chapterModel.save(chapter);
}
async removeByCourseId(course_id: string) {
await this.chapterModel.delete({ chapter_course_id: course_id });
}
async remove(chapter_id: string) {
await this.chapterModel.delete({ chapter_id });
}
}

View File

@ -0,0 +1,44 @@
import { Context, Inject, Provide } from '@midwayjs/core';
import { InjectEntityModel } from '@midwayjs/typeorm';
import { Repository } from 'typeorm';
import { Course } from '../entity/course.entity';
import { nanoid } from 'nanoid';
export interface ICourseCreate {
course_title: string;
course_summary: string;
course_cover_url: string;
}
@Provide()
export class CourseService {
@Inject()
ctx: Context;
@InjectEntityModel(Course)
courseModel: Repository<Course>;
async create(course: Course) {
course.course_id = nanoid(13);
const courseCreateRes = await this.courseModel.save(course);
return courseCreateRes.course_id;
}
async selectAll(all) {
if (!all) return await this.courseModel.find({ where: { valid: true } });
else return await this.courseModel.find();
}
async select(course: Course) {
const { course_id } = course;
return await this.courseModel.findOne({ where: { course_id } });
}
async update(course: Course) {
return await this.courseModel.save(course);
}
async remove(course_id: string) {
await this.courseModel.delete({ course_id });
}
}

View File

@ -0,0 +1,32 @@
import { Context, Inject, Provide } from '@midwayjs/core';
import { InjectEntityModel } from '@midwayjs/typeorm';
import { Repository } from 'typeorm';
import { Guide } from '../entity/guide.entity';
@Provide()
export class GuideService {
@Inject()
ctx: Context;
@InjectEntityModel(Guide)
guideModel: Repository<Guide>;
async create(guide: Guide) {
const guideCreateRes = await this.guideModel.save(guide);
return guideCreateRes.guide_id;
}
async select(course_id: string) {
return await this.guideModel.findOne({
where: { guide_course_id: course_id },
});
}
async removeByCourseId(course_id: string) {
await this.guideModel.delete({ guide_course_id: course_id });
}
async update(guide: Guide) {
await this.guideModel.save(guide);
}
}

View File

@ -0,0 +1,47 @@
// This file is auto-generated, don't edit it
import Dysmsapi20170525, * as $Dysmsapi20170525 from '@alicloud/dysmsapi20170525';
// 依赖的模块可通过下载工程中的模块依赖文件或右上角的获取 SDK 依赖信息查看
import * as $OpenApi from '@alicloud/openapi-client';
import Util, * as $Util from '@alicloud/tea-util';
import { Context, Inject, Provide } from '@midwayjs/core';
@Provide()
export class SmsService {
@Inject()
ctx: Context;
/**
* 使AK&SK初始化账号Client
* @param accessKeyId
* @param accessKeySecret
* @return Client
* @throws Exception
*/
createClient(accessKeyId: string, accessKeySecret: string): Dysmsapi20170525 {
const config = new $OpenApi.Config({ accessKeyId, accessKeySecret });
// 访问的域名
config.endpoint = `dysmsapi.aliyuncs.com`;
return new Dysmsapi20170525(config);
}
async send({ code, phoneNumbers }): Promise<void> {
// 工程代码泄露可能会导致AccessKey泄露并威胁账号下所有资源的安全性。以下代码示例仅供参考建议使用更安全的 STS 方式更多鉴权访问方式请参见https://help.aliyun.com/document_detail/378664.html
const client = this.createClient(
process.env['ACCESSKEY_ID'],
process.env['ACCESSKEY_SECRET']
);
const sendSmsRequest = new $Dysmsapi20170525.SendSmsRequest({
phoneNumbers: phoneNumbers,
signName: '寻鹿网',
templateCode: 'SMS_186510297',
templateParam: `{"code":"${code}"}`,
});
const runtime = new $Util.RuntimeOptions({});
try {
// 复制代码运行请自行打印 API 的返回值
await client.sendSmsWithOptions(sendSmsRequest, runtime);
} catch (error) {
throw new Error(Util.assertAsString(error.message));
}
}
}

View File

@ -1,14 +1,35 @@
import { Provide } from '@midwayjs/core';
import { IUserOptions } from '../interface';
import { InjectEntityModel } from '@midwayjs/typeorm';
import { Repository } from 'typeorm';
import { User } from '../entity/user.entity';
import hash from 'object-hash';
@Provide()
export class UserService {
async getUser(options: IUserOptions) {
return {
uid: options.uid,
username: 'mockedName',
phone: '12345678901',
email: 'xxx.xxx@xxx.com',
};
@InjectEntityModel(User)
userModel: Repository<User>;
async select(p: User): Promise<User> {
const { user_login } = p;
const user = await this.userModel.findOne({
where: { user_login },
});
return user;
}
async selectAll(): Promise<User[]> {
return await this.userModel.find();
}
async createUser(user: User) {
const h = hash('' + user.user_login);
user.display_name = h.substring(0, 8);
user.user_avatar = h;
const result = await this.userModel.save(user);
return result;
}
async update(user: User) {
this.userModel.save(user);
}
}

View File

@ -0,0 +1,54 @@
import { Provide } from '@midwayjs/core';
import * as tencentcloud from 'tencentcloud-sdk-nodejs';
const VodClient = tencentcloud.vod.v20180717.Client;
const clientConfig = {
credential: {
secretId: process.env.SECRET_ID,
secretKey: process.env.SECRET_KEY,
},
profile: {
httpProfile: {
endpoint: 'vod.tencentcloudapi.com',
},
},
};
@Provide()
export class VodService {
/**
* FileId查询单个媒体资源
*/
async selectMediaByFileId(fileId: string) {
const client = new VodClient(clientConfig);
const params = {
SubAppId: +process.env.SUBAPPID,
FileIds: [fileId],
};
return await client.SearchMedia(params).then(
data => ({ data }),
err => {
throw new Error(err);
}
);
}
/**
*
*/
async selectMediaList({ Offset = 0, Limit = 5000 }) {
const client = new VodClient(clientConfig);
const params = {
SubAppId: +process.env.SUBAPPID,
Categories: ['Video'],
Offset,
Limit,
};
return await client.SearchMedia(params).then(
data => ({ data }),
err => {
throw new Error(err);
}
);
}
}

View File

@ -0,0 +1,32 @@
import { Provide } from '@midwayjs/core';
import { InjectEntityModel } from '@midwayjs/typeorm';
import { Repository } from 'typeorm';
import { XCode } from '../entity/xcode.entity';
@Provide()
export class XCodeService {
@InjectEntityModel(XCode)
xcodeModel: Repository<XCode>;
async valid(code: string) {
const row = await this.xcodeModel.findOne({ where: { code } });
return row && row.user_id === -1;
}
async use(code: string, user_id: number) {
await this.xcodeModel.update(
{ code },
{ user_id, user_usetime: '' + Date.now() }
);
}
async create(codeList: XCode[]) {
await this.xcodeModel.save(codeList);
}
async selectAll() {
return await this.xcodeModel.query(
'SELECT xcode.*,`user`.user_login FROM `xcode` LEFT JOIN `user` ON xcode.user_id = `user`.id'
);
}
}

View File

@ -0,0 +1,18 @@
import { VodUploadClient, VodUploadRequest } from 'vod-node-sdk';
/**
*
* @param {string} tmpPath midwayjs缓存的地址
*/
export const uploadImagePromise = (tmpPath: string, subAppId: number) => {
const { SECRET_ID, SECRET_KEY } = process.env;
const client = new VodUploadClient(SECRET_ID, SECRET_KEY);
const req = new VodUploadRequest();
req.MediaFilePath = tmpPath;
req.SubAppId = subAppId;
return new Promise((resolve, reject) => {
client.upload('ap-shanghai', req, (err, data) => {
err ? reject(err) : resolve(data);
});
});
};

View File

@ -1 +0,0 @@
<svg t="1667271285418" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="34809" width="200" height="200"><path d="M158.165333 499.498667A42.496 42.496 0 0 0 170.666667 469.333333V256a42.666667 42.666667 0 0 1 42.666666-42.666667 42.666667 42.666667 0 0 0 0-85.333333C142.762667 128 85.333333 185.429333 85.333333 256v195.669333l-30.165333 30.165334a42.666667 42.666667 0 0 0 0 60.330666l30.165333 30.165334V768c0 70.570667 57.429333 128 128 128a42.666667 42.666667 0 0 0 0-85.333333 42.666667 42.666667 0 0 1-42.666666-42.666667v-213.333333a42.496 42.496 0 0 0-12.501334-30.165334L145.664 512l12.501333-12.501333zM978.090667 495.658667a42.709333 42.709333 0 0 0-9.258667-13.824L938.666667 451.669333V256c0-70.570667-57.429333-128-128-128a42.666667 42.666667 0 1 0 0 85.333333 42.666667 42.666667 0 0 1 42.666666 42.666667v213.333333a42.581333 42.581333 0 0 0 12.501334 30.165334l12.501333 12.501333-12.501333 12.501333A42.496 42.496 0 0 0 853.333333 554.666667v213.333333a42.666667 42.666667 0 0 1-42.666666 42.666667 42.666667 42.666667 0 1 0 0 85.333333c70.570667 0 128-57.429333 128-128v-195.669333l30.165333-30.165334a42.709333 42.709333 0 0 0 9.258667-46.506666zM669.738667 225.450667a42.752 42.752 0 0 0-69.546667 14.762666l-255.829333 512a42.624 42.624 0 0 0 23.893333 55.424 42.922667 42.922667 0 0 0 55.552-23.765333l255.786667-512a42.538667 42.538667 0 0 0-9.813334-46.421333z" p-id="34810" fill="#2c2c2c"></path></svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 16 16"><g fill="none"><path d="M1 7.998a6.998 6.998 0 1 1 13.996 0A6.998 6.998 0 0 1 1 7.998zM7.998 2h-.05c.092.18.192.395.282.634c.243.646.464 1.592.071 2.428c-.362.77-.985.97-1.45 1.09l-.068.017c-.452.117-.64.165-.775.37c-.126.192-.104.433.04.9l.032.105c.057.182.125.398.16.602c.045.254.057.572-.103.88a1.51 1.51 0 0 1-.622.651c-.235.128-.48.177-.664.21l-.069.012c-.358.063-.54.095-.714.282c-.137.147-.222.402-.272.772c-.02.151-.032.301-.045.456l-.006.082c-.014.17-.032.364-.07.53l-.024.11a5.981 5.981 0 0 0 4.347 1.866a5.97 5.97 0 0 0 3.054-.835a3.16 3.16 0 0 1-.258-.286c-.237-.298-.544-.807-.438-1.406c.051-.287.205-.529.356-.716c.154-.19.34-.366.503-.517a40.6 40.6 0 0 1 .111-.101c.125-.115.233-.213.324-.309a1.32 1.32 0 0 0 .125-.146c.023-.033.03-.05.031-.053c.05-.167-.01-.29-.084-.347c-.055-.042-.195-.105-.446.053a8.542 8.542 0 0 1-.253.158a.985.985 0 0 1-.275.117a.534.534 0 0 1-.634-.36a.637.637 0 0 1-.028-.2a1.153 1.153 0 0 1 .016-.189c.025-.21.063-.52-.057-.982c-.097-.371-.238-.654-.382-.942a9.148 9.148 0 0 1-.196-.412c-.088-.2-.184-.46-.167-.736c.02-.32.181-.58.442-.776c.317-.238.716-.783 1.061-1.334c.165-.263.307-.51.407-.69l.023-.042A5.97 5.97 0 0 0 7.998 2zm4.037 1.561c-.102.18-.233.407-.384.648c-.339.54-.824 1.24-1.309 1.603a.244.244 0 0 0-.044.04c0 .027.01.101.084.27c.033.073.078.165.13.27c.157.314.376.755.5 1.234c.067.256.099.484.11.681c.436-.144.874-.078 1.212.183c.418.323.593.885.43 1.428c-.076.248-.25.453-.39.6c-.118.123-.26.252-.388.37l-.094.085a4.033 4.033 0 0 0-.401.41c-.106.13-.142.215-.15.262c-.03.165.051.377.236.61a2.221 2.221 0 0 0 .307.313a5.985 5.985 0 0 0 2.112-4.57c0-1.758-.756-3.34-1.961-4.437zM2 7.998a5.97 5.97 0 0 0 .787 2.973l.018-.15c.054-.405.168-.933.532-1.322c.414-.444.923-.528 1.248-.581c.032-.005.063-.01.091-.016c.174-.03.279-.058.36-.103a.515.515 0 0 0 .214-.236c.014-.027.033-.09.006-.245c-.023-.133-.067-.272-.123-.451a28.642 28.642 0 0 1-.041-.134c-.13-.425-.334-1.115.08-1.744c.361-.547.94-.686 1.314-.777l.114-.028c.398-.103.633-.2.796-.547c.212-.452.12-1.062-.102-1.65a5.381 5.381 0 0 0-.43-.88A6 6 0 0 0 2 7.998z" fill="currentColor"></path></g></svg>

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

View File

@ -1,235 +0,0 @@
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg xmlns="http://www.w3.org/2000/svg" width="865.76" height="682.89" viewBox="0 0 865.76 682.89">
<defs>
<style xmlns="http://www.w3.org/1999/xhtml">*, ::after, ::before { box-sizing: border-box; }
img, svg { vertical-align: middle; }
</style>
<style xmlns="http://www.w3.org/1999/xhtml">*, body, html { -webkit-font-smoothing: antialiased; }
img, svg { max-width: 100%; }
</style>
</defs>
<rect x="167.21" y="379.13" width="39.09" height="16.52" fill="#ebebeb"/>
<path d="M189,358.3l19.37-.42,9.83-.16c3.29-.07,6.6,0,9.89-.07h.65v.66l.09,16.52v.74h-.71l-19.55-.09c-6.51,0-13-.18-19.54-.25h-.42v-.4c0-2.8.12-5.59.2-8.34Zm0,0,.21,8.34c0,2.76.16,5.47.19,8.18l-.4-.4c6.51-.08,13-.24,19.54-.25l19.58-.09-.74.74V358.3l.74.72c-3.22,0-6.44,0-9.67-.09l-9.71-.16Z" fill="#ebebeb"/>
<path d="M145.48,358.3l19.37-.42,9.83-.16c3.29-.07,6.58,0,9.89-.07h.65v.66l.09,16.52v.74h-.74L165,375.48c-6.53,0-13-.18-19.55-.25h-.41v-.4c0-2.8.13-5.59.19-8.34Zm0,0,.21,8.34c0,2.76.16,5.47.19,8.18l-.4-.4c6.51-.08,13-.24,19.55-.25l19.54-.09-.74.74V358.3l.67.66c-3.24,0-6.44,0-9.67-.09l-9.72-.16Z" fill="#ebebeb"/>
<path d="M746.68,51.89l-19.36.41-9.83.16c-3.3.08-6.6,0-9.89.09H707v-.66l-.09-16.52v-.74h.74l19.54.1c6.52,0,13,.17,19.54.24h.42v.4c0,2.8-.13,5.59-.19,8.35Zm0,0-.2-8.34c0-2.75-.16-5.47-.19-8.18l.39.41c-6.51,0-13,.24-19.54.24l-19.54.09.73-.74-.07,16.52-.66-.65h9.66l9.72.17Z" fill="#ebebeb"/>
<rect x="729.4" y="56.2" width="39.09" height="16.52" fill="#ebebeb"/>
<path d="M184.51,52l-19.38.39-9.83.18H144.76V34.7h.72l19.55.09c6.51,0,13,.16,19.54.24h.4v.4c0,2.79-.13,5.58-.19,8.34Zm0,0-.22-8.36c0-2.75-.16-5.46-.19-8.16l.41.4c-6.52.07-13,.23-19.54.23l-19.49,0,.72-.74L146.07,52l-.59-.73c3.22,0,6.42,0,9.66.09l9.71.16Z" fill="#ebebeb"/>
<rect x="420.15" y="139.6" width="39.09" height="16.52" fill="#ebebeb"/>
<path d="M729.4,364.47l19.38-.42,9.83-.16c3.29-.07,6.59,0,9.88-.09h.66v.67l.08,16.52v.73h-.8l-19.55-.08c-6.51,0-13-.18-19.54-.26h-.4V381c0-2.81.13-5.6.19-8.34Zm0,0,.22,8.34c0,2.76.16,5.47.19,8.18l-.41-.4c6.52-.08,13-.24,19.54-.25l19.55-.09-.73.74.08-16.52.65.66c-3.22,0-6.42,0-9.66-.09l-9.71-.16Z" fill="#ebebeb"/>
<rect x="473.81" y="501.71" width="39.09" height="16.52" fill="#ebebeb"/>
<path d="M441.94,118.77l19.44-.37,9.83-.16h10.54v.67l.08,16.51v.74h-.74l-19.54-.09c-6.53,0-13-.16-19.55-.25h-.41v-.4c0-2.8.13-5.59.19-8.34Zm0,0,.22,8.36c0,2.74.17,5.46.2,8.16l-.4-.4c6.51,0,13-.23,19.54-.23l19.55-.11-.74.74.07-16.52.67.67c-3.24,0-6.44,0-9.67-.09l-9.72-.16Z" fill="#ebebeb"/>
<polygon points="246.63 502.21 197.09 502.21 168.75 360.87 214.09 340.02 246.63 502.21" fill="#dbdbdb"/>
<polygon points="388.43 502.21 246.63 502.21 214.09 340.02 355.9 340.02 388.43 502.21" fill="#ebebeb"/>
<rect x="145.27" y="426.38" width="75.92" height="155.78" fill="#dbdbdb"/>
<polygon points="424.86 582.16 404.12 582.16 373.21 426.38 424.86 426.38 424.86 582.16" fill="#dbdbdb"/>
<rect x="221.19" y="426.38" width="41.72" height="155.78" fill="#ebebeb"/>
<rect x="424.86" y="426.38" width="41.72" height="155.78" fill="#ebebeb"/>
<rect x="262.91" y="502.21" width="161.95" height="79.94" fill="#ebebeb"/>
<rect x="262.91" y="467.65" width="161.95" height="34.88" fill="#ebebeb"/>
<path d="M231.42,426.38l15.76-.28,15.73-.21h.47v.49l.2,18.26v18.23c0,12.15.08,24.3,0,36.46s0,24.3-.21,36.46-.27,24.3-.53,36.46q-.39-18.24-.52-36.46c-.16-12.16-.18-24.31-.22-36.46s0-24.31,0-36.46V444.64l.18-18.23.47.47L247,426.69Z" fill="#dbdbdb"/>
<path d="M424.86,426.38c.33,12.19.48,24.38.61,36.59l.18,36.6-.18,36.59-.22,18.31-.39,18.29-.38-18.29-.22-18.31-.18-36.59.18-36.6C424.39,450.76,424.48,438.57,424.86,426.38Z" fill="#dbdbdb"/>
<path d="M424.86,502.21c-13.49.32-27,.47-40.49.6l-40.48.18-40.46-.18-20.24-.22-20.28-.38,20.25-.38,20.24-.23,40.49-.17,40.48.17C397.91,501.74,411.37,501.88,424.86,502.21Z" fill="#dbdbdb"/>
<path d="M424.86,467.65c-13.49.33-27,.47-40.49.61l-40.48.17-40.46-.17L283.19,468l-20.25-.39,20.25-.38,20.24-.22,40.49-.18,40.48.18C397.91,467.18,411.37,467.33,424.86,467.65Z" fill="#dbdbdb"/>
<polygon points="174.13 582.14 173.1 594.77 170.71 623.93 159.71 623.93 157.32 594.77 156.29 582.14 174.13 582.14" fill="#c7c7c7"/>
<polygon points="156.29 582.14 174.13 582.14 173.1 594.77 157.32 594.77 156.29 582.14" fill="#a6a6a6"/>
<polygon points="250.98 582.14 249.95 594.77 247.56 623.93 236.56 623.93 234.17 594.77 233.14 582.14 250.98 582.14" fill="#c7c7c7"/>
<polygon points="233.14 582.14 250.98 582.14 249.95 594.77 234.17 594.77 233.14 582.14" fill="#a6a6a6"/>
<polygon points="378.44 582.14 377.42 594.77 375.03 623.93 364.03 623.93 361.64 594.77 360.61 582.14 378.44 582.14" fill="#c7c7c7"/>
<polygon points="360.61 582.14 378.44 582.14 377.42 594.77 361.64 594.77 360.61 582.14" fill="#a6a6a6"/>
<polygon points="455.32 582.14 454.28 594.77 451.89 623.93 440.89 623.93 438.5 594.77 437.47 582.14 455.32 582.14" fill="#c7c7c7"/>
<polygon points="455.32 582.14 454.28 594.77 438.5 594.77 437.47 582.14 455.32 582.14" fill="#a6a6a6"/>
<rect x="175.4" y="127.2" width="253.39" height="10.88" fill="#ebebeb"/>
<polygon points="212.25 180.91 202.37 180.91 198.42 138.08 212.25 138.08 212.25 180.91" fill="#ebebeb"/>
<polygon points="391.94 180.91 401.82 180.91 405.78 138.08 391.94 138.08 391.94 180.91" fill="#ebebeb"/>
<polygon points="198.42 138.08 212.23 138.08 212.23 152.83 199.78 152.83 198.42 138.08" fill="#dbdbdb"/>
<polygon points="405.78 138.08 404.42 152.83 391.94 152.83 391.94 138.08 405.78 138.08" fill="#dbdbdb"/>
<path d="M267.16,61.94c-11.25,15.22-23,32.84-25.82,52a.26.26,0,0,0,.25.26.25.25,0,0,0,.24-.17,114,114,0,0,1,5-15.28c7.38-2,14.64-3.7,18.52-11s3.46-17.37,2.7-25.59C268,61.66,267.38,61.64,267.16,61.94Z" fill="#ebebeb"/>
<path d="M246.92,98.59a125.92,125.92,0,0,1,19.77-32m.09.09a136.1,136.1,0,0,0-13.54,21.23C257,84.1,261,80.7,264.56,76.7c0,0,.18,0,.12.12-3.5,4.19-7.38,8.75-11.81,11.81-1.22,2.26-2.46,4.53-3.72,6.79.31-.18.64-.33.95-.49m0,.09-1,.52c-.59,1.06-1.18,2.14-1.79,3.2-.11.3-.45.09-.34-.15Z" fill="#a6a6a6"/>
<path d="M250.67,94.64a67.6,67.6,0,0,0,8.64-5.76s.18,0,.1.1a46.21,46.21,0,0,1-8.62,5.91.14.14,0,0,1-.18-.07.12.12,0,0,1,.06-.18Z" fill="#a6a6a6"/>
<path d="M240.1,113.75c0,.24.42.22.42,0,0-6.29,0-12.6.1-18.92,1.61-3.25,5.06-5,7.47-7.66a33.37,33.37,0,0,0,4.43-6.25,45,45,0,0,0,5.2-17.34c.66-6.73-1.89-28.92-4.73-28.15-1.47.4-6.23,11.39-7.48,15a103.3,103.3,0,0,0-4.43,18.43C238.79,83.67,239,98.86,240.1,113.75Z" fill="#ebebeb"/>
<path d="M240.06,86.62h0a141.83,141.83,0,0,1,3.82-23.71c1.8-8.32,3.92-16.15,8.2-23.62,0-.14.28,0,.2.12A87.62,87.62,0,0,0,245.7,57.5c.29-.45.63-.87.93-1.3.12-.17.4,0,.28.16a11.29,11.29,0,0,1-1.33,1.89h0c-1.48,5.9-2.54,12-3.44,18.08l.21-.23h.09l-.36.6c-.46,3.07-.88,6.14-1.31,9.18a43,43,0,0,0,12.8-17h0a35.28,35.28,0,0,1-12.95,17.61c-.1.82-.22,1.64-.34,2.46,0,.17-.29.12-.29,0V86.77A.15.15,0,0,1,240.06,86.62Z" fill="#a6a6a6"/>
<path d="M242.64,75.09A124.48,124.48,0,0,0,253.8,55.9H254a61.86,61.86,0,0,1-11,19.35.16.16,0,0,1-.22.06A.17.17,0,0,1,242.64,75.09Z" fill="#a6a6a6"/>
<path d="M248.21,54.32c1.74-3.07,3.56-6,5.19-9.18,0-.09.17,0,.14.07a47.39,47.39,0,0,1-5.07,9.27.16.16,0,0,1-.23,0A.16.16,0,0,1,248.21,54.32Z" fill="#a6a6a6"/>
<path d="M237,97.63a63,63,0,0,1-1.47,12.4,11.1,11.1,0,0,0,.19,1.36A59.87,59.87,0,0,0,238,97.57a.45.45,0,0,0,0-.42h0a91.63,91.63,0,0,0-1.39-18.71c-1.95-11.56-6.2-22.66-14.26-31.31a.59.59,0,0,0-.83.08.36.36,0,0,0-.07.1c-3.25,8.27-2.11,18.14.67,26.39a47.73,47.73,0,0,0,6.39,12.54C231.26,90.1,234.79,93.4,237,97.63Z" fill="#ebebeb"/>
<path d="M223.29,51c7.65,12.4,13,27.08,13.61,41.73,0,.26-.43.34-.47.07-.18-1.31-.37-2.63-.59-3.93h0a50.43,50.43,0,0,1-8.67-10s0-.15.12,0A108.77,108.77,0,0,0,235.71,88c-.56-3.23-1.21-6.42-2-9.57-.2-.22-.4-.47-.6-.71s.09-.38.22-.22l.19.21a108.42,108.42,0,0,0-4-12.68A38.88,38.88,0,0,1,224,57.88s0-.13.1,0a57.55,57.55,0,0,0,3.55,4.68c.5.59,1.07,1.15,1.6,1.73a98.15,98.15,0,0,0-6.25-13,.19.19,0,1,1,.29-.26Z" fill="#a6a6a6"/>
<path d="M224.84,66.28c2.47,3.32,5.29,6.49,7.57,9.92,0,.1,0,.21-.16.13a51.34,51.34,0,0,1-7.57-10C224.6,66.26,224.77,66.18,224.84,66.28Z" fill="#a6a6a6"/>
<path d="M232.77,77l.1.11s0,.18-.12.11l-.1-.12C232.58,77.07,232.69,76.91,232.77,77Z" fill="#a6a6a6"/>
<path d="M210.15,71.68a37.83,37.83,0,0,0,8.06,18.91,20.07,20.07,0,0,0,6.53,5.11c2.83,1.4,5.8,3,8.72,4.16a58.11,58.11,0,0,1,3.1,11c.09-.32.18-.65.28-1-1.89-16.15-12.32-30-25.7-38.81a.65.65,0,0,0-.89.2A.66.66,0,0,0,210.15,71.68Z" fill="#ebebeb"/>
<path d="M214.76,76.45c-.09,0,0-.25.15-.18a55,55,0,0,1,17,21c.1.23-.21.42-.33.2-.62-1.08-1.25-2.14-1.9-3.19h0a18.34,18.34,0,0,1-8.21-4.77m.09-.09a18.87,18.87,0,0,0,3.85,2.72,33.17,33.17,0,0,0,3.88,1.47c-1.13-1.8-2.3-3.52-3.54-5.19a35.38,35.38,0,0,1-9.17-5.15.1.1,0,0,1,0-.13.1.1,0,0,1,.09,0,91.92,91.92,0,0,0,8.5,4.63,78.41,78.41,0,0,0-6-7c-1-.55-2.1-1.11-3.1-1.68a.09.09,0,0,1-.05-.12.07.07,0,0,1,.05,0,11,11,0,0,1,2.36,1.16,46.83,46.83,0,0,0-3.69-3.6Z" fill="#a6a6a6"/>
<path d="M215.05,82a4.87,4.87,0,0,1,.52.37v.09a4.07,4.07,0,0,1-.53-.31.09.09,0,0,1,0-.13Z" fill="#a6a6a6"/>
<polygon points="253.27 127.2 225.74 127.2 227.1 109.16 227.62 103.27 251.15 103.27 251.68 109.16 253.27 127.2" fill="#dbdbdb"/>
<polygon points="251.68 109.16 227.1 109.16 227.62 103.27 251.15 103.27 251.68 109.16" fill="#c7c7c7"/>
<rect x="225.74" y="98.45" width="27.28" height="6.04" fill="#dbdbdb"/>
<rect x="303.64" y="61.66" width="16.5" height="65.51" fill="#c7c7c7"/>
<rect x="303.65" y="58.15" width="16.5" height="7.2" fill="#dbdbdb"/>
<rect x="303.64" y="74.56" width="16.5" height="2.67" fill="#dbdbdb"/>
<rect x="303.64" y="111.36" width="16.5" height="7.2" fill="#dbdbdb"/>
<rect x="320.14" y="50.75" width="11.53" height="76.42" fill="#dbdbdb"/>
<rect x="322.4" y="57.92" width="6.92" height="29.39" fill="#ebebeb"/>
<rect x="376.78" y="57.92" width="11.53" height="76.42" transform="translate(45.24 300.6) rotate(-45.33)" fill="#c7c7c7"/>
<rect x="383.09" y="85.34" width="6.92" height="29.39" transform="translate(43.66 304.61) rotate(-45.33)" fill="#dbdbdb"/>
<rect x="331.69" y="69.13" width="19.57" height="58.04" fill="#a6a6a6"/>
<rect x="331.67" y="115.61" width="19.57" height="5.4" fill="#c7c7c7"/>
<rect x="331.69" y="109.96" width="19.57" height="2.1" fill="#c7c7c7"/>
<rect x="260.27" y="93.54" width="54.5" height="13.51" transform="translate(82.96 325.87) rotate(-67.03)" fill="#a6a6a6"/>
<rect x="276.77" y="110.14" width="5.08" height="13.52" transform="translate(61.72 327.46) rotate(-66.77)" fill="#c7c7c7"/>
<rect x="289.57" y="80.33" width="5.08" height="13.52" transform="translate(96.86 321.17) rotate(-66.77)" fill="#c7c7c7"/>
<rect x="288.36" y="86.77" width="1.98" height="13.52" transform="translate(89.28 322.54) rotate(-66.77)" fill="#c7c7c7"/>
<rect x="569.6" y="445.51" width="144.36" height="178.43" fill="#ebebeb"/>
<rect x="562.61" y="438.1" width="158.33" height="7.41" fill="#dbdbdb"/>
<rect x="720.95" y="438.1" width="49.79" height="7.41" fill="#ebebeb"/>
<rect x="713.96" y="445.51" width="48.91" height="178.43" fill="#dbdbdb"/>
<path d="M698.41,528.35H585.15v-64H698.41ZM585.52,528H698V464.69H585.52Z" fill="#c7c7c7"/>
<rect x="625.98" y="483.03" width="22.05" height="12.68" fill="#c7c7c7"/>
<path d="M654.76,494.93H632.31V483.8h22.45Zm-22.14-.37h21.69V484.23H632.62Z" fill="#c7c7c7"/>
<path d="M698.41,608.78H585.15v-64H698.41Zm-112.89-.37H698v-63.3H585.52Z" fill="#c7c7c7"/>
<rect x="625.98" y="563.46" width="22.05" height="12.68" fill="#c7c7c7"/>
<path d="M654.76,575.35H632.31V564.21h22.45ZM632.62,575h21.69V564.65H632.62Z" fill="#c7c7c7"/>
<polygon points="724.13 624.56 731.53 624.56 679.67 350.48 661.22 350.48 724.13 624.56" fill="#2f2e41" data-secondary="true"/>
<polygon points="661.22 350.48 670.53 391.01 687.33 391.01 679.68 350.52 679.67 350.48 661.22 350.48" opacity="0.2"/>
<polygon points="648.47 624.56 655.87 624.56 654.22 350.48 635.79 350.48 648.47 624.56" fill="#2f2e41" data-secondary="true"/>
<polygon points="635.79 350.48 637.66 391.01 654.47 391.01 654.22 350.52 654.22 350.48 635.79 350.48" opacity="0.2"/>
<polygon points="580.21 624.56 572.82 624.56 613.65 350.48 632.09 350.48 580.21 624.56" fill="#2f2e41" data-secondary="true"/>
<polygon points="607.61 391.01 624.41 391.01 632.09 350.48 613.65 350.48 613.64 350.52 607.61 391.01" opacity="0.2"/>
<rect x="595.4" y="507.14" width="107.62" height="5.35" fill="#2f2e41" data-secondary="true"/>
<ellipse cx="657.73" cy="244.11" rx="138.63" ry="98.46" transform="translate(381.44 888.99) rotate(-87.17)" fill="#0071f2" data-primary="true"/>
<polygon points="643.09 104.28 666.2 105.71 648.6 382.44 625.48 381.01 643.09 104.28" fill="#0071f2" data-primary="true"/>
<g opacity="0.1">
<polygon points="643.09 104.28 666.2 105.71 648.6 382.44 625.48 381.01 643.09 104.28"/>
</g>
<ellipse cx="636.45" cy="242.79" rx="138.63" ry="98.46" transform="translate(362.53 866.48) rotate(-87.17)" fill="#0071f2" data-primary="true"/>
<ellipse cx="636.45" cy="242.79" rx="108.14" ry="76.81" transform="translate(362.53 866.48) rotate(-87.17)" fill="#fff"/>
<ellipse cx="636.45" cy="242.79" rx="77.97" ry="55.37" transform="translate(362.53 866.48) rotate(-87.17)" fill="#0071f2" data-primary="true"/>
<ellipse cx="636.45" cy="242.79" rx="47.47" ry="33.71" transform="translate(362.53 866.48) rotate(-87.17)" fill="#fff"/>
<path d="M651.4,240.11c.8,11.63-5.25,22.16-13.52,23.67s-15.6-6.61-16.39-18.18,5.25-22.17,13.51-23.69S650.6,228.47,651.4,240.11Z" fill="#0071f2" data-primary="true"/>
<ellipse cx="636.45" cy="242.79" rx="138.63" ry="98.46" transform="translate(362.53 866.48) rotate(-87.17)" fill="#fff" opacity="0.4" style="isolation:isolate"/>
<path d="M528.17,536.16a361.87,361.87,0,0,0-.54-38.69,491.73,491.73,0,0,0-12.12-78.83c-3.38-14.58-7.54-28.93-11.88-43.25-.13-.44-.81-.31-.69.13,13.4,50.5,24.41,102.63,23.62,155.12-.21,14.76-1.91,29.13-3.31,43.73,0,.39.61.54.69.14C526.75,562.08,527.65,549,528.17,536.16Z" fill="#0071f2" data-primary="true"/>
<path d="M502.94,375.58s-20.33,51.58-15.78,63.7S501.09,454,501.09,454s-12.43,17.72-3.75,34.4,28.05,15.87,29.28,34.73C526.62,523.11,527.69,444.2,502.94,375.58Z" fill="#0071f2" data-primary="true"/>
<path d="M524.42,490.53a.66.66,0,0,0,0-.28,473.14,473.14,0,0,0-19.71-103.74c0-.09-.19,0-.16,0q6,22.62,10.67,45.49a22.46,22.46,0,0,0-9.7-5.4c-.07,0-.1.07,0,.1a25.64,25.64,0,0,1,10,6.69c.61,2.95,1.18,5.9,1.73,9-4.53-5.08-10.84-7.59-17.14-10-.09,0-.13.1,0,.13,6.57,2.67,13.09,6.11,17.55,11.81q2.33,12.84,4.07,25.76a24.51,24.51,0,0,0-12.11-6.94c-.14,0-.23.18-.08.22a25.59,25.59,0,0,1,12.28,7.87h.09c.4,3,.78,6.07,1.14,9.09a31.67,31.67,0,0,0-8.49-5.12c-.07,0-.12.1,0,.13a29.24,29.24,0,0,1,8.5,5.64.13.13,0,0,0,.12,0c.33,2.95.67,5.79,1,8.68a41.34,41.34,0,0,0-17.23-12.31.11.11,0,1,0-.07.21,45,45,0,0,1,17,13.08.4.4,0,0,0,.42.13c.45,4.43.85,8.86,1.17,13.28,0,0,.1.08.1,0C525.1,499.64,524.86,495.08,524.42,490.53Z" fill="#3f4347"/>
<path d="M508.09,421.52a25.43,25.43,0,0,0-12.21-3.81.08.08,0,0,0-.07.08.07.07,0,0,0,.07.07A49.39,49.39,0,0,1,508,421.8C508.1,421.88,508.24,421.63,508.09,421.52Z" fill="#3f4347"/>
<path d="M510.56,476.13a25.58,25.58,0,0,0-9.17-3.15c-.12,0-.12.16,0,.18a34.73,34.73,0,0,1,9.06,3.17C510.57,476.39,510.67,476.18,510.56,476.13Z" fill="#3f4347"/>
<path d="M581.57,440.12a139,139,0,0,0-23.51,20.55,84.62,84.62,0,0,0-16.49,28,208.9,208.9,0,0,0-12.28,66.9,161.76,161.76,0,0,0,3.61,37.62.37.37,0,0,0,.43.28.35.35,0,0,0,.28-.42,210.38,210.38,0,0,1,.37-65.74c3.49-21.35,9-44.56,22.53-61.9,7.38-9.43,16.59-16.86,25.7-24.54C582.68,440.43,582.08,439.77,581.57,440.12Z" fill="#0071f2" data-primary="true"/>
<path d="M532.24,540.18a126.61,126.61,0,0,1,17.15-21.91c10.16-10.17,18.72-17.39,20.05-24.11s-10.19-11.67-10.19-11.67,14.07,2.52,17.91-3.38,5.71-40.1,5.71-40.1-25.53,18.28-37.48,43.89S532.24,540.18,532.24,540.18Z" fill="#0071f2" data-primary="true"/>
<path d="M574.05,447.78c-10.77,10.59-21.6,21.85-27.95,35.74a139.33,139.33,0,0,0-5.65,14.76h0c-.33,1-.67,2-1,3a.49.49,0,0,0,0,.17,289,289,0,0,0-7.27,31.59s.1.1.11,0c1.1-5.33,2.32-10.72,3.68-16.09h.15a22.26,22.26,0,0,1,6.71-1.72c.15,0,.14-.28,0-.26a24.61,24.61,0,0,0-6.64,1.21c1.24-4.84,2.58-9.67,4.1-14.44a72,72,0,0,1,15.18-2v-.09A57.89,57.89,0,0,0,540.58,501c.25-.81.53-1.62.81-2.43a75.27,75.27,0,0,1,10.1-1.71m0-.12a49.17,49.17,0,0,0-9.89,1.21c1.4-4.12,2.95-8.18,4.58-12.14a70.35,70.35,0,0,1,4.82-9.38,61.53,61.53,0,0,1,8.2-1.48c.07,0,.07-.13,0-.13a22.17,22.17,0,0,0-7.77.84q2-3.25,4.27-6.36h0c5.74-1.27,11.56-2.45,17.43-1.35.09,0,.13-.16,0-.17-5.58-1.48-11.13-.62-16.7.47.6-.81,1.22-1.61,1.86-2.39A37.56,37.56,0,0,1,569,464.15a.06.06,0,1,0,0-.11,27.6,27.6,0,0,0-10,.88c4.74-5.9,10-11.44,15.16-16.92.31-.32.13-.41-.13-.22Z" fill="#3f4347"/>
<path d="M573.77,471.09a24.17,24.17,0,0,0-5.1-.18.2.2,0,0,0-.13.24.17.17,0,0,0,.13.13c1.69,0,3.38-.08,5.07,0a.1.1,0,0,0,.08-.12A.18.18,0,0,0,573.77,471.09Z" fill="#3f4347"/>
<path d="M555.34,504.26a23.38,23.38,0,0,0-7.88,1.58V506c2.61-.62,5.24-.87,7.88-1.31a.2.2,0,0,0,.19-.2A.19.19,0,0,0,555.34,504.26Z" fill="#3f4347"/>
<path d="M549.82,492.81a16.37,16.37,0,0,0-3.35.29c-.1,0-.1.21,0,.21a17.07,17.07,0,0,0,3.35-.16C550,493.12,550,492.81,549.82,492.81Z" fill="#3f4347"/>
<path d="M457.26,476.41a116.15,116.15,0,0,1,23.79,11.2,71,71,0,0,1,19.81,18.82,176.35,176.35,0,0,1,25.48,51.3A138,138,0,0,1,532.11,589a.31.31,0,0,1-.37.23.3.3,0,0,1-.24-.23A177.66,177.66,0,0,0,516,535.87c-7.76-16.46-17.55-34-32.47-44.88-8.17-5.9-17.33-9.82-26.47-13.92C456.44,476.91,456.78,476.24,457.26,476.41Z" fill="#0071f2" data-primary="true"/>
<path d="M520.3,546a106.15,106.15,0,0,0-18.94-13.76c-10.59-5.9-19.19-9.74-21.8-14.88s5.55-11.81,5.55-11.81-10.81,5.29-15.28,1.4-13.89-31.13-13.89-31.13,24.93,8.86,40.5,26.85S520.3,546,520.3,546Z" fill="#0071f2" data-primary="true"/>
<path d="M519,540.37C506.48,517,489.84,495.79,467,481.81h0a115,115,0,0,1,16.68,13.06,26.52,26.52,0,0,0-13.41,2.82.09.09,0,0,0,0,.13l.07,0a36.42,36.42,0,0,1,13.79-2.33h.14c.82.77,1.61,1.55,2.41,2.33a41.08,41.08,0,0,0-7.16,1m0,.09a36.82,36.82,0,0,1,7.56-.62,154.46,154.46,0,0,1,11.81,13.36,12,12,0,0,0-5,.35.1.1,0,0,0-.05.14l.05,0a25.86,25.86,0,0,1,5.35-.07c.69.87,1.37,1.73,2.05,2.61a48.21,48.21,0,0,0-15.13,1.48v.12a60,60,0,0,1,15.54-1c.64.84,1.24,1.71,1.86,2.57a32.54,32.54,0,0,0-9,1c-.12,0-.09.21,0,.18a34.79,34.79,0,0,1,9.33-.73c2.68,3.69,5.25,7.47,7.72,11.3a8.56,8.56,0,0,0-4,.23m0,.11a18,18,0,0,1,4.27.2c2.23,3.46,4.43,7,6.43,10.47.28.25.77,0,.56-.28Z" fill="#3f4347"/>
<path d="M499.6,521a19,19,0,0,0-7.25.88m0,.11a56.28,56.28,0,0,1,7.21-.74c.2,0,.2-.24,0-.25Z" fill="#3f4347"/>
<path d="M477.84,492.42a13.78,13.78,0,0,0-3.33.19.11.11,0,0,0-.11.11.11.11,0,0,0,.11.1c1.09-.12,2.17-.07,3.27-.1a.16.16,0,0,0,.18-.12A.16.16,0,0,0,477.84,492.42Z" fill="#3f4347"/>
<polygon points="552.45 623.37 496.18 623.37 504.19 564.59 506.25 549.54 542.37 549.54 544.42 564.59 552.45 623.37" fill="#3f4347"/>
<polygon points="544.42 564.59 504.19 564.59 506.25 549.54 542.37 549.54 544.42 564.59" fill="#2e3135"/>
<rect x="501.08" y="544.3" width="46.46" height="12.77" fill="#3f4347"/>
<path d="M223.65,476.08l3.1,18.14s1.31,8.66-4.74,13.88c0,0-16.17,14.38-15.35,35.13l-.49,58.48,14.37,2,34.64-91.48a32.86,32.86,0,0,0,5.71-17.65c0-.81-2.77-24.83-2.77-24.83Z" fill="#f9b499"/>
<path d="M230.51,646.31s11.92,1.3,7.68-12.91L222.5,602.2s-6.37-10.13-12.25-1.47c0,0-4.48,2.45-3.55-5,0,0-3-9.29-9.72-1.57a18.42,18.42,0,0,0-3.73,17.09l4.81,17a21.93,21.93,0,0,0,16.06,15.35A106.41,106.41,0,0,0,230.51,646.31Z" fill="#0071f2" data-primary="true"/>
<path d="M230.51,646.31s11.92,1.3,7.68-12.91L222.5,602.2s-6.37-10.13-12.25-1.47c0,0-4.48,2.45-3.55-5,0,0-3-9.29-9.72-1.57a18.42,18.42,0,0,0-3.73,17.09l4.81,17a21.93,21.93,0,0,0,16.06,15.35A106.41,106.41,0,0,0,230.51,646.31Z" opacity="0.2"/>
<path d="M173.33,613.47s-1-19.93-3.1-30.55c0,0-10-34.79,1-62.4,0,0,3.59-4.41,3.43-16.5l-2.78-23.2,41.33.49s-2.94,19.77-8.33,29.73c0,0-2.12,3.43-2,10.62,0,0-3.76,20.91-5.72,31.53,0,0-5.39,40.51-3.43,51.46L188.53,623l-9.81,1.63Z" fill="#f9b499"/>
<path d="M342.58,250.81l-1.8,12.74c0,.07,0,.13,0,.2.41,6-2.63,6.5-2.63,6.5l-8.65,2.12c-24.51,8.26-38.08,6.21-42.49,5.08a19,19,0,0,0-8.28-.33A37.65,37.65,0,0,0,266.61,282a17.51,17.51,0,0,1-4.51,2.82,23,23,0,0,1-9.24,2c-4.49.05-5.52,1.08-5.52,1.08-9.15,4.57-1.31,21.73-1.31,21.73,5.88,13.07,6.7,40.35,6.7,40.35a40.38,40.38,0,0,0,1.14,11.11c2.12,12.25.82,16.17.33,26.63,0,.26,0,.51,0,.76-.37,9.72,1.34,10.34,1.34,10.34,4.09,11.11,1.31,12.26,1.31,12.26-1.65,1.25-4,1.6-6.49,1.47a40.68,40.68,0,0,1-11.81-2.94c-7.18-3.11-7.35-1.47-7.35-1.47-1,4.34-9.46,1.59-14.1-.29A53.12,53.12,0,0,0,208,405c-21.3-4.53-34.34,0-34.34,0-6.15,2.22-10.09,2-11.42,1.88a5.08,5.08,0,0,1-.51-.08c-4.43-1-3.85-7-3.29-9.89a11,11,0,0,1,.6-2.1,9.23,9.23,0,0,0,.65-2.7,10.8,10.8,0,0,0-.72-4.8c-2-5.34,1.13-13.67,1.13-13.67,1.32-6.39,1.16-10.1.91-11.84a7.29,7.29,0,0,0-.46-1.72,6.69,6.69,0,0,1,.21-6.18,7.53,7.53,0,0,0,1.11-4.39c0-.34-.06-.66-.11-.95a7.11,7.11,0,0,0-.78-2.27,8.33,8.33,0,0,1-1-3.53l-.66-6.86c-.81-8.5-2.94-9.48-2.94-9.48,0,9-18.13,26.47-18.13,26.47-3.27,4.41-12.74,1.47-12.74,1.47a8.75,8.75,0,0,1-7.19-3.76c-3.76-4.08-2.78-16.82-2.78-16.82C115.67,321.87,118,322,118,322l10.78-37.57c12.58-18.3,23.86-16.66,23.86-16.66.65-2.45,4.24-2.78,4.24-2.78,2.78.49,3.6-1.14,3.6-1.14,4.08-8.33,13.48-10.9,13.48-10.9l49.61-8.28a8.41,8.41,0,0,1,8.79-1.24l9.47,2.28c35.94,3.6,91.16.49,91.16.49,5.79-1,8.12.52,9.05,2.07A4.28,4.28,0,0,1,342.58,250.81Z" fill="#0071f2" data-primary="true"/>
<g opacity="0.2">
<path d="M180.51,289.43h0a3.67,3.67,0,0,1-2.54-1.44l0,0c-2.07-4-6.59-6.77-6.63-6.8a30.14,30.14,0,0,1-10.21-10.29,15.63,15.63,0,0,1-2-5,.16.16,0,0,1,.14-.18.15.15,0,0,1,.18.13c0,.09,1.27,8.18,12,15.08,0,0,4.62,2.84,6.75,6.91a3.44,3.44,0,0,0,2.28,1.31h0a2.65,2.65,0,0,0,2-1.19,6.11,6.11,0,0,0,1.23-2.87c.39-2.49,1.33-6.7,3.26-7a.18.18,0,0,1,.19.14.16.16,0,0,1-.14.18c-1.24.17-2.36,2.67-3,6.69a6.4,6.4,0,0,1-1.3,3A3,3,0,0,1,180.51,289.43Z"/>
</g>
<g opacity="0.2">
<path d="M202.09,280.38c-3.25,0-6.85-6-7-6.29a.18.18,0,0,1,.06-.23.16.16,0,0,1,.22.06c0,.07,3.83,6.41,6.93,6.13,1.13-.11,2.06-1.12,2.74-3l5.4-12.1a.16.16,0,0,1,.22-.08.17.17,0,0,1,.08.22l-5.39,12.09c-.73,2-1.75,3.07-3,3.19Z"/>
</g>
<g opacity="0.2">
<path d="M234.43,279.23a.16.16,0,0,1-.14-.07l-4.73-7.51c-3.27-4.1-2-11.88-2-12.21a.18.18,0,0,1,.19-.14.16.16,0,0,1,.13.19c0,.08-1.25,8,1.93,12v0l4.74,7.51a.17.17,0,0,1,0,.23Z"/>
</g>
<g opacity="0.2">
<path d="M240.31,258.16a.16.16,0,0,1-.12-.06.17.17,0,0,1,0-.23c.16-.14,4-3.45,14.38-3.35a28,28,0,0,0,4.58-.3c3.19-.49,8.92-1.74,11.41-4.66a.16.16,0,1,1,.25.21c-2.56,3-8.37,4.28-11.61,4.77a28.69,28.69,0,0,1-4.64.31c-10.2-.1-14.11,3.23-14.15,3.27A.15.15,0,0,1,240.31,258.16Z"/>
</g>
<g opacity="0.2">
<path d="M246.85,261.75a.2.2,0,0,1-.14-.06.17.17,0,0,1,0-.23s4.38-3.14,12-2.81a.15.15,0,0,1,.15.17.17.17,0,0,1-.17.16c-7.53-.33-11.78,2.71-11.82,2.74Z"/>
</g>
<g opacity="0.2">
<path d="M157.32,273.51a.15.15,0,0,1-.16-.13c-.47-3.15-4.61-5.42-4.65-5.44a.16.16,0,0,1,.15-.29c.18.1,4.33,2.37,4.82,5.68a.15.15,0,0,1-.13.18Z"/>
</g>
<g opacity="0.2">
<path d="M228.39,409.11a.18.18,0,0,1-.17-.14c-3.2-17.47-10.6-41.87-10.77-42.45-3.88-9.06-4.41-14-4.42-14.08-1.45-16.61-11.16-37.48-11.26-37.69-6.15-13.76-6.22-24.47-6.22-24.57a.17.17,0,0,1,.16-.17.18.18,0,0,1,.17.17c0,.1.07,10.75,6.19,24.44.1.2,9.83,21.12,11.29,37.78,0,.05.53,5,4.39,14v0c.07.25,7.55,24.88,10.79,42.49a.17.17,0,0,1-.14.19Z"/>
</g>
<g opacity="0.2">
<path d="M239.82,383.78h0a.16.16,0,0,1-.14-.18c0-.12,1.68-11.34,6.05-16.71.21-1,3-13.8,5.29-15.26a.16.16,0,0,1,.23.05.15.15,0,0,1,0,.22c-2.2,1.42-5.13,15-5.16,15.09a.09.09,0,0,1,0,.07c-4.33,5.3-6,16.47-6,16.58A.15.15,0,0,1,239.82,383.78Z"/>
</g>
<g opacity="0.2">
<path d="M228.22,317a.17.17,0,0,1-.14-.08.18.18,0,0,1,.06-.23s4-2.33,2.86-9.47c0-.06-.95-6.35,3.52-8.66a.16.16,0,1,1,.15.29c-4.26,2.2-3.36,8.26-3.35,8.32,1.15,7.38-3,9.79-3,9.81Z"/>
</g>
<g opacity="0.2">
<path d="M235.25,292a.16.16,0,0,1-.15-.09.16.16,0,0,1,.08-.22l4.93-2.35a14.32,14.32,0,0,1,3.88-1.22A15.81,15.81,0,0,0,249.7,286a.18.18,0,0,1,.23.06.16.16,0,0,1-.06.22,16.27,16.27,0,0,1-5.83,2.19,14,14,0,0,0-3.79,1.18L235.32,292Z"/>
</g>
<g opacity="0.2">
<path d="M156.5,318.77l-.09,0c-.13-.1-1.18-1.06,1.92-6.1v0a5.74,5.74,0,0,0,.79-4.92.15.15,0,0,1,0-.13.19.19,0,0,1,.11-.07s1.52-.68,1.83-10.63a.17.17,0,0,1,.17-.16.17.17,0,0,1,.16.17c-.28,8.81-1.48,10.53-1.94,10.86a6,6,0,0,1-.87,5.08c-2.92,4.76-2,5.65-2,5.65a.18.18,0,0,1,0,.23A.17.17,0,0,1,156.5,318.77Z"/>
</g>
<g opacity="0.2">
<path d="M202.41,358.14h0c-10.25-2.62-20.43-11.43-27.17-18.36a136.18,136.18,0,0,1-12.29-14.54.16.16,0,0,1,0-.23.16.16,0,0,1,.22,0,135.68,135.68,0,0,0,12.26,14.51c6.71,6.89,16.85,15.66,27,18.26a.17.17,0,0,1,.12.2A.17.17,0,0,1,202.41,358.14Z"/>
</g>
<g opacity="0.2">
<path d="M192.12,360.26h0c-6.12-.43-9.16-2.07-10.64-3.37a5.06,5.06,0,0,1-1.75-2.72c-6.62-2.77-7.8-6.61-7.85-6.77a.16.16,0,1,1,.31-.09s1.21,3.88,7.75,6.59a.17.17,0,0,1,.1.14,4.54,4.54,0,0,0,1.69,2.63c1.43,1.26,4.41,2.85,10.41,3.26a.17.17,0,0,1,.15.18A.16.16,0,0,1,192.12,360.26Z"/>
</g>
<g opacity="0.2">
<path d="M124.94,348.39a40.06,40.06,0,0,1-5.54-.38.17.17,0,1,1,0-.33c.1,0,10,1.59,16.23-1.78a.2.2,0,0,1,.15,0,5.35,5.35,0,0,0,4.18-.09c2.57-1.12,4.6-4.08,6.05-8.8a.16.16,0,0,1,.2-.1.15.15,0,0,1,.11.2c-1.48,4.82-3.57,7.85-6.24,9a5.59,5.59,0,0,1-4.36.12C132.49,348,128.27,348.39,124.94,348.39Z"/>
</g>
<g opacity="0.2">
<path d="M145.72,332a.16.16,0,0,1-.16-.15c-.32-3.33-5.28-7.69-5.33-7.73a.16.16,0,1,1,.21-.25c.21.18,5.12,4.49,5.45,7.95a.17.17,0,0,1-.15.18Z"/>
</g>
<g opacity="0.2">
<path d="M145.23,319.58a.15.15,0,0,1-.11,0,14.67,14.67,0,0,0-6-3.06.17.17,0,0,1-.12-.2.16.16,0,0,1,.2-.12,15.14,15.14,0,0,1,6.12,3.14.16.16,0,0,1,0,.23A.13.13,0,0,1,145.23,319.58Z"/>
</g>
<path d="M139.52,258.32l-30.91,11.36a2.36,2.36,0,0,1-.78.16,2.52,2.52,0,0,1-2.46-1.72.14.14,0,0,0,0-.06,2.64,2.64,0,0,1-.11-.73,2.61,2.61,0,0,1,.05-.48,2.53,2.53,0,0,1,1.74-1.92l1.2-.36,30.14-9Z" fill="#0071f2" data-primary="true"/>
<polygon points="138.37 255.54 148.66 253.75 139.52 258.32 138.37 255.54" fill="#2f2e41" data-secondary="true"/>
<path d="M108.23,264.57l-1.2.36a2.53,2.53,0,0,0-1.74,1.92h0l-8.39-6.09s-2-1.33-1.85-3a1.86,1.86,0,0,1,1.64-1.59C98.69,255.91,103.64,256.31,108.23,264.57Z" fill="#2f2e41" data-secondary="true"/>
<path d="M107.83,269.84c-2.78,5.76-5.49,7.23-7,7.56A1.48,1.48,0,0,1,99,276c-.15-2.94,4.43-6.58,6.36-8a.14.14,0,0,1,0,.06A2.52,2.52,0,0,0,107.83,269.84Z" fill="#2f2e41" data-secondary="true"/>
<path d="M393,257.65l-4,1.65s6.19,2,5.07,5a.47.47,0,0,1-.53.3c-1.83-.37-10.21-1.94-14.31-.87a16.05,16.05,0,0,1-3.22.54,42,42,0,0,1-13.54-1.54,14.08,14.08,0,0,0-4.92-.44l-16.73,1.44c0-.07,0-.13,0-.2l1.8-12.74a4.28,4.28,0,0,0-.59-2.51c.74-.13,4.56-.63,13,.38,0,0,6.05,1.15,10.78-2l7.52-5.55s7-4.57,11.92.16c0,0,5.88-2.61,6.86,3.76,0,0,6.7-1.63,5.56,6,0,0,9.31-2.45,10,.82,0,0,2.65,2.5-10,4.43A19.42,19.42,0,0,0,393,257.65Z" fill="#f9b499"/>
<line x1="385.21" y1="241.33" x2="379.66" y2="242.48" fill="none" stroke="#f7a48b" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"/>
<line x1="392.07" y1="245.09" x2="387.5" y2="246.4" fill="none" stroke="#f7a48b" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"/>
<line x1="397.63" y1="251.13" x2="389.95" y2="252.28" fill="none" stroke="#f7a48b" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"/>
<path d="M382.76,258.65a14,14,0,0,1,6.21.65" fill="none" stroke="#f7a48b" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"/>
<path d="M262.53,468.9c-1,6.2-14.21,8.82-14.21,8.82-15.2,3.43-24.84.32-24.84.32-2.61,0-3.43-5.06-3.43-5.06-2.45-.49-2,5.55-2,5.55,0,7-6.2,5.72-6.2,5.72-20.56,4.9-36.69.57-40.91-.77A1.92,1.92,0,0,1,169.7,482l-3.07-16.39c-2.28-8.17-1.47-12.25-1.47-12.25A104.37,104.37,0,0,0,163,424.79c-1.21-9.94-1.14-15.17-.8-17.89,1.33.16,5.27.34,11.42-1.88,0,0,13-4.53,34.34,0a53.12,53.12,0,0,1,9.06,2.82c4.64,1.88,13.05,4.63,14.1.29,0,0,.17-1.64,7.35,1.47a40.68,40.68,0,0,0,11.81,2.94c1.21,10.47,7,25.81,7,25.81,2.61,8.33,1.63,10.13,1.63,10.13-.49,7.51,1,11.92,1,11.92A16.29,16.29,0,0,1,262.53,468.9Z" fill="#2f2e41" data-secondary="true"/>
<g opacity="0.2">
<path d="M177.74,477.72a.16.16,0,0,1-.15-.12c-3.56-11.65-2.8-25.4-2.79-25.54.64-9.77-2.38-13.74-2.41-13.77a.09.09,0,0,1,0-.06c-4.05-15.07.28-31,.32-31.13a.17.17,0,0,1,.32.09c0,.16-4.35,16-.34,30.92.3.39,3.1,4.38,2.47,14,0,.14-.77,13.83,2.77,25.43a.16.16,0,0,1-.11.2Z"/>
</g>
<g opacity="0.2">
<path d="M171.13,420.41a.17.17,0,0,1-.17-.16.18.18,0,0,1,.17-.17,24.58,24.58,0,0,0,9-2.15A21.86,21.86,0,0,0,192.46,403a.16.16,0,0,1,.2-.11.16.16,0,0,1,.12.2,22.16,22.16,0,0,1-12.55,15.13,25.13,25.13,0,0,1-9.1,2.18Z"/>
</g>
<g opacity="0.2">
<path d="M220.05,473.14a.16.16,0,0,1-.16-.16l-.54-36.35a15.5,15.5,0,0,1,5.48-12.1L227,422.7a.16.16,0,0,1,.21.25L225,424.78a15.2,15.2,0,0,0-5.37,11.85l.55,36.35a.16.16,0,0,1-.16.16Z"/>
</g>
<g opacity="0.2">
<path d="M245.37,472.65l-.09,0a.16.16,0,0,1,0-.22l5.53-7.8c0-1.23.06-5,.74-5.33.4-.22,2.19-4.32,3.68-8.08a.17.17,0,0,1,.21-.09.16.16,0,0,1,.09.21c-.52,1.32-3.16,7.92-3.83,8.26-.35.17-.57,2.77-.57,5.08a.14.14,0,0,1,0,.09l-5.55,7.84A.15.15,0,0,1,245.37,472.65Z"/>
</g>
<g opacity="0.2">
<path d="M249.62,473.14l-.09,0a.16.16,0,0,1,0-.22l5.39-7.84a.16.16,0,0,1,.23-.05.17.17,0,0,1,0,.23l-5.39,7.84A.16.16,0,0,1,249.62,473.14Z"/>
</g>
<path d="M180.85,617.88s2.77,1.31.49-5.39c0,0-4.58-8.82,4.9-11.27,0,0,6.86-2.29,10,3.76,0,0,36.27,34.79,26,41.33,0,0-2.45,2.61-17.81,1.79,0,0-14.05-2.12-21.73-1.3,0,0-14.21,2.61-14.21-5.39l.49-21.57s-1-10.45,5.07-8.66C174,611.18,178.4,612.33,180.85,617.88Z" fill="#0071f2" data-primary="true"/>
<path d="M175.46,235.45s2.94,13.56-3.11,19.61l-4.9,6.53s2.13,4.74,10.78,8.82c0,0,13.24,4.25,15.2,17.15,0,0,2,.82-.66-8.82,0,0-2.28-6,3.76-7.68,0,0,9.26-4.9,4.3-12.41L195.71,249l-11.6-18.3-7-.07Z" fill="#f9b499"/>
<path d="M199.32,255.72l2.33,4.57a6.84,6.84,0,0,1-1,7.29s-12.27-.88-15.13-18.46Z" fill="#f7a48b"/>
<rect x="181.83" y="193.47" width="43.13" height="65.02" rx="21.56" fill="#f9b499"/>
<path d="M196.53,218.79s-2.91,12.52-8.8,6l-9.23,9.69s-.92,6.35-6.15,3.41c0,0-14.54-19.11-10.62-32.67,0,0,.49-1.31-1-3.1,0,0-2.94-3.27.17-5.89a6,6,0,0,0,.81-4.24s-1.63-7.68,6.21-13.4c0,0,5.72-6.37,13.56-2,0,0,2.29-5.39,7.84-.33,0,0,4.41-5.88,10.62-2.45,0,0,3.43.33,3.43,5.56,0,0,6.05-.66,6.7,3.1,0,0,2.94-2.12,8.49.82,0,0,2.62.49,3.43,6.37,0,0-.65,2.78,3.27,3.27,0,0,6.05,1,4.9,7.84,0,0-1.47,8.66-11.11,3.76,0,0-12.25-1.31-15,6.37,0,0,.33,5.55-6.37,6C197.67,217,196.37,216.5,196.53,218.79Z" fill="#2f2e41" data-secondary="true"/>
<g opacity="0.2">
<path d="M204.38,205.74a4,4,0,0,1-2.95-1.48,2.41,2.41,0,0,0-.4-.35c-.24-.13-.49-.24-.72-.34a5,5,0,0,0-4.25.12c-4.1,2.05-6.79.89-8.34-.45-1.22-1.07-1.91-2.47-1.8-2.8a.16.16,0,0,1,.21-.1.15.15,0,0,1,.1.19,5.64,5.64,0,0,0,2.29,2.91c1.41,1,3.84,1.74,7.4,0a5.34,5.34,0,0,1,4.52-.13c.24.1.49.22.74.34a2.28,2.28,0,0,1,.48.42c.88.9,2.52,2.57,6.05.09a.16.16,0,0,1,.23,0,.16.16,0,0,1,0,.22A6.24,6.24,0,0,1,204.38,205.74Zm-18.15-5.2Z"/>
</g>
<g opacity="0.2">
<path d="M170.72,186.77h-.05a.17.17,0,0,1-.11-.2,9.24,9.24,0,0,1,4.38-5.16,8.74,8.74,0,0,1,7.11.15.17.17,0,0,1,.09.22.17.17,0,0,1-.21.09,8.37,8.37,0,0,0-6.85-.16,9.12,9.12,0,0,0-4.21,5A.16.16,0,0,1,170.72,186.77Z"/>
</g>
<g opacity="0.2">
<path d="M164.47,203a6.23,6.23,0,0,1-2.48-.52.17.17,0,1,1,.14-.3,5.71,5.71,0,0,0,4.07.19,5.94,5.94,0,0,0,3.22-3.53.16.16,0,0,1,.22-.09.17.17,0,0,1,.09.21,6.26,6.26,0,0,1-3.42,3.71A5.23,5.23,0,0,1,164.47,203Z"/>
</g>
<g opacity="0.2">
<path d="M212.54,187.75a.15.15,0,0,1-.15-.1c-.41-1-.93-1.61-1.56-1.76a2,2,0,0,0-1.61.51.17.17,0,0,1-.12,0,.14.14,0,0,1-.12-.06c-.83-1-1.63-1.47-2.39-1.37-1.29.19-2.06,2-2.07,2a.15.15,0,0,1-.18.09.15.15,0,0,1-.13-.16c0-3.2-3.75-5-4-5.07-6.3-1.73-8.59,4.41-8.61,4.47a.16.16,0,0,1-.21.1.17.17,0,0,1-.1-.21c0-.06,2.42-6.49,9-4.67h0c.16.07,3.73,1.67,4.15,4.78a3.15,3.15,0,0,1,2-1.65,3,3,0,0,1,2.59,1.36,2.28,2.28,0,0,1,1.77-.48c.74.17,1.34.83,1.79,2a.17.17,0,0,1-.09.21Z"/>
</g>
<g opacity="0.2">
<path d="M217.44,201.64l-.06,0c-2.08-.83-3.34-1.93-3.75-3.27a3.51,3.51,0,0,1,.41-2.85.15.15,0,0,1,.22,0,.15.15,0,0,1,.05.22,3.28,3.28,0,0,0-.37,2.58c.38,1.24,1.58,2.27,3.56,3.06a.17.17,0,0,1-.06.32Z"/>
</g>
<g opacity="0.2">
<path d="M223.65,202.06a4.13,4.13,0,0,1-2.56-.89,6.45,6.45,0,0,1-2.34-4.76.17.17,0,0,1,.16-.17h0a.17.17,0,0,1,.16.17,6.28,6.28,0,0,0,2.22,4.51,3.84,3.84,0,0,0,3.07.75,3.66,3.66,0,0,0,2.48-1.74l.09-.17a.18.18,0,0,1,.23-.07.17.17,0,0,1,.06.23l-.1.18a4,4,0,0,1-2.7,1.89A4.08,4.08,0,0,1,223.65,202.06Z"/>
</g>
<g opacity="0.2">
<path d="M192.47,216.58a7.25,7.25,0,0,1-4.68-1.63,8.32,8.32,0,0,1-2.36-3.13.16.16,0,0,1,.1-.21.17.17,0,0,1,.21.1,8,8,0,0,0,2.27,3c2.14,1.74,4.88,2,8.14.82a.16.16,0,0,1,.21.1.17.17,0,0,1-.1.21A11.13,11.13,0,0,1,192.47,216.58Z"/>
</g>
<ellipse cx="181.83" cy="227.04" rx="5.88" ry="8.74" fill="#f9b499"/>
<path d="M120.4,289.36l-2.94,41s2.62,7.36,12.58,6.05c0,0,11.76-1.14,8.82-13.07s-7-32.67-7-32.67,3.1-5.72,1.31-14.54a8.58,8.58,0,0,1,0-3.43s.16-2.13-1.64-2.62c0,0,8.17-9,1.31-8.16,0,0,3.43-4.58-2.45-4.09,0,0-.66-4.9-8-2,0,0-15.22,6.53-12.64,20.33a3.31,3.31,0,0,0,1,1.77C112.85,280.09,119.32,286.52,120.4,289.36Z" fill="#f9b499"/>
<path d="M115.34,267.47l-.08,0a.15.15,0,0,1-.06-.22,20.65,20.65,0,0,1,15.15-9.56.16.16,0,1,1,0,.32,20.36,20.36,0,0,0-14.91,9.4A.16.16,0,0,1,115.34,267.47Z" fill="#f7a48b"/>
<path d="M119.75,274.82a.18.18,0,0,1-.13-.06.17.17,0,0,1,0-.23s4.77-3.95,8.16-7c0,0,2.95-3.12,4.88-5.69a.16.16,0,0,1,.23,0,.16.16,0,0,1,0,.22c-1.94,2.6-4.88,5.71-4.91,5.74-3.41,3.08-8.14,7-8.19,7A.13.13,0,0,1,119.75,274.82Z" fill="#f7a48b"/>
<path d="M120.89,270.9a.19.19,0,0,1-.1,0,.16.16,0,0,1,0-.23c0-.06,4.75-5.93,11.14-7.73a.16.16,0,0,1,.2.11.15.15,0,0,1-.11.2c-6.29,1.77-10.93,7.56-11,7.62A.16.16,0,0,1,120.89,270.9Z" fill="#f7a48b"/>
<path d="M129.71,285.6a.16.16,0,0,1-.16-.16c0-3.19-2.57-8.37-2.6-8.42-1.51-3.7,4.24-6.94,4.48-7.08a.17.17,0,0,1,.22.06.17.17,0,0,1-.06.23c-.06,0-5.74,3.24-4.34,6.65.1.21,2.63,5.29,2.63,8.56A.17.17,0,0,1,129.71,285.6Z" fill="#f7a48b"/>
</svg>

Before

Width:  |  Height:  |  Size: 33 KiB

View File

@ -1,49 +0,0 @@
@import '~normalize.css';
@import './var.less';
@font-face {
font-family: backset;
src: url('../assets/font/backset.woff');
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
}
.container {
width: 1120px;
margin: 0 auto;
}
a {
&:hover {
text-decoration: none;
}
&:focus {
text-decoration: none;
box-shadow: none;
}
}
.navbar {
height: 2.4rem;
.navbar-section a {
display: flex;
align-items: center;
img {
width: 1.2rem;
}
span {
font-family: backset;
font-size: 1.2em;
padding-left: 0.5em;
color: #121212;
}
}
}

View File

@ -1,12 +0,0 @@
// 重写bootstrap5 css variable
:root {
--color-blue-primary: rgb(45, 111, 247);
--color-black-primary: rgb(39, 41, 48);
--color-yellow-light: rgb(255, 191, 0);
--color-yellow-dark: rgb(217, 163, 74);
--color-font-primary: rgb(38, 51, 59);
--color-font-light: rgb(124, 127, 133);
--color-border: rgb(225, 227, 227);
--padding-btn: 11px 12px 12px;
--radius-primary: 2px;
}

View File

@ -1,3 +0,0 @@
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />

View File

@ -1,9 +0,0 @@
<footer class="container" style="text-align: center; margin-top: 40px">
<small
>Developed by
<a href="https://backset.cn" class="secondary">Backset.cn</a> •
<a target="_blank" href="http://beian.miit.gov.cn" class="secondary"
>苏ICP备19008833号-4</a
></small
>
</footer>

View File

@ -1,26 +0,0 @@
<header class="navbar container">
<section class="navbar-section">
<a href="/">
<img src="/public/assets/img/annotation.svg" />
<span>Backset</span>
</a>
</section>
<section class="navbar-center">
<!-- logo here -->
</section>
<section class="navbar-section">
<a href="/" class="btn btn-link">主页</a>
<!-- basic dropdown button -->
<div class="dropdown dropdown-right">
<a class="btn btn-link dropdown-toggle" tabindex="0">
下拉<i class="icon icon-caret"></i>
</a>
<!-- menu component -->
<ul class="menu">
<li class="menu-item"><a href="#dropdowns">Slack</a></li>
<li class="menu-item"><a href="#dropdowns">Slack</a></li>
<li class="menu-item"><a href="#dropdowns">Slack</a></li>
</ul>
</div>
</section>
</header>

View File

@ -1,8 +0,0 @@
<script src="public/vendors.js" crossorigin="anonymous"></script>
<!-- <script src="public/chunk-lib.js" crossorigin="anonymous"></script> -->
<!-- <script src="https://code.jquery.com/jquery-3.6.3.min.js"></script> -->
<script
type="text/javascript"
src="<%= locals.assets %><%= name %>.js?v=<%= locals.version %>"
></script>

View File

@ -1,7 +0,0 @@
<link rel="icon" type="image/svg+xml" href="/public/assets/img/annotation.svg" />
<!-- <link rel="stylesheet" type="text/css" href="public/assets/css/ui.min.css" /> -->
<link
rel="stylesheet"
href="<%= locals.assets %><%= name %>.css?v=<%= locals.version %>"
/>

View File

@ -1,28 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<%- include('../_layout/base') -%> <%- include('../_layout/styles') -%>
<title>home页面</title>
</head>
<body>
<p>home</p>
<a href="/signup">去 注册 页面</a>
<span>当前assets: <%= assets %></span>
<span class="bs-tooltip fade-right" data-bs-text="Fade right">
Hover me
</span>
<span class="bs-tooltip zoom-up" data-bs-text="Zoom up">Hover me</span>
<h1>1</h1>
<select class="ttt">
<option>最近的</option>
<option selected>最热门</option>
<option>最多喜欢</option>
</select>
<%- include('../_layout/footer') -%> <%- include('../_layout/scripts') -%>
</body>
</html>

View File

@ -1 +0,0 @@
@import '../../less/common.less';

View File

@ -1,12 +0,0 @@
import $ from 'jquery';
import '@backset/ui/dist/ui.css';
import { Dropdown } from '@backset/ui';
$(function () {
new Dropdown({
selector: '.ttt',
onChange: () => {
console.log('change');
},
});
});

View File

@ -1,106 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<%- include('../_layout/base') -%> <%- include('../_layout/styles') -%>
<title>new page: <%= name %></title>
</head>
<body>
<%- include('../_layout/nav') -%>
<main class="container" id="page-signup">
<div class="columns">
<div class="column col-5 col-mx-auto">
<div class="box-container">
<img src="/assets/img/logo.png" />
</div>
<div class="login-container">
<div class="switch-login">
<button
class="btn btn-link"
id="btn-switch-login-type"
data-login-type="password"
>
验证码登录
</button>
</div>
<form onsubmit="return false;" id="password-form">
<div class="form-group">
<label class="form-label" for="username">用户名</label>
<input
class="form-input"
type="text"
id="username"
placeholder="用户名/手机号"
autocomplete="username"
autofocus
/>
</div>
<div class="form-group">
<label class="form-label" for="password">密码</label>
<input
class="form-input"
type="password"
id="password"
placeholder="密码"
autocomplete="current-password"
/>
<p class="form-input-hint">密码长度至少6位</p>
</div>
<div class="form-group">
<label class="form-checkbox">
<input type="checkbox" checked />
<i class="form-icon"></i>记住密码
</label>
</div>
<div class="form-group">
<button
id="btn-signup-password"
class="btn btn-primary btn-block"
>
登入
</button>
</div>
</form>
<form onsubmit="return false;" id="verify-form" class="d-none">
<div class="form-group">
<label class="form-label" for="tel">手机号</label>
<input
class="form-input"
type="tel"
id="tel"
placeholder="手机号"
/>
</div>
<div class="form-group">
<label class="form-label" for="input-example-1">验证码</label>
<div class="input-group">
<input
type="text"
class="form-input"
id="verify-code"
placeholder="输入验证码"
autocomplete="off"
/>
<button
id="btn-verify-code"
class="btn btn-primary input-group-btn"
>
获取验证码
</button>
</div>
</div>
<div class="form-group" style="margin-top: 2em">
<button
id="btn-signup-verify"
class="btn btn-primary btn-block"
>
登入
</button>
</div>
</form>
</div>
</div>
</div>
</main>
<%- include('../_layout/footer') -%> <%- include('../_layout/scripts') -%>
</body>
</html>

View File

@ -1,23 +0,0 @@
@import '../../less/common.less';
.login-container {
.hero {
padding-bottom: 0;
.hero-body {
padding: 0;
}
}
.switch-login {
text-align: center;
}
}
.box-container {
padding-top: 4rem;
position: relative;
text-align: center;
> img {
width: 5rem;
height: 5rem;
}
}

View File

@ -1,71 +0,0 @@
import './index.less';
import { RegUtil } from '@backset/util';
import $ from 'jquery';
$(() => {
$('#page-signup')
.on('click', '#btn-switch-login-type', handleSwitchLoginType)
.on('click', '#btn-signup-password', handlePasswordLogin)
.on('click', '#btn-signup-verify', handleVerifyLogin)
.on('click', '#btn-verify-code', handleGetVerifyCode);
/**
*
*/
function handleGetVerifyCode() {
const params = { phone: '' + $('#tel').val() };
if (!RegUtil.PHONE.test(params.phone)) return;
// return message.error({ text: '手机号格式错误' });
$('#btn-verify-code').addClass('loading');
$.post('/sms/verify', params, res => {
console.log(res);
if (res) {
$('#btn-verify-code').removeClass('loading');
}
});
}
/**
*
*/
function handleVerifyLogin() {
const params = {
login_type: 'verifycode',
user_login: '' + $('#tel').val(),
verify_code: '' + $('#verify-code').val(),
};
$.post('/auth/user/login', params, res => {
console.log(res);
});
}
/**
*
*/
function handlePasswordLogin() {
const params = {
login_type: 'password',
user_login: '' + $('#username').val(),
user_pass: '' + $('#password').val(),
};
$.post('/auth/user/login', params, res => {
console.log(res);
});
}
/**
*
*/
function handleSwitchLoginType(this: any) {
const switchVerify = $(this).attr('data-login-type') === 'password';
if (switchVerify) {
$('#verify-form').removeClass('d-none');
$('#password-form').addClass('d-none');
$(this).attr('data-login-type', 'verify').html('密码登陆');
} else {
$('#verify-form').addClass('d-none');
$('#password-form').removeClass('d-none');
$(this).attr('data-login-type', 'password').html('验证码登陆');
}
}
});

View File

@ -1,20 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>404</title>
<link
rel="stylesheet"
href="<%= assets %><%= name %>.css?v=<%= version %>"
/>
</head>
<body>
<p>404</p>
<script
type="text/javascript"
src="<%= assets %><%= name %>.js?v=<%= version %>"
></script>
</body>
</html>

View File

@ -1,2 +0,0 @@
@import '../../less/common.less';
@import '../../less/var.less';

View File

@ -1 +0,0 @@
import './index.less';

View File

@ -1,70 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<%- include('../_layout/base') -%>
<%- include('../_layout/styles') -%>
<title>new page: <%= name %></title>
</head>
<body>
<%- include('../_layout/nav') -%>
<div class="container" id="signup-module">
<div class="columns">
<div class="column col-5 col-mx-auto">
<div class="box-container">
<div class="slogan">
<img src="/public/dev/assets/img/logo.png" alt="" />
</div>
<div class="hero">
<div class="hero-body">
<h2>你好啊!</h2>
<p>欢迎加入韭菜们的数字化空间</p>
</div>
</div>
<form onsubmit="return false;" id="password-form">
<div class="form-group">
<label class="form-label" for="username">用户名</label>
<input
class="form-input"
type="text"
id="username"
placeholder="用户名"
autocomplete="off"
autofocus
/>
</div>
<div class="form-group">
<label class="form-label" for="username">手机号</label>
<input
class="form-input"
type="tel"
id="tel"
autocomplete="off"
placeholder="手机号"
minlength="11"
maxlength="11"
/>
</div>
<div class="form-group">
<label class="form-label" for="password">密码</label>
<input
class="form-input"
type="password"
id="password"
placeholder="密码"
minlength="6"
/>
<p class="form-input-hint">密码长度至少6位</p>
</div>
<div class="form-group">
<button id="btn-signup" class="btn btn-primary btn-block">
注册
</button>
</div>
</form>
</div>
</div>
</div>
</div>
<%- include('../_layout/footer') -%> <%- include('../_layout/scripts') -%>
</body>
</html>

View File

@ -1,10 +0,0 @@
@import '../../less/common.less';
.slogan {
padding-top: 4rem;
text-align: center;
img {
width: 5rem;
height: 5rem;
}
}

View File

@ -1,25 +0,0 @@
import './index.less';
import { RegUtil, ValidateUtil } from '@backset/util';
import $ from 'jquery';
$(function () {
$('#signup-module').on('click', '#btn-signup', handleCreateUser);
/**
*
*/
function handleCreateUser() {
const params = {
user_login: '' + $('#username').val(),
user_pass: '' + $('#password').val(),
user_phone: '' + $('#tel').val(),
};
if (ValidateUtil.withEmpty(params)) return;
// return message.error({ text: '请补全表单' });
if (!RegUtil.PHONE.test(params.user_phone)) return;
// return message.error({ text: '手机号格式错误' });
$.post('/user/create', params, res => {
console.log(res);
});
}
});

View File

@ -1,141 +0,0 @@
/*eslint-disable*/
const { join } = require('path');
const path = require('path');
const { readdirSync, statSync } = require('fs');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const CopyPlugin = require('copy-webpack-plugin');
const TerserPlugin = require("terser-webpack-plugin");
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
const isDev = process.env.RUNNING_ENV === 'dev';
const isProd = process.env.RUNNING_ENV === 'prod';
/**
* 引入 src/view/pages下的页面文件排除 _ 开头的文件夹
*/
const importEntry = () => {
const entries = {};
const rootDir = join(process.cwd(), '/view/page');
readdirSync(rootDir)
.filter(i => !i.startsWith('_'))
.forEach(file => {
if (statSync(join(rootDir, file)).isDirectory())
entries[file] = `/view/page/${file}/index.ts`;
});
return entries;
};
module.exports = {
entry: {
...importEntry(),
},
output: {
path: path.resolve(__dirname, 'public/'),
publicPath: '',
filename: '[name].js',
chunkFilename: '[id].chunk.js?[hash:8]',
clean: true
},
mode: isDev ? 'development' : 'production',
optimization: {
minimize: true,
minimizer: [new TerserPlugin({
terserOptions: {
compress: true,
},
})],
splitChunks: {
chunks: 'all',
cacheGroups: {
vendor: {
name: 'vendors',
test: /[\\/]node_modules[\\/]/,
priority: 10,
chunks: 'initial'
},
// lib: {
// test(module) {
// return (
// module.size() > 50 * 1024 &&
// /node_modules[/\\]/.test(module.nameForCondition() || '')
// )
// },
// name: 'chunk-lib',
// priority: 15,
// minChunks: 1,
// reuseExistingChunk: true,
// },
}
},
},
module: {
rules: [
{
test: /\.ts?$/,
use: 'ts-loader',
exclude: /node_modules/,
},
{
test: /\.css$/,
use: [MiniCssExtractPlugin.loader, 'css-loader'],
},
{
test: /\.less$/,
use: [MiniCssExtractPlugin.loader, 'css-loader', 'less-loader'],
},
{
test: /\.(scss)$/,
use: [
MiniCssExtractPlugin.loader,
{
loader: 'css-loader'
},
{
loader: 'postcss-loader',
options: {
postcssOptions: {
plugins: () => [
require('autoprefixer')
]
}
}
},
{
loader: 'sass-loader'
}
]
},
{
test: /\.(png|jpg|gif)$/i,
use: [
{
loader: 'url-loader',
options: {
limit: 8192,
},
},
],
},
],
},
plugins: [
new MiniCssExtractPlugin({
filename: '[name].css',
chunkFilename: '[id].css?[hash:8]',
}),
new CopyPlugin([
{ from: join(process.cwd(), 'view/assets'), to: 'assets' },
]),
isProd && new BundleAnalyzerPlugin({
analyzerHost: "0.0.0.0",
analyzerPort: 8088
})
].filter(Boolean),
resolve: {
extensions: ['.ts', '.js', '.ejs'],
},
externals: {
// require("jquery") 是外部的,并且可用
// 在全局变量 jQuery 上
// jquery: 'jQuery',
// $: 'jQuery'
},
};

24
apps/web/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

14
apps/web/index.html Normal file
View File

@ -0,0 +1,14 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>回溯 - Backset.cn</title>
</head>
<body>
<div id="bs-app"></div>
<script type="module" src="/src/main.tsx"></script>
<script src="/hls.js"></script>
</body>
</html>

40
apps/web/package.json Normal file
View File

@ -0,0 +1,40 @@
{
"name": "@backset/web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"less": "^4.1.3",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "6.8.0",
"@ricons/fluent": "0.12.0",
"@ricons/utils": "0.1.6",
"dplayer": "1.27.1",
"identicon.js": "2.3.3",
"react-hot-toast": "2.4.0",
"react-spinners": "0.13.8",
"react-markdown": "8.0.6",
"remark-gfm": "3.0.1",
"crypto-js": "4.1.1"
},
"devDependencies": {
"@types/react": "^18.0.27",
"@types/react-dom": "^18.0.10",
"@types/react-router-dom": "5.3.3",
"@vitejs/plugin-react": "^3.1.0",
"vite-tsconfig-paths": "4.0.5",
"typescript": "^4.9.3",
"vite": "^4.1.0",
"@types/dplayer": "1.25.2",
"@types/identicon.js": "2.3.1",
"rollup-plugin-visualizer": "5.9.0",
"vite-plugin-compression": "0.5.1",
"@types/crypto-js": "4.1.1"
}
}

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