React-全栈项目第二版-二-

77 阅读1小时+

React 全栈项目第二版(二)

原文:zh.annas-archive.org/md5/35c59f78351aeb34721c43c78c53c92a

译者:飞龙

协议:CC BY-NC-SA 4.0

第七章:将骨架扩展成社交媒体应用

社交媒体现在是网络的一个基本组成部分,我们构建的许多以用户为中心的 Web 应用最终都需要一个社交组件来推动用户参与。

对于我们的第一个真实世界 MERN 应用,我们将修改我们在 第三章,使用 MongoDB、Express 和 Node 构建后端,和 第四章,添加 React 前端以完成 MERN,中开发的 MERN 骨架应用,在本章中构建一个简单的社交媒体应用。在这个过程中,你将学习如何扩展 MERN 栈技术的集成并添加新功能以扩展你自己的全栈 Web 应用。

在本章中,我们将讨论以下主题:

  • 介绍 MERN 社交

  • 更新用户资料

  • 在 MERN 社交中关注用户

  • 发布带照片的消息

  • 在帖子中实现交互

介绍 MERN 社交

MERN 社交是一个具有基本功能的社交媒体应用,灵感来源于现有的社交媒体平台,如 Facebook 和 Twitter。此应用的主要目的是展示如何使用 MERN 栈技术实现允许用户相互连接或关注的特性,并在共享内容上进行交互。在本章构建 MERN 社交的过程中,我们将讨论以下具有社交媒体特色的特性实现:

  • 包含描述和照片的用户资料

  • 用户相互关注

  • 谁应该关注建议

  • 发布带照片的消息

  • 展示关注用户发布的新闻源

  • 按用户列出帖子

  • 点赞帖子

  • 在帖子上发表评论

你可以根据需要进一步扩展这些实现,以实现更复杂的功能。MERN 社交主页如下所示:

完整的 MERN 社交应用代码可在 GitHub 上找到,网址为 github.com/PacktPublishing/Full-Stack-React-Projects-Second-Edition/tree/master/Chapter05/mern-social。你可以克隆此代码,并在浏览本章其余部分的代码解释时运行应用。

MERN 社交应用所需的视图将通过扩展和修改 MERN 骨架应用中现有的 React 组件来开发。以下组件树显示了构成 MERN 社交前端的所有自定义 React 组件,同时也暴露了我们将在本章其余部分构建视图所使用的组合结构:

除了更新现有组件外,我们还将添加新的自定义组件来组合视图,包括一个新闻源视图,用户可以在其中创建新的帖子,也可以浏览他们关注的 MERN Social 上所有人的所有帖子。在下一节中,我们将首先更新用户资料,以展示如何上传个人照片并为平台上的每个用户添加简短的个人简介。

更新用户资料

现有的骨架应用程序仅支持用户名、电子邮件和密码。但在 MERN Social 中,我们将允许用户在注册后编辑个人资料时添加关于自己的描述,以及上传个人照片,如下面的截图所示:

为了实现此功能更新,我们需要修改用户后端和前端。在以下章节中,我们将学习如何更新后端中的用户模型和用户更新 API,然后是前端中的用户资料和用户资料编辑表单视图,以在 MERN Social 中为用户添加简短描述和个人照片。

添加关于描述

为了存储用户在about字段中输入的简短描述,我们需要在server/models/user.model.js中的用户模型中添加一个about字段:

about: {
    type: String,
    trim: true
  }

然后,为了从用户那里获取描述作为输入,我们需要在EditProfile表单中添加一个多行TextField,并像处理用户名输入一样处理值变化。

mern-social/client/user/EditProfile.js:

  <TextField
     id="multiline-flexible"
     label="About"
     multiline
     rows="2"
     value={values.about}
     onChange={handleChange('about')}
  />

最后,为了在用户资料页面上显示添加到about字段的描述文本,我们可以将其添加到现有的个人资料视图中。

mern-social/client/user/Profile.js:

<ListItem> <ListItemText primary={this.state.user.about}/> </ListItem>

通过对 MERN 骨架代码中用户功能的此修改,用户现在可以添加和更新关于自己的描述,并在个人资料中显示。接下来,我们将添加上传照片的功能,以完成用户资料的完善。

上传个人照片

允许用户上传个人照片将需要我们存储上传的图像文件,并在请求时检索它以在视图中加载。在考虑不同的文件存储选项时,有多种实现此上传功能的方法:

  • 服务器文件系统:将文件上传和保存到服务器文件系统,并将 URL 存储在 MongoDB 中。

  • 外部文件存储:将文件保存到外部存储,例如 Amazon S3,并将 URL 存储在 MongoDB 中。

  • 存储为 MongoDB 中的数据:将小于 16 MB 大小的文件作为 Buffer 类型的数据存储在 MongoDB 中。

对于 MERN Social,我们将假设用户上传的图片文件大小较小,并演示如何存储这些文件以实现个人照片上传功能。在第八章“扩展订单和支付的市场”,我们将讨论如何使用 GridFS 在 MongoDB 中存储更大的文件。

为了实现此照片上传功能,在以下章节中,我们将执行以下操作:

  • 更新用户模型以存储照片。

  • 集成更新的前端视图以从客户端上传照片。

  • 修改后端中的用户更新控制器以处理上传的图片。

更新用户模型以在 MongoDB 中存储照片

为了直接在数据库中存储上传的个人资料照片,我们将更新用户模型以添加一个photo字段,该字段以Buffer类型的数据存储文件,并包含文件的contentType

mern-social/server/models/user.model.js:

photo: {
    data: Buffer,
    contentType: String
}

用户从客户端上传的图像文件将被转换为二进制数据并存储在 MongoDB 中用户集合的文档的photo字段中。接下来,我们将看看如何从前端上传文件。

从编辑表单上传照片

当用户编辑个人资料时,他们将能够从本地文件上传图像文件。为了实现这种交互,我们将更新client/user/EditProfile.js中的EditProfile组件,添加上传照片选项,并将用户选择的文件附加到提交给服务器的表单数据中。我们将在以下章节中讨论这一点。

使用 Material-UI 的文件输入

我们将利用 HTML5 文件输入类型让用户从本地文件中选择一个图像。当用户选择一个文件时,文件输入会在改变事件中返回文件名。我们将按照以下方式将文件输入元素添加到编辑个人资料表单中:

mern-social/client/user/EditProfile.js:

<input accept="image/*" type="file"
       onChange={handleChange('photo')} 
       style={{display:'none'}} 
       id="icon-button-file" />

为了将此input元素与 Material-UI 组件集成,我们应用display:none来隐藏视图中的input元素,然后在标签内添加一个 Material-UI 按钮用于此文件输入。这样,视图显示的是 Material-UI 按钮而不是 HTML5 文件输入元素。标签的添加方式如下:

mern-social/client/user/EditProfile.js:

<label htmlFor="icon-button-file">
   <Button variant="contained" color="default" component="span">
      Upload <FileUpload/>
   </Button>
</label>

当按钮的component属性设置为span时,Button组件在label元素内渲染为一个span元素。点击Uploadspanlabel会被具有相同 ID 的文件输入注册,因此文件选择对话框被打开。一旦用户选择了一个文件,我们就可以在handleChange(...)的调用中将它设置为状态,并在视图中显示其名称,如下面的代码所示。

mern-social/client/user/EditProfile.js:

<span className={classes.filename}>
    {values.photo ? values.photo.name : ''}
</span>

这样,用户将看到他们试图上传的文件名作为个人资料照片。在选择上传的文件后,接下来我们必须将此文件附加到请求中并发送到服务器,以更新数据库中的用户信息。

附加文件提交的表单

使用表单上传文件到服务器需要多部分表单提交。这与我们在之前的 fetch 实现中发送的字符串化对象形成对比。我们将修改EditProfile组件,使其使用FormData API 以multipart/form-data类型所需的格式存储表单数据。

您可以在developer.mozilla.org/en-US/docs/…了解更多关于 FormData API 的信息。

首先,我们将更新输入handleChange函数,以便我们可以存储文本字段和文件输入的输入值,如下面的代码所示。

mern-social/client/user/EditProfile.js:

const handleChange = name => event => {
  const value = name === 'photo'
 ? event.target.files[0]
 : event.target.value
  setValues({...values, [name]: value })
}

然后,在表单提交时,我们需要初始化FormData并附加已更新的字段值,如下所示。

mern-social/client/user/EditProfile.js:

const clickSubmit = () => {
    let userData = new FormData()
    values.name && userData.append('name', values.name)
    values.email && userData.append('email', values.email)
    values.passoword && userData.append('passoword', values.passoword)
    values.about && userData.append('about', values.about)
    values.photo && userData.append('photo', values.photo)
    ...
}

在将所有字段和值附加到它之后,使用 fetch API 调用发送userData以更新用户,如下面的代码所示。

mern-social/client/user/EditProfile.js:

update({
      userId: match.params.userId
    }, {
      t: jwt.token
    }, userData).then((data) => {
      if (data && data.error) {
        setValues({...values, error: data.error})
      } else {
        setValues({...values, 'redirectToProfile': true})
      }
})

由于发送到服务器的数据的内容类型不再是'application/json',我们还需要修改api-user.js中的update fetch 方法,以从fetch调用中的头中删除Content-Type,如下所示。

mern-social/client/user/api-user.js:

const update = async (params, credentials, user) => {
  try {
    let response = await fetch('/api/users/' + params.userId, {
      method: 'PUT',
      headers: {
        'Accept': 'application/json',
        'Authorization': 'Bearer ' + credentials.t
      },
      body: user
    })
    return await response.json()
  } catch(err) {
    console.log(err)
  }}

现在,如果用户在编辑个人资料时选择上传个人照片,服务器将接收到一个带有附件的请求,其中包含其他字段值。接下来,我们需要修改服务器端代码以能够处理此请求。

处理包含文件上传的请求

在服务器上,为了处理可能包含文件的更新 API 请求,我们将使用formidable Node 模块。从命令行运行以下命令以安装formidable

yarn add formidable

formidable将允许服务器读取multipart表单数据,并让我们访问字段和文件(如果有)。如果有文件,formidable将暂时将其存储在文件系统中。我们将使用fs模块从文件系统读取它,这将检索文件类型和数据,并将其存储在用户模型的photo字段中。formidable代码将放在user.controller.js中的update控制器中,如下所示。

mern-social/server/controllers/user.controller.js:

import formidable from 'formidable'
import fs from 'fs'
const update = async (req, res) => {
  let form = new formidable.IncomingForm()
  form.keepExtensions = true
  form.parse(req, async (err, fields, files) => {
    if (err) {
      return res.status(400).json({
        error: "Photo could not be uploaded"
      })
    }
    let user = req.profile
    user = extend(user, fields)
    user.updated = Date.now()
    if(files.photo){
 user.photo.data = fs.readFileSync(files.photo.path)
 user.photo.contentType = files.photo.type
 }
    try {
      await user.save()
      user.hashed_password = undefined
      user.salt = undefined
      res.json(user)
    } catch (err) {
      return res.status(400).json({
        error: errorHandler.getErrorMessage(err)
      })
    }
  })
}

这将把上传的文件作为数据存储在数据库中。接下来,我们将设置文件检索,以便我们可以在前端视图中访问和显示用户上传的照片。

检索个人照片

要检索数据库中存储的图片并在视图中显示,最简单的方法是设置一个路由,该路由将获取数据并将其作为图像文件返回给请求客户端。在本节中,我们将学习如何设置此路由以公开照片 URL,以及如何使用此 URL 在前端视图中显示照片。

个人照片 URL

我们将为每个用户在数据库中存储的照片设置一个路由,并添加另一个路由,如果指定的用户没有上传个人照片,它将获取默认照片。这些路由将如下定义。

mern-social/server/routes/user.routes.js:

router.route('/api/users/photo/:userId')
  .get(userCtrl.photo, userCtrl.defaultPhoto)
router.route('/api/users/defaultphoto')
  .get(userCtrl.defaultPhoto)

我们将在photo控制器方法中查找照片,如果找到,将在照片路由的请求响应中发送它;否则,我们将调用next()以返回默认照片,如下面的代码所示。

mern-social/server/controllers/user.controller.js:

const photo = (req, res, next) => {
  if(req.profile.photo.data){
    res.set("Content-Type", req.profile.photo.contentType)
    return res.send(req.profile.photo.data)
  }
  next()
}

默认照片是从服务器的文件系统中检索并发送的,如下所示。

mern-social/server/controllers/user.controller.js:

import profileImage from './../../client/assets/images/profile-pic.png'
const defaultPhoto = (req, res) => {
  return res.sendFile(process.cwd()+profileImage)
}

我们可以使用这里定义的路由来在视图中显示照片,如下一节所述。

在视图中显示照片

通过设置用于检索照片的 URL 路由,我们可以在img元素的src属性中简单地使用这些路由来加载视图中的照片。例如,在Profile组件中,我们使用状态中的values中的用户 ID 来构造照片 URL,如下面的代码所示。

mern-social/client/user/Profile.js:

const photoUrl = values.user._id
      ? `/api/users/photo/${values.user._id}?${new Date().getTime()}`
      : '/api/users/defaultphoto'

为了确保在照片更新后img元素在Profile视图中重新加载,我们必须向照片 URL 添加一个时间值以绕过浏览器默认的图像缓存行为。

然后,我们可以将photoUrl设置为 Material-UI 的Avatar组件,该组件在视图中渲染链接的图像:

  <Avatar src={photoUrl}/>

MERN Social 中更新的用户个人资料现在可以显示用户上传的个人照片和about描述,如下面的截图所示:

我们已成功更新了 MERN 骨架应用程序代码,允许用户上传个人照片并在他们的个人资料中添加简短的简介。在下一节中,我们将进一步更新并实现允许用户相互关注的社交媒体风格功能。

MERN Social 中的关注用户

在 MERN Social 中,用户将能够相互关注。每个用户将有一个关注者列表和一个他们关注的列表。用户还可以查看他们可以关注的用户列表;换句话说,就是他们在 MERN Social 中尚未关注的用户。在以下章节中,我们将学习如何更新全栈代码以实现这些功能。

关注和取消关注

为了跟踪哪个用户在关注哪些其他用户,我们将为每个用户维护两个列表。当一个用户关注或取消关注另一个用户时,我们将更新一个用户的following列表和另一个用户的followers列表。首先,我们将更新后端以存储和更新这些列表,然后修改前端视图以允许用户执行关注和取消关注操作。

更新用户模型

要在数据库中存储followingfollowers的列表,我们需要更新用户模型,添加两个用户引用数组,如下面的代码所示。

mern-social/server/models/user.model.js:

following: [{type: mongoose.Schema.ObjectId, ref: 'User'}],
followers: [{type: mongoose.Schema.ObjectId, ref: 'User'}]

这些引用将指向被给定用户关注或正在关注该用户的集合中的用户。接下来,我们将更新用户控制器以确保在客户端请求的响应中返回这些列表中引用的用户详情。

更新userByID控制器方法

当从后端检索单个用户时,我们希望user对象包括followingfollowers数组中引用的用户的名字和 ID。为了检索这些详情,我们需要更新userByID控制器方法,使其填充返回的用户对象,如下面高亮显示的代码所示。

mern-social/server/controllers/user.controller.js:

const userByID = async (req, res, next, id) => {
  try {
    let user = await User.findById(id)
    .populate('following', '_id name')
 .populate('followers', '_id name')
    .exec()
    if (!user)
      return res.status('400').json({
        error: "User not found"
      })
    req.profile = user
    next()
  } catch (err) {
    return res.status('400').json({
      error: "Could not retrieve user"
    })
  }
}

我们使用 Mongoose 的populate方法来指定从查询返回的用户对象应包含followingfollowers列表中引用的用户的名字和 ID。这样,当我们通过读取 API 调用获取用户时,我们将获得followersfollowing列表中用户引用的名字和 ID。

在更新用户模型后,我们准备好添加 API 端点,以更新这些列表,要么添加要么从列表中删除用户,如下一节所述。

添加关注和取消关注的 API

当用户从视图中关注或取消关注另一个用户时,数据库中这两个用户的记录将根据followunfollow请求进行更新。

user.routes.js中设置followunfollow路由如下。

mern-social/server/routes/user.routes.js:

router.route('/api/users/follow')
  .put(authCtrl.requireSignin, 
       userCtrl.addFollowing, 
       userCtrl.addFollower)
router.route('/api/users/unfollow')
  .put(authCtrl.requireSignin, 
       userCtrl.removeFollowing, 
       userCtrl.removeFollower)

用户控制器中的addFollowing控制器方法将通过将关注用户的引用推入数组来更新当前用户的following数组,如下面的代码所示。

mern-social/server/controllers/user.controller.js:

const addFollowing = async (req, res, next) => {
  try{
    await User.findByIdAndUpdate(req.body.userId, 
                   {$push: {following: req.body.followId}}) 
    next()
  }catch(err){
    return res.status(400).json({
      error: errorHandler.getErrorMessage(err)
    })
  }
}

在成功更新following数组后,调用next(),随后执行addFollower方法,将当前用户的引用添加到被关注用户的followers数组中。addFollower方法定义如下。

mern-social/server/controllers/user.controller.js:

const addFollower = async (req, res) => {
  try{
    let result = await User.findByIdAndUpdate(req.body.followId, 
                            {$push: {followers: req.body.userId}}, 
                            {new: true})
                            .populate('following', '_id name')
                            .populate('followers', '_id name')
                            .exec()
      result.hashed_password = undefined
      result.salt = undefined
      res.json(result)
    }catch(err) {
      return res.status(400).json({
        error: errorHandler.getErrorMessage(err)
      })
    } 
}

对于取消关注,实现方式类似。removeFollowingremoveFollower控制器方法通过使用$pull而不是$push来移除用户引用来更新相应的'following''followers'数组。removeFollowingremoveFollower将如下所示。

mern-social/server/controllers/user.controller.js:

const removeFollowing = async (req, res, next) => {
  try{
    await User.findByIdAndUpdate(req.body.userId, 
                   {$pull: {following: req.body.unfollowId}}) 
    next()
  }catch(err) {
    return res.status(400).json({
      error: errorHandler.getErrorMessage(err)
    })
  }
}
const removeFollower = async (req, res) => {
  try{
    let result = await User.findByIdAndUpdate(req.body.unfollowId, 
                                {$pull: {followers: req.body.userId}}, 
                                {new: true})
                            .populate('following', '_id name')
                            .populate('followers', '_id name')
                            .exec() 
    result.hashed_password = undefined
    result.salt = undefined
    res.json(result)
  }catch(err){
      return res.status(400).json({
        error: errorHandler.getErrorMessage(err)
      })
  }
}

服务器端的用户后端已准备好关注和取消关注功能。接下来,我们将更新前端以利用这些新的后端 API 并完成此功能。

在视图中访问关注和取消关注的 API

为了在视图中访问这些 API 调用,我们将更新api-user.js以包含followunfollow获取方法。followunfollow方法将类似,调用相应的路由,使用当前用户的 ID 和凭据,以及被关注或取消关注的用户的 ID。follow方法如下。

mern-social/client/user/api-user.js:

const follow = async (params, credentials, followId) => {
  try {
    let response = await fetch('/api/users/follow/', {
      method: 'PUT',
      headers: {
        'Accept': 'application/json',
        'Content-Type': 'application/json',
        'Authorization': 'Bearer ' + credentials.t
      },
      body: JSON.stringify({userId:params.userId, followId: followId})
    })
    return await response.json()
  } catch(err) {
    console.log(err)
  }
}

unfollow获取方法类似;它接受取消关注的用户 ID 并调用unfollow API,如下面的代码所示。

mern-social/client/user/api-user.js:

const unfollow = async (params, credentials, unfollowId) => {
  try {
    let response = await fetch('/api/users/unfollow/', {
      method: 'PUT',
      headers: {
        'Accept': 'application/json',
        'Content-Type': 'application/json',
        'Authorization': 'Bearer ' + credentials.t
      },
      body: JSON.stringify({userId:params.userId, unfollowId: unfollowId})
    })
    return await response.json()
  } catch(err) {
    console.log(err)
  }
}

实现了 API 获取代码后,我们可以使用这两种方法在视图中集成后端更新,如下一节所述,这将允许用户在应用程序中关注或取消关注另一个用户。

