Merge pull request #108 from fantasticit/feat/admin

close #103
pull/111/head
fantasticit 2022-06-28 20:47:16 +08:00 committed by GitHub
commit 11f8c652a4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
58 changed files with 2283 additions and 634 deletions

1
.gitignore vendored
View File

@ -21,3 +21,4 @@ tsconfig.tsbuildinfo
scripts/update.sh
output
runtime

View File

@ -1,18 +1,13 @@
FROM node:18-alpine as builder
COPY . /app/
COPY . /app/
WORKDIR /app
ARG EIP=mrdoc.fun
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.ustc.edu.cn/g' /etc/apk/repositories
RUN npm config set registry https://registry.npmmirror.com
RUN npm i -g pm2 @nestjs/cli pnpm
RUN apk --no-cache add bash
RUN sed -i "s/localhost/$EIP/g" /app/docker/prod-sample.yaml
RUN cp -f /app/docker/prod-sample.yaml /app/config/prod.yaml
RUN apk --no-cache add bash
RUN bash build-output.sh
FROM node:18-alpine as prod
LABEL maintainer="www.mrdoc.fun"
ENV TZ=Asia/Shanghai
COPY --from=builder /app/docker/* /app/docker/
COPY --from=builder /app/output/ /app/
@ -22,7 +17,7 @@ RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.ustc.edu.cn/g' /etc/apk/repositorie
RUN npm config set registry https://registry.npmmirror.com
RUN set -x \
&& apk update \
&& apk add --no-cache tzdata redis \
&& apk add --no-cache tzdata redis \
&& chmod +x /app/docker/start.sh \
&& npm i -g pm2 @nestjs/cli pnpm \
&& rm -rf /var/cache/apk/*

193
README.md
View File

@ -19,182 +19,20 @@ Think 是一款开源知识管理工具。通过独立的知识库空间,结
欢迎进群交流。
<img width="313" alt="image" src="https://user-images.githubusercontent.com/26452939/174938220-5b7301fd-f207-4ff4-a3af-d6b2ab489727.png">
<img width="300" alt="image" src="https://user-images.githubusercontent.com/26452939/176181151-04b3be2e-86e6-4f9e-81f7-e03d9948294c.PNG">
## 预览
![知识库](http://wipi.oss-cn-shanghai.aliyuncs.com/2022-02-20/YN67GM4VQMBTZFZ88TYP8X/image.png)
![新建文档](http://wipi.oss-cn-shanghai.aliyuncs.com/2022-02-20/YN67GM4VQMBTZFZ88TYPQX/image.png)
![编辑器](http://wipi.oss-cn-shanghai.aliyuncs.com/2022-02-20/YN67GM4VQMBTZFZ88TYPZX/image.png)
<details>
<summary>查看预览图</summary>
<img alt="知识库" src="http://wipi.oss-cn-shanghai.aliyuncs.com/2022-02-20/YN67GM4VQMBTZFZ88TYP8X/image.png" width="420" />
<img alt="新建文档" src="http://wipi.oss-cn-shanghai.aliyuncs.com/2022-02-20/YN67GM4VQMBTZFZ88TYPQX/image.png" width="420" />
<img alt="编辑器" src="http://wipi.oss-cn-shanghai.aliyuncs.com/2022-02-20/YN67GM4VQMBTZFZ88TYPZX/image.png" width="420" />
</details>
## 项目结构
## 项目开发
本项目依赖 pnpm 使用 monorepo 形式进行代码组织,分包如下:
- `@think/config`: 客户端、服务端、OSS、MySQL、Redis 等配置管理
- `@think/domains`:领域模型数据定义
- `@think/constants`:常量配置
- `@think/server`:服务端
- `@think/client`:客户端
## 项目依赖
- nodejs ≥ 16.5
- pnpm
- pm2
- mysql ≥ 5.7
- redis (可选)
依赖安装命令: `npm i -g pm2 @nestjs/cli pnpm`
## Docker-compose 一键构建安装
- 实测腾讯轻量云 2C4G 机器构建需 8 分钟左右
**请注意构建前修改 `docker-compose.yml` 中的 `EIP` 参数,否则无法正常使用!!!**
```
# 首次安装
git clone https://github.com/fantasticit/think.git
cd think
vim docker-compose.yml
docker-compose up -d
# 二次更新升级
cd think
git pull
docker-compose build
docker-compose up -d
# FAQ
如遇二次更新有问题,请更新代码重新构建,然后删除本地配置文件并重启容器.
如果还不能解决,1.有能力可自行解决|2.等待更新|3.去mrdoc.fun站点留言
```
然后访问 `http://ip:5001` 即可.
## 手动安装教程
- 前台页面地址:`http://localhost:5001`
- 服务接口地址:`http://localhost:5002`
- 协作接口地址:`http://localhost:5003`
如需修改配置,开发环境编辑 `config/dev.yaml`。生产环境编辑 `config/prod.yaml` (如没有,可复制开发环境的配置修改即可.)
- 数据库
首先安装 `MySQL`,推荐使用 docker 进行安装。
```bash
docker image pull mysql:5.7
# m1 的 mac 可以用docker image pull --platform linux/x86_64 mysql:5.7
docker run -d --restart=always --name think -p 3306:3306 -e MYSQL_DATABASE=think -e MYSQL_ROOT_PASSWORD=root mysql:5.7 --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
```
- 可选Redis
如果需要文档版本服务,请在配置文件中修改 `db.redis` 的配置。
```
docker pull redis:latest
docker run --name think-redis -p 6379:6379 -d redis --appendonly yes --requirepass "root"
```
### 本地源代码运行(开发环境)
```bash
git clone https://github.com/fantasticit/think.git
cd think
pnpm install
pnpm run dev
```
然后访问 `http://ip:5001` 即可.
### 本地源代码运行(生产环境)
生产环境部署的脚本如下:
```bash
git clone https://github.com/fantasticit/think.git
cd think
pnpm install
pnpm run build
pnpm run pm2
pm2 startup
pm2 save
```
### nginx 配置参考
采用 `nginx` 作为反向代理的配置参考(部分),完整版请见 <[think/nginx.conf.bak](https://github.com/fantasticit/think/blob/main/nginx.conf.bak)>
```bash
upstream wipi_client {
server 127.0.0.1:5001;
keepalive 64;
}
# http -> https 重定向
server {
listen 80;
server_name 域名;
rewrite ^(.*)$ https://$host$1 permanent;
}
server {
listen 443 ssl;
server_name 域名;
ssl_certificate 证书存放路径;
ssl_certificate_key 证书存放路径;
location / {
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Nginx-Proxy true;
proxy_cache_bypass $http_upgrade;
proxy_pass http://wipi_client; #反向代理
proxy_set_header X-Real-IP $remote_addr; #获取客户端真实IP
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
```
### caddy2 配置参考
采用 caddy v2 作为反向代理的配置文件参考
```
# 例子中的域名,请自行替换.
think.mrdoc.fun {
encode zstd gzip
reverse_proxy localhost:5001
}
thinkapi.mrdoc.fun {
@websockets {
header Connection *Upgrade*
header Upgrade websocket
path /think/wss/*
}
encode zstd gzip
reverse_proxy /api/* localhost:5002
reverse_proxy @websockets localhost:5003
}
```
[项目开发说明](./let-us-start.md)。
## 自动化部署
@ -202,24 +40,17 @@ thinkapi.mrdoc.fun {
参考:[webhook](https://github.com/adnanh/webhook/blob/master/docs/Hook-Examples.md#incoming-github-webhook)
## 商用
如需商用,请联系作者,取得授权后可商用。
## 赞助
如果这个项目对您有帮助,并且您希望支持该项目的开发和维护,请随时扫描一下二维码进行捐赠。非常感谢您的捐款,谢谢!
如果您希望留下您的信息,可以到[感谢信](https://think.codingit.cn/wiki/eb520cdf-aa4b-4af2-ae4a-7140e21403ab/document/230548f5-3220-4c5b-a209-02b1eb0299e7)评论区留言。
<div style="display: flex;">
<img width="300" alt="alipay" src="https://think-1256095494.cos.ap-shanghai.myqcloud.com/think-alipay.jpg" />
<img width="300" alt="wechat" src="https://think-1256095494.cos.ap-shanghai.myqcloud.com/think-wechat.jpg" />
</div>
## 贡献者
## 资料
感谢所有为本项目作出贡献的同学!
- next.js 源码https://github.com/vercel/next.js
- next.js 文档https://nextjs.org/
- nest.js 源码https://github.com/nestjs/nest
- nest.js 文档https://nestjs.com/
<a href="https://github.com/fantasticit/think/contributors"><img src="https://opencollective.com/think/contributors.svg?width=890" /></a>

View File

@ -23,13 +23,22 @@ server:
enableRateLimit: true # 是否限流
rateLimitWindowMs: 60000 # 限流时间
rateLimitMax: 1000 # 单位限流时间内单个 ip 最大访问数量
email: # 邮箱服务,参考 http://help.163.com/09/1223/14/5R7P6CJ600753VB8.html?servCode=6010376 获取 SMTP 配置
host: ''
port: 465
user: ''
password: ''
admin:
name: 'sytemadmin' # 注意修改
password: 'sytemadmin' # 注意修改
email: 'sytemadmin@think.com' # 注意修改为真实邮箱地址
# 数据库配置
db:
mysql:
host: '127.0.0.1'
username: 'root'
password: 'root'
username: 'think'
password: 'think'
database: 'think'
port: 3306
charset: 'utf8mb4'

View File

@ -1,13 +1,13 @@
# 生产环境docker示例配置
# 开发环境配置
client:
port: 5001
assetPrefix: '/'
apiUrl: 'http://localhost:5002/api'
collaborationUrl: 'ws://localhost:5003/think/wss'
collaborationUrl: 'ws://localhost:5003'
# 以下为页面 meta 配置
seoAppName: '云策文档'
seoDescription: '云策文档是一款开源知识管理工具。通过独立的知识库空间,结构化地组织在线协作文档,实现知识的积累与沉淀,促进知识的复用与流通。'
seoKeywords: '云策文档,协作,文档,前端面试题,fantasticit,https://github.com/fantasticit/think'
seoKeywords: '云策文档,协作,文档,fantasticit,https://github.com/fantasticit/think'
# 预先连接的来源,空格分割(比如图片存储服务器)
dnsPrefetch: '//wipi.oss-cn-shanghai.aliyuncs.com'
# 站点地址http://think.codingit.cn/),一定要设置,否则会出现 cookie、跨域等问题
@ -23,22 +23,31 @@ server:
enableRateLimit: true # 是否限流
rateLimitWindowMs: 60000 # 限流时间
rateLimitMax: 1000 # 单位限流时间内单个 ip 最大访问数量
email: # 邮箱服务,参考 http://help.163.com/09/1223/14/5R7P6CJ600753VB8.html?servCode=6010376 获取 SMTP 配置
host: ''
port: 465
user: ''
password: ''
admin:
name: 'sytemadmin' # 注意修改
password: 'sytemadmin' # 注意修改
email: 'sytemadmin@think.com' # 注意修改为真实邮箱地址
# 数据库配置
db:
mysql:
host: 'mysql-with-think'
username: 'jonnyan404'
password: 'www.mrdoc.fun'
host: 'mysql-for-think'
username: 'think'
password: 'think'
database: 'think'
port: 3306
charset: 'utf8mb4'
timezone: '+08:00'
synchronize: true
redis:
host: '127.0.0.1'
host: 'redis-for-think'
port: '6379'
password: ''
password: 'root'
# oss 文件存储服务
oss:

View File

@ -1,44 +1,58 @@
version: "3"
version: '3'
services:
thinkdoc:
think:
build:
context: .
args:
EIP: x.x.x.x # api接口IP,必须设置,可以是 IP 或者域名.
image: think
container_name: thinkdoc
#restart: always
container_name: think
volumes:
- /path/to/you/dir/config:/app/config # 请注意修改 /path/to/you/dir 为云策文档配置文件目录.
- /path/to/you/dir/static:/app/packages/server/static # 请注意修改 /path/to/you/dir 为云策文档附件存储目录.
- ./config:/app/config
- ./runtime/static:/app/packages/server/static
environment:
- TZ=Asia/Shanghai
ports:
- "5001-5003:5001-5003"
- '5001-5003:5001-5003'
depends_on:
- mysql
- redis
networks:
- think
mysql:
image: mysql:5.7
container_name: mysql-with-think
#restart: always
restart: always
container_name: mysql-for-think
volumes:
- /path/to/you/dir/mysql:/var/lib/mysql # 请注意修改 /path/to/you/dir 为您要存储mysql数据的目录绝对路径.
- ./runtime/mysql:/var/lib/mysql
environment:
- TZ=Asia/Shanghai
- MYSQL_ROOT_PASSWORD=Jonnyan404!
- MYSQL_ROOT_PASSWORD=root
- MYSQL_DATABASE=think
- MYSQL_USER=jonnyan404
- MYSQL_PASSWORD=www.mrdoc.fun
- MYSQL_USER=think
- MYSQL_PASSWORD=think
expose:
- "3306"
- '3306'
ports:
- "63306:3306" # 如果不需要外部连接mysql,可注释此行+上一行.
- '3306:3306'
command:
- "--character-set-server=utf8mb4"
- "--collation-server=utf8mb4_unicode_ci"
# Volumes for persisting data, see https://docs.docker.com/engine/admin/volumes/volumes/
#volumes:
# thinkdoc-data:
# driver: local
# mysql-data:
# driver: local
- '--character-set-server=utf8mb4'
- '--collation-server=utf8mb4_unicode_ci'
networks:
- think
redis:
image: redis:latest
restart: always
container_name: redis-for-think
command: >
--requirepass root
expose:
- '6379'
ports:
- '6379:6379'
volumes:
- ./runtime/redis:/data
privileged: true
networks:
- think
networks:
think:
driver: bridge

View File

@ -1,14 +1,14 @@
#!/bin/sh
### Author:jonnyan404
### date:2022年5月22日
CONFIG_FILE='/app/config/prod.yaml'
if [ ! -f $CONFIG_FILE ]; then
echo "#####Generating configuration file#####"
cp /app/docker/prod-sample.yaml $CONFIG_FILE
cp -f /app/config/docker-prod-sample.yaml $CONFIG_FILE
else
echo "#####Configuration file already exists#####"
echo ""
fi
redis-server --daemonize yes
pnpm run pm2
pm2 logs

110
let-us-start.md 100644
View File

@ -0,0 +1,110 @@
# think
## 项目结构
本项目依赖 pnpm 使用 monorepo 形式进行代码组织,分包如下:
- `@think/config`: 客户端、服务端、OSS、MySQL、Redis 等配置管理
- `@think/domains`:领域模型数据定义
- `@think/constants`:常量配置
- `@think/server`:服务端
- `@think/client`:客户端
## 项目依赖
为了将项目运行起来,至少需要以下依赖。
- nodejs >=16.5.0:推荐使用 nvm 安装
- pnpm安装 nodejs 后,运行 `npm i -g pnpm` 即可安装
- pm2安装 nodejs 后,运行 `npm i -g pm2` 即可安装
- MySQL 5.7
- Redis
## 配置文件
项目所有的配置文件都在 `config` 目录下,其中 `dev.yaml` 中各字段均有解释,生产环境打包依赖 `prod.yaml`(需要自行修改为所需配置)。如果运行不起来,请对比 `dev.yaml` 检查配置。
**如果部署遇到问题,首先请确认相应配置是否正确!**
## 项目运行
无论是开发环境,还是生产环境,项目运行成功后会在 3 个端口启动相应服务(默认 5001、5002、5003具体端口号由 `config` 文件夹下的配置文件决定。
- 前台页面地址:`http://localhost:5001`
- 服务接口地址:`http://localhost:5002`
- 协作接口地址:`http://localhost:5003`
### 本地开发
1. 安装数据库
首先安装 `MySQL``Redis`,推荐使用 docker 进行安装。
```bash
docker image pull mysql:5.7
# m1 的 mac 可以用docker image pull --platform linux/x86_64 mysql:5.7
docker run -d --restart=always --name mysql-for-think-dev -p 3306:3306 -e MYSQL_ROOT_PASSWORD=root -e MYSQL_USER=think -e MYSQL_PASSWORD=think -e MYSQL_DATABASE=think mysql:5.7 --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
docker pull redis:latest
docker run --name redis-for-think-dev -p 6379:6379 -d redis --appendonly yes --requirepass "root"
```
2. 安装依赖并运行
```bash
git clone https://github.com/fantasticit/think.git
cd think
pnpm install
pnpm run dev
```
### 生产部署
首先确认在 `config` 文件夹下新建 `prod.yaml` 配置文件,然后运行以下命令。
```bash
git clone https://github.com/fantasticit/think.git
cd think
pnpm install # 安装依赖
pnpm run build # 项目打包
# 以下如果没有安装 pm2直接 pnpm run start推荐使用 pm2
pnpm run pm2
pm2 startup
pm2 save
```
### docker-compose
也可以使用 docker-compose 进行项目部署。首先,根据需要修改 `docker-compose.yml` 中的数据库、Redis 相关用户名、密码等配置,然后,修改 `config/docker-prod-sample.yaml` 中对应的配置。
```bash
# 首次安装
git clone https://github.com/fantasticit/think.git
cd think
vim docker-compose.yml
docker-compose up -d
# 二次更新升级
cd think
git pull
docker-compose build
docker-compose up -d
# 如果二次更新有问题
docker-compose kill
docker-compose rm
docker image rm think # 删掉构建的镜像
docker-compose up -d
```
### nginx 配置参考
无论以何种方式进行项目部署,项目运行成功后会在 3 个端口启动服务(默认 5001、5002、5003具体由配置文件决定。`nginx` 配置参考 <[think/nginx.conf.sample](https://github.com/fantasticit/think/blob/main/nginx.conf.sample)>。
特别强调,在 `config` 文件夹的配置中 `client.siteUrl` 一定要配置正确,否则客户端可能无法正常运行。
```yaml
# 站点地址http://think.codingit.cn/),一定要设置,否则会出现 cookie、跨域等问题
siteUrl: 'http://localhost:5001'
```

View File

@ -0,0 +1,29 @@
import { TabPane, Tabs } from '@douyinfe/semi-ui';
import React from 'react';
import { Mail } from './mail';
import { System } from './system';
interface IProps {
tab?: string;
onNavigate: (arg: string) => void;
}
const TitleMap = {
base: '系统管理',
mail: '邮箱服务',
};
export const SystemConfig: React.FC<IProps> = ({ tab, onNavigate }) => {
return (
<Tabs lazyRender type="line" activeKey={tab} onChange={onNavigate}>
<TabPane tab={TitleMap['base']} itemKey="base">
<System />
</TabPane>
<TabPane tab={TitleMap['mail']} itemKey="mail">
<Mail />
</TabPane>
</Tabs>
);
};

View File

@ -0,0 +1,88 @@
import { Banner, Button, Form, Toast } from '@douyinfe/semi-ui';
import { DataRender } from 'components/data-render';
import { useSystemConfig } from 'data/user';
import { useToggle } from 'hooks/use-toggle';
import React, { useCallback } from 'react';
export const Mail = () => {
const { data, loading, error, sendTestEmail, updateSystemConfig } = useSystemConfig();
const [changed, toggleChanged] = useToggle(false);
const onFormChange = useCallback(() => {
toggleChanged(true);
}, [toggleChanged]);
const onFinish = useCallback(
(values) => {
updateSystemConfig(values).then(() => {
Toast.success('操作成功');
});
},
[updateSystemConfig]
);
return (
<DataRender
loading={loading}
error={error}
normalContent={() => (
<div style={{ marginTop: 16 }}>
<Banner
type="warning"
description="配置邮箱服务后,请测试是否正确,否则可能导致无法注册用户,找回密码!"
closeIcon={null}
/>
<Form initValues={data} onChange={onFormChange} onSubmit={onFinish}>
<Form.Input
field="emailServiceHost"
label="邮件服务地址"
style={{ width: '100%' }}
placeholder="输入邮件服务地址"
rules={[{ required: true, message: '请输入邮件服务地址' }]}
/>
<Form.Input
field="emailServicePort"
label="邮件服务端口"
style={{ width: '100%' }}
placeholder="输入邮件服务端口"
rules={[{ required: true, message: '请输入邮件服务端口' }]}
/>
<Form.Input
field="emailServicePassword"
label="邮件服务密码"
style={{ width: '100%' }}
placeholder="输入邮件服务密码"
rules={[{ required: true, message: '请输入邮件服务密码' }]}
/>
<Form.Input
field="emailServiceUser"
label="邮件服务用户"
style={{ width: '100%' }}
placeholder="输入邮件服务密码"
rules={[{ required: true, message: '请输入邮件服务密码' }]}
/>
<Button
htmlType="submit"
type="primary"
theme="solid"
disabled={!changed}
loading={loading}
style={{ margin: '16px 0' }}
>
</Button>
<Button style={{ margin: '16px' }} onClick={sendTestEmail}>
</Button>
</Form>
</div>
)}
/>
);
};

View File

@ -0,0 +1,49 @@
import { Banner, Button, Form, Toast } from '@douyinfe/semi-ui';
import { DataRender } from 'components/data-render';
import { useSystemConfig } from 'data/user';
import { useToggle } from 'hooks/use-toggle';
import React, { useCallback } from 'react';
export const System = () => {
const { data, loading, error, updateSystemConfig } = useSystemConfig();
const [changed, toggleChanged] = useToggle(false);
const onFormChange = useCallback(() => {
toggleChanged(true);
}, [toggleChanged]);
const onFinish = useCallback(
(values) => {
updateSystemConfig(values).then(() => {
Toast.success('操作成功');
});
},
[updateSystemConfig]
);
return (
<DataRender
loading={loading}
error={error}
normalContent={() => (
<div style={{ marginTop: 16 }}>
<Banner type="warning" description="系统锁定后,除系统管理员外均不可登录,谨慎修改!" closeIcon={null} />
<Form labelPosition="left" initValues={data} onChange={onFormChange} onSubmit={onFinish}>
<Form.Switch field="isSystemLocked" label="系统锁定" />
<Button
htmlType="submit"
type="primary"
theme="solid"
disabled={!changed}
loading={loading}
style={{ margin: '16px 0' }}
>
</Button>
</Form>
</div>
)}
/>
);
};

View File

@ -16,7 +16,6 @@ import {
Tooltip,
Typography,
} from '@douyinfe/semi-ui';
import { IUser } from '@think/domains';
import { DataRender } from 'components/data-render';
import { DocumentLinkCopyer } from 'components/document/link';
import { useDoumentMembers } from 'data/document';
@ -52,6 +51,7 @@ const renderChecked = (onChange, authKey: 'readable' | 'editable') => (checked,
export const DocumentCollaboration: React.FC<IProps> = ({ wikiId, documentId, disabled = false }) => {
const { isMobile } = IsOnMobile.useHook();
const ref = useRef<HTMLInputElement>();
const toastedUsersRef = useRef([]);
const { user: currentUser } = useUser();
const [visible, toggleVisible] = useToggle(false);
const { users, loading, error, addUser, updateUser, deleteUser } = useDoumentMembers(documentId, {
@ -162,7 +162,10 @@ export const DocumentCollaboration: React.FC<IProps> = ({ wikiId, documentId, di
return joinUser.name !== currentUser.name;
})
.forEach((joinUser) => {
Toast.info(`${joinUser.name}-${joinUser.clientId}加入文档`);
if (!toastedUsersRef.current.includes(joinUser.clientId)) {
Toast.info(`${joinUser.name}-${joinUser.clientId}加入文档`);
toastedUsersRef.current.push(joinUser.clientId);
}
});
setCollaborationUsers(joinUsers);
@ -171,6 +174,7 @@ export const DocumentCollaboration: React.FC<IProps> = ({ wikiId, documentId, di
event.on(JOIN_USER, handler);
return () => {
toastedUsersRef.current = [];
event.off(JOIN_USER, handler);
};
}, [currentUser]);

View File

@ -2,7 +2,8 @@ import { IconSpin } from '@douyinfe/semi-icons';
import { Avatar, Button, Dropdown, Typography } from '@douyinfe/semi-ui';
import { useUser } from 'data/user';
import { useToggle } from 'hooks/use-toggle';
import React from 'react';
import Router from 'next/router';
import React, { useCallback } from 'react';
import { UserSetting } from './setting';
@ -12,6 +13,10 @@ export const User: React.FC = () => {
const { user, loading, error, toLogin, logout } = useUser();
const [visible, toggleVisible] = useToggle(false);
const toAdmin = useCallback(() => {
Router.push('/admin');
}, []);
if (loading) return <Button icon={<IconSpin />} theme="borderless" type="tertiary" />;
if (error || !user) {
@ -32,6 +37,11 @@ export const User: React.FC = () => {
<Dropdown.Item onClick={() => toggleVisible(true)}>
<Text></Text>
</Dropdown.Item>
{user.isSystemAdmin ? (
<Dropdown.Item onClick={toAdmin}>
<Text></Text>
</Dropdown.Item>
) : null}
<Dropdown.Divider />
<Dropdown.Item onClick={logout}>
<Text>退</Text>

View File

@ -65,7 +65,13 @@ export const UserSetting: React.FC<IProps> = ({ visible, toggleVisible }) => {
disabled
placeholder="请输入账户名称"
></Form.Input>
<Form.Input label="邮箱" field="email" style={{ width: '100%' }} placeholder="请输入账户邮箱"></Form.Input>
<Form.Input
disabled
label="邮箱"
field="email"
style={{ width: '100%' }}
placeholder="请输入账户邮箱"
></Form.Input>
</Form>
</Modal>
);

View File

@ -42,7 +42,9 @@ export const WikiCard: React.FC<{ wiki: IWikiWithIsMember; shareMode?: boolean }
</header>
<main>
<div style={{ marginBottom: 12 }}>
<Text strong>{wiki.name}</Text>
<Paragraph ellipsis={{ rows: 1 }} strong>
{wiki.name}
</Paragraph>
<Paragraph ellipsis={{ rows: 1 }}>{wiki.description}</Paragraph>
</div>
<div>

View File

@ -7,7 +7,7 @@ import Link from 'next/link';
import styles from './index.module.scss';
const { Text } = Typography;
const { Text, Paragraph } = Typography;
export const WikiPinCard: React.FC<{ wiki: IWiki }> = ({ wiki }) => {
return (
@ -35,7 +35,9 @@ export const WikiPinCard: React.FC<{ wiki: IWiki }> = ({ wiki }) => {
</div>
</header>
<main>
<Text strong>{wiki.name}</Text>
<Paragraph ellipsis={{ rows: 1 }} strong>
{wiki.name}
</Paragraph>
</main>
<footer>
<Text type="tertiary" size="small">

View File

@ -9,7 +9,7 @@ import { useWikiDetail, useWikiTocs } from 'data/wiki';
import { triggerCreateDocument } from 'event';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import styles from './index.module.scss';
import { Tree } from './tree';
@ -29,7 +29,7 @@ export const WikiTocs: React.FC<IProps> = ({
docAsLink = '/wiki/[wikiId]/document/[documentId]',
getDocLink = (documentId) => `/wiki/${wikiId}/document/${documentId}`,
}) => {
const { pathname } = useRouter();
const { pathname, query } = useRouter();
const { data: wiki, loading: wikiLoading, error: wikiError } = useWikiDetail(wikiId);
const { data: tocs, loading: tocsLoading, error: tocsError } = useWikiTocs(wikiId);
const { data: starWikis } = useStarWikis();
@ -39,6 +39,7 @@ export const WikiTocs: React.FC<IProps> = ({
error: starDocumentsError,
} = useWikiStarDocuments(wikiId);
const [parentIds, setParentIds] = useState<Array<string>>([]);
const otherStarWikis = useMemo(() => (starWikis || []).filter((wiki) => wiki.id !== wikiId), [starWikis, wikiId]);
useEffect(() => {
if (!tocs || !tocs.length) return;
@ -73,15 +74,14 @@ export const WikiTocs: React.FC<IProps> = ({
</div>
}
error={wikiError}
normalContent={() => (
<Dropdown
trigger={'click'}
position="bottomRight"
render={
<Dropdown.Menu style={{ width: 180 }}>
{(starWikis || [])
.filter((wiki) => wiki.id !== wikiId)
.map((wiki) => {
normalContent={() =>
otherStarWikis.length ? (
<Dropdown
trigger={'click'}
position="bottomRight"
render={
<Dropdown.Menu style={{ width: 180 }}>
{otherStarWikis.map((wiki) => {
return (
<Dropdown.Item key={wiki.id}>
<Link
@ -93,35 +93,55 @@ export const WikiTocs: React.FC<IProps> = ({
<a
style={{
display: 'flex',
alignItems: 'center',
width: '100%',
}}
>
<span>
<Avatar
shape="square"
size="small"
src={wiki.avatar}
style={{
marginRight: 8,
width: 24,
height: 24,
borderRadius: 4,
}}
>
{wiki.name.charAt(0)}
</Avatar>
<Text strong ellipsis={{ rows: 1 }}>
{wiki.name}
</Text>
</span>
<Avatar
shape="square"
size="small"
src={wiki.avatar}
style={{
marginRight: 8,
width: 24,
height: 24,
borderRadius: 4,
}}
>
{wiki.name.charAt(0)}
</Avatar>
<Text strong style={{ width: 120 }} ellipsis={{ showTooltip: true }}>
{wiki.name}
</Text>
</a>
</Link>
</Dropdown.Item>
);
})}
</Dropdown.Menu>
}
>
</Dropdown.Menu>
}
>
<div className={styles.titleWrap}>
<span>
<Avatar
shape="square"
size="small"
src={wiki.avatar}
style={{
marginRight: 8,
width: 24,
height: 24,
borderRadius: 4,
}}
>
{wiki.name.charAt(0)}
</Avatar>
<Text strong>{wiki.name}</Text>
</span>
<IconSmallTriangleDown />
</div>
</Dropdown>
) : (
<div className={styles.titleWrap}>
<span>
<Avatar
@ -139,10 +159,9 @@ export const WikiTocs: React.FC<IProps> = ({
</Avatar>
<Text strong>{wiki.name}</Text>
</span>
<IconSmallTriangleDown />
</div>
</Dropdown>
)}
)
}
/>
<DataRender
@ -170,7 +189,12 @@ export const WikiTocs: React.FC<IProps> = ({
}
error={wikiError}
normalContent={() => (
<div className={cls(styles.linkWrap, pathname === '/wiki/[wikiId]' && styles.isActive)}>
<div
className={cls(
styles.linkWrap,
(pathname === '/wiki/[wikiId]' || query.documentId === wiki.homeDocumentId) && styles.isActive
)}
>
<Link
href={{
pathname: `/wiki/[wikiId]`,

View File

@ -1,5 +1,7 @@
import { ILoginUser, IUser, UserApiDefinition } from '@think/domains';
import { Toast } from '@douyinfe/semi-ui';
import { ILoginUser, ISystemConfig, IUser, UserApiDefinition } from '@think/domains';
import { getStorage, setStorage } from 'helpers/storage';
import { useAsyncLoading } from 'hooks/use-async-loading';
import Router, { useRouter } from 'next/router';
import { useCallback, useEffect } from 'react';
import { useQuery } from 'react-query';
@ -16,6 +18,63 @@ export const toLogin = () => {
}
};
/**
*
* @returns
*/
export const useVerifyCode = () => {
const [sendVerifyCode, loading] = useAsyncLoading((params: { email: string }) =>
HttpClient.request({
method: UserApiDefinition.sendVerifyCode.method,
url: UserApiDefinition.sendVerifyCode.client(),
params,
})
);
return {
sendVerifyCode,
loading,
};
};
/**
*
* @returns
*/
export const useRegister = () => {
const [registerWithLoading, loading] = useAsyncLoading((data) =>
HttpClient.request({
method: UserApiDefinition.register.method,
url: UserApiDefinition.register.client(),
data,
})
);
return {
register: registerWithLoading,
loading,
};
};
/**
*
* @returns
*/
export const useResetPassword = () => {
const [resetPasswordWithLoading, loading] = useAsyncLoading((data) =>
HttpClient.request({
method: UserApiDefinition.resetPassword.method,
url: UserApiDefinition.resetPassword.client(),
data,
})
);
return {
reset: resetPasswordWithLoading,
loading,
};
};
export const useUser = () => {
const router = useRouter();
const { data, error, refetch } = useQuery<ILoginUser>('user', () => {
@ -78,3 +137,40 @@ export const useUser = () => {
updateUser,
};
};
/**
*
* @returns
*/
export const useSystemConfig = () => {
const { data, error, isLoading, refetch } = useQuery(UserApiDefinition.getSystemConfig.client(), () =>
HttpClient.request<ISystemConfig>({
method: UserApiDefinition.getSystemConfig.method,
url: UserApiDefinition.getSystemConfig.client(),
})
);
const sendTestEmail = useCallback(async () => {
return await HttpClient.request<ISystemConfig>({
method: UserApiDefinition.sendTestEmail.method,
url: UserApiDefinition.sendTestEmail.client(),
}).then(() => {
Toast.success('测试邮件发送成功');
});
}, []);
const updateSystemConfig = useCallback(
async (data: Partial<ISystemConfig>) => {
const ret = await HttpClient.request<ISystemConfig>({
method: UserApiDefinition.updateSystemConfig.method,
url: UserApiDefinition.updateSystemConfig.client(),
data,
});
refetch();
return ret;
},
[refetch]
);
return { data, error, loading: isLoading, refresh: refetch, sendTestEmail, updateSystemConfig };
};

View File

@ -0,0 +1,34 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useToggle } from './use-toggle';
import { useIsomorphicLayoutEffect } from './user-isomorphic-layout-effect';
export const useInterval = (callback: () => void, delay: number) => {
const savedCallback = useRef(callback);
const timer = useRef(null);
const [canActive, toggleCanActive] = useToggle(false);
useIsomorphicLayoutEffect(() => {
savedCallback.current = callback;
}, [callback]);
useEffect(() => {
if (!canActive) return;
clearInterval(timer.current);
timer.current = setInterval(() => savedCallback.current(), delay);
return () => clearInterval(timer.current);
}, [canActive, delay]);
const start = useCallback(() => {
toggleCanActive(true);
}, [toggleCanActive]);
const stop = useCallback(() => {
clearInterval(timer.current);
toggleCanActive(false);
}, [toggleCanActive]);
return { start, stop };
};

View File

@ -0,0 +1,3 @@
import { useEffect, useLayoutEffect } from 'react';
export const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect;

View File

@ -0,0 +1,225 @@
import React from 'react';
export const Forbidden = () => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink"
data-name="Layer 1"
width="20em"
height="15em"
viewBox="0 0 807.45276 499.98424"
>
<path
id="ad903c08-5677-4dbe-a9c7-05a0eb46801f-129"
data-name="Path 461"
d="M252.30849,663.16553a22.728,22.728,0,0,0,21.947-3.866c7.687-6.452,10.1-17.081,12.058-26.924l5.8-29.112-12.143,8.362c-8.733,6.013-17.662,12.219-23.709,20.929s-8.686,20.6-3.828,30.024"
transform="translate(-196.27362 -200.00788)"
fill="#e6e6e6"
/>
<path
id="a94887ac-0642-4b28-b311-c351a0f7f12b-130"
data-name="Path 462"
d="M253.34651,698.41151c-1.229-8.953-2.493-18.02-1.631-27.069.766-8.036,3.217-15.885,8.209-22.321a37.13141,37.13141,0,0,1,9.527-8.633c.953-.6,1.829.909.881,1.507a35.29989,35.29989,0,0,0-13.963,16.847c-3.04,7.732-3.528,16.161-3,24.374.317,4.967.988,9.9,1.665,14.83a.9.9,0,0,1-.61,1.074.878.878,0,0,1-1.074-.61Z"
transform="translate(-196.27362 -200.00788)"
fill="#f2f2f2"
/>
<path
d="M496.87431,505.52556a6.9408,6.9408,0,0,1-2.85071.67077l-91.60708,2.51425a14.3796,14.3796,0,0,1-.62506-28.75241l91.60729-2.51381a7.00744,7.00744,0,0,1,7.15064,6.8456l.32069,14.75586a7.01658,7.01658,0,0,1-3.99577,6.47974Z"
transform="translate(-196.27362 -200.00788)"
fill="#6c63ff"
/>
<path
d="M379.332,698.59808H364.57245a7.00786,7.00786,0,0,1-7-7V568.58392a7.00785,7.00785,0,0,1,7-7H379.332a7.00786,7.00786,0,0,1,7,7V691.59808A7.00787,7.00787,0,0,1,379.332,698.59808Z"
transform="translate(-196.27362 -200.00788)"
fill="#6c63ff"
/>
<path
d="M418.52435,698.59808H403.76459a7.00786,7.00786,0,0,1-7-7V568.58392a7.00785,7.00785,0,0,1,7-7h14.75976a7.00786,7.00786,0,0,1,7,7V691.59808A7.00787,7.00787,0,0,1,418.52435,698.59808Z"
transform="translate(-196.27362 -200.00788)"
fill="#6c63ff"
/>
<circle cx="196.71571" cy="182.69717" r="51" fill="#6c63ff" />
<path
d="M410.30072,605.205H373.61127a43.27708,43.27708,0,0,1-37.56043-65.05664l51.30933-88.87012a6.5,6.5,0,0,1,11.2583,0l50.27612,87.08057A44.56442,44.56442,0,0,1,410.30072,605.205Z"
transform="translate(-196.27362 -200.00788)"
fill="#2f2e41"
/>
<path
d="M405.02686,404.114c3.30591-.0918,7.42029-.20655,10.59-2.522a8.13274,8.13274,0,0,0,3.20007-6.07275,5.47084,5.47084,0,0,0-1.86035-4.49315c-1.65552-1.39894-4.073-1.72706-6.67823-.96144l2.69922-19.72558-1.98144-.27149-3.17322,23.18994,1.65466-.75928c1.91834-.87988,4.55164-1.32763,6.188.05518a3.51514,3.51514,0,0,1,1.15271,2.89551,6.14686,6.14686,0,0,1-2.38122,4.52783c-2.46668,1.80176-5.74622,2.03418-9.46582,2.13818Z"
transform="translate(-196.27362 -200.00788)"
fill="#2f2e41"
/>
<rect x="226.50312" y="172.03238" width="10.77161" height="2" fill="#2f2e41" />
<rect x="192.50312" y="172.03238" width="10.77161" height="2" fill="#2f2e41" />
<path
d="M380.99359,593.79839a6.94088,6.94088,0,0,1-.67077-2.85072l-2.51425-91.60708a14.3796,14.3796,0,0,1,28.75241-.62506l2.51381,91.60729a7.00744,7.00744,0,0,1-6.8456,7.15064l-14.75586.32069a7.01655,7.01655,0,0,1-6.47974-3.99576Z"
transform="translate(-196.27362 -200.00788)"
fill="#6c63ff"
/>
<path
d="M388.25747,345.00549c6.19637,8.10336,16.033,13.53931,26.42938,12.25223,9.90031-1.22567,18.06785-8.12619,20.117-18.0055a29.66978,29.66978,0,0,0-7.79665-26.1905c-7.00748-7.37032-17.03634-11.335-26.96311-12.69456-18.80446-2.57537-38.1172,4.04852-52.33518,16.4023a64.1102,64.1102,0,0,0-16.69251,22.37513,62.72346,62.72346,0,0,0-5.175,27.07767c.54633,18.375,8.595,36.71479,22.48271,48.90083a63.37666,63.37666,0,0,0,5.40808,4.23578c1.58387,1.11112,3.08464-1.48868,1.51415-2.59042-14.222-9.977-23.29362-26.21093-25.78338-43.26844a59.92391,59.92391,0,0,1,14.05278-48.33971c11.48411-13.058,28.32271-21.54529,45.7628-22.30575,17.54894-.76521,39.47915,7.06943,42.7631,26.60435,1.47191,8.7558-1.801,17.95926-9.82454,22.3428-8.59053,4.69326-19.12416,2.76181-26.50661-3.29945a30.448,30.448,0,0,1-4.86258-5.01092c-1.157-1.51313-3.76387-.02044-2.59041,1.51416Z"
transform="translate(-196.27362 -200.00788)"
fill="#2f2e41"
/>
<rect
id="fc777aff-63b1-4720-84dc-e3a9c20790b9"
data-name="ab2e16f2-9798-47da-b25d-769524f3c86f"
x="484.20919"
y="242.03206"
width="437.1948"
height="207.45652"
transform="translate(-238.48792 -95.97299) rotate(-8.21995)"
fill="#f1f1f1"
/>
<rect
id="ecffa418-b240-4504-be04-512edea7ccda"
data-name="bf81c03f-68cf-4889-8697-1102f95f97bb"
x="496.79745"
y="259.81556"
width="412.19197"
height="173.08746"
transform="translate(-238.57266 -95.95442) rotate(-8.21995)"
fill="#fff"
/>
<rect
id="b49ce3f1-9d75-4481-986b-3b6beb000c79"
data-name="f065dccc-d150-492a-a09f-a7f3f89523f0"
x="468.80837"
y="231.16611"
width="437.19481"
height="18.57334"
transform="translate(-223.58995 -99.25677) rotate(-8.21995)"
fill="#e5e5e5"
/>
<circle
id="a4219562-805a-49cd-8b89-b1f92f7a9e75"
data-name="bdbbf39c-df25-4682-8b85-5a6af4a1bd14"
cx="288.67474"
cy="71.34324"
r="3.4425"
fill="#fff"
/>
<circle
id="b0f6399c-6944-4f74-a888-473f61f9730c"
data-name="abcd4292-0b1f-4102-9b5e-e8bbd87baabc"
cx="301.6071"
cy="69.47507"
r="3.4425"
fill="#fff"
/>
<circle
id="b03f93dc-2c99-4323-9b17-02f51b8830c0"
data-name="a3fb731e-8b3d-41ca-96f2-91600dc0b434"
cx="314.54005"
cy="67.6068"
r="3.4425"
fill="#fff"
/>
<rect
id="a6067cfc-0392-4d68-afe4-e34d11a8f0ac"
data-name="ab2e16f2-9798-47da-b25d-769524f3c86f"
x="370.25796"
y="100.18309"
width="437.1948"
height="207.45652"
fill="#e6e6e6"
/>
<rect
id="ecd65817-7467-4dbd-a435-c0f1d9841c98"
data-name="bf81c03f-68cf-4889-8697-1102f95f97bb"
x="382.75969"
y="117.97286"
width="412.19197"
height="173.08746"
fill="#fff"
/>
<rect
id="eea6c39d-8a45-4eb1-bab9-6120f465de14"
data-name="f065dccc-d150-492a-a09f-a7f3f89523f0"
x="370.07154"
y="88.19711"
width="437.19481"
height="18.57334"
fill="#cbcbcb"
/>
<circle
id="ab9e51f9-7431-4d30-8193-f9435a6bd5c3"
data-name="bdbbf39c-df25-4682-8b85-5a6af4a1bd14"
cx="383.87383"
cy="99.11864"
r="3.4425"
fill="#fff"
/>
<circle
id="a54ed687-3b0d-413b-b405-af8897a5c032"
data-name="abcd4292-0b1f-4102-9b5e-e8bbd87baabc"
cx="396.94043"
cy="99.11864"
r="3.4425"
fill="#fff"
/>
<circle
id="fd1d2195-7e97-488f-8f4b-7061a06deb9a"
data-name="a3fb731e-8b3d-41ca-96f2-91600dc0b434"
cx="410.00762"
cy="99.11864"
r="3.4425"
fill="#fff"
/>
<rect x="620.27691" y="144.28855" width="58.05212" height="4.36334" fill="#e6e6e6" />
<rect x="620.27691" y="157.09784" width="89.64514" height="4.36332" fill="#e6e6e6" />
<rect x="621.20899" y="169.29697" width="73.05881" height="4.36332" fill="#e6e6e6" />
<rect x="620.27691" y="182.68222" width="42.65054" height="4.36332" fill="#e6e6e6" />
<rect x="620.27691" y="195.75677" width="64.37073" height="4.36332" fill="#e6e6e6" />
<rect x="593.81776" y="142.916" width="7.10843" height="7.10842" fill="#e6e6e6" />
<rect x="593.81776" y="155.72527" width="7.10843" height="7.10841" fill="#e6e6e6" />
<rect x="593.81776" y="167.92442" width="7.10843" height="7.10843" fill="#e6e6e6" />
<rect x="593.81776" y="181.30967" width="7.10843" height="7.10843" fill="#e6e6e6" />
<rect x="593.81776" y="194.38423" width="7.10843" height="7.10843" fill="#e6e6e6" />
<rect x="620.27691" y="208.91306" width="58.05212" height="4.36332" fill="#e6e6e6" />
<rect x="620.27691" y="221.72236" width="89.64514" height="4.36332" fill="#e6e6e6" />
<rect x="621.20899" y="233.92149" width="73.05881" height="4.36332" fill="#e6e6e6" />
<rect x="620.27691" y="247.30674" width="42.65054" height="4.36332" fill="#e6e6e6" />
<rect x="620.27691" y="260.38129" width="64.37073" height="4.36332" fill="#e6e6e6" />
<rect x="593.81776" y="207.54051" width="7.10843" height="7.10843" fill="#e6e6e6" />
<rect x="593.81776" y="220.34979" width="7.10843" height="7.10841" fill="#e6e6e6" />
<rect x="593.81776" y="232.54894" width="7.10843" height="7.10843" fill="#e6e6e6" />
<rect x="593.81776" y="245.93419" width="7.10843" height="7.10843" fill="#e6e6e6" />
<rect x="593.81776" y="259.00875" width="7.10843" height="7.10843" fill="#e6e6e6" />
<rect x="436.63003" y="243.13905" width="58.05213" height="4.36333" fill="#e6e6e6" />
<rect x="428.86266" y="254.4769" width="73.05881" height="4.36332" fill="#e6e6e6" />
<path
d="M699.66075,388.1056a37.91872,37.91872,0,0,1-55.87819,33.382l-.00736-.00737a37.907,37.907,0,1,1,55.88555-33.37461Z"
transform="translate(-196.27362 -200.00788)"
fill="#e6e6e6"
/>
<circle cx="465.67554" cy="175.95347" r="10.30421" fill="#fff" />
<path
d="M679.54362,407.55657a53.11056,53.11056,0,0,1-35.56788-.13775l-.00738-.0051,7.6766-15.15329h20.60841Z"
transform="translate(-196.27362 -200.00788)"
fill="#fff"
/>
<path
d="M547.86351,482.19293c-17.96014,0-32.5719-15.52155-32.5719-34.60067,0-19.07858,14.61176-34.60014,32.5719-34.60014s32.5719,15.52156,32.5719,34.60014C580.43541,466.67138,565.82365,482.19293,547.86351,482.19293Zm0-60.4582c-13.13954,0-23.82929,11.59955-23.82929,25.85753s10.68975,25.85806,23.82929,25.85806,23.82928-11.60008,23.82928-25.85806S561.00305,421.73473,547.86351,421.73473Z"
transform="translate(-196.27362 -200.00788)"
fill="#6c63ff"
/>
<path
d="M578.70786,542.49212h-61.6887a20.54138,20.54138,0,0,1-20.51852-20.51826V461.46391a14.06356,14.06356,0,0,1,14.04747-14.04774h74.6308a14.06356,14.06356,0,0,1,14.04747,14.04774v60.50995A20.54138,20.54138,0,0,1,578.70786,542.49212Z"
transform="translate(-196.27362 -200.00788)"
fill="#3f3d56"
/>
<path
d="M559.88461,481.84022a12.0211,12.0211,0,1,0-17.48524,10.69829v18.808h10.92827v-18.808A12.01088,12.01088,0,0,0,559.88461,481.84022Z"
transform="translate(-196.27362 -200.00788)"
fill="#fff"
/>
<path
d="M578.27362,699.99212h-381a1,1,0,0,1,0-2h381a1,1,0,0,1,0,2Z"
transform="translate(-196.27362 -200.00788)"
fill="#3f3d56"
/>
</svg>
);
};

