Repository: B2D1/TodoList
Branch: master
Commit: 600ca9447531
Files: 54
Total size: 44.8 KB
Directory structure:
gitextract_ivoo60g2/
├── .gitignore
├── README.md
├── package.json
├── public/
│ ├── index.html
│ ├── manifest.json
│ └── robots.txt
├── server/
│ ├── package.json
│ ├── src/
│ │ ├── app.ts
│ │ ├── config.ts
│ │ ├── db/
│ │ │ ├── index.ts
│ │ │ ├── models/
│ │ │ │ ├── todo.ts
│ │ │ │ └── user.ts
│ │ │ └── schemas/
│ │ │ ├── todo.ts
│ │ │ └── user.ts
│ │ ├── routes/
│ │ │ ├── todo.ts
│ │ │ └── user.ts
│ │ ├── services/
│ │ │ ├── todo.ts
│ │ │ └── user.ts
│ │ └── utils/
│ │ ├── enum.ts
│ │ └── response.ts
│ └── tsconfig.json
├── src/
│ ├── App.css
│ ├── App.tsx
│ ├── api/
│ │ ├── request.ts
│ │ ├── todo.ts
│ │ └── user.ts
│ ├── common/
│ │ ├── config/
│ │ │ └── index.ts
│ │ ├── enum/
│ │ │ └── index.ts
│ │ └── interface/
│ │ └── index.ts
│ ├── components/
│ │ ├── TodoItem/
│ │ │ ├── index.module.scss
│ │ │ └── index.tsx
│ │ ├── TodoModal/
│ │ │ └── index.tsx
│ │ └── UserForm/
│ │ └── index.tsx
│ ├── index.css
│ ├── index.tsx
│ ├── react-app-env.d.ts
│ ├── reportWebVitals.ts
│ ├── saga.ts
│ ├── store/
│ │ ├── index.ts
│ │ ├── todo/
│ │ │ ├── actions.ts
│ │ │ ├── reducers.ts
│ │ │ ├── saga.ts
│ │ │ └── types.ts
│ │ └── user/
│ │ ├── actions.ts
│ │ ├── reducers.ts
│ │ ├── saga.ts
│ │ └── types.ts
│ ├── utils/
│ │ └── index.ts
│ └── views/
│ ├── Home/
│ │ └── index.tsx
│ ├── Login/
│ │ ├── index.module.scss
│ │ └── index.tsx
│ └── Todo/
│ ├── index.module.scss
│ └── index.tsx
└── tsconfig.json
================================================
FILE CONTENTS
================================================
================================================
FILE: .gitignore
================================================
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/server/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
/server/dist
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
.vscode
npm-debug.log*
yarn-debug.log*
yarn-error.log*
================================================
FILE: README.md
================================================
## 基于 TS + React + AntD + Koa + MongoDB 实现的 TodoList 全栈应用