关注和取消关注按钮

允许用户关注或取消关注另一个用户的按钮将根据当前用户是否已经关注了该用户而条件性地显示,如下面的截图所示:

在以下章节中,我们将把这个按钮添加到一个独立的 React 组件中,将其与现有的用户资料视图集成,并连接到关注和取消关注的获取方法。

FollowProfileButton 组件

我们将创建一个名为FollowProfileButton的单独组件,该组件将被添加到Profile组件中。这个组件将根据当前用户是否已经是资料中用户的关注者来显示FollowUnfollow按钮。FollowProfileButton组件如下所示。

mern-social/client/user/FollowProfileButton.js:

export default function FollowProfileButton (props) {
 const followClick = () => {
 props.onButtonClick(follow)
 }
 const unfollowClick = () => {
 props.onButtonClick(unfollow)
 }
  return (<div>
    { props.following
       ? (<Button variant="contained" color="secondary" 
                  onClick={unfollowClick}>Unfollow</Button>)
       : (<Button variant="contained" color="primary" 
                  onClick={followClick}>Follow</Button>)
   }
   </div>)
}
FollowProfileButton.propTypes = {
 following: PropTypes.bool.isRequired,
 onButtonClick: PropTypes.func.isRequired
}

FollowProfileButton添加到资料中时,following值将由Profile组件确定并发送,作为属性传递给FollowProfileButton,同时传递一个点击处理函数,该函数作为参数调用特定的followunfollow获取 API。生成的资料视图将如下所示:

为了将这个FollowProfileButton组件与资料视图集成,我们需要更新现有的Profile组件,如以下所述。

更新资料组件

Profile视图中,FollowProfileButton应该只在用户查看其他用户的资料时显示,因此我们需要修改查看资料时显示EditDelete按钮的条件,如下所示:

{auth.isAuthenticated().user && 
      auth.isAuthenticated().user._id == values.user._id 
    ? (edit and delete buttons) 
    : (follow button)
}

Profile组件中,在useEffect中成功获取用户数据后,我们将检查已登录用户是否已经在关注资料中的用户,并将following值设置为相应的状态,如下面的代码所示。

mern-social/client/user/Profile.js:

let following = checkFollow(data)
setValues({...values, user: data, following: following}) 

为了确定在 following 中设置的值,checkFollow 方法将检查已登录用户是否存在于获取的用户 followers 列表中,如果找到则返回 match;如果没有找到匹配项,则返回 undefinedcheckFollow 方法定义如下。

mern-social/client/user/Profile.js:

const checkFollow = (user) => {
    const match = user.followers.some((follower)=> {
      return follower._id == jwt.user._id
    })
    return match
}

Profile 组件还将定义 FollowProfileButton 的点击处理程序,以便在关注或取消关注操作完成后更新 Profile 的状态,如下面的代码所示。

mern-social/client/user/Profile.js:

  const clickFollowButton = (callApi) => {
    callApi({
      userId: jwt.user._id
    }, {
      t: jwt.token
    }, values.user._id).then((data) => {
      if (data.error) {
        setValues({...values, error: data.error})
      } else {
        setValues({...values, user: data, following: !values.following})
      }
    })
  }

点击处理程序定义接受获取 API 调用作为参数,并将其作为属性传递给 FollowProfileButton,当它添加到 Profile 视图中时,还传递 following 值,如下所示。

mern-social/client/user/Profile.js:

<FollowProfileButton following={this.state.following} onButtonClick={this.clickFollowButton}/>

这将加载 FollowProfileButton 到个人资料视图中,考虑到所有必要的条件,并为当前用户提供在 MERN 社交应用程序中关注或取消关注其他用户的选项。接下来,我们将扩展此功能,允许用户在用户个人资料视图中查看关注者或粉丝列表。

列出关注者和粉丝

为了让用户在 MERN 社交平台上轻松访问他们关注的用户和他们被关注的用户,我们将将这些列表添加到他们的个人资料视图中。在每个用户的个人资料中,我们将添加一个包含他们的粉丝和关注者的列表,如下面的截图所示:

图片

followingfollowers 列表中引用的用户详细信息已经在加载个人资料时使用 read API 获取的用户对象中。为了渲染这些独立的粉丝和关注者列表,我们将创建一个新的组件,称为 FollowGrid

创建 FollowGrid 组件

FollowGrid 组件将接受一个用户列表作为属性,显示用户的头像和名字,并将它们链接到每个用户的个人资料。我们可以将此组件添加到 Profile 视图中以显示 关注者粉丝FollowGrid 组件的定义如下。

mern-social/client/user/FollowGrid.js:

export default function FollowGrid (props) {
  const classes = useStyles()
    return (<div className={classes.root}>
      <GridList cellHeight={160} className={classes.gridList} cols={4}>
        {props.people.map((person, i) => {
           return <GridListTile style={{'height':120}} key={i}>
              <Link to={"/user/" + person._id}>
                <Avatar src={'/api/users/photo/'+person._id} 
                        className={classes.bigAvatar}/>
                <Typography className={classes.tileText}>
                   {person.name}
                </Typography>
              </Link>
            </GridListTile>
        })}
      </GridList>
    </div>)
}

FollowGrid.propTypes = {
  people: PropTypes.array.isRequired
}

要将 FollowGrid 组件添加到 Profile 视图中,我们可以将其放置在视图中所需的位置,并将 followersfollowings 列表作为 people 属性传递:

<FollowGrid people={props.user.followers}/>
<FollowGrid people={props.user.following}/>

如前所述,在 MERN 社交平台中,我们选择在“个人资料”组件的标签页中显示 FollowGrid 组件。我们使用 Material-UI 标签组件创建了一个单独的 ProfileTabs 组件,并将其添加到 Profile 组件中。这个 ProfileTabs 组件包含两个 FollowGrid 组件,其中包含关注者和粉丝列表,以及一个显示用户发布的帖子的 PostList 组件。

在本章后面将讨论 PostList 组件。在下一节中,我们将添加一个功能,允许用户发现平台上他们尚未关注的其他用户。

寻找关注的人

“谁值得关注”功能将向登录用户显示 MERN Social 中他们尚未关注的用户列表,从而给他们提供关注他们或查看他们个人资料的选择,如下截图所示:

图片

为了实现这个功能,我们需要添加一个后端 API,该 API 返回当前登录用户未关注的用户列表,然后通过添加一个加载并显示此用户列表的组件来更新前端。

获取未关注用户

我们将在服务器上实现一个新的 API 来查询数据库并获取当前用户未关注的用户列表。此路由将按如下定义。

mern-social/server/routes/user.routes.js:

router.route('/api/users/findpeople/:userId')
   .get(authCtrl.requireSignin, userCtrl.findPeople)

findPeople 控制器方法中,我们将查询数据库中的 User 集合以找到不在当前用户 following 列表中的用户。

mern-social/server/controllers/user.controller.js:

const findPeople = async (req, res) => {
  let following = req.profile.following
  following.push(req.profile._id)
  try {
    let users = await User.find({ _id:{ $nin : following }})
                          .select('name')
    res.json(users)
  }catch(err){
    return res.status(400).json({
      error: errorHandler.getErrorMessage(err)
    })
  }
}

此查询将返回一个数组,包含当前用户未关注的用户。为了在前端使用此用户列表,我们将更新 api-user.js 文件并添加对该 API 的获取。findPeople 获取方法定义如下。

mern-social/client/user/api-user.js:

const findPeople = async (params, credentials, signal) => {
  try {
    let response = await fetch('/api/users/findpeople/' + params.userId, {
      method: 'GET',
      signal: signal,
      headers: {
        'Accept': 'application/json',
        'Content-Type': 'application/json',
        'Authorization': 'Bearer ' + credentials.t
      }
    }) 
    return await response.json()
  } catch(err) {
    console.log(err)
  }
}

我们可以在组件中使用这个 findPeople 获取方法来显示用户列表。在下一节中,我们将创建 FindPeople 组件来完成这个目的。

FindPeople 组件

为了显示“谁值得关注”功能,我们将创建一个名为 FindPeople 的组件,该组件可以被添加到任何视图或独立渲染。在这个组件中,我们将通过在 useEffect 中调用 findPeople 方法来获取未关注的用户。如下代码所示。

mern-social/client/user/FindPeople.js:

 useEffect(() => {
    const abortController = new AbortController()
    const signal = abortController.signal

    findPeople({
      userId: jwt.user._id
    }, {
      t: jwt.token
    }, signal).then((data) => {
      if (data && data.error) {
        console.log(data.error)
      } else {
        setValues({...values, users:data})
      }
    })
    return function cleanup(){
      abortController.abort()
    }
 }, [])

获取的用户列表将被迭代并在 Material-UI List 组件中渲染,每个列表项包含用户的头像、姓名、到个人主页的链接以及一个 Follow 按钮,如下代码所示。

mern-social/client/user/FindPeople.js:

<List>
     {values.users.map((item, i) => {
         return <span key={i}>
            <ListItem>
               <ListItemAvatar className={classes.avatar}>
                  <Avatar src={'/api/users/photo/'+item._id}/>
               </ListItemAvatar>
               <ListItemText primary={item.name}/>
               <ListItemSecondaryAction className={classes.follow}>
                  <Link to={"/user/" + item._id}>
                    <IconButton variant="contained" color="secondary" 
                                className={classes.viewButton}>
                      <ViewIcon/>
                    </IconButton>
                  </Link>
                  <Button aria-label="Follow" variant="contained" 
                          color="primary" 
                          onClick={()=> {clickFollow(item, i)}}>
                      Follow
                  </Button>
               </ListItemSecondaryAction>
            </ListItem>
          </span>
        })
      }
</List>

点击 Follow 按钮将调用关注 API 并通过剪切掉新关注的用户来更新要关注的用户列表。clickFollow 方法如下实现此行为。

mern-social/client/user/FindPeople.js:

const clickFollow = (user, index) => {
  follow({
    userId: jwt.user._id
  }, {
    t: jwt.token
  }, user._id).then((data) => {
    if (data.error) {
      console.log(data.error)
    } else {
      let toFollow = values.users
      toFollow.splice(index, 1)
      setValues({...values, users: toFollow, open: true, 
                            followMessage: `Following ${user.name}!`})
    }
  })
}

我们还将添加一个 Material-UI Snackbar 组件,当用户成功关注新用户时,它会临时打开以告知用户他们开始关注这位新用户。Snackbar 将按如下方式添加到视图代码中。

mern-social/client/user/FindPeople.js:

<Snackbar
   anchorOrigin={{
        vertical: 'bottom',
        horizontal: 'right',
     }}
   open={values.open}
   onClose={handleRequestClose}
   autoHideDuration={6000}
   message={<span className={classes.snack}>{values.followMessage}</span>}
/>

如下截图所示,Snackbar 将在页面右下角显示包含被关注用户名的 message,并在设置的时间后自动隐藏:

图片

MERN Social 用户现在可以相互关注,查看每个用户的关注者和被关注者列表,还可以看到他们可以关注的人的列表。在 MERN Social 中关注另一个用户的主要目的是查看和互动他们的共享帖子。在下一节中,我们将查看帖子功能的实现。

在 MERN Social 上发布

MERN Social 中的帖子功能将允许用户在 MERN Social 应用程序平台上分享内容,并通过评论或点赞帖子与他人互动,如下面的截图所示:

图片

对于这个功能,我们将实现一个包含帖子后端和前端的完整全栈切片。帖子后端将包括一个新的 Mongoose 模型,用于结构化要存储在数据库中的帖子数据,而帖子 CRUD API 端点将允许前端与数据库中的帖子集合进行交互。帖子前端将包括与帖子相关的 React 组件,允许用户查看帖子、添加新帖子、与帖子互动以及删除自己的帖子。在接下来的章节中,我们将定义帖子模式中帖子数据的结构,然后根据我们正在实现的特定帖子相关功能,逐步添加帖子后端 API 和前端组件。

Mongoose 的帖子模式模型

为了定义存储每个帖子详细信息的结构,并将每个帖子作为文档存储在 MongoDB 的集合中,我们将在 server/models/post.model.js 中定义帖子模式的 Mongoose 模型。帖子模式将存储帖子的文本内容、照片、发布帖子的用户引用、创建时间、用户对帖子的点赞以及用户对帖子的评论。该模式将按照以下字段存储这些详细信息,每个字段都按照相应的代码定义。

  • 帖子文本text 将是一个必填字段,需要在创建新帖子时从视图中提供:
text: {
  type: String,
  required: 'Text is required'
}
  • 帖子照片photo 将在帖子创建时从用户的本地文件上传并存储在 MongoDB 中,类似于用户个人资料照片上传功能。每个帖子中的照片是可选的:
photo: {
  data: Buffer,
  contentType: String
}
  • 帖子作者:创建帖子需要用户先登录,这样我们就可以在 postedBy 字段中存储发布帖子的用户引用:
postedBy: {type: mongoose.Schema.ObjectId, ref: 'User'}
  • 创建时间created 时间将在数据库中创建帖子时自动生成:
created: { type: Date, default: Date.now }
  • 点赞:对特定帖子点赞的用户引用将存储在 likes 数组中:
likes: [{type: mongoose.Schema.ObjectId, ref: 'User'}]
  • 评论:每个帖子的评论将包含文本内容、创建时间以及评论用户的引用。每个帖子将有一个包含 comments 的数组:
comments: [{
    text: String,
    created: { type: Date, default: Date.now },
    postedBy: { type: mongoose.Schema.ObjectId, ref: 'User'}
  }]

这个模式定义将使我们能够在 MERN Social 中实现所有与帖子相关的功能。接下来,我们将从新闻源功能开始讨论,了解如何编写前端 React 组件。

新闻源组件

在 MERN Social 上,每个用户将看到他们关注的用户分享的帖子,以及他们自己分享的帖子,所有这些帖子都汇总在新闻源视图中。在进一步探讨 MERN Social 中帖子相关功能的实现之前,我们将查看这个新闻源视图的组成,以展示如何设计嵌套 UI 组件并共享状态的基本示例。Newsfeed 组件将包含两个主要的子组件——一个新帖子表单和来自关注用户的帖子列表,如下面的截图所示:

新闻源组件

Newsfeed 组件的基本结构如下,其中包含 NewPost 组件和 PostList 组件。

mern-social/client/post/Newsfeed.js:

<Card>
   <Typography type="title"> Newsfeed </Typography>
   <Divider/>
   <NewPost addUpdate={addPost}/>
   <Divider/>
   <PostList removeUpdate={removePost} posts={posts}/>
</Card>

作为父组件,Newsfeed 将控制在子组件中渲染的帖子数据的状态。当在子组件中修改帖子数据时,例如在 NewPost 组件中添加新帖子或在 PostList 组件中删除帖子,它将提供一种方法来更新组件间的帖子状态。

在这里具体来说,在 Newsfeed 组件中,我们最初调用服务器以获取当前登录用户关注的用户的帖子列表。然后我们将这个帖子列表设置到状态中,以便在 PostList 组件中渲染。Newsfeed 组件为 NewPostPostList 提供了 addPostremovePost 函数,这些函数将在创建新帖子或删除现有帖子时使用,以更新 Newsfeed 状态中的帖子列表,并最终在 PostList 中反映出来。

Newsfeed 组件中定义的 addPost 函数将获取在 NewPost 组件中创建的新帖子,并将其添加到状态中的帖子中。addPost 函数将如下所示。

mern-social/client/post/Newsfeed.js:

const addPost = (post) => {
    const updatedPosts = [...posts]
    updatedPosts.unshift(post)
    setPosts(updatedPosts)
}

Newsfeed 组件中定义的 removePost 函数将从 PostList 中的 Post 组件中获取已删除的帖子,并将其从状态中的帖子中删除。removePost 函数将如下所示。

mern-social/client/post/Newsfeed.js:

const removePost = (post) => {
    const updatedPosts = [...posts]
    const index = updatedPosts.indexOf(post)
    updatedPosts.splice(index, 1)
    setPosts(updatedPosts)
}

通过这种方式在 Newsfeed 的状态中更新帖子,PostList 将将更改后的帖子列表渲染给观众。这种从父组件到子组件以及返回的状态更新机制将应用于其他功能,例如帖子中的评论更新以及当在 Profile 组件中为单个用户渲染 PostList 时。

要开始完整实现 Newsfeed,我们需要能够从服务器获取帖子列表并在 PostList 中显示它。在下一节中,我们将为前端创建这个 PostList 组件,并将 PostList API 端点添加到后端。

列出帖子

在 MERN 社交中,我们在 Newsfeed 和每个用户的个人资料中列出帖子。我们将创建一个通用的 PostList 组件,该组件可以渲染提供的任何帖子列表,我们可以在 NewsfeedProfile 组件中使用它。PostList 组件定义如下。

mern-social/client/post/PostList.js:

export default function PostList (props) {
    return (
      <div style={{marginTop: '24px'}}>
        {props.posts.map((item, i) => {
            return <Post post={item} key={i} 
                         onRemove={props.removeUpdate}/>
          })
        }
      </div>
    )
}
PostList.propTypes = {
  posts: PropTypes.array.isRequired,
  removeUpdate: PropTypes.func.isRequired
}

PostList 组件将遍历从 NewsfeedProfile 传递给它的作为 props 的帖子列表,并将每个帖子的数据传递给一个将渲染帖子详细信息的 Post 组件。PostList 还会将从父组件发送的 removeUpdate 函数传递给 Post 组件,以便在删除单个帖子时更新状态。接下来,我们将在从后端获取相关帖子后完成新闻源视图中的帖子列表。

在新闻源中列出帖子

我们将在服务器上设置一个 API,该 API 查询 Post 集合并返回一个列表,其中包含指定用户关注的用户的帖子。然后,为了填充新闻源视图,这些帖子将通过调用此 API 在前端检索,并在 Newsfeed 中的 PostList 中显示。

帖子新闻源 API

要实现针对新闻源的特定 API,我们需要添加一个路由端点,该端点将接收新闻源帖子请求并相应地响应用户端请求。

在后端,我们需要定义一个路由路径,该路径将接收检索特定用户新闻源帖子的请求,如下所示。

server/routes/post.routes.js

router.route('/api/posts/feed/:userId')
  .get(authCtrl.requireSignin, postCtrl.listNewsFeed)

我们在这个路由中使用 :userID 参数来指定当前登录的用户。我们将利用 userByID 控制器方法在 user.controller 中获取用户详细信息,就像我们之前所做的那样,并将这些信息附加到在 listNewsFeed 帖子控制器方法中访问的请求对象中。将以下内容添加到 mern-social/server/routes/post.routes.js:

router.param('userId', userCtrl.userByID)

post.routes.js 文件将与 user.routes.js 文件非常相似。为了在 Express 应用程序中加载这些新路由,我们需要在 express.js 中挂载帖子路由,就像我们为 auth 和用户路由所做的那样。帖子相关路由的挂载方式如下。

mern-social/server/express.js:

app.use('/', postRoutes)

post.controller.js 中的 listNewsFeed 控制器方法将查询数据库中的 Post 集合以获取匹配的帖子。listNewsFeed 控制器方法定义如下。

mern-social/server/controllers/post.controller.js:

const listNewsFeed = async (req, res) => {
  let following = req.profile.following
  following.push(req.profile._id)
  try {
    let posts = await Post.find({postedBy:{ $in : req.profile.following }})
                          .populate('comments.postedBy', '_id name')
                          .populate('postedBy', '_id name')
                          .sort('-created')
                          .exec()
    res.json(posts)
  } catch(err) {
    return res.status(400).json({
      error: errorHandler.getErrorMessage(err)
    })
  }
}

在对 Post 集合的查询中,我们找到所有具有 postedBy 用户引用与当前用户的关注者和当前用户匹配的帖子。返回的帖子将按 created 时间戳排序,最新的帖子排在第一位。每个帖子还将包含创建帖子的用户和评论帖子的用户的 idname。接下来,我们将在前端 Newsfeed 组件中获取此 API 并渲染列表详情。

在视图中获取新闻源帖子

我们将在前端使用新闻源 API 来获取相关帖子并在新闻源视图中显示这些帖子。首先,我们将添加一个获取方法来向 API 发送请求,如下面的代码所示。

