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