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 全栈应用 ![image](https://user-images.githubusercontent.com/36991862/114294191-69457700-9acf-11eb-9a27-ebe78825d171.png) ### 应用特点 - 前后端均用 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 (
    Hi,{username}
    • setFinished(false)} > 未完成
    • setFinished(true)} > 已完成
      {todo.length ? ( todo .filter((v) => v.status === isFinished) .map((v) => ( )) ) : ( )}
    ); }; 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" ] }