client/post/api-post.js:

const listNewsFeed = async (params, credentials, signal) => {
  try {
    let response = await fetch('/api/posts/feed/'+ params.userId, {
      method: 'GET',
      signal: signal,
      headers: {
        'Accept': 'application/json',
        'Content-Type': 'application/json',
        'Authorization': 'Bearer ' + credentials.t
      }
    }) 
    return await response.json()
  } catch(err) {
    console.log(err)
  }
}

这是将加载在 PostList 中渲染的帖子的一种获取方法,PostList 被添加为 Newsfeed 组件的子组件。因此,这个获取操作需要在 Newsfeed 组件的 useEffect 钩子中调用,如下面的代码所示。

mern-social/client/post/Newsfeed.js:

  useEffect(() => {
    const abortController = new AbortController()
    const signal = abortController.signal

    listNewsFeed({
      userId: jwt.user._id
    }, {
      t: jwt.token
    }, signal).then((data) => {
      if (data.error) {
        console.log(data.error)
      } else {
        setPosts(data)
      }
    })
    return function cleanup(){
      abortController.abort()
    }

  }, [])

这将从后端检索帖子列表并将其设置到 Newsfeed 组件的状态中,以最初加载在 PostList 组件中渲染的帖子,如下面的截图所示:

在本章后面将讨论如何渲染列表中的单个帖子细节。在下一节中,我们将为 Profile 组件渲染相同的 PostList 并显示特定用户分享的帖子。

在个人资料中列出用户帖子

获取特定用户创建的帖子列表并在 Profile 中显示的实现将与我们在上一节中讨论的关于在新闻源中列出帖子的实现类似。首先,我们将在服务器上设置一个 API,该 API 查询帖子集合并返回特定用户的帖子到 Profile 视图。

用户帖子 API

为了检索特定用户分享的帖子,我们需要添加一个路由端点来接收对这些帖子的请求并相应地响应请求客户端。

在后端,我们将定义另一个与帖子相关的路由,该路由将接收查询以返回特定用户的帖子,如下所示。

mern-social/server/routes/post.routes.js:

router.route('/api/posts/by/:userId')
    .get(authCtrl.requireSignin, postCtrl.listByUser)

post.controller.js 中的 listByUser 控制器方法将查询帖子集合以找到在 postedBy 字段中与路由中指定的 userId 参数匹配的引用。listByUser 控制器方法将如下所示。

mern-social/server/controllers/post.controller.js:

const listByUser = async (req, res) => {
  try {
    let posts = await Post.find({postedBy: req.profile._id})
                          .populate('comments.postedBy', '_id name')
                          .populate('postedBy', '_id name')
                          .sort('-created')
                          .exec()
    res.json(posts)
  } catch(err) {
    return res.status(400).json({
      error: errorHandler.getErrorMessage(err)
    })
  }
}

这个查询将返回由特定用户创建的帖子列表。我们需要从前端调用这个 API,我们将在下一节中这样做。

在视图中获取用户帖子

我们将在前端使用 list-posts-by-user API 来获取相关帖子并在个人资料视图中显示这些帖子。为了使用此 API,我们将在前端添加一个获取方法,如下所示。

mern-social/client/post/api-post.js:

const listByUser = async (req, res) => {
  try {
    let posts = await Post.find({postedBy: req.profile._id})
                          .populate('comments.postedBy', '_id name')
                          .populate('postedBy', '_id name')
                          .sort('-created')
                          .exec()
    res.json(posts)
  } catch(err) {
    return res.status(400).json({
      error: errorHandler.getErrorMessage(err)
    })
  }
}

这个 fetch 方法将加载 PostList 所需的帖子,PostList 被添加到 Profile 视图中。我们将更新 Profile 组件,使其定义一个 loadPosts 方法,该方法调用 listByUser 获取方法。loadPosts 方法将如下所示。

mern-social/client/user/Profile.js:

const loadPosts = (user) => {
  listByUser({
    userId: user
  }, {
    t: jwt.token
  }).then((data) => {
    if (data.error) {
      console.log(data.error)
    } else {
      setPosts(data)
    }
    })
}

Profile 组件中,loadPosts 方法将在从服务器在 useEffect() 钩子函数中获取用户详情之后,使用正在加载的用户 ID 被调用。为特定用户加载的帖子被设置到状态中,并在添加到 Profile 组件的 PostList 组件中渲染。Profile 组件还提供了一个类似于 Newsfeed 组件的 removePost 函数,作为属性传递给 PostList 组件,以便在帖子被删除时更新帖子列表。Profile 组件中的结果 PostList 将渲染成如下截图所示:

列出在 MERN Social 上已分享的帖子的功能现在已经完成。但在测试这些功能之前,我们需要实现允许用户创建新帖子的功能。我们将在下一节中这样做。

创建新帖子

创建新帖子功能将允许已登录用户发布消息,并且可以选择通过从本地文件上传来添加图片到帖子中。为了实现这个功能,在接下来的章节中,我们将向后端添加一个创建帖子 API 端点,允许上传图像文件,并且在前端添加一个 NewPost 组件,该组件将利用此端点让用户创建新帖子。

创建帖子 API

在服务器上,我们将定义一个 API 来在数据库中创建帖子,首先在 mern-social/server/routes/post.routes.js 中声明一个接受 POST 请求的路由 /api/posts/new/:userId

router.route('/api/posts/new/:userId')
  .post(authCtrl.requireSignin, postCtrl.create)

post.controller.js 中的 create 方法将使用 formidable 模块来访问字段和图像文件(如果有),就像我们为用户个人资料图片更新所做的那样。create 控制器方法将如下所示。

mern-social/server/controllers/post.controller.js:

const create = (req, res, next) => {
  let form = new formidable.IncomingForm()
  form.keepExtensions = true
  form.parse(req, async (err, fields, files) => {
    if (err) {
      return res.status(400).json({
        error: "Image could not be uploaded"
      })
    }
    let post = new Post(fields)
    post.postedBy= req.profile
    if(files.photo){
      post.photo.data = fs.readFileSync(files.photo.path)
      post.photo.contentType = files.photo.type
    }
    try {
      let result = await post.save()
      res.json(result)
    } catch (err) {
      return res.status(400).json({
        error: errorHandler.getErrorMessage(err)
      })
    }
  })
}

与个人资料图片上传类似,与新帖子一起上传的图片将以二进制格式存储在 Post 文档中。我们需要添加一个路由来检索并返回此图片到前端,我们将在下一步中这样做。

检索帖子的图片

为了检索上传的图片,我们还将设置一个 photo 路由端点,在请求时,将返回与特定帖子关联的图片。图片 URL 路由将与其他与帖子相关的路由一起定义,如下所示。

mern-social/server/routes/post.routes.js:

router.route('/api/posts/photo/:postId').get(postCtrl.photo)

photo 控制器将返回存储在 MongoDB 中的 photo 数据,以图像文件的形式。这定义如下。

mern-social/server/controllers/post.controller.js:

const photo = (req, res, next) => {
    res.set("Content-Type", req.post.photo.contentType)
    return res.send(req.post.photo.data)
}

由于图片路由使用 :postID 参数,我们将设置一个 postByID 控制器方法来通过其 ID 获取特定帖子,然后再将其返回给图片请求。我们将在 post.routes.js 中添加 param 调用,如下所示代码所示。

mern-social/server/routes/post.routes.js:

  router.param('postId', postCtrl.postByID)

postByID将与userByID方法类似,并将从数据库检索到的帖子附加到请求对象中,以便可以通过next方法访问。postByID方法定义如下。

mern-social/server/controllers/post.controller.js:

const postByID = async (req, res, next, id) => {
  try{
    let post = await Post.findById(id)
                         .populate('postedBy', '_id name')
                         .exec()
    if (!post)
      return res.status('400').json({
        error: "Post not found"
      })
    req.post = post
    next()
  }catch(err){
    return res.status('400').json({
      error: "Could not retrieve use post"
    })
  }
}

在此实现中,附加的帖子数据还将包含postedBy用户引用的 ID 和名称,因为我们调用了populate()。在下一节中,我们将在前端添加一个 fetch 方法来访问此 API 端点。

在视图中获取创建帖子 API

我们将通过添加一个create方法来更新api-post.js,以便对创建 API 进行fetch调用。create fetch 方法将如下所示。

mern-social/client/post/api-post.js:

const create = async (params, credentials, post) => {
  try {
    let response = await fetch('/api/posts/new/'+ params.userId, {
      method: 'POST',
      headers: {
        'Accept': 'application/json',
        'Authorization': 'Bearer ' + credentials.t
      },
      body: post
    })
    return await response.json()
  } catch(err) {
    console.log(err)
  }
}

此方法与用户edit fetch 方法类似,将使用FormData对象发送多部分表单提交,该对象将包含文本字段和图像文件。最后,我们准备好将此创建新帖子的功能集成到后端,与允许用户编写帖子并将其提交到后端的客户端组件。

创建 NewPost 组件

我们在Newsfeed组件中添加的NewPost组件将允许用户编写包含文本消息和可选图像的新帖子,如下面的截图所示:

图片

NewPost组件将是一个标准表单,包含 Material-UI 的TextField和一个文件上传按钮,如EditProfile中实现的那样,它将值设置在FormData对象中,以便在提交帖子时传递给create fetch 方法的调用。帖子提交将调用以下clickPost方法。

mern-social/client/post/NewPost.js:

const clickPost = () => {
    let postData = new FormData()
    postData.append('text', values.text)
    postData.append('photo', values.photo)
    create({
      userId: jwt.user._id
    }, {
      t: jwt.token
    }, postData).then((data) => {
      if (data.error) {
        setValues({...values, error: data.error})
      } else {
        setValues({...values, text:'', photo: ''})
        props.addUpdate(data)
      }
    })
}

如前所述,NewPost组件被添加为Newsfeed的子组件,并作为属性传递了addUpdate方法。在成功创建帖子后,表单视图将被清空,并执行addUpdate,以便将新帖子更新到Newsfeed中的帖子列表。在下一节中,我们将添加Post组件,以便显示每个帖子和其详细信息。

Post 组件

每个帖子中的帖子详情将在Post组件中渲染,该组件将从PostList组件接收帖子数据作为 props,以及onRemove属性,如果删除帖子则需要应用。在以下章节中,我们将查看 Post 接口的不同部分以及如何实现每个部分。

布局

Post组件的布局将包含一个显示发布者详情的标题,帖子的内容,一个包含点赞和评论计数的操作栏,以及一个评论部分,如下面的截图所示:

图片

接下来,我们将探讨此 Post 组件的标题、内容、操作和评论部分的实现细节。

标题

标题将包含有关发布用户的信息,如姓名、头像和链接到用户个人资料,以及帖子创建的日期。在标题部分显示这些详情的代码将如下所示。

mern-social/client/post/Post.js:

<CardHeader
   avatar={
      <Avatar src={'/api/users/photo/'+props.post.postedBy._id}/>
   }
   action={ props.post.postedBy._id === auth.isAuthenticated().user._id &&
              <IconButton onClick={deletePost}>
                <DeleteIcon />
              </IconButton>
          }
   title={<Link to={"/user/" + props.post.postedBy._id}>{props.post.postedBy.name}</Link>}
   subheader={(new Date(props.post.created)).toDateString()}
   className={classes.cardHeader}
/>

如果已登录用户正在查看自己的帖子,标题还将条件性地显示一个delete按钮。这个标题部分将位于主要内容部分之上,我们将在下一节讨论。

内容

内容部分将显示帖子的文本和图片(如果帖子包含图片)。在内容部分显示这些详情的代码将如下所示。

mern-social/client/post/Post.js:

<CardContent className={classes.cardContent}>
  <Typography component="p" className={classes.text}> 
    {props.post.text} 
  </Typography>
  {props.post.photo && 
    (<div className={classes.photo}>
       <img className={classes.media}
            src={'/api/posts/photo/'+ props.post._id}/>
    </div>)
  }
</CardContent>

如果给定的帖子包含照片,将通过在img标签的src属性中添加照片 API 来加载图片。紧随此内容部分之后是行动部分。

行动

行动部分将包含一个交互式的“点赞”选项,显示帖子的总点赞数,以及一个评论图标,显示帖子的总评论数。显示这些行动的代码将如下所示。

mern-social/client/post/Post.js:

<CardActions>
   { values.like
       ? <IconButton onClick={clickLike} className={classes.button} 
                     aria-label="Like" color="secondary">
            <FavoriteIcon />
         </IconButton>
       : <IconButton onClick={clickLike} className={classes.button} 
                     aria-label="Unlike" color="secondary">
            <FavoriteBorderIcon />
         </IconButton> } <span>{values.likes}</span>
         <IconButton className={classes.button} 
                     aria-label="Comment" color="secondary">
            <CommentIcon/>
         </IconButton> <span>{values.comments.length}</span>
</CardActions>

我们将在本章后面讨论“点赞”按钮的实现。每个帖子的点赞详情是通过接收在 props 中的post对象检索的。

Post组件中,最后一部分将显示在给定帖子上的评论。我们将在下一节讨论这个问题。

评论

评论部分将包含Comments组件中的所有与评论相关的元素,并将获取props,如postIdcomments数据,以及一个更新状态的方法,该方法可以在Comments组件中添加或删除评论时调用。

评论部分将通过以下代码在视图中渲染。

mern-social/client/post/Post.js:

<Comments postId={props.post._id} 
         comments={values.comments} 
          updateComments={updateComments}/>

这个Comments组件的实现将在本章后面详细说明。这四个部分构成了我们在Post组件中实现的单个帖子视图,该组件在PostList组件中渲染。每个帖子的标题还显示了创建者的删除按钮。我们将在下一节实现这个删除帖子功能。

删除帖子

如果已登录用户和特定帖子的postedBy用户相同,则delete按钮才会可见。为了从数据库中删除帖子,我们将在后端设置一个删除帖子 API,该 API 在前端也将有一个在点击delete时应用的方法。删除帖子 API 端点的路由将如下所示。

mern-social/server/routes/post.routes.js:

router.route('/api/posts/:postId')
      .delete(authCtrl.requireSignin, 
                postCtrl.isPoster, 
                  postCtrl.remove)

删除路由将在调用帖子上的remove之前检查授权,确保认证用户和postedBy用户是同一用户。以下代码中实现的isPoster方法在执行next方法之前检查已登录用户是否是帖子的原始创建者。

mern-social/server/controllers/post.controller.js:

const isPoster = (req, res, next) => {
  let isPoster = req.post && req.auth &&
  req.post.postedBy._id == req.auth._id
  if(!isPoster){
    return res.status('403').json({
      error: "User is not authorized"
    })
  }
  next()
}

删除 API 的其余实现(包括 remove 控制器方法和前端 fetch 方法)与其他 API 实现相同。这里的重要区别在于,在删除帖子功能中,当删除成功时,会调用 Post 组件中的 onRemove 更新方法。onRemove 方法作为 prop 从 NewsfeedProfile 发送,以在删除成功时更新状态中的帖子列表。

当在帖子中点击 delete 按钮时,将调用定义在 Post 组件中的以下 deletePost 方法。

mern-social/client/post/Post.js:

const deletePost = () => { 
    remove({
      postId: props.post._id
    }, {
      t: jwt.token
    }).then((data) => {
      if (data.error) {
        console.log(data.error)
      } else {
        props.onRemove(props.post)
      }
    })
}

此方法向删除帖子 API 发起 fetch 调用,并在成功后,通过执行从父组件接收的 onRemove 方法来更新状态中的帖子列表。

这部分完成了后端和前端中 Post CRUD 特性的实现。然而,我们还没有完成允许 MERN Social 用户与这些帖子互动的功能。在下一节中,我们将添加点赞帖子以及评论帖子的功能。

与帖子互动

任何社交媒体平台的核心功能都是用户能够与共享内容互动。对于在 MERN Social 应用程序中创建的帖子,我们将添加点赞和为单个帖子留下评论的选项。

要完成此功能的实现,首先,我们必须修改后端,以便我们可以添加更新现有帖子详情(包括点赞该帖子的用户详情和评论详情)的 API 端点。

然后,在前端,我们必须修改 UI,以便用户可以在帖子上点赞和留下评论。

点赞

Post 组件动作栏部分的点赞选项将允许用户点赞或取消点赞帖子,并显示帖子的总点赞数。为了记录“点赞”,我们必须设置点赞和取消点赞 API,以便在用户与渲染在每个帖子中的动作栏交互时在视图中调用。

点赞 API

点赞 API 将是一个 PUT 请求,它将更新 Post 文档中的 likes 数组。请求将在定义如下 api/posts/like 路由处接收。

mern-social/server/routes/post.routes.js:

  router.route('/api/posts/like')
    .put(authCtrl.requireSignin, postCtrl.like)

like 控制器方法中,请求体中接收到的帖子 ID 将用于查找特定的 Post 文档,并通过将当前用户的 ID 推送到 likes 数组来更新它,如下面的代码所示。

mern-social/server/controllers/post.controller.js:

const like = async (req, res) => {
  try {
    let result = await Post.findByIdAndUpdate(req.body.postId, 
                                {$push: {likes: req.body.userId}}, 
                                {new: true})
    res.json(result)
  } catch(err) {
      return res.status(400).json({
        error: errorHandler.getErrorMessage(err)
      })
  }
}

要使用此 API,将在 api-post.js 中添加一个名为 like 的 fetch 方法,该方法将在用户点击 like 按钮时使用。like fetch 的定义如下。

mern-social/client/post/api-post.js:

const like = async (params, credentials, postId) => {
  try {
    let response = await fetch('/api/posts/like/', {
      method: 'PUT',
      headers: {
        'Accept': 'application/json',
        'Content-Type': 'application/json',
        'Authorization': 'Bearer ' + credentials.t
      },
      body: JSON.stringify({userId:params.userId, postId: postId})
    })
    return await response.json()
  } catch(err) {
    console.log(err)
  }
}

类似地,在下一节中,我们还将实现一个取消点赞 API 端点,以便用户可以取消之前点赞的帖子。

取消点赞 API

unlike API 将类似于like API 实现,有自己的路由。这将被声明如下。

mern-social/server/routes/post.routes.js:

  router.route('/api/posts/unlike')
    .put(authCtrl.requireSignin, postCtrl.unlike)

控制器中的unlike方法将通过其 ID 查找帖子,并通过使用$pull而不是$push来移除当前用户的 ID 来更新likes数组。unlike控制器方法将如下所示。

mern-social/server/controllers/post.controller.js:

const unlike = async (req, res) => {
  try {
    let result = await Post.findByIdAndUpdate(req.body.postId, 
                                {$pull: {likes: req.body.userId}}, 
                                {new: true})
    res.json(result)
  } catch(err) {
    return res.status(400).json({
      error: errorHandler.getErrorMessage(err)
    })
  }
}

unlike API 也将有一个相应的 fetch 方法,类似于api-post.js中的like方法。

这些 API 将在用户与视图中的点赞按钮交互时被调用。但是,首先需要确定点赞按钮是否应该允许点赞或取消点赞操作。我们将在下一节中探讨这个问题。

检查帖子是否被点赞和统计点赞数

Post组件被渲染时,我们需要检查当前登录的用户是否已经点赞了这篇帖子,以便显示适当的like选项。下面的checkLike方法检查当前登录的用户是否在帖子的likes数组中被引用。

mern-social/client/post/Post.js:

const checkLike = (likes) => {
  let match = likes.indexOf(jwt.user._id) !== -1
  return match
}

这个checkLike函数可以在设置like状态变量的初始值时被调用,该变量跟踪当前用户是否喜欢了给定的帖子。以下截图显示了帖子没有被点赞和当前用户点赞时的点赞按钮的渲染方式:

使用checkLike方法在状态中设置的like值可以用来渲染心形轮廓按钮或完整的心形按钮。如果用户没有点赞帖子,将渲染心形轮廓按钮;点击它将调用like API,显示完整的心形按钮,并增加likes计数。完整的心形按钮将表示当前用户已经点赞了这篇帖子;点击这个按钮将调用unlike API,渲染心形轮廓按钮,并减少likes计数。

Post组件挂载并接收到属性时,likes计数也会通过将likes值设置为状态的props.post.likes.length来初始化,如下面的代码所示。