### 应用特点
- 前后端均用 TypeScript 编写
- 接口统一遵循 RESTful 风格
- 实现服务端的优雅错误处理
### 技术栈
- 语言
- TypeScript(赋予 JS 强类型语言的特性)
- 前端
- React(当下最流行的前端框架)
- Axios(处理 HTTP 请求)
- Ant-Design(阿里开源的 UI 语言框架)
- React-Router(处理页面路由)
- Redux(数据状态管理)
- Redux-Saga(处理异步 Action)
- 后端
- Koa(基于 Node.js 平台的下一代 web 开发框架)
- Mongoose(内置数据验证, 查询构建,业务逻辑钩子等,开箱即用)
### 本地运行
```bash
# clone
git clone https://github.com/B2D1/TodoList.git
```
```bash
cd /TodoList/server
yarn
# 启动后端服务,监听本地 5000 端口,请自行下载 MongoDB,并开启数据库服务
yarn start
```
```bash
cd /TodoList
yarn
# 启动 react 项目
yarn start
```
### 相关链接
[TS + React + AntD + Koa2 + MongoDB 打造 TodoList 全栈应用](https://baobangdong.cn/todolist-full-stack-application/)
================================================
FILE: package.json
================================================
{
"name": "TodoList",
"version": "1.0.0",
"private": true,
"dependencies": {
"@testing-library/jest-dom": "^5.11.4",
"@testing-library/react": "^11.1.0",
"@testing-library/user-event": "^12.1.10",
"@types/jest": "^26.0.15",
"@types/react": "^17.0.0",
"@types/react-dom": "^17.0.0",
"antd": "^4.15.0",
"axios": "^0.21.1",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-redux": "^7.2.3",
"react-router-dom": "^5.2.0",
"react-scripts": "4.0.3",
"redux": "^4.0.5",
"redux-saga": "^1.1.3",
"sass": "^1.32.8",
"typescript": "^4.1.2",
"web-vitals": "^1.0.1"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"@types/react-router-dom": "^5.1.7",
"redux-devtools-extension": "^2.13.9"
}
}
================================================
FILE: public/index.html
================================================
TodoList
================================================
FILE: public/manifest.json
================================================
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}
================================================
FILE: public/robots.txt
================================================
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:
================================================
FILE: server/package.json
================================================
{
"name": "server",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"start": "ts-node ./src/app.ts",
"watch": "nodemon",
"build": "tsc",
"serve": "node dist/app.js"
},
"nodemonConfig": {
"ignore": [
"node_modules"
],
"watch": [
"src"
],
"exec": "yarn start",
"ext": "ts"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@koa/cors": "^3.1.0",
"koa": "^2.13.1",
"koa-bodyparser": "^4.3.0",
"koa-router": "^10.0.0",
"mongoose": "^5.12.2",
"nodemon": "^2.0.7",
"ts-node": "^9.1.1",
"typescript": "^4.2.3"
},
"devDependencies": {
"@types/koa": "^2.13.1",
"@types/koa-bodyparser": "^4.3.0",
"@types/koa-router": "^7.4.1",
"@types/koa__cors": "^3.0.2",
"@types/node": "^12.0.8"
}
}
================================================
FILE: server/src/app.ts
================================================
import Koa from 'koa';
import bodyParser from 'koa-bodyparser';
import cors from '@koa/cors';
import Config from './config';
import connectDB from './db';
import todoRouter from './routes/todo';
import userRouter from './routes/user';
const app = new Koa();
connectDB(Config.MONGODB_URI);
app
.use(cors())
.use(bodyParser())
.use(userRouter.routes())
.use(todoRouter.routes());
app.listen(Config.PORT, () => {
console.log(`server starts successful: http://localhost:${Config.PORT}`);
});
================================================
FILE: server/src/config.ts
================================================
const Config = {
/**
* 监听端口
*/
PORT: 5000,
/**
* MongoDB 数据库地址
*/
MONGODB_URI: "mongodb://localhost:27017/todo",
};
export default Config;
================================================
FILE: server/src/db/index.ts
================================================
import mongoose from 'mongoose';
export default (db: string) => {
const connect = () => {
mongoose
.connect(db, {
useCreateIndex: true,
useNewUrlParser: true,
useUnifiedTopology: true,
useFindAndModify: false,
})
.then(() => {
return console.log(`Successfully connected to ${db}`);
})
.catch((error) => {
console.log('Error connecting to database: ', error);
return process.exit(1);
});
};
connect();
mongoose.connection.on('disconnected', connect);
};
================================================
FILE: server/src/db/models/todo.ts
================================================
import { model } from "mongoose";
import { ITodo, TodoSchema } from "../schemas/todo";
export default model("Todo", TodoSchema);
================================================
FILE: server/src/db/models/user.ts
================================================
import { model } from 'mongoose';
import { UserSchema, IUser } from '../schemas/user';
export default model('User', UserSchema);
================================================
FILE: server/src/db/schemas/todo.ts
================================================
import { Document, Schema } from 'mongoose';
export interface ITodo extends Document {
content: string;
status: boolean;
}
export const TodoSchema: Schema = new Schema({
content: String,
status: {
type: Boolean,
default: false,
},
});
TodoSchema.index({ content: 'text' });
================================================
FILE: server/src/db/schemas/user.ts
================================================
import { Document, Schema } from 'mongoose';
import { ITodo } from './todo';
export interface IUser extends Document {
usr: string;
psd: string;
todos: ITodo[];
}
export const UserSchema: Schema = new Schema({
usr: {
type: String,
required: true,
unique: true,
},
psd: {
type: String,
required: true,
},
todos: [
{
type: Schema.Types.ObjectId,
ref: 'Todo',
},
],
});
================================================
FILE: server/src/routes/todo.ts
================================================
import { Context } from 'koa';
import Router from 'koa-router';
import TodoService from '../services/todo';
import { StatusCode } from '../utils/enum';
import createRes from '../utils/response';
const todoService = new TodoService();
const todoRouter = new Router({
prefix: '/api/todos',
});
todoRouter
.get('/search', async (ctx: Context) => {
const { userId, query } = ctx.query;
try {
const data = await todoService.searchTodo(
userId as string,
query as string
);
if (data) {
createRes({
ctx,
data,
});
}
} catch (error) {
createRes({
ctx,
errorCode: 1,
msg: error.message,
});
}
})
.get('/:userId', async (ctx: Context) => {
const userId = ctx.params.userId;
try {
const data = await todoService.getAllTodos(userId);
if (data) {
createRes({
ctx,
data,
});
}
} catch (error) {
createRes({
ctx,
errorCode: 1,
msg: error.message,
});
}
})
.put('/status', async (ctx: Context) => {
const payload = ctx.request.body;
const { todoId } = payload;
try {
const data = await todoService.updateTodoStatus(todoId);
if (data) {
createRes({ ctx, statusCode: StatusCode.Accepted });
}
} catch (error) {
createRes({
ctx,
errorCode: 1,
msg: error.message,
});
}
})
.put('/content', async (ctx: Context) => {
const payload = ctx.request.body;
const { todoId, content } = payload;
try {
const data = await todoService.updateTodoContent(todoId, content);
if (data) {
createRes({ ctx, statusCode: StatusCode.Accepted });
}
} catch (error) {
createRes({
ctx,
errorCode: 1,
msg: error.message,
});
}
})
.post('/', async (ctx: Context) => {
const payload = ctx.request.body;
const { userId, content } = payload;
try {
const data = await todoService.addTodo(userId, content);
if (data) {
createRes({
ctx,
statusCode: StatusCode.Created,
data,
});
}
} catch (error) {
createRes({
ctx,
errorCode: 1,
msg: error.message,
});
}
})
.delete('/:todoId', async (ctx: Context) => {
const todoId = ctx.params.todoId;
try {
const data = await todoService.deleteTodo(todoId);
if (data) {
createRes({ ctx, statusCode: StatusCode.NoContent });
}
} catch (error) {
createRes({
ctx,
errorCode: 1,
msg: error.message,
});
}
});
export default todoRouter;
================================================
FILE: server/src/routes/user.ts
================================================
import { Context, Request } from 'koa';
import Router from 'koa-router';
import UserService from '../services/user';
import { StatusCode } from '../utils/enum';
import createRes from '../utils/response';
const userService = new UserService();
const userRouter = new Router({
prefix: '/api/users',
});
userRouter
.post('/login', async (ctx: Context) => {
const payload = ctx.request.body;
const { username, password } = payload;
try {
const user = await userService.validUser(username, password);
createRes({
ctx,
data: {
userId: user._id,
username: user.usr,
},
});
} catch (error) {
createRes({
ctx,
errorCode: 1,
msg: error.message,
});
}
})
.post('/', async (ctx: Context) => {
const payload = ctx.request.body;
const { username, password } = payload;
try {
const data = await userService.addUser(username, password);
if (data) {
createRes({
ctx,
statusCode: StatusCode.Created,
});
}
} catch (error) {
createRes({
ctx,
errorCode: 1,
msg: error.message,
});
}
});
export default userRouter;
================================================
FILE: server/src/services/todo.ts
================================================
import Todo from '../db/models/todo';
import User from '../db/models/user';
export default class TodoService {
public async addTodo(userId: string, content: string) {
const todo = new Todo({
content,
});
try {
const res = await todo.save();
const user = await User.findById(userId);
user?.todos.push(res.id);
await user?.save();
return res;
} catch (error) {
throw new Error('新增失败 ( ̄o ̄).zZ');
}
}
public async deleteTodo(todoId: string) {
try {
return await Todo.findByIdAndDelete(todoId);
} catch (error) {
throw new Error('删除失败 ( ̄o ̄).zZ');
}
}
public async getAllTodos(userId: string) {
try {
const res = await User.findById(userId).populate('todos');
return res?.todos;
} catch (error) {
throw new Error('获取失败 ( ̄o ̄).zZ');
}
}
public async updateTodoStatus(todoId: string) {
try {
const oldRecord = await Todo.findById(todoId);
const record = await Todo.findByIdAndUpdate(todoId, {
status: !oldRecord?.status,
});
return record;
} catch (error) {
throw new Error('更新状态失败 ( ̄o ̄).zZ');
}
}
public async updateTodoContent(todoId: string, content: string) {
try {
return await Todo.findByIdAndUpdate(todoId, { content });
} catch (error) {
throw new Error('更新内容失败 ( ̄o ̄).zZ');
}
}
public async searchTodo(userId: string, query: string) {
try {
// MongoDB Text Search 对中文支持不佳
// e.g. 当 query 为“你好”,“你好张三"不匹配,”你好,张三“匹配
// return await User.findById(userId).populate({
// path: 'todos',
// match: { $text: { $search: query } },
// });
return await User.findById(userId).populate({
path: 'todos',
match: { content: { $regex: new RegExp(query), $options: 'i' } },
});
} catch (error) {
throw new Error('查询失败 ( ̄o ̄).zZ');
}
}
}
================================================
FILE: server/src/services/user.ts
================================================
import User from '../db/models/user';
export default class UserService {
public async addUser(usr: string, psd: string) {
try {
const user = new User({
usr,
psd,
todos: [],
});
return await user.save();
} catch (error) {
if (error.code === 11000) {
// MongoError: E11000 duplicate key error collection
throw new Error('用户名已存在 ( ̄o ̄).zZ');
} else {
throw error;
}
}
}
public async validUser(usr: string, psd: string) {
try {
const user = await User.findOne({
usr,
});
// 查询用户
if (!user) {
throw new Error('用户不存在 ( ̄o ̄).zZ');
}
// 校验密码
if (psd === user.psd) {
return user;
}
throw new Error('密码错误 ( ̄o ̄).zZ');
} catch (error) {
throw new Error(error.message);
}
}
}
================================================
FILE: server/src/utils/enum.ts
================================================
export enum StatusCode {
/**
* 成功
*/
OK = 200,
/**
* 更新成功
*/
Accepted = 202,
/**
* 删除成功
*/
NoContent = 204,
/**
* 创建成功
*/
Created = 201,
}
================================================
FILE: server/src/utils/response.ts
================================================
import { Context } from "koa";
import { StatusCode } from "./enum";
interface IRes {
ctx: Context;
statusCode?: number;
data?: any;
errorCode?: number;
msg?: string;
}
const createRes = (params: IRes) => {
params.ctx.status = params.statusCode! || StatusCode.OK;
params.ctx.body = {
error_code: params.errorCode || 0,
data: params.data || null,
msg: params.msg || "",
};
};
export default createRes;
================================================
FILE: server/tsconfig.json
================================================
{
"compilerOptions": {
"module": "commonjs",
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"target": "es6",
"noImplicitAny": true,
"moduleResolution": "node",
"sourceMap": true,
"outDir": "dist",
"baseUrl": ".",
"paths": {
"*": ["node_modules/*", "src/types/*"]
}
},
"include": ["src/**/*"]
}
================================================
FILE: src/App.css
================================================
@import '~antd/dist/antd.css';
================================================
FILE: src/App.tsx
================================================
import './App.css';
import { message } from 'antd';
import { BrowserRouter, Route } from 'react-router-dom';
import Home from 'views/Home';
import Login from './views/Login';
import Todo from './views/Todo';
// 配置全局 message
message.config({
duration: 1,
maxCount: 3,
});
const App = () => (
<>
>
);
export default App;
================================================
FILE: src/api/request.ts
================================================
import { message } from 'antd';
import axios from 'axios';
import Config from 'common/config';
import { IRes } from 'common/interface';
const request = axios.create({
baseURL: Config.API_URI,
headers: {
'Content-Type': 'application/json; charset=UTF-8',
},
});
request.interceptors.response.use((response) => {
const res: IRes = response.data;
// 当 error_code 不为 0 时,统一弹出错误提示框,并抛出错误
if (res.error_code) {
message.warn(res.msg);
throw new Error(res.msg);
}
// 当 error_code 为 0 时,继续返回请求
return response.data;
});
export default request;
================================================
FILE: src/api/todo.ts
================================================
import request from './request';
class TodoAPI {
static PREFIX = '/todos';
static fetchTodo(userId: string) {
return request.get(`${TodoAPI.PREFIX}/${userId}`);
}
static addTodo(userId: string, content: string) {
return request.post(`${TodoAPI.PREFIX}`, {
userId,
content,
});
}
static searchTodo(userId: string, query: string) {
return request.get(
`${TodoAPI.PREFIX}/search?userId=${userId}&query=${query}`
);
}
static deleteTodo(todoId: string) {
return request.delete(`${TodoAPI.PREFIX}/${todoId}`);
}
static updateTodoStatus(todoId: string) {
return request.put(`${TodoAPI.PREFIX}/status`, {
todoId,
});
}
static updateTodoContent(todoId: string, content: string) {
return request.put(`${TodoAPI.PREFIX}/content`, {
todoId,
content,
});
}
}
export default TodoAPI;
================================================
FILE: src/api/user.ts
================================================
import request from './request';
class UserAPI {
static PREFIX = '/users';
static login(username: string, password: string) {
return request.post(`${UserAPI.PREFIX}/login`, {
username,
password,
});
}
static register(username: string, password: string) {
return request.post(`${UserAPI.PREFIX}`, {
username,
password,
});
}
}
export default UserAPI;
================================================
FILE: src/common/config/index.ts
================================================
enum Config {
API_URI = "http://localhost:5000/api/",
}
export default Config;
================================================
FILE: src/common/enum/index.ts
================================================
export enum ModalType {
Edit = 'EDIT',
Add = 'ADD',
}
================================================
FILE: src/common/interface/index.ts
================================================
export interface IRes {
data: any;
error_code: number;
msg: string;
}
================================================
FILE: src/components/TodoItem/index.module.scss
================================================
.item {
display: flex;
min-height: 3rem;
line-height: 3rem;
padding: 0.5rem 1rem;
background-color: #fff;
border-bottom: 1px solid #ddd;
justify-content: space-between;
align-items: center;
.content {
width: 300px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
font-weight: bold;
font-size: 1.2rem;
@media screen and (max-width: 700px) {
width: 150px;
font-size: 1rem;
}
}
}
.icon {
display: inline-block;
font-size: 1.5rem;
transition: transform 0.2s ease;
cursor: pointer;
&:hover {
transform: scale(1.2);
}
& + .icon {
margin-left: 1rem;
}
@media screen and (max-width: 700px) {
font-size: 1.2rem;
}
}
================================================
FILE: src/components/TodoItem/index.tsx
================================================
import {
CheckOutlined,
DeleteOutlined,
EditOutlined,
UndoOutlined,
} from '@ant-design/icons';
import { ModalType } from 'common/enum';
import { FC } from 'react';
import styles from './index.module.scss';
interface ITodoItem {
id: string;
type: string;
content: string;
finished: boolean;
onShowModal: (type: ModalType, todoId: string, content: string) => void;
onUpdateStatus: (todoId: string) => void;
onDelete: (todoId: string) => void;
}
const TodoItem: FC = ({
id,
content,
finished,
onUpdateStatus,
onDelete,
onShowModal,
}) => (
{content}
onShowModal(ModalType.Edit, id, content)}
/>
{finished ? (
onUpdateStatus(id)}
/>
) : (
onUpdateStatus(id)}
/>
)}
onDelete(id)} />
);
export default TodoItem;
================================================
FILE: src/components/TodoModal/index.tsx
================================================
import { Form, Input, Modal } from 'antd';
import { ModalType } from 'common/enum';
import { FC, useEffect } from 'react';
interface ITodoModal {
todoId: string;
modalType: string;
visible: boolean;
title: string;
content: string;
onClose: () => void;
onAdd: (content: string) => void;
onUpdateContent: (todoId: string, content: string) => void;
}
const TodoModal: FC = ({
content,
visible,
title,
modalType,
todoId,
onClose,
onAdd,
onUpdateContent,
}) => {
const [form] = Form.useForm();
const handleOK = () => {
form.submit();
};
const handleFinish = () => {
const content = form.getFieldValue('content');
if (modalType === ModalType.Add) {
onAdd(content);
}
if (modalType === ModalType.Edit) {
onUpdateContent(todoId, content);
}
handleCancel();
};
const handleCancel = () => {
form.resetFields();
onClose();
};
useEffect(() => {
form.setFieldsValue({ content });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [content]);
return (
);
};
export default TodoModal;
================================================
FILE: src/components/UserForm/index.tsx
================================================
import { LockOutlined, UserOutlined } from '@ant-design/icons';
import { Button, Form, Input } from 'antd';
import React from 'react';
import { connect, ConnectedProps } from 'react-redux';
import { AppState } from 'store';
import { login, register, setLoading } from 'store/user/actions';
const mapDispatch = {
register,
login,
setLoading,
};
const mapState = ({ user }: AppState) => ({
user,
});
interface OwnProps {
showLogin: boolean;
}
const connector = connect(mapState, mapDispatch);
type PropsFromRedux = ConnectedProps;
type Props = PropsFromRedux & OwnProps;
const UserForm: React.FC = ({
register,
login,
setLoading,
showLogin,
user: { loading },
}) => {
const [form] = Form.useForm();
const onFinish = (values: any) => {
setLoading(true);
const { username, password } = values;
if (showLogin) {
login({
username,
password,
});
} else {
register({
username,
password,
});
}
form.setFieldsValue({ username: '', password: '' });
};
return (
}
placeholder="用户名"
autoComplete="off"
/>
} type="password" placeholder="密码" />
);
};
export default connector(UserForm);
================================================
FILE: src/index.css
================================================
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
================================================
FILE: src/index.tsx
================================================
import { ConfigProvider } from 'antd';
import zhCN from 'antd/lib/locale-provider/zh_CN';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import reportWebVitals from './reportWebVitals';
import App from './App';
import { store } from './store';
ReactDOM.render(
,
document.getElementById('root')
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();
================================================
FILE: src/react-app-env.d.ts
================================================
///
================================================
FILE: src/reportWebVitals.ts
================================================
import { ReportHandler } from 'web-vitals';
const reportWebVitals = (onPerfEntry?: ReportHandler) => {
if (onPerfEntry && onPerfEntry instanceof Function) {
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
getCLS(onPerfEntry);
getFID(onPerfEntry);
getFCP(onPerfEntry);
getLCP(onPerfEntry);
getTTFB(onPerfEntry);
});
}
};
export default reportWebVitals;
================================================
FILE: src/saga.ts
================================================
import { takeEvery } from 'redux-saga/effects';
import {
addTodo,
deleteTodo,
fetchTodo,
searchTodo,
updateTodoContent,
updateTodoStatus,
} from './store/todo/saga';
import {
ADD_TODO,
DELETE_TODO,
FETCH_TODO,
SEARCH_TODO,
UPDATE_TODO_CONTENT,
UPDATE_TODO_STATUS,
} from './store/todo/types';
import { login, logout, register } from './store/user/saga';
import { LOGIN, LOGOUT, REGISTER } from './store/user/types';
function* rootSaga() {
yield takeEvery(LOGIN, login);
yield takeEvery(LOGOUT, logout);
yield takeEvery(REGISTER, register);
yield takeEvery(FETCH_TODO, fetchTodo);
yield takeEvery(SEARCH_TODO, searchTodo);
yield takeEvery(ADD_TODO, addTodo);
yield takeEvery(DELETE_TODO, deleteTodo);
yield takeEvery(UPDATE_TODO_STATUS, updateTodoStatus);
yield takeEvery(UPDATE_TODO_CONTENT, updateTodoContent);
}
export default rootSaga;
================================================
FILE: src/store/index.ts
================================================
import { applyMiddleware, combineReducers, createStore } from 'redux';
import { composeWithDevTools } from 'redux-devtools-extension';
import createSagaMiddleware from 'redux-saga';
import rootSaga from '../saga';
import todoReducer from './todo/reducers';
import userReducer from './user/reducers';
const sagaMiddleware = createSagaMiddleware();
const rootReducer = combineReducers({ todo: todoReducer, user: userReducer });
export const store = createStore(
rootReducer,
composeWithDevTools(applyMiddleware(sagaMiddleware))
);
export type AppState = ReturnType;
export type AppDispatch = typeof store.dispatch;
sagaMiddleware.run(rootSaga);
================================================
FILE: src/store/todo/actions.ts
================================================
import {
ADD_TODO,
CLEAR_TODO,
DELETE_TODO,
FETCH_TODO,
SEARCH_TODO,
UPDATE_TODO_CONTENT,
UPDATE_TODO_STATUS,
} from './types';
export const addTodo = (userId: string, content: string) => ({
type: ADD_TODO,
payload: { userId, content },
});
export const fetchTodo = (userId: string) => ({
type: FETCH_TODO,
payload: { userId },
});
export const searchTodo = (userId: string, query: string) => ({
type: SEARCH_TODO,
payload: { userId, query },
});
export const deleteTodo = (todoId: string) => ({
type: DELETE_TODO,
payload: { todoId },
});
export const updateTodoStatus = (todoId: string) => ({
type: UPDATE_TODO_STATUS,
payload: { todoId },
});
export const updateTodoContent = (todoId: string, content: string) => ({
type: UPDATE_TODO_CONTENT,
payload: { todoId, content },
});
export const clearTodo = () => ({
type: CLEAR_TODO,
});
================================================
FILE: src/store/todo/reducers.ts
================================================
import {
ADD_TODO_SUC,
CLEAR_TODO,
DELETE_TODO_SUC,
FETCH_TODO_SUC,
ITodoState,
SEARCH_TODO_SUC,
TodoActionTypes,
UPDATE_TODO_CONTENT_SUC,
UPDATE_TODO_STATUS_SUC,
} from './types';
const initialState: ITodoState[] = [];
export default function todoReducer(
state = initialState,
action: TodoActionTypes
) {
switch (action.type) {
case ADD_TODO_SUC:
return [...state, action.payload];
case FETCH_TODO_SUC:
return [...action.payload];
case DELETE_TODO_SUC:
return state.filter((v) => v._id !== action.payload.todoId);
case UPDATE_TODO_STATUS_SUC:
return state.map((v) =>
v._id === action.payload.todoId ? { ...v, status: !v.status } : v
);
case SEARCH_TODO_SUC:
return [...action.payload];
case UPDATE_TODO_CONTENT_SUC:
return state.map((v) =>
v._id === action.payload.todoId
? { ...v, content: action.payload.content }
: v
);
case CLEAR_TODO:
return initialState;
default:
return state;
}
}
================================================
FILE: src/store/todo/saga.ts
================================================
import { message } from 'antd';
import TodoAPI from 'api/todo';
import { IRes } from 'common/interface';
import { call, put } from 'redux-saga/effects';
import {
ADD_TODO_SUC,
DELETE_TODO_SUC,
FETCH_TODO_SUC,
SEARCH_TODO_SUC,
UPDATE_TODO_CONTENT_SUC,
UPDATE_TODO_STATUS_SUC,
IAddAction,
IDeleteAction,
IFetchAction,
ISearchAction,
IUpdateContentAction,
IUpdateStatusAction,
} from './types';
export function* fetchTodo(action: IFetchAction) {
const { userId } = action.payload;
try {
const res: IRes = yield call(TodoAPI.fetchTodo, userId);
yield put({
type: FETCH_TODO_SUC,
payload: res.data,
});
} catch {}
}
export function* addTodo(action: IAddAction) {
const { userId, content } = action.payload;
try {
const res: IRes = yield call(TodoAPI.addTodo, userId, content);
yield put({
type: ADD_TODO_SUC,
payload: res.data,
});
message.success('新增成功');
} catch {}
}
export function* deleteTodo(action: IDeleteAction) {
const { todoId } = action.payload;
try {
yield call(TodoAPI.deleteTodo, todoId);
yield put({
type: DELETE_TODO_SUC,
payload: { todoId },
});
message.success('删除成功');
} catch {}
}
export function* searchTodo(action: ISearchAction) {
const { userId, query } = action.payload;
try {
const res: IRes = yield call(TodoAPI.searchTodo, userId, query);
yield put({
type: SEARCH_TODO_SUC,
payload: res.data.todos,
});
} catch {}
}
export function* updateTodoStatus(action: IUpdateStatusAction) {
const { todoId } = action.payload;
try {
yield call(TodoAPI.updateTodoStatus, todoId);
yield put({
type: UPDATE_TODO_STATUS_SUC,
payload: { todoId },
});
} catch {}
}
export function* updateTodoContent(action: IUpdateContentAction) {
const { todoId, content } = action.payload;
try {
yield call(TodoAPI.updateTodoContent, todoId, content);
yield put({
type: UPDATE_TODO_CONTENT_SUC,
payload: { todoId, content },
});
message.success('编辑成功');
} catch {}
}
================================================
FILE: src/store/todo/types.ts
================================================
// Constant
export const FETCH_TODO = 'FETCH_TODO';
export const FETCH_TODO_SUC = 'FETCH_TODO_SUC';
export const ADD_TODO = 'ADD_TODO';
export const ADD_TODO_SUC = 'ADD_TODO_SUC';
export const SEARCH_TODO = 'SEARCH_TODO';
export const SEARCH_TODO_SUC = 'SEARCH_TODO_SUC';
export const DELETE_TODO = 'DELETE_TODO';
export const DELETE_TODO_SUC = 'DELETE_TODO_SUC';
export const UPDATE_TODO_CONTENT = 'UPDATE_TODO_CONTENT';
export const UPDATE_TODO_CONTENT_SUC = 'UPDATE_TODO_CONTENT_SUC';
export const UPDATE_TODO_STATUS = 'UPDATE_TODO_STATUS';
export const UPDATE_TODO_STATUS_SUC = 'UPDATE_TODO_STATUS_SUC';
export const CLEAR_TODO = 'CLEAR_TODO';
// State
export interface ITodoState {
_id: string;
content: string;
userId: string;
status: boolean;
}
// Action
export interface IFetchAction {
type: typeof FETCH_TODO;
payload: { userId: string };
}
export interface IFetchSucAction {
type: typeof FETCH_TODO_SUC;
payload: ITodoState[];
}
export interface IAddAction {
type: typeof ADD_TODO;
payload: {
userId: string;
content: string;
};
}
export interface IAddSucAction {
type: typeof ADD_TODO_SUC;
payload: ITodoState;
}
export interface ISearchAction {
type: typeof SEARCH_TODO;
payload: { userId: string; query: string };
}
export interface ISearchSucAction {
type: typeof SEARCH_TODO_SUC;
payload: ITodoState[];
}
export interface IDeleteAction {
type: typeof DELETE_TODO;
payload: {
todoId: string;
};
}
export interface IDeleteSucAction {
type: typeof DELETE_TODO_SUC;
payload: {
todoId: string;
};
}
export interface IUpdateContentAction {
type: typeof UPDATE_TODO_CONTENT;
payload: {
todoId: string;
content: string;
};
}
export interface IUpdateContentSucAction {
type: typeof UPDATE_TODO_CONTENT_SUC;
payload: {
todoId: string;
content: string;
};
}
export interface IUpdateStatusAction {
type: typeof UPDATE_TODO_STATUS;
payload: {
todoId: string;
};
}
export interface IClearTodoAction {
type: typeof CLEAR_TODO;
}
export interface IUpdateStatusSucAction {
type: typeof UPDATE_TODO_STATUS_SUC;
payload: {
todoId: string;
};
}
export type TodoActionTypes =
| IFetchAction
| IFetchSucAction
| IAddAction
| IAddSucAction
| IUpdateContentAction
| IUpdateContentSucAction
| IUpdateStatusAction
| IUpdateStatusSucAction
| ISearchAction
| ISearchSucAction
| IDeleteAction
| IDeleteSucAction
| IClearTodoAction;
================================================
FILE: src/store/user/actions.ts
================================================
import {
IAuthState,
LOGIN,
REGISTER,
LOGOUT,
KEEP_LOGIN,
IUserState,
SET_LOADING,
} from './types';
export const login = (authState: IAuthState) => ({
type: LOGIN,
payload: authState,
});
export const register = (authState: IAuthState) => ({
type: REGISTER,
payload: authState,
});
export const logout = () => ({
type: LOGOUT,
});
export const keepLogin = (userState: Partial) => ({
type: KEEP_LOGIN,
payload: userState,
});
export const setLoading = (loading: boolean) => ({
type: SET_LOADING,
payload: { loading },
});
================================================
FILE: src/store/user/reducers.ts
================================================
import {
IUserState,
KEEP_LOGIN,
LOGIN_SUC,
LOGOUT_SUC,
REGISTER_SUC,
SET_LOADING,
UserActionTypes,
} from './types';
const initialState: IUserState = {
userId: '',
username: '',
errMsg: '',
loading: false,
};
export default function userReducer(
state = initialState,
action: UserActionTypes
) {
switch (action.type) {
case REGISTER_SUC:
return {
...state,
};
case LOGIN_SUC:
return {
...state,
...action.payload,
};
case LOGOUT_SUC:
return initialState;
case KEEP_LOGIN:
return {
...state,
...action.payload,
};
case SET_LOADING:
return {
...state,
loading: action.payload.loading,
};
default:
return state;
}
}
================================================
FILE: src/store/user/saga.ts
================================================
import { message } from 'antd';
import UserAPI from 'api/user';
import { IRes } from 'common/interface';
import { call, put } from 'redux-saga/effects';
import { CLEAR_TODO } from 'store/todo/types';
import { LocalStorage } from 'utils';
import {
ILoginAction,
IRegisterAction,
LOGIN_SUC,
LOGOUT_SUC,
REGISTER_SUC,
SET_LOADING,
} from './types';
export function* login(action: ILoginAction) {
const { username, password } = action.payload;
try {
const res: IRes = yield call(UserAPI.login, username, password);
yield call(LocalStorage.set, 'userId', res.data.userId);
yield call(LocalStorage.set, 'username', res.data.username);
yield put({
type: LOGIN_SUC,
payload: { ...res.data, errMsg: res.msg },
});
yield put({
type: SET_LOADING,
payload: { loading: false },
});
} catch {
yield put({
type: SET_LOADING,
payload: { loading: false },
});
}
}
export function* logout() {
try {
yield call(LocalStorage.remove, 'userId');
yield call(LocalStorage.remove, 'username');
yield put({
type: LOGOUT_SUC,
});
yield put({
type: CLEAR_TODO,
});
} catch {}
}
export function* register(action: IRegisterAction) {
const { username, password } = action.payload;
try {
yield call(UserAPI.register, username, password);
yield put({
type: REGISTER_SUC,
});
yield put({
type: SET_LOADING,
payload: { loading: false },
});
message.success('注册成功');
} catch {
yield put({
type: SET_LOADING,
payload: { loading: false },
});
}
}
================================================
FILE: src/store/user/types.ts
================================================
// Constant
export const REGISTER = 'REGISTER';
export const REGISTER_SUC = 'REGISTER_SUC';
export const LOGIN = 'LOGIN';
export const LOGIN_SUC = 'LOGIN_SUC';
export const LOGOUT = 'LOGOUT';
export const LOGOUT_SUC = 'LOGOUT_SUC';
export const KEEP_LOGIN = 'KEEP_LOGIN';
export const SET_LOADING = 'SET_LOADING';
// State
export interface IAuthState {
username: string;
password: string;
}
export interface IUserState {
userId: string;
username: string;
errMsg: string;
loading: boolean;
}
// Action
export interface ILoginAction {
type: typeof LOGIN;
payload: IAuthState;
}
export interface ILoginSucAction {
type: typeof LOGIN_SUC;
payload: IUserState;
}
export interface ILogoutAction {
type: typeof LOGOUT;
}
export interface ILogoutSucAction {
type: typeof LOGOUT_SUC;
}
export interface IRegisterAction {
type: typeof REGISTER;
payload: IAuthState;
}
export interface IRegSucAction {
type: typeof REGISTER_SUC;
}
export interface IKeepLogin {
type: typeof KEEP_LOGIN;
payload: IUserState;
}
export interface ISetLoadingAction {
type: typeof SET_LOADING;
payload: { loading: boolean };
}
export type UserActionTypes =
| ILoginAction
| ILoginSucAction
| ILogoutAction
| ILogoutSucAction
| IKeepLogin
| IRegisterAction
| IRegSucAction
| ISetLoadingAction;
================================================
FILE: src/utils/index.ts
================================================
export class LocalStorage {
static get(key: string) {
return localStorage.getItem(key);
}
static set(key: string, value: string) {
localStorage.setItem(key, value);
}
static remove(key: string) {
localStorage.removeItem(key);
}
}
================================================
FILE: src/views/Home/index.tsx
================================================
import { FC, useEffect } from 'react';
import { Redirect, RouteComponentProps } from 'react-router-dom';
import { LocalStorage } from 'utils';
import { connect, ConnectedProps } from 'react-redux';
import { AppState } from 'store';
import { keepLogin } from 'store/user/actions';
const mapState = ({ user }: AppState) => ({
user,
});
const mapDispatch = {
keepLogin,
};
const connector = connect(mapState, mapDispatch);
type PropsFromRedux = ConnectedProps;
const Home: FC = ({
user,
keepLogin,
}) => {
useEffect(() => {
const userId = LocalStorage.get('userId');
const username = LocalStorage.get('username');
// local 有用户信息,但 session 无 userId,自动登录
if (userId && username && !user.userId) {
keepLogin({ userId, username });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return user.userId ? : ;
};
export default connector(Home);
================================================
FILE: src/views/Login/index.module.scss
================================================
.wrapper {
width: 100vw;
height: 100vh;
background: #f6f6f6;
.container {
position: relative;
top: 30%;
width: 320px;
margin: auto;
}
.tip {
span:nth-child(2) {
color: #096dd9;
cursor: pointer;
&:hover {
border-bottom: 1px solid currentColor;
}
}
}
}
================================================
FILE: src/views/Login/index.tsx
================================================
import UserForm from 'components/UserForm';
import { FC, useState } from 'react';
import styles from './index.module.scss';
const Login: FC = () => {
const [showLogin, setShowLogin] = useState(true);
const toggleForm = () => {
setShowLogin(!showLogin);
};
return (
To-Do List
Or
{showLogin ? '现在注册!' : '已有账号!'}
);
};
export default Login;
================================================
FILE: src/views/Todo/index.module.scss
================================================
.wrapper {
width: 90vw;
max-width: 600px;
min-width: 300px;
display: flex;
flex-direction: column;
align-items: center;
margin: 0 auto;
padding: 6rem 0;
}
.user {
position: absolute;
right: 2rem;
top: 2rem;
> span {
margin-right: 1rem;
}
}
.newTodo {
margin-left: 2rem;
}
.main {
width: 100%;
}
.queryBar {
width: 80%;
display: flex;
margin-bottom: 30px;
}
.nav {
display: flex;
border-bottom: 2px solid rgba(114, 111, 112, 0.5);
li {
position: relative;
display: flex;
flex: 1;
padding: 1rem 0;
cursor: pointer;
.dot {
width: 1.5rem;
height: 1.5rem;
margin: 0 1rem;
border-radius: 100%;
}
&.active::after {
display: block;
content: '';
position: absolute;
background-color: #539fe6;
width: 100%;
bottom: -2px;
height: 2px;
}
}
}
ul {
padding: 0;
margin: 0;
list-style: none;
}
.noData {
margin-top: 3rem;
}
.pending {
background-color: #726f70;
}
.resolved {
background-color: #f25f66;
}
================================================
FILE: src/views/Todo/index.tsx
================================================
import { Button, Empty, Input } from 'antd';
import { ModalType } from 'common/enum';
import TodoItem from 'components/TodoItem';
import TodoModal from 'components/TodoModal';
import { ChangeEvent, FC, useEffect, useState } from 'react';
import { connect, ConnectedProps } from 'react-redux';
import { AppState } from 'store';
import {
addTodo,
deleteTodo,
fetchTodo,
searchTodo,
updateTodoContent,
updateTodoStatus,
} from 'store/todo/actions';
import { keepLogin, logout } from 'store/user/actions';
import styles from './index.module.scss';
const mapState = ({ todo, user }: AppState) => ({
todo,
user,
});
const mapDispatch = {
logout,
keepLogin,
addTodo,
deleteTodo,
fetchTodo,
searchTodo,
updateTodoContent,
updateTodoStatus,
};
const connector = connect(mapState, mapDispatch);
type PropsFromRedux = ConnectedProps;
interface ITodoProps extends PropsFromRedux {}
const Todo: FC = ({
todo,
user: { userId, username },
logout,
deleteTodo,
fetchTodo,
updateTodoContent,
updateTodoStatus,
addTodo,
searchTodo,
}) => {
const [visible, setVisible] = useState(false);
const [isFinished, setFinished] = useState(false);
const [modalType, setModalType] = useState(ModalType.Add);
const [modalTitle, setModalTitle] = useState('');
const [content, setContent] = useState('');
const [todoId, setTodoId] = useState('');
const handleAdd = (content: string) => {
addTodo(userId, content);
setFinished(false);
};
const handleUpdateContent = (todoId: string, content: string) => {
updateTodoContent(todoId, content);
};
const handleDelete = (todoId: string) => {
deleteTodo(todoId);
};
const handleUpdateStatus = (todoId: string) => {
updateTodoStatus(todoId);
};
const handleSearch = (ev: ChangeEvent) => {
searchTodo(userId, ev.target.value);
};
const handleCloseModal = () => {
setVisible(false);
setContent('');
};
const handleOpenModal = (
type: ModalType,
todoId?: string,
content?: string
) => {
setVisible(true);
if (type === ModalType.Add) {
setModalTitle('新增待办事项');
setContent('');
setModalType(ModalType.Add);
}
if (type === ModalType.Edit) {
setModalTitle('编辑待办事项');
setModalType(ModalType.Edit);
setContent(content!);
setTodoId(todoId!);
}
};
useEffect(() => {
userId && fetchTodo(userId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [userId]);
return (
);
};
export default connector(Todo);
================================================
FILE: tsconfig.json
================================================
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"baseUrl": "src"
},
"include": [
"src"
]
}