View File

@ -38,7 +38,7 @@ const menus = [
},
{
itemKey: '/star',
text: '收藏',
text: '星标',
onClick: () => {
Router.push({
pathname: `/star`,

View File

@ -0,0 +1,12 @@
.wikiItemWrap {
padding: 12px 16px !important;
margin: 8px 2px;
cursor: pointer;
background-color: var(--semi-color-bg-2);
border: 1px solid var(--semi-color-border) !important;
}
.titleWrap {
display: flex;
justify-content: space-between;
}

View File

@ -0,0 +1,55 @@
import { Typography } from '@douyinfe/semi-ui';
import { SystemConfig } from 'components/admin/system-config';
import { Seo } from 'components/seo';
import { useUser } from 'data/user';
import { Forbidden } from 'illustrations/forbidden';
import { SingleColumnLayout } from 'layouts/single-column';
import type { NextPage } from 'next';
import Router, { useRouter } from 'next/router';
import React, { useCallback } from 'react';
import styles from './index.module.scss';
const { Title, Text } = Typography;
const Page: NextPage = () => {
const { user } = useUser();
const { query = {} } = useRouter();
const { tab = 'base' } = query as {
tab?: string;
};
const navigate = useCallback((tab = 'base') => {
Router.push({
pathname: `/admin`,
query: { tab },
});
}, []);
return (
<SingleColumnLayout>
<Seo title="管理后台" />
<div className="container">
{user && user.isSystemAdmin ? (
<>
<div className={styles.titleWrap}>
<Title heading={3} style={{ margin: '8px 0' }}>
</Title>
</div>
<SystemConfig tab={tab} onNavigate={navigate} />
</>
) : (
<div style={{ display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center' }}>
<Forbidden />
<Text strong type="danger">
</Text>
</div>
)}
</div>
</SingleColumnLayout>
);
};
export default Page;

View File

@ -0,0 +1,35 @@
.wrap {
position: relative;
display: flex;
flex-direction: column;
height: 100%;
background-color: var(--semi-color-bg-0);
.content {
position: relative;
z-index: 10;
display: flex;
padding: 10vh 24px;
flex: 1;
flex-direction: column;
justify-content: center;
align-items: center;
.form {
width: 100%;
max-width: 400px;
padding: 32px 24px;
margin: 0 auto;
border: 1px solid var(--semi-color-border);
border-radius: var(--border-radius);
box-shadow: var(--box-shadow);
footer {
padding-top: 16px;
border-top: 1px solid var(--semi-color-border);
margin-top: 12px;
text-align: center;
}
}
}
}

View File

@ -0,0 +1,170 @@
import { Button, Col, Form, Layout, Modal, Row, Space, Toast, Typography } from '@douyinfe/semi-ui';
import { Author } from 'components/author';
import { LogoImage, LogoText } from 'components/logo';
import { Seo } from 'components/seo';
import { useResetPassword, useVerifyCode } from 'data/user';
import { useInterval } from 'hooks/use-interval';
import { useRouterQuery } from 'hooks/use-router-query';
import { useToggle } from 'hooks/use-toggle';
import Link from 'next/link';
import Router from 'next/router';
import React, { useCallback, useState } from 'react';
import styles from './index.module.scss';
const { Content, Footer } = Layout;
const { Title, Text } = Typography;
const Page = () => {
const query = useRouterQuery();
const [email, setEmail] = useState('');
const [hasSendVerifyCode, toggleHasSendVerifyCode] = useToggle(false);
const [countDown, setCountDown] = useState(0);
const { reset, loading } = useResetPassword();
const { sendVerifyCode, loading: sendVerifyCodeLoading } = useVerifyCode();
const onFormChange = useCallback((formState) => {
setEmail(formState.values.email);
}, []);
const { start, stop } = useInterval(() => {
setCountDown((v) => {
if (v - 1 <= 0) {
stop();
toggleHasSendVerifyCode(false);
return 0;
}
return v - 1;
});
}, 1000);
const onFinish = useCallback(
(values) => {
reset(values).then((res) => {
Modal.confirm({
title: <Title heading={5}></Title>,
content: <Text>?</Text>,
okText: '确认',
cancelText: '取消',
onOk() {
Router.push('/login', { query });
},
});
});
},
[reset, query]
);
const getVerifyCode = useCallback(() => {
stop();
sendVerifyCode({ email })
.then(() => {
Toast.success('请前往邮箱查收验证码');
setCountDown(60);
start();
toggleHasSendVerifyCode(true);
})
.catch(() => {
toggleHasSendVerifyCode(false);
});
}, [email, toggleHasSendVerifyCode, sendVerifyCode, start, stop]);
return (
<Layout className={styles.wrap}>
<Seo title="重置密码" />
<Content className={styles.content}>
<Title heading={4} style={{ marginBottom: 16, textAlign: 'center' }}>
<Space>
<LogoImage></LogoImage>
<LogoText></LogoText>
</Space>
</Title>
<Form
className={styles.form}
initValues={{ name: '', password: '' }}
onChange={onFormChange}
onSubmit={onFinish}
>
<Title type="tertiary" heading={5} style={{ marginBottom: 16, textAlign: 'center' }}>
</Title>
<Form.Input
noLabel
field="email"
placeholder={'请输入邮箱'}
rules={[
{
type: 'email',
message: '请输入正确的邮箱地址!',
},
{
required: true,
message: '请输入邮箱地址!',
},
]}
/>
<Row gutter={8} style={{ paddingTop: 12 }}>
<Col span={16}>
<Form.Input
noLabel
fieldStyle={{ paddingTop: 0 }}
placeholder={'请输入验证码'}
field="verifyCode"
rules={[{ required: true, message: '请输入邮箱收到的验证码!' }]}
/>
</Col>
<Col span={8}>
<Button disabled={!email || countDown > 0} loading={sendVerifyCodeLoading} onClick={getVerifyCode} block>
{hasSendVerifyCode ? countDown : '获取验证码'}
</Button>
</Col>
</Row>
<Form.Input
noLabel
mode="password"
field="password"
label="密码"
style={{ width: '100%' }}
placeholder="输入用户密码"
rules={[{ required: true, message: '请输入新密码' }]}
/>
<Form.Input
noLabel
mode="password"
field="confirmPassword"
label="密码"
style={{ width: '100%' }}
placeholder="确认用户密码"
rules={[{ required: true, message: '请再次输入密码' }]}
/>
<Button htmlType="submit" type="primary" theme="solid" block loading={loading} style={{ margin: '16px 0' }}>
</Button>
<footer>
<Link
href={{
pathname: '/login',
query,
}}
>
<Text link style={{ textAlign: 'center' }}>
</Text>
</Link>
</footer>
</Form>
</Content>
<Footer>
<Author></Author>
</Footer>
</Layout>
);
};
export default Page;

View File

@ -51,9 +51,10 @@ const Page = () => {
field="name"
label="账户"
style={{ width: '100%' }}
placeholder="输入账户名称"
rules={[{ required: true, message: '请输入账户' }]}
placeholder="输入账户名称或邮箱"
rules={[{ required: true, message: '请输入账户或邮箱' }]}
></Form.Input>
<Form.Input
noLabel
mode="password"
@ -67,16 +68,29 @@ const Page = () => {
</Button>
<footer>
<Link
href={{
pathname: '/register',
query,
}}
>
<Text link style={{ textAlign: 'center' }}>
</Text>
</Link>
<Space>
<Link
href={{
pathname: '/register',
query,
}}
>
<Text link style={{ textAlign: 'center' }}>
</Text>
</Link>
<Link
href={{
pathname: '/forgetPassword',
query,
}}
>
<a>
<Text type="tertiary"></Text>
</a>
</Link>
</Space>
</footer>
</Form>
</Content>

View File

@ -1,13 +1,14 @@
import { Button, Form, Layout, Modal, Space, Typography } from '@douyinfe/semi-ui';
import { Button, Col, Form, Layout, Modal, Row, Space, Toast, Typography } from '@douyinfe/semi-ui';
import { Author } from 'components/author';
import { LogoImage, LogoText } from 'components/logo';
import { Seo } from 'components/seo';
import { useAsyncLoading } from 'hooks/use-async-loading';
import { useRegister, useVerifyCode } from 'data/user';
import { useInterval } from 'hooks/use-interval';
import { useRouterQuery } from 'hooks/use-router-query';
import { useToggle } from 'hooks/use-toggle';
import Link from 'next/link';
import Router from 'next/router';
import React from 'react';
import { register as registerApi } from 'services/user';
import React, { useCallback, useState } from 'react';
import styles from './index.module.scss';
@ -16,21 +17,58 @@ const { Title, Text } = Typography;
const Page = () => {
const query = useRouterQuery();
const [registerWithLoading, loading] = useAsyncLoading(registerApi);
const onFinish = (values) => {
registerWithLoading(values).then((res) => {
Modal.confirm({
title: <Title heading={5}></Title>,
content: <Text>?</Text>,
okText: '确认',
cancelText: '取消',
onOk() {
Router.push('/login');
},
});
const [email, setEmail] = useState('');
const [hasSendVerifyCode, toggleHasSendVerifyCode] = useToggle(false);
const [countDown, setCountDown] = useState(0);
const { register, loading } = useRegister();
const { sendVerifyCode, loading: sendVerifyCodeLoading } = useVerifyCode();
const onFormChange = useCallback((formState) => {
setEmail(formState.values.email);
}, []);
const { start, stop } = useInterval(() => {
setCountDown((v) => {
if (v - 1 <= 0) {
stop();
toggleHasSendVerifyCode(false);
return 0;
}
return v - 1;
});
};
}, 1000);
const onFinish = useCallback(
(values) => {
register(values).then((res) => {
Modal.confirm({
title: <Title heading={5}></Title>,
content: <Text>?</Text>,
okText: '确认',
cancelText: '取消',
onOk() {
Router.push('/login', { query });
},
});
});
},
[register, query]
);
const getVerifyCode = useCallback(() => {
stop();
sendVerifyCode({ email })
.then(() => {
Toast.success('请前往邮箱查收验证码');
setCountDown(60);
start();
toggleHasSendVerifyCode(true);
})
.catch(() => {
toggleHasSendVerifyCode(false);
});
}, [email, toggleHasSendVerifyCode, sendVerifyCode, start, stop]);
return (
<Layout className={styles.wrap}>
@ -42,7 +80,12 @@ const Page = () => {
<LogoText></LogoText>
</Space>
</Title>
<Form className={styles.form} initValues={{ name: '', password: '' }} onSubmit={onFinish}>
<Form
className={styles.form}
initValues={{ name: '', password: '' }}
onChange={onFormChange}
onSubmit={onFinish}
>
<Title type="tertiary" heading={5} style={{ marginBottom: 16, textAlign: 'center' }}>
</Title>
@ -72,6 +115,40 @@ const Page = () => {
placeholder="确认用户密码"
rules={[{ required: true, message: '请再次输入密码' }]}
></Form.Input>
<Form.Input
noLabel
field="email"
placeholder={'请输入邮箱'}
rules={[
{
type: 'email',
message: '请输入正确的邮箱地址!',
},
{
required: true,
message: '请输入邮箱地址!',
},
]}
/>
<Row gutter={8} style={{ paddingTop: 12 }}>
<Col span={16}>
<Form.Input
noLabel
fieldStyle={{ paddingTop: 0 }}
placeholder={'请输入验证码'}
field="verifyCode"
rules={[{ required: true, message: '请输入邮箱收到的验证码!' }]}
/>
</Col>
<Col span={8}>
<Button disabled={!email || countDown > 0} loading={sendVerifyCodeLoading} onClick={getVerifyCode} block>
{hasSendVerifyCode ? countDown : '获取验证码'}
</Button>
</Col>
</Row>
<Button htmlType="submit" type="primary" theme="solid" block loading={loading} style={{ margin: '16px 0' }}>
</Button>

View File

@ -7,6 +7,14 @@ export declare const UserApiDefinition: {
server: "/";
client: () => string;
};
/**
*
*/
sendVerifyCode: {
method: "get";
server: "sendVerifyCode";
client: () => string;
};
/**
*
*/
@ -15,6 +23,14 @@ export declare const UserApiDefinition: {
server: "register";
client: () => string;
};
/**
*
*/
resetPassword: {
method: "post";
server: "resetPassword";
client: () => string;
};
/**
*
*/
@ -39,4 +55,36 @@ export declare const UserApiDefinition: {
server: "update";
client: () => string;
};
/**
*
*/
toggleLockUser: {
method: "post";
server: "lock/user";
client: () => string;
};
/**
*
*/
getSystemConfig: {
method: "get";
server: "config/system";
client: () => string;
};
/**
*
*/
sendTestEmail: {
method: "get";
server: "config/system/sendTestEmail";
client: () => string;
};
/**
*
*/
updateSystemConfig: {
method: "post";
server: "config/system/updateSystemConfig";
client: () => string;
};
};

View File

@ -10,6 +10,14 @@ exports.UserApiDefinition = {
server: '/',
client: function () { return '/user'; }
},
/**
*
*/
sendVerifyCode: {
method: 'get',
server: 'sendVerifyCode',
client: function () { return '/verify/sendVerifyCode'; }
},
/**
*
*/
@ -18,6 +26,14 @@ exports.UserApiDefinition = {
server: 'register',
client: function () { return '/user/register'; }
},
/**
*
*/
resetPassword: {
method: 'post',
server: 'resetPassword',
client: function () { return '/user/resetPassword'; }
},
/**
*
*/
@ -41,5 +57,37 @@ exports.UserApiDefinition = {
method: 'patch',
server: 'update',
client: function () { return "/user/update"; }
},
/**
*
*/
toggleLockUser: {
method: 'post',
server: 'lock/user',
client: function () { return "/user/lock/user"; }
},
/**
*
*/
getSystemConfig: {
method: 'get',
server: 'config/system',
client: function () { return "/user/config/system"; }
},
/**
*
*/
sendTestEmail: {
method: 'get',
server: 'config/system/sendTestEmail',
client: function () { return "/user/config/system/sendTestEmail"; }
},
/**
*
*/
updateSystemConfig: {
method: 'post',
server: 'config/system/updateSystemConfig',
client: function () { return "/user/config/system/updateSystemConfig"; }
}
};

View File

@ -5,3 +5,4 @@ export * from './message';
export * from './template';
export * from './comment';
export * from './pagination';
export * from './system';

View File

@ -17,3 +17,4 @@ __exportStar(require("./message"), exports);
__exportStar(require("./template"), exports);
__exportStar(require("./comment"), exports);
__exportStar(require("./pagination"), exports);
__exportStar(require("./system"), exports);

View File

@ -0,0 +1,7 @@
export interface ISystemConfig {
isSystemLocked: boolean;
emailServiceHost: string;
emailServicePassword: string;
emailServicePort: string;
emailServiceUser: string;
}

View File

@ -0,0 +1,2 @@
"use strict";
exports.__esModule = true;

View File

@ -24,6 +24,7 @@ export interface IUser {
email?: string;
role: UserRole;
status: UserStatus;
isSystemAdmin?: boolean;
}
/**
*

View File

@ -10,6 +10,15 @@ export const UserApiDefinition = {
client: () => '/user',
},
/**
*
*/
sendVerifyCode: {
method: 'get' as const,
server: 'sendVerifyCode' as const,
client: () => '/verify/sendVerifyCode',
},
/**
*
*/
@ -19,6 +28,15 @@ export const UserApiDefinition = {
client: () => '/user/register',
},
/**
*
*/
resetPassword: {
method: 'post' as const,
server: 'resetPassword' as const,
client: () => '/user/resetPassword',
},
/**
*
*/
@ -45,4 +63,40 @@ export const UserApiDefinition = {
server: 'update' as const,
client: () => `/user/update`,
},
/**
*
*/
toggleLockUser: {
method: 'post' as const,
server: 'lock/user' as const,
client: () => `/user/lock/user`,
},
/**
*
*/
getSystemConfig: {
method: 'get' as const,
server: 'config/system' as const,
client: () => `/user/config/system`,
},
/**
*
*/
sendTestEmail: {
method: 'get' as const,
server: 'config/system/sendTestEmail' as const,
client: () => `/user/config/system/sendTestEmail`,
},
/**
*
*/
updateSystemConfig: {
method: 'post' as const,
server: 'config/system/updateSystemConfig' as const,
client: () => `/user/config/system/updateSystemConfig`,
},
};

View File

@ -5,3 +5,4 @@ export * from './message';
export * from './template';
export * from './comment';
export * from './pagination';
export * from './system';

View File

@ -0,0 +1,7 @@
export interface ISystemConfig {
isSystemLocked: boolean;
emailServiceHost: string;
emailServicePassword: string;
emailServicePort: string;
emailServiceUser: string;
}

View File

@ -26,6 +26,7 @@ export interface IUser {
email?: string;
role: UserRole;
status: UserStatus;
isSystemAdmin?: boolean;
}
/**

View File

@ -49,6 +49,7 @@
"lodash": "^4.17.21",
"mysql2": "^2.3.3",
"nestjs-pino": "^2.5.2",
"nodemailer": "^6.7.5",
"nuid": "^1.1.6",
"passport": "^0.5.2",
"passport-jwt": "^4.0.0",
@ -61,6 +62,7 @@
"rxjs": "^7.2.0",
"typeorm": "^0.2.41",
"ua-parser-js": "^1.0.2",
"validator": "^13.7.0",
"y-prosemirror": "^1.0.14",
"yjs": "^13.5.24"
},

View File

@ -3,8 +3,10 @@ import { DocumentEntity } from '@entities/document.entity';
import { DocumentAuthorityEntity } from '@entities/document-authority.entity';
import { MessageEntity } from '@entities/message.entity';
import { StarEntity } from '@entities/star.entity';
import { SystemEntity } from '@entities/system.entity';
import { TemplateEntity } from '@entities/template.entity';
import { UserEntity } from '@entities/user.entity';
import { VerifyEntity } from '@entities/verify.entity';
import { ViewEntity } from '@entities/view.entity';
import { WikiEntity } from '@entities/wiki.entity';
import { WikiUserEntity } from '@entities/wiki-user.entity';
@ -15,8 +17,10 @@ import { DocumentModule } from '@modules/document.module';
import { FileModule } from '@modules/file.module';
import { MessageModule } from '@modules/message.module';
import { StarModule } from '@modules/star.module';
import { SystemModule } from '@modules/system.module';
import { TemplateModule } from '@modules/template.module';
import { UserModule } from '@modules/user.module';
import { VerifyModule } from '@modules/verify.module';
import { ViewModule } from '@modules/view.module';
import { WikiModule } from '@modules/wiki.module';
import { forwardRef, Inject, Module } from '@nestjs/common';
@ -40,6 +44,8 @@ const ENTITIES = [
MessageEntity,
TemplateEntity,
ViewEntity,
VerifyEntity,
SystemEntity,
];
const MODULES = [
@ -52,6 +58,8 @@ const MODULES = [
MessageModule,
TemplateModule,
ViewModule,
VerifyModule,
SystemModule,
];
@Module({

View File

@ -0,0 +1,7 @@
import { Controller } from '@nestjs/common';
import { SystemService } from '@services/system.service';
@Controller('system')
export class SystemController {
constructor(private readonly systemService: SystemService) {}
}

View File

@ -1,4 +1,4 @@
import { CreateUserDto } from '@dtos/create-user.dto';
import { RegisterUserDto, ResetPasswordDto } from '@dtos/create-user.dto';
import { LoginUserDto } from '@dtos/login-user.dto';
import { UpdateUserDto } from '@dtos/update-user.dto';
import { JwtGuard } from '@guard/jwt.guard';
@ -11,6 +11,7 @@ import {
HttpStatus,
Patch,
Post,
Query,
Request,
Res,
UseGuards,
@ -41,7 +42,7 @@ export class UserController {
@UseInterceptors(ClassSerializerInterceptor)
@Post(UserApiDefinition.register.server)
@HttpCode(HttpStatus.CREATED)
async register(@Body() user: CreateUserDto) {
async register(@Body() user: RegisterUserDto) {
return await this.userService.createUser(user);
}
@ -62,6 +63,16 @@ export class UserController {
return { ...data, token };
}
/**
*
*/
@UseInterceptors(ClassSerializerInterceptor)
@Post(UserApiDefinition.resetPassword.server)
@HttpCode(HttpStatus.OK)
async resetPassword(@Body() user: ResetPasswordDto) {
return await this.userService.resetPassword(user);
}
/**
*
*/
@ -88,4 +99,48 @@ export class UserController {
async updateUser(@Request() req, @Body() dto: UpdateUserDto) {
return await this.userService.updateUser(req.user, dto);
}
/**
*
*/
@UseInterceptors(ClassSerializerInterceptor)
@Get(UserApiDefinition.getSystemConfig.server)
@HttpCode(HttpStatus.OK)
@UseGuards(JwtGuard)
async getSystemConfig(@Request() req) {
return await this.userService.getSystemConfig(req.user);
}
/**
*
*/
@UseInterceptors(ClassSerializerInterceptor)
@Get(UserApiDefinition.sendTestEmail.server)
@HttpCode(HttpStatus.OK)
@UseGuards(JwtGuard)
async sendTestEmail(@Request() req) {
return await this.userService.sendTestEmail(req.user);
}
/**
*
*/
@UseInterceptors(ClassSerializerInterceptor)
@Post(UserApiDefinition.updateSystemConfig.server)
@HttpCode(HttpStatus.OK)
@UseGuards(JwtGuard)
async toggleLockSystem(@Request() req, @Body() systemConfig) {
return await this.userService.updateSystemConfig(req.user, systemConfig);
}
/**
*
*/
@UseInterceptors(ClassSerializerInterceptor)
@Post(UserApiDefinition.toggleLockUser.server)
@HttpCode(HttpStatus.OK)
@UseGuards(JwtGuard)
async toggleLockUser(@Request() req, @Query('targetUserId') targetUserId) {
return await this.userService.toggleLockUser(req.user, targetUserId);
}
}

View File

@ -0,0 +1,23 @@
import {
ClassSerializerInterceptor,
Controller,
Get,
HttpCode,
HttpStatus,
Query,
UseInterceptors,
} from '@nestjs/common';
import { VerifyService } from '@services/verify.service';
import { UserApiDefinition } from '@think/domains';
@Controller('verify')
export class VerifyController {
constructor(private readonly verifyService: VerifyService) {}
@UseInterceptors(ClassSerializerInterceptor)
@Get(UserApiDefinition.sendVerifyCode.server)
@HttpCode(HttpStatus.CREATED)
async sendVerifyCode(@Query('email') email) {
return await this.verifyService.sendVerifyCode(email);
}
}

View File

@ -1,27 +1,57 @@
import { IsEmail, IsNotEmpty, IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
export class CreateUserDto {
/**
*
*/
export class RegisterUserDto {
@MaxLength(20, { message: '用户账号最多20个字符' })
@MinLength(5, { message: '用户账号至少5个字符' })
@IsString({ message: '用户名称类型错误正确类型为String' })
@IsNotEmpty({ message: '用户账号不能为空' })
@MinLength(5, { message: '用户账号至少5个字符' })
@MaxLength(20, { message: '用户账号最多20个字符' })
readonly name: string;
name: string;
@MinLength(5, { message: '用户密码至少5个字符' })
@IsString({ message: '用户密码类型错误正确类型为String' })
@IsNotEmpty({ message: '用户密码不能为空' })
@MinLength(5, { message: '用户密码至少5个字符' })
password: string;
@IsString({ message: ' 用户确认密码类型错误正确类型为String' })
@MinLength(5, { message: '用户密码至少5个字符' })
readonly confirmPassword: string;
@MinLength(5, { message: '用户二次确认密码至少5个字符' })
@IsString({ message: '用户二次确认密码类型错误正确类型为String' })
@IsNotEmpty({ message: '用户二次确认密码不能为空' })
confirmPassword: string;
@IsString({ message: '用户头像类型错误正确类型为String' })
@IsOptional()
readonly avatar?: string;
@IsEmail({ message: '请输入正确的邮箱地址' })
@IsString({ message: '用户邮箱类型错误正确类型为String' })
@IsNotEmpty({ message: '用户邮箱不能为空' })
email: string;
@IsString({ message: ' 用户邮箱类型错误正确类型为String' })
@IsEmail()
@IsOptional()
readonly email?: string;
@MinLength(5, { message: '邮箱验证码至少5个字符' })
@IsString({ message: '邮箱验证码错误正确类型为String' })
@IsNotEmpty({ message: '邮箱验证码不能为空' })
verifyCode: string;
}
/**
*
*/
export class ResetPasswordDto {
@MinLength(5, { message: '用户密码至少5个字符' })
@IsString({ message: '用户密码类型错误正确类型为String' })
@IsNotEmpty({ message: '用户密码不能为空' })
password: string;
@MinLength(5, { message: '用户二次确认密码至少5个字符' })
@IsString({ message: '用户二次确认密码类型错误正确类型为String' })
@IsNotEmpty({ message: '用户二次确认密码不能为空' })
confirmPassword: string;
@IsEmail({ message: '请输入正确的邮箱地址' })
@IsString({ message: '用户邮箱类型错误正确类型为String' })
@IsNotEmpty({ message: '用户邮箱不能为空' })
email: string;
@MinLength(5, { message: '邮箱验证码至少5个字符' })
@IsString({ message: '邮箱验证码错误正确类型为String' })
@IsNotEmpty({ message: '邮箱验证码不能为空' })
verifyCode: string;
}

View File

@ -1,4 +1,4 @@
import { IsNotEmpty, IsString, MaxLength, MinLength } from 'class-validator';
import { IsEmail, IsNotEmpty, IsString, MaxLength, MinLength } from 'class-validator';
export class LoginUserDto {
@IsString({ message: '用户名称类型错误正确类型为String' })

View File

@ -0,0 +1,56 @@
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm';
@Entity('system')
export class SystemEntity {
@PrimaryGeneratedColumn('uuid')
public id: string;
/**
*
*/
@Column({ type: 'boolean', default: false, comment: '是否锁定系统' })
isSystemLocked: boolean;
/**
*
*/
@Column({ type: 'text', default: null })
emailServiceHost: string;
/**
*
*/
@Column({ type: 'text', default: null })
emailServicePort: string;
/**
*
*/
@Column({ type: 'text', default: null })
emailServiceUser: string;
/**
*
*/
@Column({ type: 'text', default: null })
emailServicePassword: string;
@CreateDateColumn({
type: 'timestamp',
name: 'createdAt',
comment: '创建时间',
})
createdAt: Date;
@UpdateDateColumn({
type: 'timestamp',
name: 'updatedAt',
comment: '更新时间',
})
updatedAt: Date;
}

View File

@ -28,12 +28,15 @@ export class UserEntity {
@Column({ type: 'varchar', length: 200, comment: '用户加密密码' })
public password: string;
@Column({ type: 'varchar', comment: '头像地址', default: '' })
@Column({ type: 'varchar', length: 500, comment: '头像地址', default: '' })
public avatar: string;
@Column({ type: 'varchar', comment: '邮箱地址', default: '' })
@Column({ type: 'varchar', comment: '邮箱地址' })
public email: string;
@Column({ type: 'boolean', default: false, comment: '是否为系统管理员' })
isSystemAdmin: boolean;
@Column({
type: 'enum',
enum: UserRole,

View File

@ -0,0 +1,27 @@
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm';
@Entity('verify')
export class VerifyEntity {
@PrimaryGeneratedColumn('uuid')
public id: string;
@Column({ type: 'varchar', comment: '邮箱地址' })
public email: string;
@Column({ type: 'varchar', comment: '验证码' })
public verifyCode: string;
@CreateDateColumn({
type: 'timestamp',
name: 'createdAt',
comment: '创建时间',
})
createdAt: Date;
@UpdateDateColumn({
type: 'timestamp',
name: 'updatedAt',
comment: '更新时间',
})
updatedAt: Date;
}

View File

@ -0,0 +1,13 @@
import { SystemController } from '@controllers/system.controller';
import { SystemEntity } from '@entities/system.entity';
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { SystemService } from '@services/system.service';
@Module({
imports: [TypeOrmModule.forFeature([SystemEntity])],
providers: [SystemService],
exports: [SystemService],
controllers: [SystemController],
})
export class SystemModule {}

View File

@ -13,6 +13,9 @@ import { getConfig } from '@think/config';
import { Request as RequestType } from 'express';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { SystemModule } from './system.module';
import { VerifyModule } from './verify.module';
const config = getConfig();
const jwtConfig = config.jwt as {
secretkey: string;
@ -61,6 +64,8 @@ const jwtModule = JwtModule.register({
forwardRef(() => WikiModule),
forwardRef(() => MessageModule),
forwardRef(() => StarModule),
forwardRef(() => VerifyModule),
forwardRef(() => SystemModule),
passModule,
jwtModule,
],

View File

@ -0,0 +1,14 @@
import { VerifyController } from '@controllers/verify.controller';
import { VerifyEntity } from '@entities/verify.entity';
import { SystemModule } from '@modules/system.module';
import { forwardRef, Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { VerifyService } from '@services/verify.service';
@Module({
imports: [TypeOrmModule.forFeature([VerifyEntity]), forwardRef(() => SystemModule)],
providers: [VerifyService],
exports: [VerifyService],
controllers: [VerifyController],
})
export class VerifyModule {}

View File

@ -0,0 +1,121 @@
import { SystemEntity } from '@entities/system.entity';
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import * as nodemailer from 'nodemailer';
import { Repository } from 'typeorm';
@Injectable()
export class SystemService {
constructor(
@InjectRepository(SystemEntity)
private readonly systemRepo: Repository<SystemEntity>,
private readonly confifgService: ConfigService
) {
this.loadFromConfigFile();
}
/**
*
* @returns
*/
public async getConfigFromDatabase() {
const data = await this.systemRepo.find();
return (data && data[0]) || null;
}
/**
*
* @param patch
* @returns
*/
public async updateConfigInDatabase(patch: Partial<SystemEntity>) {
const current = await this.getConfigFromDatabase();
return await this.systemRepo.save(await this.systemRepo.merge(current, patch));
}
/**
*
*/
private async loadFromConfigFile() {
const currentConfig = await this.getConfigFromDatabase();
const emailConfigKeys = ['emailServiceHost', 'emailServicePort', 'emailServiceUser', 'emailServicePassword'];
if (currentConfig && emailConfigKeys.every((configKey) => Boolean(currentConfig[configKey]))) {
return;
}
// 同步邮件服务配置
const emailConfigFromConfigFile = await this.confifgService.get('server.email');
let emailConfig = {};
if (emailConfigFromConfigFile && typeof emailConfigFromConfigFile === 'object') {
emailConfig = {
emailServiceHost: emailConfigFromConfigFile.host,
emailServicePort: emailConfigFromConfigFile.port,
emailServiceUser: emailConfigFromConfigFile.user,
emailServicePassword: emailConfigFromConfigFile.password,
};
}
const newConfig = currentConfig
? await this.systemRepo.merge(currentConfig, emailConfig)
: await this.systemRepo.create(emailConfig);
await this.systemRepo.save(newConfig);
console.log('[think] 已载入文件配置:', newConfig);
}
/**
*
* @param content
*/
public async sendEmail(mail: { to: string; subject: string; text?: string; html?: string }) {
const config = await this.getConfigFromDatabase();
if (!config) {
throw new HttpException('系统未配置邮箱服务,请联系系统管理员', HttpStatus.SERVICE_UNAVAILABLE);
}
const emailConfig = {
host: config.emailServiceHost,
port: +config.emailServicePort,
user: config.emailServiceUser,
pass: config.emailServicePassword,
};
if (Object.keys(emailConfig).some((key) => !emailConfig[key])) {
throw new HttpException('系统邮箱服务配置不完善,请联系系统管理员', HttpStatus.SERVICE_UNAVAILABLE);
}
const transporter = nodemailer.createTransport({
host: emailConfig.host,
port: emailConfig.port,
secure: emailConfig.port === 465,
auth: {
user: emailConfig.user,
pass: emailConfig.pass,
},
});
return new Promise((resolve, reject) => {
setTimeout(() => {
reject(new Error(`发送邮件失败`));
}, 10 * 1000);
transporter.sendMail(
{
from: emailConfig.user,
...mail,
},
(err, info) => {
if (err) {
reject(err);
} else {
resolve(info);
}
}
);
});
}
}

View File

@ -1,6 +1,7 @@
import { CreateUserDto } from '@dtos/create-user.dto';
import { RegisterUserDto, ResetPasswordDto } from '@dtos/create-user.dto';
import { LoginUserDto } from '@dtos/login-user.dto';
import { UpdateUserDto } from '@dtos/update-user.dto';
import { SystemEntity } from '@entities/system.entity';
import { UserEntity } from '@entities/user.entity';
import { forwardRef, HttpException, HttpStatus, Inject, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
@ -8,11 +9,14 @@ import { JwtService } from '@nestjs/jwt';
import { InjectRepository } from '@nestjs/typeorm';
import { MessageService } from '@services/message.service';
import { StarService } from '@services/star.service';
import { VerifyService } from '@services/verify.service';
import { WikiService } from '@services/wiki.service';
import { UserStatus } from '@think/domains';
import { instanceToPlain } from 'class-transformer';
import { Repository } from 'typeorm';
import { SystemService } from './system.service';
export type OutUser = Omit<UserEntity, 'comparePassword' | 'encryptPassword' | 'encrypt' | 'password'>;
@Injectable()
@ -33,8 +37,47 @@ export class UserService {
private readonly starService: StarService,
@Inject(forwardRef(() => WikiService))
private readonly wikiService: WikiService
) {}
private readonly wikiService: WikiService,
@Inject(forwardRef(() => VerifyService))
private readonly verifyService: VerifyService,
@Inject(forwardRef(() => SystemService))
private readonly systemService: SystemService
) {
this.createDefaultSystemAdminFromConfigFile();
}
/**
*
*/
private async createDefaultSystemAdminFromConfigFile() {
if (await this.userRepo.findOne({ isSystemAdmin: true })) {
return;
}
const config = await this.confifgService.get('server.admin');
if (!config.name || !config.password || !config.email) {
throw new Error(`请指定名称、密码和邮箱`);
}
if (await this.userRepo.findOne({ name: config.name })) {
return;
}
try {
await this.userRepo.save(
await this.userRepo.create({
...config,
isSystemAdmin: true,
})
);
console.log('[think] 已创建默认系统管理员,请尽快登录系统修改密码');
} catch (e) {
console.error(`[think] 创建默认系统管理员失败:`, e.message);
}
}
/**
* id
@ -71,7 +114,13 @@ export class UserService {
* @param user CreateUserDto
* @returns
*/
async createUser(user: CreateUserDto): Promise<OutUser> {
async createUser(user: RegisterUserDto): Promise<OutUser> {
const currentSystemConfig = await this.systemService.getConfigFromDatabase();
if (currentSystemConfig.isSystemLocked) {
throw new HttpException('系统维护中,暂不可注册', HttpStatus.FORBIDDEN);
}
if (await this.userRepo.findOne({ name: user.name })) {
throw new HttpException('该账户已被注册', HttpStatus.BAD_REQUEST);
}
@ -88,6 +137,10 @@ export class UserService {
throw new HttpException('该邮箱已被注册', HttpStatus.BAD_REQUEST);
}
if (!(await this.verifyService.checkVerifyCode(user.email, user.verifyCode))) {
throw new HttpException('验证码不正确,请检查', HttpStatus.BAD_REQUEST);
}
const res = await this.userRepo.create(user);
const createdUser = await this.userRepo.save(res);
const wiki = await this.wikiService.createWiki(createdUser, {
@ -105,14 +158,60 @@ export class UserService {
return instanceToPlain(createdUser) as OutUser;
}
/**
*
* @param registerUser
*/
public async resetPassword(resetPasswordDto: ResetPasswordDto) {
const currentSystemConfig = await this.systemService.getConfigFromDatabase();
if (currentSystemConfig.isSystemLocked) {
throw new HttpException('系统维护中,暂不可使用', HttpStatus.FORBIDDEN);
}
const { email, password, confirmPassword, verifyCode } = resetPasswordDto;
const inDatabaseUser = await this.userRepo.findOne({ email });
if (!inDatabaseUser) {
throw new HttpException('该邮箱尚未注册', HttpStatus.BAD_REQUEST);
}
if (password !== confirmPassword) {
throw new HttpException('两次密码不一致,请重试', HttpStatus.BAD_REQUEST);
}
if (!(await this.verifyService.checkVerifyCode(email, verifyCode))) {
throw new HttpException('验证码不正确,请检查', HttpStatus.BAD_REQUEST);
}
const user = await this.userRepo.save(
await this.userRepo.merge(inDatabaseUser, { password: UserEntity.encryptPassword(password) })
);
return instanceToPlain(user);
}
/**
*
* @param user
* @returns
*/
async login(user: LoginUserDto): Promise<{ user: OutUser; token: string; domain: string; expiresIn: number }> {
const currentSystemConfig = await this.systemService.getConfigFromDatabase();
const { name, password } = user;
const existUser = await this.userRepo.findOne({ where: { name } });
let existUser = await this.userRepo.findOne({ where: { name } });
if (!existUser) {
existUser = await this.userRepo.findOne({ where: { email: name } });
}
const isExistUserSystemAdmin = existUser ? existUser.isSystemAdmin : false;
if (currentSystemConfig.isSystemLocked && !isExistUserSystemAdmin) {
throw new HttpException('系统维护中,暂不可登录', HttpStatus.FORBIDDEN);
}
if (!existUser || !(await UserEntity.comparePassword(password, existUser.password))) {
throw new HttpException('用户名或密码错误', HttpStatus.BAD_REQUEST);
@ -168,4 +267,80 @@ export class UserService {
const [data] = await query.getManyAndCount();
return data;
}
/**
*
* @param user
* @param targetUserId
*/
async toggleLockUser(user: UserEntity, targetUserId) {
const currentUser = await this.userRepo.findOne(user.id);
if (!currentUser.isSystemAdmin) {
throw new HttpException('您无权操作', HttpStatus.FORBIDDEN);
}
const targetUser = await this.userRepo.findOne(targetUserId);
if (!targetUser) {
throw new HttpException('目标用户不存在', HttpStatus.NOT_FOUND);
}
const nextStatus = targetUser.status === UserStatus.normal ? UserStatus.locked : UserStatus.normal;
return await this.userRepo.save(await this.userRepo.merge(targetUser, { status: nextStatus }));
}
/**
*
* @param user
* @returns
*/
async getSystemConfig(user: UserEntity) {
const currentUser = await this.userRepo.findOne(user.id);
if (!currentUser.isSystemAdmin) {
throw new HttpException('您无权操作', HttpStatus.FORBIDDEN);
}
return await this.systemService.getConfigFromDatabase();
}
/**
*
* @param user
*/
async sendTestEmail(user: UserEntity) {
const currentUser = await this.userRepo.findOne(user.id);
if (!currentUser.isSystemAdmin) {
throw new HttpException('您无权操作', HttpStatus.FORBIDDEN);
}
const currentConfig = await this.systemService.getConfigFromDatabase();
try {
await this.systemService.sendEmail({
to: currentConfig.emailServiceUser,
subject: '测试邮件',
html: `<p>测试邮件</p>`,
});
return '测试邮件发送成功';
} catch (err) {
throw new HttpException('测试邮件发送失败!', HttpStatus.BAD_REQUEST);
}
}
/**
*
* @param user
* @param targetUserId
*/
async updateSystemConfig(user: UserEntity, systemConfig: Partial<SystemEntity>) {
const currentUser = await this.userRepo.findOne(user.id);
if (!currentUser.isSystemAdmin) {
throw new HttpException('您无权操作', HttpStatus.FORBIDDEN);
}
return await this.systemService.updateConfigInDatabase(systemConfig);
}
}

View File

@ -0,0 +1,69 @@
import { VerifyEntity } from '@entities/verify.entity';
import { forwardRef, HttpException, HttpStatus, Inject, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { SystemService } from '@services/system.service';
import { randomInt } from 'node:crypto';
import { Repository } from 'typeorm';
import { isEmail } from 'validator';
@Injectable()
export class VerifyService {
constructor(
@InjectRepository(VerifyEntity)
private readonly verifyRepo: Repository<VerifyEntity>,
@Inject(forwardRef(() => SystemService))
private readonly systemService: SystemService
) {}
/**
*
* @param record
*/
private async deleteVerifyCode(id) {
await this.verifyRepo.remove(await this.verifyRepo.find(id));
}
/**
*
* @param email
*/
public async sendVerifyCode(email: string) {
if (!email || !isEmail(email)) {
throw new HttpException('请检查邮箱地址', HttpStatus.BAD_REQUEST);
}
const verifyCode = randomInt(1000000).toString().padStart(6, '0');
const record = await this.verifyRepo.save(await this.verifyRepo.create({ email, verifyCode }));
await this.systemService.sendEmail({
to: email,
subject: '验证码',
html: `<p>您的验证码为 ${verifyCode}</p>`,
});
const timer = setTimeout(() => {
this.deleteVerifyCode(record.id);
clearTimeout(timer);
}, 5 * 60 * 1000);
}
/**
*
* @param email
* @param verifyCode
* @returns
*/
public async checkVerifyCode(email: string, verifyCode: string) {
if (!email || !isEmail(email)) {
throw new HttpException('请检查邮箱地址', HttpStatus.BAD_REQUEST);
}
const ret = await this.verifyRepo.findOne({ email, verifyCode });
if (!ret) {
throw new HttpException('验证码错误', HttpStatus.BAD_REQUEST);
}
return Boolean(ret);
}
}

View File

@ -326,6 +326,10 @@ export class WikiService {
const withHomeDocumentIdWiki = await this.wikiRepo.merge(wiki, { homeDocumentId });
await this.wikiRepo.save(withHomeDocumentIdWiki);
await this.starService.toggleStar(user, {
wikiId: wiki.id,
});
return withHomeDocumentIdWiki;
}

File diff suppressed because it is too large Load Diff