mern-social/client/post/Post.js:

 const [values, setValues] = useState({
    like: checkLike(props.post.likes),
    likes: props.post.likes.length,
    comments: props.post.comments
  })

当发生“点赞”或“取消点赞”操作时,与点赞相关的值会再次更新,并且更新后的帖子数据会从 API 调用中返回。接下来,我们将看看如何处理点赞按钮上的点击。

处理点赞点击

为了处理likeunlike按钮的点击,我们将设置一个clickLike方法,该方法将根据是“点赞”还是“取消点赞”操作调用适当的 fetch 方法,然后更新帖子的likelikes计数的状态。这个clickLike方法将定义如下。

mern-social/client/post/Post.js:

  const clickLike = () => {
    let callApi = values.like ? unlike : like
    const jwt = auth.isAuthenticated()
    callApi({
      userId: jwt.user._id
    }, {
      t: jwt.token
    }, props.post._id).then((data) => {
      if (data.error) {
        console.log(data.error)
      } else {
        setValues({...values, like: !values.like, 
 likes: data.likes.length})
      }
    })
  }

在点击时将调用哪个点赞或取消点赞 API 端点取决于状态中like变量的值。一旦成功调用所选 API 端点,状态中的值将更新,以便它们可以在视图中反映出来。

这完成了点赞功能的实现,包括与前端集成的后端 API,以实现对特定帖子的点赞和取消点赞。接下来,我们将添加评论功能,以完成我们为 MERN Social 设定的社交媒体应用程序功能。

评论

每个帖子中的评论部分将允许已登录用户添加评论、查看评论列表以及删除自己的评论。对评论列表的任何更改,如新增或删除,都将更新评论以及Post组件动作栏部分的评论计数。以下截图显示了结果评论部分:

要实现一个功能性的评论部分,我们将更新后端以包含相应的评论和取消评论 API 端点,并创建此Comments组件以便与后端更新集成。

添加评论

当用户添加评论时,数据库中的Post文档将使用新的评论进行更新。首先,我们需要实现一个 API,该 API 从客户端接收评论详情并更新Post文档。然后,我们需要在前端创建 UI,使我们能够编写新的评论并将其提交到后端 API。

评论 API

要实现添加评论 API,我们将设置如下PUT路由来更新帖子。

mern-social/server/routes/post.routes.js:

router.route('/api/posts/comment')
    .put(authCtrl.requireSignin, postCtrl.comment)

在以下代码中定义的comment控制器方法将根据其 ID 找到要更新的相关帖子,并将请求体中接收到的评论对象推送到帖子的comments数组中。

mern-social/server/controllers/post.controller.js:

const comment = async (req, res) => {
  let comment = req.body.comment
  comment.postedBy = req.body.userId
  try {
    let result = await Post.findByIdAndUpdate(req.body.postId, 
                                   {$push: {comments: comment}}, 
                                   {new: true})
                            .populate('comments.postedBy', '_id name')
                            .populate('postedBy', '_id name')
                            .exec()
    res.json(result)
  } catch(err) {
    return res.status(400).json({
      error: errorHandler.getErrorMessage(err)
    })
  }
}

在响应中,将发送回更新后的帖子对象,其中包含在帖子和评论中填充的postedBy用户的详细信息。

要在视图中使用此 API,我们将在api-post.js中设置一个获取方法,该方法接受当前用户的 ID、帖子 ID 和视图中的comment对象,并将其与添加评论请求一起发送。评论获取方法如下所示。

mern-social/client/post/api-post.js:

const comment = async (params, credentials, postId, comment) => {
  try {
    let response = await fetch('/api/posts/comment/', {
      method: 'PUT',
      headers: {
        'Accept': 'application/json',
        'Content-Type': 'application/json',
        'Authorization': 'Bearer ' + credentials.t
      },
      body: JSON.stringify({userId:params.userId, postId: postId, 
                            comment: comment})
    })
    return await response.json()
  } catch(err) {
    console.log(err)
  }
}

我们可以在用户提交新评论时在 UI 中使用此获取方法,正如下一节所讨论的。

在视图中编写内容

Comments组件中的“添加评论”部分将允许已登录用户输入评论文本:

这将包含显示用户照片的头像和一个文本字段,当用户按下Enter键时将添加评论。此添加评论部分将以以下代码在视图中渲染。

mern-social/client/post/Comments.js:

<CardHeader
    avatar={
            <Avatar className={classes.smallAvatar} 
               src=  {'/api/users/photo/'
                  +auth.isAuthenticated().user._id}/>
           }
    title={ <TextField
                onKeyDown={addComment}
                multiline
                value={text}
                onChange={handleChange}
                placeholder="Write something ..."
                className={classes.commentField}
                margin="normal"
                />
          }
              className={classes.cardHeader}
/>

文本将在值更改时存储在状态中,在 onKeyDown 事件中,如果按下 Enter 键,addComment 方法将调用 comment fetch 方法。Enter 键对应于 keyCode 13,如下面的代码所示。

mern-social/client/post/Comments.js:

const addComment = (event) => {
    if(event.keyCode == 13 && event.target.value){
      event.preventDefault()
      comment({
        userId: jwt.user._id
      }, {
        t: jwt.token
      }, props.postId, {text: text}).then((data) => {
        if (data.error) {
          console.log(data.error)
        } else {
          setText('')
          props.updateComments(data.comments)
        }
      })
    }
}

Comments 组件从 Post 组件接收 updateComments 方法(如前文所述)作为属性。这将在新评论添加时执行,以更新评论列表和 Post 视图中的评论计数。Comments 中列出所有评论的部分将在下一节中添加。

列出评论

Comments 组件从 Post 组件接收特定帖子的评论列表作为属性。然后,它遍历单个评论以渲染评论者的细节和评论内容。此视图的实现代码如下。

mern-social/client/post/Comments.js:

{ props.comments.map((item, i) => {
            return <CardHeader
                      avatar={
                        <Avatar className={classes.smallAvatar} 
                          src={'/api/users/photo/'+item.postedBy._id}/>
                      }
                      title={commentBody(item)}
                      className={classes.cardHeader}
                      key={i}/>
            })
}

commentBody 渲染内容,包括评论者的名字(链接到其个人资料)、评论文本和评论创建日期。commentBody 定义如下。

mern-social/client/post/Comments.js:

const commentBody = item => {
      return (
        <p className={classes.commentText}>
          <Link to={"/user/" + item.postedBy._id}>
              {item.postedBy.name} </Link><br/>
          {item.text}
          <span className={classes.commentDate}>
            { (new Date(item.created)).toDateString()} |
            { auth.isAuthenticated().user._id === item.postedBy._id &&
              <Icon onClick={deleteComment(item)} 
                    className={classes.commentDelete}>delete</Icon> }
          </span>
        </p>
      )
}

如果评论的 postedBy 引用与当前登录用户匹配,commentBody 将为该评论渲染一个删除选项。我们将在下一节中查看此评论删除选项的实现。

删除评论

点击评论中的删除按钮将通过从相应帖子的 comments 数组中删除评论来更新数据库中的帖子。删除按钮可以在以下屏幕截图所示的评论下方看到:

要实现此删除评论功能,我们需要在后端添加一个 uncomment API,然后在前端使用它。

不评论 API

我们将在以下 PUT 路由中实现 uncomment API。

mern-social/server/routes/post.routes.js:

router.route('/api/posts/uncomment')
    .put(authCtrl.requireSignin, postCtrl.uncomment)

uncomment 控制器方法将通过 ID 查找相关帖子,并从帖子的 comments 数组中拉取具有被删除评论 ID 的评论,如下面的代码所示。

mern-social/server/controllers/post.controller.js:

const uncomment = async (req, res) => {
  let comment = req.body.comment
  try{
    let result = await Post.findByIdAndUpdate(req.body.postId, 
                                  {$pull: {comments: {_id: comment._id}}},  
                                  {new: true})
                          .populate('comments.postedBy', '_id name')
                          .populate('postedBy', '_id name')
                          .exec()
    res.json(result)
  } catch(err) {
    return res.status(400).json({
      error: errorHandler.getErrorMessage(err)
    })
  }
}

响应中将返回更新后的帖子,类似于评论 API。

要在视图中使用此 API,我们还需要在 api-post.js 中设置一个 fetch 方法,类似于 addComment fetch 方法,它接受当前用户的 ID、帖子 ID 和要随 uncomment 请求一起发送的已删除 comment 对象。接下来,我们将学习如何在点击删除按钮时使用此 fetch 方法。

从视图中删除评论

当评论者点击评论的删除按钮时,Comments 组件将调用 deleteComment 方法来获取 uncomment API 并更新评论,以及评论计数,当评论成功从服务器移除时。deleteComment 方法定义如下。

mern-social/client/post/Comments.js:

const deleteComment = comment => event => {
    uncomment({
      userId: jwt.user._id
    }, {
      t: jwt.token
    }, props.postId, comment).then((data) => {
      if (data.error) {
        console.log(data.error)
      } else {
        props.updateComments(data.comments)
      }
    })
  }

在成功从后端移除评论后,从 Post 组件的 props 中发送的 updateComments 方法将被调用。这将更新 Post 组件的状态以更新视图。这将在下一节中讨论。

评论计数更新

updateComments 方法,它将允许在添加或删除评论时更新 comments 和评论计数,定义在 Post 组件中,并作为 prop 传递给 Comments 组件。updateComments 方法将如下所示:

mern-social/client/post/Post.js:

const updateComments = (comments) => {
    setValues({...values, comments: comments})
}

此方法接受更新的评论列表作为参数,并更新包含在视图中渲染的评论列表的状态。Post 组件中的评论初始状态是在组件挂载并接收帖子数据作为 props 时设置的。这里设置的评论作为 props 发送到 Comments 组件,并用于在帖子布局的动作栏中渲染点赞动作旁边的评论计数,如下所示。

mern-social/client/post/Post.js:

<IconButton aria-label="Comment" color="secondary">
  <CommentIcon/>
</IconButton> <span>{values.comments.length}</span>

Post 组件中的评论计数与在 Comments 组件中渲染和更新的评论之间的关系,简单展示了如何在 React 中通过嵌套组件之间共享数据变化来创建动态和交互式的用户界面。

MERN 社交应用现在包含了我们之前为该应用定义的功能集。用户可以使用照片和描述更新他们的个人资料,在应用中相互关注,并创建带有照片和文本的帖子,以及点赞和评论帖子。这里展示的实现可以被调整和进一步扩展,以添加更多功能,以便利用与 MERN 栈一起工作的揭示机制。

摘要

本章中我们开发的 MERN 社交应用展示了如何将 MERN 栈技术结合使用,以构建一个功能齐全且功能正常的具有社交媒体功能的完整 Web 应用程序。

我们首先更新了骨架应用中的用户功能,允许任何在 MERN 社交上有账户的人添加关于自己的描述,以及从本地文件上传个人照片。在上传个人照片的实现中,我们探讨了如何从客户端上传多部分表单数据,然后在服务器上接收它,直接将文件数据存储在 MongoDB 数据库中,然后能够检索它以供查看。

接下来,我们进一步更新了用户功能,允许用户在 MERN 社交平台上相互关注。在用户模型中,我们增加了维护用户引用数组的能力,以表示每个用户的关注者和被关注者列表。扩展这一能力,我们在视图中加入了关注和取消关注选项,并显示了关注者、被关注者以及尚未关注的用户列表。

然后,我们增加了允许用户发布内容并通过点赞或评论与内容互动的功能。在后台,我们设置了帖子模型和相应的 API,这些 API 能够存储可能包含或不包含图片的帖子内容,以及维护任何用户对帖子产生的点赞和评论记录。

最后,在实现发布、点赞和评论功能的视图时,我们探讨了如何使用组件组合以及如何在组件之间共享变化的状态值来创建复杂和交互式的视图。

通过完成这个 MERN 社交应用程序的实现,我们学习了如何扩展和修改基本应用程序代码,使其根据我们所需的功能发展成为完整的网络应用程序。您可以将类似的策略应用于将基本应用程序扩展为任何您选择的现实世界应用程序。

在下一章中,我们将进一步扩展 MERN 堆栈中的这些功能,并在开发在线教室应用程序时通过扩展基本应用程序来解锁新的可能性。

第八章:使用 MERN 开发 Web 应用程序

在本部分,我们使用上一节中的 MERN 框架应用程序开发两个不同的 Web 应用程序,展示了如何实现和添加从基本到复杂的功能,以及这些功能如何添加到不断增长的 MERN 应用程序中。

本节包含以下章节:

  • 第六章,构建基于 Web 的课堂应用

  • 第七章,使用在线市场练习 MERN 技能

  • 第八章,扩展市场以支持订单和支付

  • 第九章,向市场添加实时竞价功能

第九章:构建基于 Web 的教室应用程序

随着世界向互联网发展,我们在不同学科领域学习和获取知识的方式也在改变。目前,在网络上,有大量的在线平台为教师和学生提供了远程教学和学习的选项,无需在教室中物理上聚集。

在本章中,我们将通过扩展 MERN 堆栈骨架应用程序来构建一个简单的在线教室应用程序。这个教室应用程序将支持多个用户角色,课程内容和课程的添加,学生报名,进度跟踪和课程报名统计。在构建这个应用程序的过程中,我们将揭示这个堆栈的更多功能,例如如何实现基于角色的资源访问和操作,如何组合多个模式,以及如何运行不同的查询操作以收集统计数据。到本章结束时,您将熟悉在基于 MERN 的任何应用程序中轻松集成新全栈功能的所需技术。

我们将在本章中涵盖以下主题,以构建在线教室应用程序:

  • 介绍 MERN 教室

  • 为用户添加教育者角色

  • 将课程添加到教室

  • 更新课程中的课程

  • 发布课程

  • 报名参加课程

  • 跟踪进度和报名统计

介绍 MERN 教室

MERN 教室是一个简单的在线教室应用程序,允许教育者添加由各种课程组成的课程,而学生可以报名参加这些课程。此外,该应用程序将允许学生跟踪他们在课程中的进度,而讲师可以监控有多少学生报名参加了某个课程,以及有多少学生完成了每个课程。包含所有这些功能的完成应用程序将最终拥有以下截图所示的首页:

图片

完整的 MERN 教室应用程序的代码可在 GitHub 上的github.com/PacktPublishing/Full-Stack-React-Projects-Second-Edition/tree/master/Chapter06/mern-classroom仓库中找到。您可以将此代码克隆并运行,以在阅读本章其余部分的代码解释时运行应用程序。

MERN 教室应用程序所需的视图将通过扩展和修改 MERN 骨架应用程序中的现有 React 组件来开发。以下图表中的组件树展示了构成 MERN 教室前端的所有自定义 React 组件,并揭示了我们将用于构建本章其余部分视图的组成结构:

图片

我们将添加与课程、课程和注册相关的新 React 组件;我们还将修改现有的组件,如 EditProfile、Menu 和 Home 组件,正如我们在本章的其余部分构建 MERN Classroom 应用程序的不同功能时一样。在 Classroom 应用程序中的大多数这些功能都将取决于用户成为教育者的能力。在下一节中,我们将通过更新用户来开始实现 MERN Classroom 应用程序,以便他们可以选择教育者角色。

更新用户的教育者角色

在 MERN Classroom 应用程序注册的用户将有机会通过在EditProfile表单组件中选择此选项来成为平台上的教育者。此选项在表单中的外观如下——显示当用户不是教育者时,以及当他们选择成为教育者时:

当用户选择成为教育者时,与普通用户相比,他们将被允许创建和管理自己的课程。如下面的截图所示,MERN Classroom 将为教育者显示导航菜单中的 TEACH 选项,即它不会显示给普通用户:

在接下来的几节中,我们将通过首先更新用户模型,然后是EditProfile视图,最后是将仅对教育者可见的 TEACH 链接添加到菜单中,来添加此教育者功能。

向用户模型添加角色

MERN 骨架应用程序中的现有用户模型需要一个表示教育者的值,默认设置为false以表示普通用户,但可以设置为true以表示同时也是教育者的用户。为了将此新字段添加到用户模式中,我们将添加以下代码。

mern-classroom/server/models/user.model.js:

educator: {
    type: Boolean,
    default: false
}

educator值必须发送到前端,一旦用户成功登录,就会收到用户详情,以便视图可以根据显示与教育者相关的信息相应地渲染。为了进行此更改,我们需要更新在signin控制器方法中发送回的响应,如下面的代码所示:

mern-classroom/server/controllers/auth.controller.js

...
l
      token,
      user: {
        _id: user._id,
        name: user.name,
        email: user.email,
        educator: user.educator
      }
    })
...
} 

通过在响应中发送此educator字段值,我们可以根据角色特定的授权考虑因素渲染前端视图。

在到达这些条件渲染视图之前,我们首先需要在EditProfile视图中实现选择教育者角色的选项,正如下一节所讨论的。

更新 EditProfile 视图

要成为 MERN Classroom 应用程序中的教育者,已登录的用户需要更新他们的个人资料。他们将在编辑个人资料视图中看到一个切换按钮,该按钮可以激活或停用教育者功能。为了实现这一点,首先,我们将更新EditProfile组件,以便在FormControlLabel中添加一个 Material-UI Switch组件,如下面的代码所示。

mern-classroom/client/user/EditProfile.js:

<Typography variant="subtitle1" className={classes.subheading}>
     I am an Educator
</Typography>
<FormControlLabel
     control={
             <Switch classes={{
                                checked: classes.checked,
                                bar: classes.bar,
                              }}
                      checked={values.educator}
                      onChange={handleCheck}
             />}
     label={values.educator? 'Yes' : 'No'}
/>

任何对开关的更改都将通过调用以下代码中定义的handleCheck方法,将值设置为状态中的educator变量。

mern-classroom/client/user/EditProfile.js:

const handleCheck = (event, checked) => {
   setValues({...values, 'educator': checked})
} 

handleCheck方法接收一个表示开关是否被选中的checked布尔值,并将此值设置为educator

在表单提交时,educator值被添加到发送到服务器的更新详细信息中,如下面的代码所示。

mern-classroom/client/user/EditProfile.js:

clickSubmit = () => {
    const jwt = auth.isAuthenticated() 
    const user = {
      name: this.state.name || undefined,
      email: this.state.email || undefined,
      password: this.state.password || undefined,
      educator: values.educator || undefined
    }
    update({
      userId: this.match.params.userId
    }, {
      t: jwt.token
    }, user).then((data) => {
      if (data.error) {
        this.setState({error: data.error})
      } else {
        auth.updateUser(data, ()=> {
 setValues({...values, userId: data._id, redirectToProfile: true})
 })
      }
    })
  }

一旦成功更新了编辑个人资料视图,存储在sessionStorage中以供身份验证目的的用户详细信息也应更新。通过调用auth.updateUser方法来完成此sessionStorage更新。它与其他auth-helper.js方法一起定义,并传递更新的用户数据和更新视图的回调函数。此updateUser方法定义如下。

mern-classroom/client/auth/auth-helper.js:

updateUser(user, cb) {
  if(typeof window !== "undefined"){
    if(sessionStorage.getItem('jwt')){
       let auth = JSON.parse(sessionStorage.getItem('jwt'))
       auth.user = user
       sessionStorage.setItem('jwt', JSON.stringify(auth))
       cb()
     }
  }
}

一旦更新的教育者角色在前端可用,我们就可以用它来相应地渲染前端。在下一节中,我们将看到如何根据教育者或普通用户查看应用程序来不同地渲染菜单。

渲染一个教学选项

在教室应用程序的前端,我们可以根据教育者是否正在浏览应用程序来渲染不同的选项。在本节中,我们将添加代码以有条件地显示导航栏上的TEACH链接,该链接仅对已登录且也是教育者的用户可见。

我们将在之前仅对已登录用户渲染的代码中更新Menu组件,如下所示。

mern-classroom/client/core/Menu.js:

{auth.isAuthenticated() && (<span>
    {auth.isAuthenticated().user.educator && 
       (<Link to="/teach/courses">
           <Button style={isPartActive(history, "/teach/")}>
               <Library/> Teach </Button>
        </Link>)
    }
    ...
}

这个链接仅对教育者可见,将他们带到教育者仪表板视图,在那里他们可以管理他们正在教授的课程。

本节教会了我们如何在应用程序中将用户角色更新为教育者角色,现在我们可以开始集成允许教育者向教室添加课程的功能。

向教室添加课程

MERN Classroom 中的教育工作者可以创建课程并为每个课程添加课程。在本节中,我们将介绍与课程相关的功能实现,例如添加新课程、按特定讲师列出课程以及显示单个课程的详细信息。为了存储课程数据并启用课程管理,我们首先将实现一个用于课程的 Mongoose 模式,然后是创建和列出课程的后端 API,以及为授权的教育工作者和与应用程序中的课程交互的普通用户的前端视图。

定义课程模型

定义课程模式——在server/models/course.model.js中定义——将具有简单的字段来存储课程详情,包括图片、类别、课程是否已发布以及创建课程的用户的引用。定义课程字段的代码如下,并附有说明:

  • 课程名称和描述namedescription字段将具有字符串类型,其中name为必填字段:
name: { 
    type: String, 
    trim: true, 
    required: 'Name is required' 
},
description: { 
    type: String, 
    trim: true 
},
  • 课程图片image字段将存储用户上传的课程图片文件,以二进制数据形式存储在 MongoDB 数据库中:
image: { 
    data: Buffer, 
    contentType: String 
},
  • 课程类别category字段将存储课程类别值作为字符串,并且它是一个必填字段:
category: {
  type: String,
  required: 'Category is required'
},
  • 课程发布状态published字段将是一个布尔值,表示课程是否已发布:
published: {
  type: Boolean,
  default: false
},
  • 课程讲师instructor字段将引用创建课程的用户:
instructor: {
    type: mongoose.Schema.ObjectId, 
    ref: 'User'
}
  • 创建和更新时间createdupdated字段将具有Date类型,其中created在添加新课程时生成,而updated在修改任何课程详细信息时更改:
updated: Date,
created: { 
    type: Date, 
    default: Date.now 
},

此模式定义中的字段将使我们能够在 MERN Classroom 中实现与课程相关的功能。为了开始这些功能,在下一节中,我们将实现一个全栈切片,允许教育工作者创建新课程。

创建新课程

在 MERN Classroom 中,一个已登录的用户——同时也是教育工作者——将能够创建新课程。为了实现此功能,在以下章节中,我们将添加一个创建课程 API 到后台,以及在前端获取此 API 的方法,以及一个创建新课程表单视图,该视图接受用户输入的课程字段。

创建课程 API

为了在后台开始创建课程 API 的实现,我们将添加一个POST路由,该路由验证当前用户是否为教育工作者,然后使用请求体中传递的课程数据创建一个新的课程。该路由定义如下:

mern-classroom/server/routes/course.routes.js

router.route('/api/courses/by/:userId')
  .post(authCtrl.requireSignin, authCtrl.hasAuthorization, 
         userCtrl.isEducator, 
          courseCtrl.create)

course.routes.js文件将与user.routes文件非常相似,为了将这些新路由加载到 Express 应用程序中,我们需要在express.js中挂载课程路由,就像我们为 auth 和用户路由所做的那样,如下面的代码所示:

mern-classroom/server/express.js

app.use('/', courseRoutes)

接下来,我们将更新用户控制器以在创建新课程之前添加isEducator方法——这将确保当前用户实际上是一名教育工作者。isEducator方法定义如下:

mern-classroom/server/controllers/user.controller.js:

const isEducator = (req, res, next) => {
  const isEducator = req.profile && req.profile.educator
  if (!isEducator) {
     return res.status('403').json({
        error: "User is not an educator"
     })
  }
  next()
}

课程控制器中的create方法使用formidableNode 模块来解析可能包含用户上传的课程图片的文件的多部分请求。如果有文件,formidable将暂时将其存储在文件系统中,然后我们使用fs模块读取它以检索文件类型和数据,然后将其存储在课程文档的image字段中。create控制器方法将如下所示:

mern-classroom/server/controllers/course.controller.js:

const create = (req, res) => {
  let form = new formidable.IncomingForm()
  form.keepExtensions = true
  form.parse(req, async (err, fields, files) => {
    if (err) {
      return res.status(400).json({
        error: "Image could not be uploaded"
      })
    }
    let course = new Course(fields)
    course.instructor= req.profile
    if(files.image){
      course.image.data = fs.readFileSync(files.image.path)
      course.image.contentType = files.image.type
    }
    try {
      let result = await course.save()
      res.json(result)
    }catch (err){
      return res.status(400).json({
        error: errorHandler.getErrorMessage(err)
      })
    }
  })
}

如果用户上传了课程图片,该图片文件将作为数据存储在 MongoDB 中。然后,为了在视图中显示,它将从数据库中作为单独的GETAPI 路由中的图片文件检索。GETAPI 被设置为 Express 路由在/api/courses/photo/:courseId,它从 MongoDB 获取图像数据并将其作为文件发送响应。文件上传、存储和检索的实现步骤在第五章“从简单的社交媒体应用程序开始”的上传个人照片部分中详细说明。

服务器上的创建课程 API 端点准备就绪后,接下来,我们可以在前端添加一个fetch方法来利用它。

在视图中获取创建 API

为了在前端使用创建 API,我们将在客户端设置一个fetch方法,通过传递多部分表单数据,向创建 API 发送POST请求,如下所示:

mern-classroom/client/course/api-course.js

const create = async (params, credentials, course) => {
    try {
        let response = await fetch('/api/courses/by/'+ params.userId, {
          method: 'POST',
          headers: {
            'Accept': 'application/json',
            'Authorization': 'Bearer ' + credentials.t
          },
          body: course
        })
          return response.json()
        } catch(err) { 
          console.log(err)
        }
}

此方法将在新课程表单视图中用于将用户输入的课程详细信息提交到后端以在数据库中创建新课程。在下一节中,我们将实现这个新课程表单视图的 React 组件。

新课程组件

为了让教育工作者能够创建新的课程,我们将在应用程序的前端添加一个包含表单的 React 组件。这个表单视图将看起来如下所示:

表单将包含一个上传课程图片的选项,输入字段用于输入课程名称、描述和类别;以及提交按钮,该按钮将保存已输入的详细信息到数据库中。

我们将定义NewCourseReact 组件来实现这个表单。如下所示,我们首先使用useState钩子初始化状态;使用空输入字段值、空错误消息和一个初始化为falseredirect变量。

mern-classroom/client/course/NewCourse.js:

export default function NewCourse() {
  ...  
  const [values, setValues] = useState({
    name: '',
    description: '',
    image: '',
    category: '',
    redirect: false,
    error: ''
  })
  ...
}

在表单视图中,我们首先给用户一个上传课程图片文件的选项。为了呈现这个选项,我们将在NewCourse的返回函数中使用 Material-UI 按钮和 HTML5 文件输入元素添加文件上传元素,如下面的代码所示。

mern-classroom/client/course/NewCourse.js:

<input accept="image/*" onChange={handleChange('image')} 
                        type="file" style={display:'none'} />
<label htmlFor="icon-button-file">
     <Button variant="contained" color="secondary" component="span">
         Upload Photo <FileUpload/>
     </Button>
</label> 
<span>{values.image ? values.image.name : ''}</span>

然后,我们使用 Material-UI 的TextField组件添加namedescriptioncategory表单字段。

mern-classroom/client/course/NewCourse.js:

<TextField 
    id="name" 
    label="Name" 
    value={values.name} onChange={handleChange('name')}/> <br/>
<TextField
    id="multiline-flexible"
    label="Description"
    multiline
    rows="2"
    value={values.description}
    onChange={handleChange('description')}/> <br/>
<TextField 
    id="category" 
    label="Category" 
    value={values.category} 
    onChange={handleChange('category')}/> 

我们将在NewCourse中定义一个处理函数,以便我们可以跟踪表单视图中这些字段的变化。handleChange函数将定义如下:

mern-classroom/client/course/NewCourse.js

const handleChange = name => event => {
    const value = name === 'image'
      ? event.target.files[0]
      : event.target.value
    setValues({...values, [name]: value })
}

这个handleChange函数接受输入字段中输入的新值,并将其设置为状态,包括如果用户上传了文件,则包括文件名。

最后,在视图中,你可以添加提交按钮,当点击时,应该调用一个点击处理函数。我们将在NewCourse中定义一个函数来完成这个目的,如下所示。

mern-classroom/client/course/NewCourse.js:

const clickSubmit = () => {
    let courseData = new FormData()
    values.name && courseData.append('name', values.name)
    values.description && courseData.append('description',
       values.description)
    values.image && courseData.append('image', values.image)
    values.category && courseData.append('category', values.category)
    create({
      userId: jwt.user._id
    }, {
      t: jwt.token
    }, courseData).then((data) => {
      if (data.error) {
        setValues({...values, error: data.error})
      } else {
        setValues({...values, error: '', redirect: true})
      }
    })
}

当表单提交时,将调用此clickSubmit函数。它首先从状态中获取输入值并将其设置为FormData对象。这确保了数据以正确的格式存储,这是发送包含文件上传请求所需的multipart/form-data编码类型。然后,调用createfetch 方法在后端创建一个新的课程。最后,根据服务器的响应,要么显示错误消息,要么使用以下代码将用户重定向到MyCourses视图。

mern-classroom/client/course/NewCourse.js:

if (values.redirect) {
      return (<Redirect to={'/teach/courses'}/>)
}

NewCourse组件只能由已登录的也是讲师的用户查看。因此,我们将向MainRouter组件添加一个PrivateRoute,这将只为授权用户在/teach/course/new渲染此表单。

mern-classroom/client/MainRouter.js:

<PrivateRoute path="/teach/course/new" component={NewCourse}/>

此链接可以添加到任何可能由教育者访问的视图组件中,例如将在下一节中实现的MyCourses视图,以列出由教育者创建的课程。

按教育者列出课程

授权的教育者将能够看到他们在平台上创建的课程列表。为了实现此功能,在以下章节中,我们将添加一个后端 API 来检索特定讲师的课程列表,然后我们将在前端调用此 API 以在 React 组件中渲染这些数据。

列出课程 API

为了实现返回由特定讲师创建的课程列表的 API,首先,我们将在后端添加一个路由来检索当服务器在/api/courses/by/:userId接收GET请求时由给定用户创建的所有课程。此路由将声明如下。

mern-classroom/server/routes/course.routes.js:

router.route('/api/courses/by/:userId')
  .get(authCtrl.requireSignin, 
            authCtrl.hasAuthorization, 
               courseCtrl.listByInstructor)

为了处理路由中的:userId参数并从数据库检索关联的用户,我们将在用户控制器中使用userByID方法。我们将在course.routes.js中的课程路由中添加以下代码,以便用户在request对象中作为profile可用。

mern-classroom/server/routes/course.routes.js:

router.param('userId', userCtrl.userByID) 

course.controller.js中的listByInstructor控制器方法将查询数据库中的Course集合以获取匹配的课程,如下所示。

mern-classroom/server/controllers/course.controller.js:

const listByInstructor = (req, res) => {
  Course.find({instructor: req.profile._id}, (err, courses) => {
    if (err) {
      return res.status(400).json({
        error: errorHandler.getErrorMessage(err)
      })
    }
    res.json(courses)
  }).populate('instructor', '_id name')
}

在查询课程集合时,我们找到所有具有与用户指定的userId参数匹配的instructor字段的课程。然后,将这些课程作为响应发送给客户端。在下一节中,我们将看到如何从前端调用此 API。

在视图中获取列表 API

为了在前端使用列表 API,我们将定义一个fetch方法,该方法可以被 React 组件用来加载这些课程列表。需要用来通过特定讲师检索课程列表的fetch方法将定义如下。

mern-classroom/client/course/api-course.js

const listByInstructor = async (params, credentials, signal) => {
    try {
      let response = await fetch('/api/courses/by/'+params.userId, {
        method: 'GET',
        signal: signal,
        headers: {
          'Accept': 'application/json',
          'Authorization': 'Bearer ' + credentials.t
        }
      })
      return response.json()
    } catch(err) {
      console.log(err)
    }
}

listByInstructor方法将获取userId值以生成要调用的 API 路由,并将接收与提供的userId值关联的用户创建的课程列表。在教室应用程序中,我们将在下一节讨论的MyCourses组件中使用此方法。

MyCourses 组件

MyCourses组件中,我们将使用listByInstructor API 从服务器获取数据后,在 Material-UI List中渲染课程列表。如图所示,此组件将作为教育者的仪表板,其中列出他们的课程,并有一个选项添加新课程:

图片

为了实现此组件,我们首先需要获取和渲染课程列表。我们将在useEffect钩子中执行获取 API 调用,并将接收到的课程数组设置在状态中,如下所示。

mern-classroom/client/course/MyCourses.js

export default function MyCourses(){
  const [courses, setCourses] = useState([])
  const [redirectToSignin, setRedirectToSignin] = useState(false)
  const jwt = auth.isAuthenticated()

  useEffect(() => {
    const abortController = new AbortController()
    const signal = abortController.signal
    listByInstructor({
      userId: jwt.user._id
    }, {t: jwt.token}, signal).then((data) => {
      if (data.error) {
        setRedirectToSignin(true)
      } else {
        setCourses(data)
      }
    })
    return function cleanup(){
      abortController.abort()
    }
  }, [])
  if (redirectToSignin) {
    return <Redirect to='/signin'/>
  }
  ...
}

listByInstructor API 被获取时,我们将传递当前登录用户的认证令牌以在服务器端检查授权。用户应该只能看到他们自己的课程,如果当前用户没有权限进行此获取调用,视图将被重定向到登录页面。否则,将返回并显示在视图中的课程列表。

在此MyCourses组件的视图中,我们将通过使用map迭代检索到的课程数组,在视图中渲染每个课程数据,每个ListItem都将链接到单个课程视图,如下所示:

mern-classroom/client/course/MyCourses.js

{courses.map((course, i) => {
   return <Link to={"/teach/course/"+course._id} key={i}>
            <ListItem button>
              <ListItemAvatar>
                <Avatar src={'/api/courses/photo/'+course._id+"?" + 
                                        new Date().getTime()}/>
              </ListItemAvatar>
              <ListItemText primary={course.name} 
                            secondary={course.description}/>
            </ListItem>
            <Divider/>
          </Link>}
       )
}

MyCourses组件只能由已登录且也是教育者的用户查看。因此,我们将在MainRouter组件中添加一个PrivateRoute,这样只有授权用户才能在/seller/courses渲染此组件。

mern-classroom/client/MainRouter.js:

<PrivateRoute path="/seller/courses" component={MyCourses}/>

我们在菜单上的TEACH链接中使用这个前端路由,该链接将已登录的教育者导向此MyCourses视图。在这个视图中,用户可以点击列表中的每一门课程,并转到显示特定课程详情的页面。在下一节中,我们将实现渲染单个课程的功能。

显示课程

MERN Classroom 应用程序的用户,包括访客、已登录的学生和教育者,都将能够浏览课程页面,并具有与其授权级别相关的交互。在接下来的几节中,我们将通过在后台添加读取课程 API、从前端调用此 API 的方法以及将容纳课程详情视图的 React 组件来实现单个课程视图功能。

一个读取课程 API

为了在后台实现一个读取课程 API,我们首先声明GET路由和参数处理触发器,如下面的代码所示。

mern-classroom/server/routes/course.routes.js:

router.route('/api/courses/:courseId')
  .get(courseCtrl.read)
router.param('courseId', courseCtrl.courseByID)

我们将添加这个GET路由来查询具有 ID 的Course集合,并在响应中返回相应的课程。路由 URL 中的:courseId参数将调用courseByID控制器方法,该方法与userByID控制器方法类似。它从数据库中检索课程,并将其附加到用于next方法的请求对象中,如下面的代码所示。

mern-classroom/server/controllers/course.controller.js:

const courseByID = async (req, res, next, id) => {
  try {
    let course = await Course.findById(id)
                             .populate('instructor', '_id name')
    if (!course)
      return res.status('400').json({
        error: "Course not found"
      })
    req.course = course
    next()
  } catch (err) {
    return res.status('400').json({
      error: "Could not retrieve course"
    })
  }
}

从数据库查询的课程对象还将包含讲师的名称和 ID 详情,正如我们在populate()方法中指定的。在将此课程对象附加到请求对象后调用next()将调用read控制器方法。然后read控制器方法将此course对象作为响应返回给客户端,如下面的代码所示。

mern-classroom/server/controllers/course.controller.js:

const read = (req, res) => {
  req.course.image = undefined
  return res.json(req.course)
}

在发送响应之前,我们将移除图片字段,因为图片将通过单独的路由作为文件检索。随着后端这个 API 的准备好,你现在可以添加实现,以便在前端调用它,通过在api-course.js中添加一个fetch方法,该方法与其他已经添加的fetch方法类似。我们将使用fetch方法来调用将在渲染课程详情的 React 组件中读取课程 API,正如下一节所讨论的。

课程组件

Course组件将渲染单个课程特定的细节和用户交互,如下面的截图所示:

完成的 Course 组件将包含以下部分:

  • 一个显示课程详情的部分,对所有访问者可见。我们将在这个部分实现这一部分。

  • 一个 课程 部分,包含课程列表,对所有访问者可见,以及添加新课程的选项,这个选项只对这门课程的讲师可见。我们将在下一节实现课程部分。

  • 编辑、删除和发布选项,只有讲师可见。这部分将在本章后面讨论。

  • 一个未在上一张图片中显示的 报名 选项,只有在课程被讲师发布后才会可见。这部分将在本章后面实现。

要开始实现此 Course 组件,我们首先将使用 useEffect 钩子通过读取 API 的 fetch 调用检索课程详情,然后我们将设置接收到的值到状态中,如下面的代码所示。

mern-classroom/client/course/Course.js

export default function Course ({match}) {  
  const [course, setCourse] = useState({instructor:{}})
  const [values, setValues] = useState({
      error: ''
  }) 
  useEffect(() => {
      const abortController = new AbortController()
      const signal = abortController.signal

      read({courseId: match.params.courseId}, signal).then((data) => {
        if (data.error) {
          setValues({...values, error: data.error})
        } else {
          setCourse(data)
        }
      })
    return function cleanup(){
      abortController.abort()
    }
  }, [match.params.courseId])
...
}

useEffect 只会在路由参数中的 courseId 发生变化时运行。

在视图中,我们将渲染接收到的详情,例如课程名称、描述、类别、图片以及链接到讲师用户资料的 Material-UI Card 组件,如下面的代码所示:

mern-classroom/client/course/Course.js

<Card>
    <CardHeader
          title={course.name}
          subheader={<div>
                        <Link to={"/user/"+course.instructor._id}>
                           By {course.instructor.name}
                        </Link>
                        <span>{course.category}</span>
                     </div>
                    }
    />
    <CardMedia image={imageUrl} title={course.name} />
    <div>
         <Typography variant="body1">
              {course.description}
         </Typography>
    </div>
</Card>

imageUrl 包含检索课程图片作为文件响应的路由,并且它被构建如下:

mern-classroom/client/course/Course.js

const imageUrl = course._id
          ? `/api/courses/photo/${course._id}?${new Date().getTime()}`
          : '/api/courses/defaultphoto'

当课程讲师登录并查看课程页面时,我们将在 Course 组件中渲染编辑和其他课程数据修改选项。目前,我们只关注如何有条件地将 edit 选项添加到视图代码中:

mern-classroom/client/course/Course.js

{auth.isAuthenticated().user && auth.isAuthenticated().user._id == course.instructor._id &&
    (<span><Link to={"/teach/course/edit/" + course._id}>
               <IconButton aria-label="Edit" color="secondary">
                  <Edit/>
               </IconButton>
           </Link>
     </span>)
}

如果当前用户已登录,并且他们的 ID 与课程讲师的 ID 匹配,那么只有在这种情况下才会渲染 编辑 选项。这部分将在接下来的章节中进一步编辑,以便展示发布和删除选项。

为了在前端加载此 Course 组件,我们将在 MainRouter 中添加一个路由,如下所示:

<Route path="/course/:courseId" component={Course}/>

此路由 URL (/course/:courseId) 现在可以添加到任何组件中,以链接到特定的课程,其中 :courseId 参数将被课程 ID 值替换。点击链接将用户带到相应的课程视图。

我们现在已经将相关的后端模型和 API 端点与前端视图集成,这意味着我们已经实现了新课程创建、讲师课程列表和单课程显示功能的运行实现。我们现在可以继续扩展这些实现,让讲师能够为每个课程添加课程,并根据需要更新课程,然后再发布。

更新带有课程的课程

MERN Classroom 中的每个课程都将包含一个课程内容的课程列表,以及学生在注册时需要覆盖的内容。我们将保持课程结构简单,在这个应用程序中,我们将更多关注课程管理实现,并允许学生按顺序完成课程。在接下来的几节中,我们将专注于课程管理的实现,我们还将探讨如何更新现有课程——无论是编辑详情还是删除课程。首先,我们将探讨如何存储课程详情,然后我们将实现全栈功能,允许讲师添加课程、更新课程、更新课程详情以及删除课程。

存储课程

在我们能够存储和检索每个课程的课程详情之前,我们需要定义课程数据结构并将其与课程数据结构关联。

我们将首先定义课程模型,其中包含标题、内容和资源 URL 字段,这些字段都是字符串类型,如下面的代码所示。

mern-classroom/server/models/course.model.js

const LessonSchema = new mongoose.Schema({
  title: String,
  content: String,
  resource_url: String
})
const Lesson = mongoose.model('Lesson', LessonSchema)

这些架构将允许教育者为他们自己的课程创建和存储基本的课程。为了将课程与课程结构集成,我们将在课程模型中添加一个名为 lessons 的字段,该字段将存储一个课程文档数组,如下面的代码所示。

mern-classroom/server/models/course.model.js

lessons: [LessonSchema]

使用这个更新的课程架构和模型,我们现在可以继续实施允许教育者向他们的课程添加课程的实现,如下一节所述。

添加新课程

MERN Classroom 应用程序上的教育者将能够向他们仍在构建且尚未发布的课程中添加新课程。在接下来的几节中,我们将实现这一功能,首先通过实现一个添加课程到现有课程的后端 API,然后创建一个前端表单视图来输入和发送新课程详情,最后在课程页面上显示新添加的课程。

添加课程 API

为了实现一个允许我们为特定课程添加和存储新课程的后端 API,我们首先需要声明如下所示的 PUT 路由:

mern-classroom/server/routes/course.routes.js:

router.route('/api/courses/:courseId/lesson/new')
  .put(authCtrl.requireSignin, 
                    courseCtrl.isInstructor,         
                          courseCtrl.newLesson)

当这个路由接收到一个包含课程 ID 的 URL 的 PUT 请求时,我们首先将使用 isInstructor 方法检查当前用户是否是该课程的讲师,然后我们将使用 newLesson 方法将课程保存在数据库中。isInstructor 控制器方法定义如下:

mern-classroom/server/controllers/course.controller.js:

const isInstructor = (req, res, next) => {
    const isInstructor = req.course && req.auth &&     
                         req.course.instructor._id == req.auth._id
    if(!isInstructor){
      return res.status('403').json({
        error: "User is not authorized"
      })
    }
    next()
}

使用 isInstructor 方法,我们首先检查已登录用户是否有与给定课程讲师相同的用户 ID。如果用户未授权,则响应中返回错误,否则调用 next() 中间件以执行 newLesson 方法。此 newLesson 控制器方法定义如下:

mern-classroom/server/controllers/course.controller.js:

const newLesson = async (req, res) => {
  try {
    let lesson = req.body.lesson
    let result = await Course.findByIdAndUpdate(req.course._id, 
                                              {$push: {lessons: lesson}, 
                                                updated: Date.now()}, 
                                                {new: true})
                            .populate('instructor', '_id name')
                            .exec()
    res.json(result)
  } catch (err) {
    return res.status(400).json({
      error: errorHandler.getErrorMessage(err)
    })
  }
}

在此 newLesson 控制器方法中,我们使用 MongoDB 的 findByIdAndUpdate 来查找相应的课程文档,并通过将请求体中接收的新课程对象推送到其 lessons 数组字段来更新它。

为了在前端访问此 API 以添加新课程,您还需要添加相应的 fetch 方法,就像我们为其他 API 实现所做的那样。

此 API 将用于基于表单的组件,该组件将接收用户对每门新课程的输入,并将其发送到后端。我们将在下一节实现此基于表单的组件。

新课程组件

在每门课程中,当课程尚未发布时,讲师可以通过填写表格来添加课程。为了实现添加新课程的表单视图,我们将创建一个名为 NewLesson 的 React 组件,并将其添加到 Course 组件中。此组件将在课程页面上的对话框中渲染以下表单:

图片

在定义 NewLesson 组件时,我们首先使用 useState 钩子初始化状态中的表单值。此组件还将从 Course 组件接收 props,如下所示。

mern-classroom/client/course/NewLesson.js

export default function NewLesson(props) {
  const [open, setOpen] = useState(false)
  const [values, setValues] = useState({
    title: '',
    content: '',
    resource_url: ''
  })
...
}
NewLesson.propTypes = {
    courseId: PropTypes.string.isRequired,
    addLesson: PropTypes.func.isRequired
}

NewLesson 组件将从要添加的父组件(在这种情况下为 Course 组件)接收 courseId 值和 addLesson 函数作为 props。我们通过向 NewLesson 添加 PropTypes 验证来使这些 props 成为必需。在表单提交时,这些 props 将在本组件中使用。

接下来,我们将添加一个按钮来切换包含表单的对话框,如下所示。

mern-classroom/client/course/NewLesson.js

<Button aria-label="Add Lesson" color="primary" variant="contained" 
        onClick={handleClickOpen}>
   <Add/> New Lesson
</Button>
<Dialog open={open} onClose={handleClose} aria-labelledby="form-dialog-   title">
   <div className={classes.form}>
      <DialogTitle id="form-dialog-title">Add New Lesson</DialogTitle>
            ...
      <DialogActions>
        <Button onClick={handleClose} 
           color="primary" variant="contained">
            Cancel
        </Button>
        <Button onClick={clickSubmit} 
            color="secondary" variant="contained">
            Add
        </Button>
      </DialogActions>
   </div>
</Dialog>

根据状态变量 open 的状态,Material-UI 的 Dialog 组件保持打开或关闭。我们在以下函数中更新 open 值,这些函数在对话框打开和关闭操作时被调用。

mern-classroom/client/course/NewLesson.js

const handleClickOpen = () => {
   setOpen(true)
}

const handleClose = () => {
   setOpen(false)
}

Dialog 组件内部使用 TextFieldsDialogContent 中添加了输入新课程标题、内容和资源 URL 值的表单字段,如下所示。

mern-classroom/client/course/NewLesson.js

<DialogContent>
     <TextField label="Title" type="text" fullWidth
                value={values.title} onChange={handleChange('title')} />
     <br/>
     <TextField label="Content" type="text" multiline rows="5" fullWidth
             value={values.content} onChange={handleChange('content')}/>
     <br/>
     <TextField label="Resource link" type="text" fullWidth
                value={values.resource_url} 
                onChange={handleChange('resource_url')} />
     <br/>
</DialogContent>

输入字段中输入的值将通过以下定义的 handleChange 函数进行捕获:

mern-classroom/client/course/NewLesson.js

const handleChange = name => event => {
    setValues({ ...values, [name]: event.target.value })
}

最后,当表单提交时,我们将在clickSubmit函数中将新的课程详情发送到服务器,如下面的代码所示。

mern-classroom/client/course/NewLesson.js

const clickSubmit = () => {
    const jwt = auth.isAuthenticated()
    const lesson = {
      title: values.title || undefined,
      content: values.content || undefined,
      resource_url: values.resource_url || undefined
    }
    newLesson({
      courseId: props.courseId
    }, {
      t: jwt.token
    }, lesson).then((data) => {
      if (data && data.error) {
        setValues({...values, error: data.error})
      } else {
          props.addLesson(data)
          setValues({...values, title: '',
          content: '',
          resource_url: ''})
          setOpen(false)
      }
    })
  }

课程详情将通过带有从课程组件接收的课程 ID 作为属性的请求发送到添加课程 API。在服务器成功更新响应后,除了清空表单字段外,还执行了作为属性传递的addLesson更新函数,以在课程组件中渲染最新的课程。从Course组件传递的addLesson函数定义如下:

mern-classroom/client/course/Course.js

const addLesson = (course) => {
    setCourse(course)
}

添加到课程组件中的NewLesson组件应该仅在当前用户是课程的讲师且课程尚未发布时才渲染。为了执行此检查和条件渲染NewLesson组件,我们可以在课程组件中添加以下代码:

mern-classroom/client/course/Course.js

{ auth.isAuthenticated().user && 
  auth.isAuthenticated().user._id == course.instructor._id && 
  !course.published &&
            (<NewLesson courseId={course._id} addLesson={addLesson}/>)
}

这将允许应用程序上的教育工作者向他们的课程添加课程。接下来,我们将添加代码以在课程页面上渲染这些课程。

显示课程

特定课程的课程将在课程页面下方以列表形式呈现,包括课程总数,如下面的截图所示:

图片

为了渲染这个课程列表,我们将更新Course组件,使用map函数遍历课程数组,每个课程将在 Material-UI 的ListItem组件中显示,如下面的代码所示。

mern-classroom/client/course/Course.js

<List>
    {course.lessons && course.lessons.map((lesson, index) => {
       return(<span key={index}>
                   <ListItem>
                      <ListItemAvatar>
                        <Avatar> {index+1} </Avatar>
                      </ListItemAvatar>
                      <ListItemText primary={lesson.title} />
                   </ListItem>
                   <Divider variant="inset" component="li" />
              </span>)
    })}
</List>

每个列表项旁边的数字是使用数组的当前索引值计算的。也可以通过访问course.lessons.length来显示课程总数。

既然讲师可以添加和查看每门课程的课程,那么在下一节中,我们将实现更新这些添加的课程的能力,同时修改其他课程详情。

编辑课程

一旦教育工作者添加了课程并且有更多更新要合并,教育工作者将能够以讲师的身份编辑课程的详情。编辑课程包括更新其课程的能力。为了在应用程序中实现这一功能,首先,我们必须创建一个后端 API,允许对特定课程执行更新操作。

然后,需要在前端使用课程及其课程更改后的详细信息来访问这个更新的 API。在接下来的章节中,我们将构建这个后端 API 和EditCourse React 组件,这将允许讲师更改课程详情和课程。

更新课程 API

在后端,我们需要一个 API,允许如果请求用户是给定课程的授权讲师,则更新现有课程。我们首先声明接受客户端更新请求的 PUT 路由,如下所示:

mern-classroom/server/routes/course.routes.js:

router.route('/api/courses/:courseId')
  .put(authCtrl.requireSignin, courseCtrl.isInstructor, 
            courseCtrl.update)

接收到 /api/courses/:courseId 路由的 PUT 请求首先检查已登录用户是否是 URL 中提供的 courseId 关联的课程讲师。如果发现用户是授权的,则调用 update 控制器。课程控制器中的 update 方法定义如下所示。

mern-classroom/server/controllers/course.controller.js:

const update = (req, res) => {
  let form = new formidable.IncomingForm()
  form.keepExtensions = true
  form.parse(req, async (err, fields, files) => {
    if (err) {
      return res.status(400).json({
        error: "Photo could not be uploaded"
      })
    }
    let course = req.course
    course = extend(course, fields)
    if(fields.lessons){
 course.lessons = JSON.parse(fields.lessons)
 }
    course.updated = Date.now()
    if(files.image){
      course.image.data = fs.readFileSync(files.image.path)
      course.image.contentType = files.image.type
    }
    try {
      await course.save()
      res.json(course)
    } catch (err) {
      return res.status(400).json({
        error: errorHandler.getErrorMessage(err)
      })
    }
  })
}

由于请求体可能包含文件上传,我们在这里使用 formidable 来解析多部分数据。课程数组是一个嵌套对象的数组,我们需要在保存之前将课程数组特别解析并分配给课程。正如我们将在下一节中看到的那样,从前端发送的课程数组在发送之前将被字符串化,因此在这个控制器中,我们需要额外检查是否收到了 lessons 字段,并在解析后单独分配它。

要在前端使用此 API,您需要定义一个 fetch 方法,该方法接受课程 ID、用户认证凭据和更新的课程详情,以便对更新课程 API 进行 fetch 调用——就像我们对其他 API 实现所做的那样。

现在我们有一个可以在前端使用的课程更新 API,我们可以使用它来更新课程的详细信息。我们将在 EditCourse 组件中使用它,接下来将讨论该组件。

The EditCourse component

在前端,我们将添加一个用于编辑课程的视图,它将包含两个部分。第一部分将允许用户更改课程详情,包括名称、类别、描述和图片;第二部分将允许修改课程的课程。以下截图显示了课程的第一个部分:

图片

为了实现此视图,我们将定义一个名为 EditCourse 的 React 组件。此组件将首先通过在 useEffect 钩子中调用 read fetch 方法来加载课程详情,如下面的代码所示。

mern-classroom/client/course/EditCourse.js

  useEffect(() => {
      const abortController = new AbortController()
      const signal = abortController.signal

      read({courseId: match.params.courseId}, signal).then((data) => {
        if (data.error) {
          setValues({...values, error: data.error})
        } else {
          setCourse(data)
        }
      })
    return function cleanup(){
      abortController.abort()
    }
  }, [match.params.courseId])

在成功接收到响应中的课程数据后,将通过调用 setCourse 将其设置为状态中的 course 变量,并将其用于填充视图。视图的第一部分将渲染与课程视图类似但使用 TextFields 的课程详情,并提供上传新图片和保存按钮以进行更新调用,如下面的代码所示。

mern-classroom/client/course/EditCourse.js

<CardHeader title={<TextField label="Title" type="text" fullWidth
    value={course.name} onChange={handleChange('name')}/>}
            subheader={<div><Link to={"/user/"+course.instructor._id}>
                               By {course.instructor.name}
                            </Link>
  {<TextField label="Category" type="text" fullWidth
      value={course.category} 
        onChange={handleChange('category')}/>}
                       </div>}
            action={<Button variant="contained" color="secondary" 
                            onClick={updateCourse}>Save</Button>}
/>
<div className={classes.flex}>
   <CardMedia image={imageUrl} title={course.name}/>
   <div className={classes.details}>
      <TextField multiline rows="5" label="Description" type="text"
                 value={course.description} 
                 onChange={handleChange('description')} /><br/>
      <input accept="image/*" 
         onChange={handleChange('image')}  type="file" />
      <label htmlFor="icon-button-file">
        <Button variant="outlined" color="secondary" component="span">
          Change Photo
          <FileUpload/>
        </Button>
      </label> <span>{course.image ? course.image.name : ''}</span><br/>
   </div>
</div>

输入字段的更改将通过 handleChange 方法来处理,该方法定义如下。

mern-classroom/client/course/EditCourse.js

  const handleChange = name => event => {
    const value = name === 'image'
    ? event.target.files[0]
    : event.target.value
    setCourse({ ...course, [name]: value })
  }

当点击保存按钮时,我们将获取所有课程详情并将其设置为FormData,然后使用课程更新 API 以多部分格式发送到后端。在保存时调用的clickSubmit函数将定义如下:

mern-classroom/client/course/EditCourse.js

const clickSubmit = () => {
    let courseData = new FormData()
    course.name && courseData.append('name', course.name)
    course.description && courseData.append('description'
        , course.description)
    course.image && courseData.append('image', course.image)
    course.category && courseData.append('category', course.category)
    courseData.append('lessons', JSON.stringify(course.lessons))
    update({
        courseId: match.params.courseId
      }, {
        t: jwt.token
      }, courseData).then((data) => {
        if (data && data.error) {
            console.log(data.error)
          setValues({...values, error: data.error})
        } else {
          setValues({...values, redirect: true})
        }
      })
  }

课程课程也通过这个FormData发送,但由于课程是以嵌套对象的数组形式存储的,而FormData只接受简单的键值对,所以在分配之前我们需要将lessons值进行字符串化。

为了在前端加载EditCourse,我们需要为其声明一个前端路由。此组件只能由已登录且也是课程讲师的用户查看。因此,我们将在MainRouter组件中添加一个PrivateRoute,这将只为授权用户在/teach/course/edit/:courseId渲染此视图。

mern-marketplace/client/MainRouter.js:

<PrivateRoute path="/teach/course/edit/:courseId" component={EditCourse}/>

此链接添加到课程视图中,以便允许访问EditCourse页面。

我们已经探讨了如何在保存时更新并发送课程详情以及所有课程到后端,但我们还剩下编辑课程课程的接口。在接下来的章节中,我们将通过查看更新课程课程的实现来完成EditCourse组件。

更新课程

为了允许讲师更新他们添加到课程中的课程,我们将在EditCourse组件中添加以下部分,这将允许用户编辑课程详情、重新排列课程的顺序以及删除课程:

图片

这些课程更新功能的实现主要依赖于数组操作技术。在接下来的章节中,我们将添加列表中单个课程的接口,并讨论如何实现编辑、移动和删除功能。

编辑课程详情

用户将能够在EditCourse组件中编辑每个字段的课程详情。在视图中,课程列表中的每个项目将包含三个TextFields,用于课程中的每个字段。这些字段将预先填充现有值,如下面的代码所示。

mern-classroom/client/course/EditCourse.js

<ListItemText
  primary={<><TextField label="Title" type="text" fullWidth
                        value={lesson.title} 
                        onChange={handleLessonChange('title', index)} />
                        <br/>
            <TextField multiline rows="5" label="Content" type="text"
                       fullWidth value={lesson.content} 
                       onChange={handleLessonChange('content', index)}/>
                       <br/>
             <TextField label="Resource link" type="text" fullWidth
                        value={lesson.resource_url} 
                  onChange={handleLessonChange('resource_url', index)}/>
                  <br/>
          </>}
/>

为了处理每个字段中值的变化,我们将定义一个handleLessonChange方法,它将接受字段名称和数组中相应课程的索引。handleLessonChange方法将定义如下:

mern-classroom/client/course/EditCourse.js

const handleLessonChange = (name, index) => event => {
    const lessons = course.lessons
    lessons[index][name] = event.target.value
    setCourse({ ...course, lessons: lessons })
}

在课程中,课程数组在设置指定字段中提供的索引的值后更新状态。当用户在EditCourse视图中点击保存时,这个经过修改的课程将包含修改后的课程并保存到数据库。接下来,我们将看看我们如何允许用户重新排列课程的顺序。

移动课程以重新排列顺序

在更新课程时,用户还将能够重新排列列表中的每个课程。除了第一个课程外,每个课程都将有一个向上箭头按钮。此按钮将按以下方式添加到视图中的每个课程项:

mern-classroom/client/course/EditCourse.js

{ index != 0 && 
    <IconButton color="primary" onClick={moveUp(index)}>
         <ArrowUp />
    </IconButton>
}

当用户点击此按钮时,当前索引的课程将被向上移动,而上面的课程将移动到数组中的该位置。moveUp函数将按以下方式实现此行为:

mern-classroom/client/course/EditCourse.js

const moveUp = index => event => {
      const lessons = course.lessons
      const moveUp = lessons[index]
      lessons[index] = lessons[index-1]
      lessons[index-1] = moveUp
      setCourse({ ...course, lessons: lessons })
}

重新排列的课程数组随后更新到状态中,当用户在EditCourse页面保存更改时,它将被保存到数据库中。接下来,我们将实现从列表中删除课程的功能。

删除课程

EditCourse页面,渲染在课程列表中的每个项目都将有一个删除选项。删除按钮将按以下方式添加到视图中的每个列表项:

mern-classroom/client/course/EditCourse.js

<ListItemSecondaryAction>
     <IconButton edge="end" aria-label="up" color="primary" 
                onClick={deleteLesson(index)}>
           <DeleteIcon />
     </IconButton>
</ListItemSecondaryAction>}

当点击删除按钮时,我们将获取正在被删除的课程索引,并将其从lessons数组中移除。当按钮被点击时调用的deleteLesson函数定义如下:

mern-classroom/client/course/EditCourse.js

const deleteLesson = index => event => {
    const lessons = course.lessons
    lessons.splice(index, 1)
    setCourse({...course, lessons:lessons})
}

在此函数中,我们正在通过从给定索引中删除课程来切割数组,然后将更新后的数组添加到状态中的课程中。当用户在EditCourse页面点击保存按钮时,这个新的课程数组将与课程对象一起发送到数据库。

这总结了讲师可以改变其课程的三种不同方式。通过使用与 React 组件特性集成的数组操作技术,用户现在可以编辑细节、重新排列顺序以及删除课程。在下一节中,我们将讨论修改课程所剩下的唯一功能,即从数据库中删除课程的能力。

删除课程

在 MERN Classroom 应用程序中,如果课程尚未发布,讲师将能够永久删除课程。为了允许讲师删除课程,首先,我们将定义一个从数据库中删除课程的后端 API,然后实现一个 React 组件,当用户与前端交互以执行此删除时,将使用此 API。

删除课程 API

为了实现一个后端 API,该 API 接受从数据库中删除指定课程的请求,我们首先定义一个如下所示的 DELETE 路由。

mern-classroom/server/routes/course.routes.js:

router.route('/api/courses/:courseId')
  .delete(authCtrl.requireSignin, courseCtrl.isInstructor, 
            courseCtrl.remove)

此 DELETE 路由接受课程 ID 作为 URL 参数,并在继续到以下代码中定义的remove控制器方法之前,检查当前用户是否已登录并有权执行此删除操作。

mern-classroom/server/controllers/course.controller.js:

const remove = async (req, res) => {
  try {
    let course = req.course
    let deleteCourse = await course.remove()
    res.json(deleteCourse)
  } catch (err) {
    return res.status(400).json({
      error: errorHandler.getErrorMessage(err)
    })
  }
}

remove方法简单地从数据库中对应提供的 ID 的课程集合中删除课程文档。要在前端访问此后端 API,你还需要一个具有此路由的 fetch 方法;类似于其他 API 实现。fetch 方法需要接受课程 ID 和当前用户的认证凭据,然后使用这些值调用删除 API。

当用户通过界面上的按钮点击执行删除操作时,将使用 fetch 方法。在下一节中,我们将讨论一个名为DeleteCourse的 React 组件,这是此交互发生的地方。

DeleteCourse组件

当讲师登录并查看未发布的课程时,他们将在课程页面上看到一个删除选项。此删除选项将在名为DeleteCourse的独立 React 组件中实现,并将此组件添加到Course组件中。DeleteCourse组件基本上是一个按钮,当点击时,会打开一个Dialog组件,提示用户确认删除操作,如下面的截图所示:

截图

DeleteCourse组件的实现与第四章中讨论的DeleteUser组件类似,即添加 React 前端以完成 MERN。当DeleteCourse组件添加到Course组件中时,它将接受课程 ID 和从Course组件中获取的onRemove函数定义作为 props,而不是用户 ID,如下面的代码所示:

mern-classroom/client/course/Course.js

<DeleteCourse course={course} onRemove={removeCourse}/>

使用此实现,课程讲师将能够从平台上删除课程。

在本节中,我们通过扩展课程模型并实现课程模型,增加了向课程添加课程的能力。然后,我们添加了必要的后端 API 和用户界面更新,以便添加课程、修改课程详情和课程,以及删除课程和课程。现在课程模块已经准备好,我们可以实现发布课程并使其在应用程序中可供报名的能力。我们将在下一节讨论此发布功能。

发布课程

在 MERN Classroom 中,只有已发布的课程才可供平台上的其他用户报名。一旦讲师创建了课程并更新了课程内容,他们将有发布课程的选择。发布的课程将列在主页上,所有访客都可以查看。在本节的其余部分,我们将探讨允许讲师发布课程并在前端列出这些发布课程的实施。

实现发布选项

每个课程的讲师在至少向课程添加一个课时后,将有权发布他们的课程。发布课程还意味着课程将不能再被删除,无法添加新课时,也无法删除现有课时。因此,当讲师选择发布时,他们将被要求确认此操作。在本节中,我们将探讨如何使用和扩展现有的课程模块以集成此发布功能。

发布按钮状态

在课程视图中,当讲师登录时,他们将根据课程是否可以发布以及是否已经发布,看到“发布”按钮的三个状态,如下所示截图:

图片

此按钮的状态主要取决于课程文档的published属性是否设置为truefalse,以及lessons数组的长度。按钮将被添加到Course组件中,如下所示代码:

mern-classroom/client/course/Course.js

{ !course.published ? 
            (<> <Button color="secondary" variant="outlined" 
                        onClick={clickPublish}>
                   { course.lessons.length == 0 ? 
                           "Add atleast 1 lesson to publish" 
                           : "Publish" }
                </Button>
                <DeleteCourse course={course} onRemove={removeCourse}/>
            </>) : (
                  <Button color="primary" 
                          variant="outlined">Published</Button>
            )
}

删除选项只有在课程尚未发布时才会渲染。当点击“发布”按钮时,我们将打开一个对话框,要求用户确认。当按钮被点击时,将调用clickPublish函数,定义如下:

mern-classroom/client/course/Course.js

const clickPublish = () => {
    if(course.lessons.length > 0){ 
      setOpen(true)
    }
  }

clickPublish 函数只有在课程数组长度大于零时才会打开对话框;防止讲师在没有课程的情况下发布课程。接下来,我们将添加对话框,让讲师在确认后发布课程。

确认发布

当讲师点击“发布”按钮时,他们将看到一个对话框,告知他们此操作的结果,并给他们提供发布课程或取消操作的选择。对话框将如下所示:

图片

为了实现此对话框,我们将使用 Material-UI 的Dialog组件,包括标题和内容文本,以及发布和取消按钮,如下所示代码。

mern-classroom/client/course/Course.js

<Dialog open={open} onClose={handleClose} aria-labelledby="form-dialog-title">
   <DialogTitle id="form-dialog-title">Publish Course</DialogTitle>
   <DialogContent>
      <Typography variant="body1">
         Publishing your course will make it live to students 
            for enrollment.            
      </Typography>
      <Typography variant="body1">
         Make sure all lessons are added and ready for publishing.
      </Typography>
   </DialogContent>
   <DialogActions>
      <Button onClick={handleClose} color="primary" variant="contained">
         Cancel
      </Button>
      <Button onClick={publish} color="secondary" variant="contained">
          Publish
      </Button>
   </DialogActions>
</Dialog>

当用户点击对话框中的“发布”按钮作为确认发布课程时,我们将向后端发出更新 API 调用,将课程的published属性设置为true。定义此更新的publish函数如下:

mern-classroom/client/course/Course.js

  const publish = () => {
    let courseData = new FormData()
      courseData.append('published', true)
      update({
          courseId: match.params.courseId
        }, {
          t: jwt.token
        }, courseData).then((data) => {
          if (data && data.error) {
            setValues({...values, error: data.error})
          } else {
            setCourse({...course, published: true})
            setOpen(false)
          }
      })
  }

在此函数中,我们使用的是已经定义并用于从“编辑课程”视图保存其他课程细节修改的相同更新 API。一旦后端成功更新了published值,它也会在Course组件的状态中更新。

在课程中,这个published属性可以用来在CourseEditCourse组件中条件性地隐藏添加新课程、删除课程和删除课程的选项,以防止讲师在课程发布后执行这些操作。由于课程是由讲师发布的,因此这些课程将在平台上的所有用户视图中被列出,如以下章节所述。

列出已发布课程

访问 MERN Classroom 应用程序的所有访客都将能够访问已发布的课程。为了展示这些已发布的课程,我们将添加一个功能来从数据库中检索所有已发布的课程,并在主页上以列表形式显示课程。在接下来的章节中,我们将通过首先定义后端 API 来实现这个功能,该 API 将接收请求并返回已发布课程的列表。然后,我们将实现前端组件,该组件将获取此 API 并渲染课程。

已发布课程 API

为了从数据库中检索已发布课程的列表,我们将在后端实现一个 API,首先声明一个接收 GET 请求的路径'/api/courses/published',如下面的代码所示:

mern-classroom/server/routes/course.routes.js:

router.route('/api/courses/published')
  .get(courseCtrl.listPublished)

向此路由发送 GET 请求将调用listPublished控制器方法,该方法启动对具有published属性值为true的课程的查询。然后,结果课程在响应中返回。listPublished控制器方法定义如下:

mern-classroom/server/controllers/course.controller.js:

const listPublished = (req, res) => {
  Course.find({published: true}, (err, courses) => {
    if (err) {
      return res.status(400).json({
        error: errorHandler.getErrorMessage(err)
      })
    }
    res.json(courses)
  }).populate('instructor', '_id name')
}

为了在前端使用这个列表 API,我们还需要在客户端定义一个 fetch 方法,就像我们为所有其他 API 调用所做的那样。然后,这个 fetch 方法将在组件中使用,用于检索并显示已发布的课程。在下一节中,我们将探讨在 React 组件中渲染检索到的课程列表的实现。

课程组件

为了显示已发布课程的列表,我们将设计一个组件,该组件从它所添加的父组件接收课程数组作为 props。在 MERN Classroom 应用程序中,我们将在主页上渲染已发布的课程,如下一张截图所示:

Home组件中,我们将使用useEffect钩子从后端检索已发布课程的列表,如下面的代码所示:

mern-classroom/client/core/Home.js

  useEffect(() => {
    const abortController = new AbortController()
    const signal = abortController.signal
    listPublished(signal).then((data) => {
      if (data.error) {
        console.log(data.error)
      } else {
        setCourses(data)
      }
    })
    return function cleanup(){
      abortController.abort()
    }
  }, [])

一旦收到课程列表,它就被设置到状态中的courses变量。当它被添加到Home组件时,我们将这个courses数组作为 props 传递给Courses组件,如下所示:

mern-classroom/client/core/Home.js

<Courses courses={courses} />

Courses组件将接受这些属性并遍历数组,使用 Material-UI 的GridList组件渲染每个课程。Courses组件的定义如下所示:

mern-classroom/client/course/Courses.js

export default function Courses(props){
  return (
    <GridList cellHeight={220} cols={2}>
       {props.courses.map((course, i) => {
          return (
            <GridListTile key={i} style={{padding:0}}>
              <Link to={"/course/"+course._id}>
                <img src={'/api/courses/photo/'+course._id} 
                            alt={course.name} />
              </Link>
              <GridListTileBar 
                 title={<Link to={"/course/"+course._id}>
                            {course.name}</Link>}
                 subtitle={<span>{course.category}</span>}
                 actionIcon={auth.isAuthenticated() ? 
                                <Enroll courseId={course._id}/> : 
                                <Link to="/signin">
                                    Sign in to Enroll</Link>
                            }
              />
            </GridListTile>)
       })}
    </GridList>
  )
}
Courses.propTypes = {
  courses: PropTypes.array.isRequired
}

列表中的每个课程将显示其名称、类别和图片,并将链接到单个课程页面。将实现一个独立的组件来显示每个课程的注册选项,但仅对已登录并浏览主页的用户显示。

由于现在讲师可以发布课程,所有应用程序的访客都可以查看,我们可以开始实施课程注册功能。

注册课程

MERN Classroom 应用程序的所有访客都有登录并注册任何已发布课程的选项。注册课程将使他们能够访问课程详情,并允许他们系统地学习课程。为了实现此功能,在本节中,我们首先定义一个注册模型来在数据库中存储注册详情。然后,我们将添加后端 API,以便当最终用户与将要添加到前端的前端Enroll组件交互时创建新的注册。最后,我们将实现一个视图,使学生能够查看和与其注册的课程内容进行交互。

定义注册模型

我们将定义一个注册模式(schema)和模型来存储应用程序中每个注册的详情。它将包含存储正在注册的课程引用和作为学生注册的用户引用的字段。它还将存储与相关课程中的课程相对应的数组,该数组将存储每个课程对该学生的完成状态。此外,我们还将存储三个时间戳值;第一个值将表示学生何时注册,第二个值将表示他们上次完成课程或更新注册的时间,最后,当他们完成课程时。此注册模型将在server/models/enrollment.model.js中定义,以下列表中给出了定义注册字段的代码及其说明:

  • 课程引用course字段将存储与此次注册关联的课程文档的引用:
course: {
    type: mongoose.Schema.ObjectId, 
    ref: 'Course'
}
  • 学生引用student字段将存储创建此注册的用户引用,该用户通过选择注册课程来完成注册:
student: {
    type: mongoose.Schema.ObjectId, 
    ref: 'User'
}
  • 课程状态lessonStatus字段将存储一个数组,其中包含对存储在相关课程lessons数组中每个课程的引用。对于lessonStatus数组中的每个对象,我们将添加一个complete字段,该字段将存储一个布尔值,表示相应的课程是否已完成:
lessonStatus: [{
      lesson: {type: mongoose.Schema.ObjectId, ref: 'Lesson'}, 
      complete: Boolean
}]
  • 注册时间enrolled 字段将是一个表示注册创建时间的 Date 值;换句话说,当学生注册课程时:
enrolled: {
    type: Date,
    default: Date.now
}
  • 更新时间updated 字段将是另一个 Date 值,每次完成一个课时都会更新,指示用户上次在课程课时上工作的日期:
updated: Date
  • 完成时间completed 字段也将是 Date 类型,它只会在课程中的所有课程都完成时设置:
completed: Date

在此模式定义中的字段将使我们能够实现 MERN Classroom 中的所有注册相关功能。在下一节中,我们将实现用户注册课程的 功能,并使用此注册模型存储注册的详细信息。

创建注册 API

当用户选择注册课程时,我们将创建一个新的注册并将其存储在后端。为了实现此功能,我们需要在服务器上定义一个创建注册的 API,首先声明一个接受 POST 请求的路线 '/api/enrollment/new/:courseId',如下面的代码所示:

mern-classroom/server/routes/enrollment.routes.js

router.route('/api/enrollment/new/:courseId')
  .get(authCtrl.requireSignin, enrollmentCtrl.findEnrollment, enrollmentCtrl.create)
router.param('courseId', courseCtrl.courseByID)

此路由在 URL 中接受课程 ID 作为参数。因此,我们还添加了来自课程控制器的 courseByID 控制器方法,以处理此参数并从数据库中检索相应的课程。从客户端请求中发起请求的用户通过请求中发送的用户身份验证凭据进行识别。在此路由接收到的 POST 请求将首先检查用户是否已认证,然后检查他们是否已经注册了此课程,在为该用户在此课程中创建新的注册之前。

findEnrollment 控制器方法将查询数据库中的 Enrollments 集合,以检查是否存在具有给定课程 ID 和用户 ID 的注册。findEnrollment 方法定义如下。

mern-classroom/server/controllers/enrollment.controller.js

const findEnrollment = async (req, res, next) => {
  try {
    let enrollments = await Enrollment.find({course:req.course._id, 
                                             student: req.auth._id})
    if(enrollments.length == 0){
      next()
    }else{
      res.json(enrollments[0])
    }
  } catch (err) {
    return res.status(400).json({
      error: errorHandler.getErrorMessage(err)
    })
  }
}

如果查询返回匹配的结果,则将返回的结果注册发送回响应,否则将调用 create 控制器方法来创建一个新的注册。

create 控制器方法从给定的课程引用、用户引用和课程中的课程数组生成要保存到数据库中的新注册对象。create 方法定义如下。

mern-classroom/server/controllers/enrollment.controller.js

const create = async (req, res) => {
  let newEnrollment = {
    course: req.course,
    student: req.auth,
  }
  newEnrollment.lessonStatus = req.course.lessons.map((lesson)=>{
    return {lesson: lesson, complete:false}
  })
  const enrollment = new Enrollment(newEnrollment)
  try {
    let result = await enrollment.save()
    return res.status(200).json(result)
  } catch (err) {
    return res.status(400).json({
      error: errorHandler.getErrorMessage(err)
    })
  }
}

course 中的 lessons 数组被迭代以生成新注册文档的 lessonStatus 对象数组。lessonStatus 数组中的每个对象都将 complete 值初始化为 false。基于这些值成功保存新注册文档后,新文档将发送回响应。

为注册 API 定义的 所有路由,例如此创建 API,都在 enrollment.routes.js 文件中声明,并且它将类似于我们应用程序中已经创建的其他路由文件。与其他路由一样,我们需要通过在 express.js 中挂载注册路由来将这些新路由加载到 Express 应用中。注册相关路由的挂载方式如下。

mern-social/server/express.js:

app.use('/', enrollmentRoutes)

要在前端访问创建 API,您还需要定义一个类似于应用程序中已定义的其他 fetch 方法的 fetch 方法。使用此 fetch 方法,下一节中讨论的 Enroll 组件将能够调用此创建注册 API。

注册组件

Enroll 组件将简单地包含一个按钮,该按钮将启动对后端的注册调用,并在服务器成功返回新的注册文档 ID 时重定向用户。此组件从添加它的父组件接收相关课程的 ID 作为属性。此属性将在创建注册 API 调用时使用。Enroll 组件的定义如下所示。

mern-classroom/client/enrollment/Enroll.js:

export default function Enroll(props) {
  const [values, setValues] = useState({
    enrollmentId: '',
    error: '',
    redirect: false
  })
  if(values.redirect){
     return (<Redirect to={'/learn/'+values.enrollmentId}/>)
  }
  return (
      <Button variant="contained" color="secondary" 
              onClick={clickEnroll}> Enroll </Button>
  )

当点击 ENROLL 按钮时,将使用提供的课程 ID 获取创建注册 API,以检索现有注册或创建新的注册并在响应中接收它。当按钮被点击时将调用的 clickEnroll 函数定义如下:

mern-classroom/client/enrollment/Enroll.js:

 const clickEnroll = () => {
    const jwt = auth.isAuthenticated()
    create({
      courseId: props.courseId
    }, {
      t: jwt.token
    }).then((data) => {
        console.log(data)
      if (data && data.error) {
        setValues({...values, error: data.error})
      } else {
        setValues({...values, enrollmentId: data._id, redirect: true})
      }
    })
  }

当服务器成功返回注册时,用户将被重定向到将显示特定注册详情的视图。

由于 Enroll 组件从父组件接收课程 ID 作为属性,因此我们还在组件中添加了 PropType 验证(如下所示),因为其功能和实现依赖于传递此属性。

mern-classroom/client/enrollment/Enroll.js:

Enroll.propTypes = {
  courseId: PropTypes.string.isRequired
}

当服务器在 API 调用中成功响应时,用户将被重定向到已注册课程视图,在那里他们可以浏览课程内容。我们将在下一节中实现此视图。

已注册课程视图

对于用户注册的每门课程,他们都会看到一个视图,列出课程的详细信息以及课程中的每个课程;并可以选择完成每个课程。在以下章节中,我们将通过首先添加返回给定注册详情的后端 API 来实现此视图,然后在前端使用此 API 构建已注册课程视图。

读取注册 API

将返回数据库中注册详情的后端 API 将被定义为接受请求在 '/api/enrollment/:enrollmentId' 的 GET 路由,并如下声明:

mern-classroom/server/routes/enrollment.routes.js

router.route('/api/enrollment/:enrollmentId')
  .get(authCtrl.requireSignin, enrollmentCtrl.isStudent, 
               enrollmentCtrl.read)
router.param('enrollmentId', enrollmentCtrl.enrollmentByID)

在此路由上的 GET 请求将首先调用enrollmentByID方法,因为它在 URL 声明中包含enrollmentId参数。enrolmentByID方法将根据提供的 ID 查询Enrollments集合,如果找到匹配的注册文档,我们将使用 Mongoose 的populate方法确保引用的课程、嵌套的课程讲师和引用的学生详情也被填充。enrollmentByID控制器方法定义如下所示:

mern-classroom/server/controllers/enrollment.controller.js:

const enrollmentByID = async (req, res, next, id) => {
  try {
    let enrollment = await Enrollment.findById(id)
                          .populate({path: 'course', populate:{ 
                                                   path: 'instructor'}})
 .populate('student', '_id name')
    if (!enrollment)
      return res.status('400').json({
        error: "Enrollment not found"
      })
    req.enrollment = enrollment
    next()
  } catch (err) {
    return res.status('400').json({
      error: "Could not retrieve enrollment"
    })
  }
}

结果的注册对象附加到请求对象,并传递给下一个控制器方法。在将此注册对象返回给客户端之前,我们将在isStudent方法中检查当前登录的用户是否与该特定注册关联的学生,正如以下代码中定义的那样。

mern-classroom/server/controllers/enrollment.controller.js:

const isStudent = (req, res, next) => {
  const isStudent = req.auth && req.auth._id == 
                                req.enrollment.student._id
  if (!isStudent) {
    return res.status('403').json({
      error: "User is not enrolled"
    })
  }
  next()
}

isStudent方法检查通过请求中发送的认证凭据识别的用户是否与注册中引用的学生匹配。如果两个用户不匹配,则返回带有错误信息的 403 状态,否则,将调用下一个控制器方法以返回注册对象。下一个控制器方法是read方法,其定义如下:

mern-classroom/server/controllers/enrollment.controller.js:

const read = (req, res) => {
  return res.json(req.enrollment)
}

要在前端使用此读取注册 API,您还需要定义一个相应的 fetch 方法,如在本应用程序中实现的所有其他 API 一样。然后,此 fetch 方法将用于检索要在学生交互的 React 组件中渲染的注册详情。我们将在下一节中实现此Enrollment组件。

注册组件

Enrollment组件将加载从读取注册 API 接收到的课程和课程详情。在这个视图中,学生将能够遍历课程中的每一课,并标记为完成。课程标题将列在抽屉中,给学生一个关于课程包含的内容以及他们已经进展到哪里的整体概念。抽屉中的每一项都会展开以显示课程的详细信息,如下面的截图所示:

图片

要实现此视图,首先,我们需要在useEffect钩子中调用读取注册 API 的 fetch 调用,以检索注册详情并将其设置到状态中,如下面的代码所示。

mern-classroom/client/enrollment/Enrollment.js:

export default function Enrollment ({match}) {
  const [enrollment, setEnrollment] = useState({course:{instructor:[]}, 
                                                lessonStatus: []})
  const [values, setValues] = useState({
      redirect: false,
      error: '',
      drawer: -1
    })
  const jwt = auth.isAuthenticated()
  useEffect(() => {
      const abortController = new AbortController()
      const signal = abortController.signal
      read({enrollmentId: match.params.enrollmentId}, 
           {t: jwt.token}, signal).then((data) => {
             if (data.error) {
              setValues({...values, error: data.error})
             } else {
              setEnrollment(data)
             }
      })
      return function cleanup(){
          abortController.abort()
      }
  }, [match.params.enrollmentId])
....

我们将使用 Material-UI 的Drawer组件来实现抽屉布局。在抽屉中,我们保留第一个项目为课程概览,这将使用户了解课程详情,类似于单个课程页面。当用户进入此报名视图时,他们将首先看到课程概览。

在以下代码中,在添加此第一个抽屉项目后,我们为课程创建了一个单独的章节,其中遍历lessonStatus数组以在抽屉中列出课程标题。

mern-classroom/client/enrollment/Enrollment.js:

<Drawer variant="permanent">
    <div className={classes.toolbar} />
    <List>
      <ListItem button onClick={selectDrawer(-1)} 
        className={values.drawer == -1 ? 
                    classes.selectedDrawer : classes.unselected}>
        <ListItemIcon><Info /></ListItemIcon>
        <ListItemText primary={"Course Overview"} />
      </ListItem>
    </List>
    <Divider />
    <List>
      <ListSubheader component="div">
          Lessons
      </ListSubheader>
      {enrollment.lessonStatus.map((lesson, index) => (
          <ListItem button key={index} onClick={selectDrawer(index)} 
                    className={values.drawer == index ? 
                           classes.selectedDrawer : classes.unselected}>
            <ListItemAvatar> 
                <Avatar> {index+1} </Avatar> 
            </ListItemAvatar>
            <ListItemText 
                primary={enrollment.course.lessons[index].title} />
            <ListItemSecondaryAction> { lesson.complete ? 
                <CheckCircle/> : <RadioButtonUncheckedIcon />}
            </ListItemSecondaryAction>
          </ListItem>
      ))}
    </List>
    <Divider />
</Drawer>

抽屉中“课程”部分的每个项目也将使用户能够直观地了解课程是否已完成,或者是否尚未完成。这些勾选或未勾选的图标将根据lessonStatus数组中每个项目的complete字段的布尔值进行渲染。

为了确定当前选中的抽屉,我们将使用初始化的drawer值到状态中,值为-1。-1 值将与课程概览抽屉项目和视图相关联,而lessonStatus中每个项目的索引将确定从抽屉中选择时显示哪个课程。当点击抽屉项目时,我们将调用selectDrawer方法,将其-1 或被点击课程的索引作为其参数。selectDrawer方法定义如下:

mern-classroom/client/enrollment/Enrollment.js:

const selectDrawer = (index) => event => {
   setValues({...values, drawer:index})
}

selectDrawer方法根据抽屉中点击的项目设置状态中的drawer值。实际的内容视图也将根据以下结构有条件地渲染:

{ values.drawer == - 1 && (Overview of course) }
{ values.drawer != - 1 && (Individual lesson content based on the index value represented in drawer) }

课程概览部分可以根据课程页面进行设计和实现。为了渲染单个课程的详细信息,我们可以使用以下Card组件:

mern-classroom/client/enrollment/Enrollment.js:

{values.drawer != -1 && (<>
     <Typography variant="h5">{enrollment.course.name}</Typography>
     <Card> <CardHeader 
              title={enrollment.course.lessons[values.drawer].title} 
            />
            <CardContent> 
               <Typography variant="body1">            
                      {enrollment.course.lessons[values.drawer].content}
               </Typography>
            </CardContent>
            <CardActions>
               <a href={enrollment.course.lessons[values.drawer].resource_url}>                       
                    <Button variant="contained" color="primary">
                        Resource Link</Button>
               </a>
            </CardActions>
     </Card>
  </>
)}

这将渲染所选课程的详细信息,包括标题、内容和资源 URL 值。通过这种实现,我们现在有了一种让用户报名课程并查看他们报名详情的方法。这种报名数据最初是从课程详情创建的,但也会存储特定于报名学生的详细信息,以及他们在课程和课程整体中的进度。为了能够记录和跟踪这种进度,并向学生和教师显示相关的统计信息,我们将在下一节进一步更新此实现以添加这些功能。

跟踪进度和报名统计

在 MERN Classroom 这样的教室应用程序中,让学生可视化他们在报名课程中的进度,并让教师看到有多少学生报名并完成了他们的课程,这可能非常有价值。

在这个应用中,一旦学生报名参加一门课程,他们就能逐个完成课程中的每一课,并标记为完成,直到所有课程都完成,整个课程才算完成。应用会提供视觉提示,让学生知道他们在课程中的报名状态。对于讲师来说,一旦他们发布了一门课程,我们会显示报名该课程的学生总数,以及完成该课程的学生总数。

在以下章节中,我们将实现这些功能,从让用户完成课程并跟踪他们在课程中的进度开始,然后列出他们的报名,带有哪些已完成和哪些正在进行中的指示,最后显示每个发布的课程的报名统计数据。

完成课程

我们必须扩展报名 API 和报名视图实现,以便学生首先完成课程,然后完成整个课程。我们将在后端添加一个课程完成 API,并在前端使用这个 API 在用户执行此操作时标记课程为完成。在以下章节中,我们将添加这个 API,然后修改Enrollment组件以使用这个 API,并视觉上指示哪些课程已完成。

完成的课程 API

我们将在后端为报名添加一个complete API 端点,该端点将标记指定的课程为完成,当所有课程都完成时,也会标记报名的课程为完成。为了实现这个 API,我们将首先声明一个 PUT 路由,如下面的代码所示:

mern-classroom/server/routes/enrollment.routes.js

router.route('/api/enrollment/complete/:enrollmentId')
  .put(authCtrl.requireSignin, 
        enrollmentCtrl.isStudent, 
            enrollmentCtrl.complete)

当接收到'/api/enrollment/complete/:enrollmentId' URL 的 PUT 请求时,我们首先确保已登录的用户是与这个报名记录关联的学生,然后我们将调用complete报名控制器方法。complete方法定义如下:

mern-classroom/server/controllers/enrollment.controller.js

const complete = async (req, res) => {
  let updatedData = {}
  updatedData['lessonStatus.$.complete']= req.body.complete 
  updatedData.updated = Date.now()
  if(req.body.courseCompleted)
    updatedData.completed = req.body.courseCompleted
    try {
      let enrollment = await 
                        Enrollment.updateOne({'lessonStatus._id':                                                
                                               req.body.lessonStatusId}, 
                                             {'$set': updatedData})
      res.json(enrollment)
    } catch (err) {
      return res.status(400).json({
        error: errorHandler.getErrorMessage(err)
      })
    }
}

在这个complete方法中,我们使用 MongoDB 的updateOne操作来更新包含对应于请求中提供的lessonStatusId值的lessonStatus对象的报名文档。

在生成的报名文档中,我们更新lessonStatus数组中特定对象的complete字段和报名文档的updated字段。如果请求中发送了courseCompleted值,我们也会更新报名文档中的completed字段。一旦报名文档更新成功,它就会作为响应发送回去。

要在前端使用这个 complete API 端点,你还需要定义一个相应的获取方法,就像我们为其他 API 实现所做的那样。这个获取方法应该向完成注册路由发送一个 PUT 请求,并将相关值作为请求发送。如下一节所述,我们将在这个 Enrollment 组件中使用这个实现的 API,以便允许学生完成课程。

视图中的完成课程

Enrollment 组件中,我们在抽屉视图中渲染每个课程的详细信息,我们将给学生提供标记课程为完成的选项。这个选项将根据给定的课程是否已经完成而条件性地渲染。这个选项将被添加到 CardHeaderaction 属性中,如下面的代码所示:

mern-classroom/client/enrollment/Enrollment.js:

action={<Button 
          onClick={markComplete} 
          variant={enrollment.lessonStatus[values.drawer].complete ?                                         
                            'contained' : 'outlined'} color="secondary">
          {enrollment.lessonStatus[values.drawer].complete ?
                            "Completed" : "Mark as complete"}
         </Button>}

如果给定的 lessonStatus 对象中的 complete 属性设置为 true,则渲染一个填充的按钮,上面写着“已完成”,否则渲染一个带有“标记为完成”文本的轮廓按钮。点击此按钮将调用 markComplete 函数,该函数将发出 API 调用来更新数据库中的注册。这个 markComplete 函数定义如下:

mern-classroom/client/enrollment/Enrollment.js:

const markComplete = () => {
   if(!enrollment.lessonStatus[values.drawer].complete){
     const lessonStatus = enrollment.lessonStatus
     lessonStatus[values.drawer].complete = true
     let count = totalCompleted(lessonStatus)
     let updatedData = {}
     updatedData.lessonStatusId = lessonStatus[values.drawer]._id
     updatedData.complete = true
     if(count == lessonStatus.length){
       updatedData.courseCompleted = Date.now()
     }
     complete({
       enrollmentId: match.params.enrollmentId
      }, {
        t: jwt.token
      }, updatedData).then((data) => {
      if (data && data.error) {
        setValues({...values, error: data.error})
      } else {
        setEnrollment({...enrollment, lessonStatus: lessonStatus})
      }
     })
   }
}

在这个函数中,在向后端发出 API 调用之前,我们在 updatedData 对象中准备要随请求发送的值。我们发送 lessonStatus 的详细信息,包括用户完成的课程的 ID 值和设置为 truecomplete 值。我们还计算完成课程的总数是否等于课程总数,这样我们就可以在请求中设置并发送 courseCompleted 值。

完成课程的总数是通过 totalCompleted 函数计算的,该函数定义如下:

mern-classroom/client/enrollment/Enrollment.js:

const totalCompleted = (lessons) => {
  let count = lessons.reduce((total, lessonStatus) => {
                   return total + (lessonStatus.complete ? 1 : 0)}, 0)
  setTotalComplete(count)
  return count
}

我们使用数组 reduce 函数来查找并统计 lessonStatus 数组中完成课程的计数。这个计数值也存储在状态中,以便可以在抽屉底部的视图中渲染,如下面的截图所示:

截图

学生的课程旁边将有一个勾选图标,作为指示哪些课程已完成或未完成。我们还给学生一个总数,显示完成课程的数量。当所有课程都完成时,课程被认为是完成的。这让学生对自己的课程进度有一个概念。接下来,我们将添加一个功能,允许用户查看他们注册的所有课程的状况。

列出用户的全部注册

一旦学生登录到 MERN Classroom,他们就能在主页上查看他们所有注册的列表。为了实现这个功能,我们首先定义一个后端 API,它返回给定用户的注册列表,然后在前端使用它来向用户渲染注册列表。

注册列表 API

注册列表 API 将接收一个 GET 请求并查询 Enrollments 集合,以找到具有与学生参考匹配的当前登录用户的注册。为了实现这个 API,我们首先声明 '/api/enrollment/enrolled' 的 GET 路由,如下面的代码所示:

mern-classroom/server/routes/enrollment.routes.js

router.route('/api/enrollment/enrolled')
  .get(authCtrl.requireSignin, enrollmentCtrl.listEnrolled)

对这个路由的 GET 请求将调用 listEnrolled 控制器方法,该方法将查询数据库并将结果作为响应返回给客户端。listEnrolled 方法定义如下:

mern-classroom/server/controllers/enrollment.controller.js

const listEnrolled = async (req, res) => {
  try {
    let enrollments = await Enrollment.find({student: req.auth._id})
                                            .sort({'completed': 1})
                                            .populate('course', '_id name category')
    res.json(enrollments)
  } catch (err) {
    console.log(err)
    return res.status(400).json({
      error: errorHandler.getErrorMessage(err)
    })
  }
}

Enrollments 集合的查询将找到所有与学生参考匹配的用户 ID 相匹配的注册。结果注册将被填充有引用课程的名称和类别值,并且列表将被排序,以便完成的注册放在未完成的注册之后。

通过在客户端为这个 API 定义相应的获取方法,我们可以在将渲染这些注册的 React 组件中调用它。我们将在下一节中查看这个组件的实现。

注册组件

Enrollments 组件将在主页上渲染,并且它将从 Home 组件接收注册列表作为属性。接收到的注册列表将在这个组件中按顺序渲染,以便向用户展示他们已注册的课程。我们还将使用每个状态的代表性图标来指示列表中的注册课程是否已完成,或正在进行中,如下面的截图所示:

图片

这个列出注册的视图将非常类似于 Courses 组件,该组件列出已发布的课程。在 Enrollments 中,而不是课程,将迭代从 Home 组件接收到的注册以渲染每个注册,如下面的代码所示:

mern-classroom/client/enrollment/Enrollments.js

{props.enrollments.map((course, i) => (
  <GridListTile key={i}>
     <Link to={"/learn/"+course._id}>
       <img src={'/api/courses/photo/'+course.course._id} 
           alt= {course.course.name} />
     </Link>
     <GridListTileBar
       title={<Link to={"/learn/"+course._id}>{course.course.name}</Link>}
       actionIcon={<div> {course.completed ? 
 (<CompletedIcon color="secondary"/>)
 : (<InProgressIcon/>)
 }
                   </div>}
     />
  </GridListTile>
))}

根据单个注册是否已经有一个 complete 日期值,我们将有条件地渲染图标。这将使用户了解他们已经完成的注册课程,以及他们尚未完成的课程。

既然我们已经实现了允许应用程序中的学生注册课程、完成课程和跟踪进度的功能,我们也可以通过扩展这些实现来提供关于课程的注册统计信息,正如我们接下来将要看到的。

报名统计信息

一旦讲师发布课程,并且应用程序中的其他用户开始报名并完成课程中的课程,我们将显示课程的报名总数和课程完成数作为简单的报名统计信息。为了实现此功能,在以下章节中,我们首先实现一个返回报名统计信息的 API,然后展示这些统计信息在视图中的显示。

报名统计 API

为了实现一个将查询数据库中的Enrollments集合以计算特定课程统计信息的后端 API,我们首先需要在'/api/enrollment/stats/:courseId'上声明一个 GET 路由,如下所示。

mern-classroom/server/routes/enrollment.routes.js

router.route('/api/enrollment/stats/:courseId')
  .get(enrollmentCtrl.enrollmentStats)

在此 URL 上的 GET 请求将返回一个stats对象,其中包含课程的全部报名和全部完成数,这些是通过 URL 参数中提供的courseId确定的。此实现定义在enrollmentStats控制器方法中,如下所示。

mern-classroom/server/controllers/enrollment.controller.js

const enrollmentStats = async (req, res) => {
  try {
    let stats = {}
    stats.totalEnrolled = await Enrollment.find({course:req.course._id})
                                          .countDocuments()
    stats.totalCompleted = await Enrollment.find({course:req.course._id})
                                         .exists('completed', true)
                                          .countDocuments()
    res.json(stats)
  } catch (err) {
    return res.status(400).json({
      error: errorHandler.getErrorMessage(err)
    })
  }
} 

在这个enrollmentStats方法中,我们使用请求中提供的课程 ID 对Enrollments集合执行两个查询。在第一个查询中,我们简单地找到给定课程的全部报名,并使用 MongoDB 的countDocuments()对这些结果进行计数。在第二个查询中,我们找到给定课程的全部报名,并检查这些报名中是否存在completed字段。然后我们最终得到这些结果的计数。这些数字将作为响应发送回客户端。

与其他 API 实现类似,您还需要在客户端定义相应的 fetch 方法,该方法将向此路由发出 GET 请求。使用此 fetch 方法,我们将检索并显示每个已发布课程的这些统计信息,如下一节所述。

显示已发布课程的报名统计信息

报名统计信息可以从后端检索并在课程视图中渲染,如下所示:

为了检索这些报名统计信息,我们将在Course组件中添加第二个useEffect钩子,以便对报名统计 API 进行 fetch 调用,如下所示:

mern-classroom/client/course/Course.js

  useEffect(() => {
    const abortController = new AbortController()
    const signal = abortController.signal
    enrollmentStats({courseId: match.params.courseId}, 
                    {t:jwt.token}, signal).then((data) => {
                      if (data.error) {
                        setValues({...values, error: data.error})
                      } else {
                        setStats(data)
                      }
                   })
    return function cleanup(){
      abortController.abort()
    }
  }, [match.params.courseId])

这将接收给定课程的报名统计信息,并将其设置到状态中的stats变量,我们可以在视图中渲染它,如下所示:

mern-classroom/client/course/Course.js

{course.published && 
    (<div> <span> <PeopleIcon /> {stats.totalEnrolled} enrolled </span>
           <span> <CompletedIcon/> {stats.totalCompleted} completed </span>
     </div>)
}

在将此功能添加到课程组件后,任何正在浏览 MERN Classroom 应用程序中课程的访客,应用程序中的已发布课程将看起来如下所示:

这张课程页面截图,包含了课程详情、报名选项和报名统计信息,成功地捕捉了我们为了实现这一视图而在本章中实现的所有功能。一个注册了教室应用的用户成为了教育者,创建并发布了这个课程,其中包含课程内容。然后,其他用户报名参加了课程并完成了课程内容,从而生成了报名统计信息。我们只是简单地扩展了 MERN 框架应用,添加了更多模型、API 和 React 前端组件,这些组件检索并渲染了接收到的数据,以构建一个完整的教室应用。

摘要

在本章中,我们通过扩展框架应用开发了名为 MERN Classroom 的简单在线教室应用。我们集成了允许用户拥有多个角色的功能,包括教育者和学生;作为讲师添加和发布包含课程内容的课程;作为学生报名课程并完成课程内容;以及跟踪课程完成进度和报名统计信息。

在实现这些功能的过程中,我们练习了如何扩展构成前端-后端同步应用的全栈组件切片。我们通过实现数据架构和模型、添加新的后端 API 以及将这些 API 与前端的新 React 组件集成来添加新功能,从而完成全栈切片。通过逐步构建这个应用,从较小的实现单元到复杂和组合功能,你现在应该对如何结合 MERN 基础的全栈应用的不同部分有了更好的理解。

为了学习如何整合更加复杂的功能,并找到在使用此堆栈开发高级现实世界应用时可能遇到的棘手问题的解决方案,我们将在下一章开始构建一个基于 MERN 的、功能丰富的在线市场应用。