Skip to content

Latest commit

 

History

History
1737 lines (1309 loc) · 54.7 KB

File metadata and controls

1737 lines (1309 loc) · 54.7 KB

六、通过在线市场练习新的 MERN 技能

随着越来越多的企业继续转向网络,在在线市场环境下进行买卖的能力已成为许多网络平台的核心要求。在本章和下一章中,我们将利用 MERN stack 技术开发一个在线市场应用,该应用具有使用户能够买卖的功能。

在本章中,我们将通过扩展具有以下功能的 MERN 框架来开始构建在线市场:

  • 具有卖家帐户的用户
  • 店铺管理
  • 产品管理
  • 按名称和类别进行产品搜索

梅恩市场

MERN Marketplace 应用将允许用户成为卖家,他们可以管理多个店铺,并在每个店铺添加他们想要销售的产品。访问 MERN Marketplace 的用户将能够搜索和浏览他们想要购买的产品,并将产品添加到他们的购物车以下订单:

The code for the complete MERN Marketplace application is available on GitHub: github.com/shamahoque/mern-marketplace. The implementations discussed in this chapter can be accessed in the seller-shops-products branch of the repository. You can clone this code and run the application as you go through the code explanations in the rest of this chapter. 

将通过扩展和修改 MERN skeleton 应用中的现有 React 组件来开发与卖家帐户、商店和产品相关的功能所需的视图。下图中的组件树显示了构成本章开发的 MERN Marketplace 前端的所有自定义 React 组件:

作为卖家的用户

任何在 MERN Marketplace 注册的用户都可以通过更新其个人资料选择成为卖家:

与普通用户不同,成为卖家将允许用户创建和管理自己的店铺,在那里他们可以管理产品:

要添加此卖家功能,我们需要更新用户模型、编辑纵断面图,并将“我的店铺”链接添加到仅卖家可见的菜单。

更新用户模型

用户模型需要一个卖家值,默认情况下,卖家值设置为false表示普通用户,也可以设置为true表示同样是卖家的用户。

mern-marketplace/server/models/user.model.js

seller: {
    type: Boolean,
    default: false
}

The seller value must be sent to the client with the user details received on successful sign-in, so the view can be rendered accordingly to show information relevant to the seller.

更新编辑纵断面图

登录用户将在编辑纵断面图中看到一个切换,以激活或停用卖方功能。我们将更新EditProfile组件,在FormControlLabel中添加Material-UI``Switch组件。

mern-marketplace/client/user/EditProfile.js

<Typography type="subheading" component="h4" className={classes.subheading}>
    Seller Account
</Typography>
<FormControlLabel
    control = { <Switch classes={{ checked: classes.checked, bar: classes.bar}}
                  checked={this.state.seller}
                  onChange={this.handleCheck}
                /> }
    label={this.state.seller? 'Active' : 'Inactive'}
/>

对开关的任何更改都将通过调用handleCheck方法设置为sellerin 状态的值。

mern-marketplace/client/user/EditProfile.js

handleCheck = (event, checked) => {
    this.setState({'seller': checked})
} 

提交时,seller值将添加到更新中发送到服务器的详细信息中。

mern-marketplace/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,
      seller: this.state.seller
    }
    update({
      userId: this.match.params.userId
    }, {
      t: jwt.token
    }, user).then((data) => {
      if (data.error) {
        this.setState({error: data.error})
      } else {
        auth.updateUser(data, ()=> {
 this.setState({'userId':data._id,'redirectToProfile':true})
 })
      }
    })
  }

更新成功后,sessionStorage中存储的用于身份验证的用户详细信息也应更新。调用auth.updateUser方法来进行sessionStorage更新。它与其他auth-helper.js方法一起定义,并将更新的用户数据和更新视图的回调函数作为参数传递。

mern-marketplace/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()
     }
  }
}

更新菜单

在导航栏中,为了有条件地显示指向My Shops的链接,该链接仅对同时也是卖家的已登录用户可见,我们将在之前的代码中更新Menu组件,如下所示,该代码仅在用户登录时呈现。

mern-marketplace/client/core/Menu.js

{auth.isAuthenticated().user.seller && 
  (<Link to="/seller/shops">
  <Button color = {isPartActive(history, "/seller/")}> My Shops </Button>
   </Link>)
}

市场上的商店

MERN Marketplace 上的卖家可以创建店铺并向每个店铺添加产品。为了存储店铺数据并启用店铺管理,我们将为店铺实施 Mongoose 模式、访问和修改店铺数据的后端 API,以及店铺所有者和买家浏览市场的前端视图。

商店模型

server/models/shop.model.js中定义的店铺模式将具有存储店铺详细信息的简单字段,以及一个徽标图像和对拥有店铺的用户的引用。

  • 店铺名称和说明:名称和说明字段为字符串类型,必填字段为name
name: { 
    type: String, 
    trim: true, 
    required: 'Name is required' 
},
description: { 
    type: String, 
    trim: true 
},
  • 店铺 logo 图像image字段将用户上传的 logo 图像文件作为数据存储在 MongoDB 数据库中:
image: { 
    data: Buffer, 
    contentType: String 
},
  • 店主:店主字段将引用正在创建店铺的用户:
owner: {
    type: mongoose.Schema.ObjectId, 
    ref: 'User'
}
  • 时刻创建更新:createdupdated字段为Date类型,新增店铺时生成created,修改店铺明细时变更updated
updated: Date,
created: { 
    type: Date, 
    default: Date.now 
},

此模式定义中的字段将使我们能够在 MERN Marketplace 中实现所有与商店相关的功能。

创建一个新商店

在 MERN Marketplace 中,登录的用户和卖家将能够创建新店铺。

创建车间 API

在后端,我们将添加一个 POST 路由,用于验证当前用户是否是卖家,并使用请求中传递的店铺数据创建一个新店铺。

mern-marketplace/server/routes/shop.routes.js

router.route('/api/shops/by/:userId')
    .post(authCtrl.requireSignin,authCtrl.hasAuthorization, 
           userCtrl.isSeller, shopCtrl.create)

shop.routes.js文件将非常类似于user.routes文件,要在 Express 应用中加载这些新路线,我们需要在express.js中装载店铺路线,就像我们在验证和用户路线中所做的那样。

mern-marketplace/server/express.js

app.use('/', shopRoutes)

我们将更新用户控制器以添加isSeller方法,这将确保当前用户在创建新店铺之前实际上是卖家。

mern-marketplace/server/controllers/user.controller.js

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

shop controller 中的create方法使用formidablenpm 模块解析多部分请求,该请求可能包含用户上传的店铺徽标图像文件。如果有文件,formidable会临时存储在文件系统中,我们会使用fs模块读取文件类型和数据,将其存储到车间文档的image字段中。

mern-marketplace/server/controllers/shop.controller.js

const create = (req, res, next) => {
  let form = new formidable.IncomingForm()
  form.keepExtensions = true
  form.parse(req, (err, fields, files) => {
    if (err) {
      res.status(400).json({
        message: "Image could not be uploaded"
      })
    }
    let shop = new Shop(fields)
    shop.owner= req.profile
    if(files.image){
      shop.image.data = fs.readFileSync(files.image.path)
      shop.image.contentType = files.image.type
    }
    shop.save((err, result) => {
      if (err) {
        return res.status(400).json({
          error: errorHandler.getErrorMessage(err)
        })
      }
      res.status(200).json(result)
    })
  })
}

The logo image file for the shop is uploaded by the user and stored in MongoDB as data. Then, in order to be shown in the views, it is retrieved from the database as an image file at a separate GET API. The GET API is set up as an Express route at /api/shops/logo/:shopId, which gets the image data from MongoDB and sends it as a file in the response. The implementation steps for file upload, storage, and retrieval are outlined in detail in the Upload profile photo section in Chapter 5, Starting with a Simple Social Media Application.

在视图中获取创建 API

在前端,为了使用这个创建 API,我们将在client/shop/api-shop.js中设置一个fetch方法,通过传递多部分表单数据对创建 API 进行 post 请求:

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

新闻商店组件

NewShop组件中,我们将呈现一个表单,允许卖家通过输入名称和描述并从其本地文件系统上载徽标图像文件来创建店铺:

我们将使用 MaterialUI 按钮和 HTML5 文件输入元素添加文件上传元素。

mern-marketplace/client/shop/NewShop.js

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

名称和说明表单字段将添加TextField组件。

mern-marketplace/client/shop/NewShop.js

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

这些表单字段更改将通过handleChange方法进行跟踪。

mern-marketplace/client/shop/NewShop.js

handleChange = name => event => {
    const value = name === 'image'
      ? event.target.files[0]
      : event.target.value
    this.shopData.set(name, value)
    this.setState({ [name]: value })
}

handleChange方法使用新值更新状态并填充shopData,这是一个FormData对象,确保数据以multipart/form-data编码类型所需的正确格式存储。shopData对象在componentDidMount中初始化。

mern-marketplace/client/shop/NewShop.js

componentDidMount = () => {
  this.shopData = new FormData()
}

在表单提交时,在clickSubmit函数中调用create获取方法。

mern-marketplace/client/shop/NewShop.js

  clickSubmit = () => {
    const jwt = auth.isAuthenticated()
    create({
      userId: jwt.user._id
    }, {
      t: jwt.token
    }, this.shopData).then((data) => {
      if (data.error) {
        this.setState({error: data.error})
      } else {
        this.setState({error: '', redirect: true})
      }
    })
 }

成功创建店铺后,用户将重定向回MyShops视图。

mern-marketplace/client/shop/NewShop.js

if (this.state.redirect) {
      return (<Redirect to={'/seller/shops'}/>)
}

NewShop组件只能由同时也是卖家的登录用户查看。因此,我们将在MainRouter组件中添加一个PrivateRoute,该组件将仅为/seller/shop/new处的授权用户呈现此表单。

mern-marketplace/client/MainRouter.js

<PrivateRoute path="/seller/shop/new" component={NewShop}/>

此链接可以添加到卖方可以访问的任何视图组件。

名单商店

在 MERN Marketplace 中,普通用户将能够浏览平台上所有商店的列表,商店所有者将管理自己商店的列表。

列出所有商店

所有商店的列表将从后端获取并显示给最终用户。

商店名单 API

在后端,当服务器在'/api/shops'收到 GET 请求时,我们会在server/routes/shop.routes.js中添加一条路由,检索数据库中存储的所有店铺:

router.route('/api/shops')
    .get(shopCtrl.list)

shop.controller.js中的list控制器方法将查询数据库中的店铺集合,返回所有店铺。

mern-marketplace/server/controllers/shop.controller.js

const list = (req, res) => {
  Shop.find((err, shops) => {
    if (err) {
      return res.status(400).json({
        error: errorHandler.getErrorMessage(err)
      })
    }
    res.json(shops)
  })
}

把所有的商店都找来看看

在前端,为了使用此列表 API 获取店铺,我们将在client/shop/api-shop.js中设置一个fetch方法:

const list = () => {
  return fetch('/api/shops', {
    method: 'GET',
  }).then(response => {
    return response.json()
  }).catch((err) => console.log(err))
}

车间组件

Shops组件中,我们将在组件挂载时获取数据并将数据设置为状态后,在List物料界面中呈现店铺列表:

componentDidMount中调用loadShops方法,在组件安装时加载车间。

mern-marketplace/client/shop/Shops.js

componentDidMount = () => {
    this.loadShops()
}

它使用listfetch 方法检索车间列表,并将数据设置为 state。

mern-marketplace/client/shop/Shops.js

loadShops = () => {
    list().then((data) => {
      if (data.error) {
        console.log(data.error)
      } else {
        this.setState({shops: data})
      }
    })
 }

Shops组件中,使用map迭代检索到的店铺数组,每个店铺的数据呈现在物料 UIListItem的视图中,每个ListItem也链接到单个店铺的视图。

mern-marketplace/client/shop/Shops.js

{this.state.shops.map((shop, i) => {
  return <Link to={"/shops/"+shop._id} key={i}>
          <Divider/>
          <ListItem button>
            <ListItemAvatar>
            <Avatar src={'/api/shops/logo/'+shop._id+"?" + new 
            Date().getTime()}/>
            </ListItemAvatar>
            <div>
              <Typography type="headline" component="h2" 
             color="primary">
                {shop.name}
              </Typography>
              <Typography type="subheading" component="h4">
                {shop.description}
              </Typography>
            </div>
           </ListItem><Divider/>
         </Link>})}

最终用户将在/shops/all访问Shops组件,使用 React 路由进行设置,并在MainRouter.js中声明。

mern-marketplace/client/MainRouter.js

 <Route path="/shops/all" component={Shops}/>

按业主列出店铺名单

授权卖家将看到他们创建的店铺列表,他们可以通过编辑或删除列表中的任何店铺来管理该列表。

按业主划分的店铺

我们将添加一个 GET 路由来检索特定用户拥有的店铺,并将其添加到后端中声明的店铺路由中。

mern-marketplace/server/routes/shop.routes.js

router.route('/api/shops/by/:userId')
    .get(authCtrl.requireSignin, authCtrl.hasAuthorization, shopCtrl.listByOwner)

为了处理:userId参数并从数据库中检索相关用户,我们将使用用户控制器中的userByID方法。我们将在shop.routes.js中的Shop路由中添加以下内容,这样用户在request对象中可以作为profile使用。

mern-marketplace/server/routes/shop.routes.js

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

shop.controller.js中的listByOwner控制器方法查询数据库中的Shop集合,得到匹配店铺。

mern-marketplace/server/controllers/shop.controller.js

const listByOwner = (req, res) => {
  Shop.find({owner: req.profile._id}, (err, shops) => {
    if (err) {
      return res.status(400).json({
        error: errorHandler.getErrorMessage(err)
      })
    }
    res.json(shops)
  }).populate('owner', '_id name')
}

在对店铺集合的查询中,我们找到了所有店铺,owner字段与使用userId参数指定的用户匹配。

获取视图中用户拥有的所有店铺

在前端,为了使用此 list by owner API 获取特定用户的店铺,我们将在client/shop/api-shop.js中添加一个获取方法:

const listByOwner = (params, credentials) => {
  return fetch('/api/shops/by/'+params.userId, {
    method: 'GET',
    headers: {
      'Accept': 'application/json',
      'Authorization': 'Bearer ' + credentials.t
    }
  }).then((response) => {
    return response.json()
  }).catch((err) => {
    console.log(err)
  })
}

MyShops 组件

MyShops组件与Shops组件类似,它在componentDIdMount中获取当前用户拥有的店铺列表,并在ListItem中呈现每个店铺:

此外,每个商店都有一个editdelete选项,这与shops中的项目列表不同。

mern-marketplace/client/shop/MyShops.js

<ListItemSecondaryAction>
   <Link to={"/seller/shop/edit/" + shop._id}>
       <IconButton aria-label="Edit" color="primary">
             <Edit/>
       </IconButton>
   </Link>
   <DeleteShop shop={shop} onRemove={this.removeShop}/>
</ListItemSecondaryAction>

Edit按钮链接到编辑车间视图。DeleteShop组件处理删除操作,通过调用MyShops传递的removeShop方法更新列表,用当前用户修改后的店铺列表更新状态。

mern-marketplace/client/shop/MyShops.js

removeShop = (shop) => {
    const updatedShops = this.state.shops
    const index = updatedShops.indexOf(shop)
    updatedShops.splice(index, 1)
    this.setState({shops: updatedShops})
}

MyShops组件只能由同时也是卖家的登录用户查看。因此,我们将在MainRouter组件中添加一个PrivateRoute,该组件将在/seller/shops处仅为授权用户呈现此组件。

mern-marketplace/client/MainRouter.js

<PrivateRoute path="/seller/shops" component={MyShops}/>

陈列商店

任何浏览 MERN Marketplace 的用户都可以浏览每个单独的商店。

阅读商店 API

在后端,我们将添加一个GET路由,该路由使用 ID 查询Shop集合,并在响应中返回商店。

mern-marketplace/server/routes/shop.routes.js

router.route('/api/shop/:shopId')
    .get(shopCtrl.read)
router.param('shopId', shopCtrl.shopByID)

路由 URL 中的:shopId参数将调用shopByID控制器方法,类似于userByID控制器方法,从数据库中检索店铺,并将其附加到next方法中使用的请求对象。

mern-marketplace/server/controllers/shop.controller.js

const shopByID = (req, res, next, id) => {
  Shop.findById(id).populate('owner', '_id name').exec((err, shop) => {
    if (err || !shop)
      return res.status('400').json({
        error: "Shop not found"
      })
    req.shop = shop
    next()
  })
}

然后,read控制器方法在响应中将此shop对象返回给客户端。

mern-marketplace/server/controllers/shop.controller.js

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

把商店带到风景区

api-shop.js中,我们将在前端添加一个fetch方法来使用这个读取 API。

mern-marketplace/client/shop/api-shop.js

const read = (params, credentials) => {
  return fetch('/api/shop/' + params.shopId, {
    method: 'GET'
  }).then((response) => {
    return response.json()
  }).catch((err)  => console.log(err) )
}

车间组件

Shop组件将使用产品列表组件呈现车间详细信息和指定车间的产品列表,这将在产品部分中讨论:

在浏览器中可以通过/shops/:shopId路径访问Shop组件,该路径在MainRouter中定义如下。

mern-marketplace/client/MainRouter.js

<Route path="/shops/:shopId" component={Shop}/>

componentDidMount中,使用read方法从api-shop.js获取店铺详细信息。

mern-marketplace/client/shop/Shop.js

componentDidMount = () => {
    read({
      shopId: this.match.params.shopId
    }).then((data) => {
      if (data.error) {
        this.setState({error: data.error})
      } else {
        this.setState({shop: data})
      }
    })
}

检索到的店铺数据设置为状态,并在视图中呈现,以显示店铺名称、徽标和描述。

mern-marketplace/client/shop/Shop.js

<CardContent>
   <Typography type="headline" component="h2">
       {this.state.shop.name}
   </Typography><br/>
   <Avatar src={logoUrl}/><br/>
   <Typography type="subheading" component="h2">
       {this.state.shop.description}
   </Typography><br/>
</CardContent>

logoUrl指向从数据库中检索徽标图像(如果存在)的路径,其定义如下。

mern-marketplace/client/shop/Shop.js

const logoUrl = this.state.shop._id
 ? `/api/shops/logo/${this.state.shop._id}?${new Date().getTime()}`
 : '/api/shops/defaultphoto'

编辑商店

授权卖家还可以编辑他们拥有的店铺的详细信息。

编辑商店 API

在后端,我们将添加一条PUT路线,允许授权卖家编辑他们的一家店铺。

mern-marketplace/server/routes/shop.routes.js

router.route('/api/shops/:shopId')
    .put(authCtrl.requireSignin, shopCtrl.isOwner, shopCtrl.update)

isOwner控制器方法确保登录用户实际上是正在编辑的店铺的所有者。

mern-marketplace/server/controllers/shop.controller.js

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

update控制器方法将使用前面讨论的create控制器方法中的formidablefs模块来解析表单数据并更新数据库中的现有店铺。

mern-marketplace/server/controllers/shop.controller.js

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

在视图中获取编辑 API

在视图中使用fetch方法调用编辑 API,该方法获取表单数据并将多部分请求发送到后端。

mern-marketplace/client/shop/api-shop.js

const update = (params, credentials, shop) => {
  return fetch('/api/shops/' + params.shopId, {
    method: 'PUT',
    headers: {
      'Accept': 'application/json',
      'Authorization': 'Bearer ' + credentials.t
    },
    body: shop
  }).then((response) => {
    return response.json()
  }).catch((err) => {
    console.log(err)
  })
}

编辑车间组件

EditShop组件将显示一个类似于创建新店铺表单的表单,预先填充了现有店铺详细信息。此组件还将显示本店的产品列表,将在产品部分讨论:

表单部分类似于NewShop组件中的表单,具有相同的表单字段和一个formData对象,该对象保存通过update获取方法发送的多部分表单数据。

EditShop组件只能由授权的店主访问。因此我们将在MainRouter组件中添加一个PrivateRoute,该组件将仅在/seller/shop/edit/:shopId为授权用户呈现该组件。

mern-marketplace/client/MainRouter.js

<PrivateRoute path="/seller/shop/edit/:shopId" component={EditShop}/>

此链接为MyShops组件中的每个店铺添加了一个编辑图标。

删除店铺

授权卖家可以从MyShops列表中删除自己的任何店铺。

删除商店 API

在后端,我们将添加一条DELETE路线,允许授权卖家删除他们自己的一家店铺。

mern-marketplace/server/routes/shop.routes.js

router.route('/api/shops/:shopId')
    .delete(authCtrl.requireSignin, shopCtrl.isOwner, shopCtrl.remove)

如果isOwner确认登录用户是店铺的所有者,则remove控制器方法从数据库中删除指定店铺。

mern-marketplace/server/controllers/shop.controller.js

const remove = (req, res, next) => {
  let shop = req.shop
  shop.remove((err, deletedShop) => {
    if (err) {
      return res.status(400).json({
        error: errorHandler.getErrorMessage(err)
      })
    }
    res.json(deletedShop)
  })
}

在视图中获取删除 API

我们将在前端添加一个相应的方法,向 deleteapi 发出删除请求。

mern-marketplace/client/shop/api-shop.js

const remove = (params, credentials) => {
  return fetch('/api/shops/' + params.shopId, {
    method: 'DELETE',
    headers: {
      'Accept': 'application/json',
      'Content-Type': 'application/json',
      'Authorization': 'Bearer ' + credentials.t
    }
  }).then((response) => {
    return response.json()
  }).catch((err) => {
    console.log(err)
  })
}

删除车间组件

列表中每个车间的DeleteShop组件添加到MyShops组件中。它以shop对象和onRemove方法作为MyShops的道具:

这个组件基本上是一个图标按钮,单击它会打开一个确认对话框,询问用户是否确定要删除他们的店铺。

mern-marketplace/client/shop/DeleteShop.js

<IconButton aria-label="Delete" onClick={this.clickButton} color="secondary">
   <DeleteIcon/>
</IconButton>
<Dialog open={this.state.open} onRequestClose={this.handleRequestClose}>
   <DialogTitle>{"Delete "+this.props.shop.name}</DialogTitle>
      <DialogContent>
         <DialogContentText>
            Confirm to delete your shop {this.props.shop.name}.
         </DialogContentText>
      </DialogContent>
      <DialogActions>
         <Button onClick={this.handleRequestClose} color="primary">
            Cancel
         </Button>
         <Button onClick={this.deleteShop} color="secondary" 
          autoFocus="autoFocus">
            Confirm
         </Button>
      </DialogActions>
</Dialog>

在对话框中用户确认删除后,在deleteShop中调用delete获取方法。

mern-marketplace/client/shop/DeleteShop.js

  deleteShop = () => {
    const jwt = auth.isAuthenticated()
    remove({
      shopId: this.props.shop._id
    }, {t: jwt.token}).then((data) => {
      if (data.error) {
        console.log(data.error)
      } else {
        this.setState({open: false}, () => {
          this.props.onRemove(this.props.shop)
        })
      }
    })
 }

删除成功后,对话框关闭,MyShops中的店铺列表通过调用onRemove道具更新,该道具将removeShop方法作为道具从MyShops传入。

这些店铺视图将允许买家和卖家与店铺互动。商店还将有产品,下面将讨论,所有者将管理这些产品,购买者将浏览这些产品,并选择将其添加到购物车中。

产品

产品是市场应用中最关键的方面。在 MERN Marketplace 中,卖家可以在其店铺中管理产品,访客可以搜索和浏览产品。

产品模型

产品将存储在数据库中的产品集合中,使用 Mongoose 定义模式。对于 MERN Marketplace,我们将保持产品模式的简单性,并支持诸如产品名称、描述、图像、类别、数量、价格、创建地点、更新地点和商店参考等字段。

  • 产品名称及说明namedescription字段为String类型,其中name字段为required字段:
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 
},
  • 产品数量quantity字段表示店铺可供销售的金额:
quantity: { 
    type: Number, 
    required: "Quantity is required" 
},
  • 产品价格price字段将保存此产品将花费买方的单价:
price: { 
    type: Number, 
    required: "Price is required" 
},
  • 产品车间shop字段将引用添加产品的车间:
shop: {
    type: mongoose.Schema.ObjectId, 
    ref: 'Shop'
}
  • 时间创建更新:createdupdated字段为Date类型,新增产品时生成created,修改同一产品明细时updated时间发生变化:
updated: Date,
created: { 
    type: Date, 
    default: Date.now 
},

此模式定义中的字段将使我们能够在 MERN Marketplace 中实现所有与产品相关的功能。

创建新产品

MERN Marketplace 的卖家将能够向他们拥有的店铺添加新产品,并在平台上创建新产品。

创建产品 API

在后端,我们将在/api/products/by/:shopId添加一个路由,该路由接受包含产品数据的POST请求,以创建与:shopId参数标识的店铺关联的新产品。在数据库中创建新产品之前,处理此请求的代码将首先检查当前用户是否是要添加新产品的商店的所有者。

此创建产品 API 路由在product.routes.js文件中声明,它使用来自车间控制器的shopByIDisOwner方法来处理:shopId参数,并验证当前用户是否为车间所有者。

mern-marketplace/server/routes/product.routes.js

router.route('/api/products/by/:shopId')
  .post(authCtrl.requireSignin, 
            shopCtrl.isOwner, 
                productCtrl.create)
router.param('shopId', shopCtrl.shopByID)

product.routes.js文件将非常类似于shop.routes.js文件,要在 Express app 中加载这些新路线,我们需要在express.js中装载产品路线,就像我们在商店路线中所做的那样。

mern-marketplace/server/express.js

app.use('/', productRoutes)

产品控制器中的create方法使用formidablenpm 模块解析可能包含用户上传的图像文件以及产品字段的多部分请求。解析后的数据作为新产品保存到Product集合中。

mern-marketplace/server/controllers/product.controller.js

const create = (req, res, next) => {
  let form = new formidable.IncomingForm()
  form.keepExtensions = true
  form.parse(req, (err, fields, files) => {
    if (err) {
      return res.status(400).json({
        message: "Image could not be uploaded"
      })
    }
    let product = new Product(fields)
    product.shop= req.shop
    if(files.image){
      product.image.data = fs.readFileSync(files.image.path)
      product.image.contentType = files.image.type
    }
    product.save((err, result) => {
      if (err) {
        return res.status(400).json({
          error: errorHandler.getErrorMessage(err)
        })
      }
      res.json(result)
    })
  })
}

在视图中获取创建 API

在前端,为了使用这个创建 API,我们将在client/product/api-product.js中设置一个fetch方法,通过从视图传递多部分表单数据向创建 API 发出 post 请求。

mern-marketplace/client/product/api-product.js

const create = (params, credentials, product) => {
  return fetch('/api/products/by/'+ params.shopId, {
      method: 'POST',
      headers: {
        'Accept': 'application/json',
        'Authorization': 'Bearer ' + credentials.t
      },
      body: product
    })
    .then((response) => {
      return response.json()
    }).catch((err) => console.log(err))
}

新产品组件

NewProduct部件与NewShop部件相似。它将包含一个表单,允许卖家通过输入名称、描述、类别、数量和价格,并从其本地文件系统上载产品映像文件来创建产品:

NewProduct组件将仅在与特定店铺关联的路线上加载,因此只有作为卖家的登录用户才能将产品添加到他们拥有的店铺中。为了定义此路由,我们在MainRouter组件中添加了一个PrivateRoute,它将仅为/seller/:shopId/products/new处的授权用户呈现此表单。

mern-marketplace/client/MainRouter.js

<PrivateRoute path="/seller/:shopId/products/new" component={NewProduct}/>

列出产品

在 MERN Marketplace 中,产品将以多种方式呈现给用户,两个主要区别在于产品为卖家列出的方式和为买家列出的方式。

按店铺列出

市场访客将浏览每家店铺的产品,卖家将管理每家店铺的产品列表。

按店铺空气污染指数划分的产品

要从数据库中的特定商店检索产品,我们将在/api/products/by/:shopId处设置一个获取路径,如下所示。

mern-marketplace/server/routes/product.routes.js

router.route('/api/products/by/:shopId')
    .get(productCtrl.listByShop)

响应此请求执行的listByShop控制器方法将查询产品集合,以返回与给定店铺参考匹配的产品。

mern-marketplace/server/controllers/product.controller.js

const listByShop = (req, res) => {
  Product.find({shop: req.shop._id}, (err, products) => {
    if (err) {
      return res.status(400).json({
        error: errorHandler.getErrorMessage(err)
      })
    }
    res.json(products)
  }).populate('shop', '_id name').select('-image')
}

在前端,为了通过 shop API 使用此列表获取特定店铺中的产品,我们将在api-product.js中添加一个获取方法

mern-marketplace/client/product/api-product.js

const listByShop = (params) => {
  return fetch('/api/products/by/'+params.shopId, {
    method: 'GET'
  }).then((response) => {
    return response.json()
  }).catch((err) => {
    console.log(err)
  }) 
}

面向买家的产品组件

Products组件主要用于向可能购买产品的访客展示产品。我们将使用此组件呈现与买方相关的产品列表。它将从显示产品列表的父组件接收产品列表作为道具:

商店中的产品列表将以单个Shop视图显示给用户。所以这个Products组件被添加到Shop组件中,并作为道具给出了相关产品的列表。searched道具会传递此列表是否是产品搜索的结果,因此可以呈现适当的消息。

mern-marketplace/client/shop/Shop.js

<Products products={this.state.products} searched={false}/></Card>

Shop组件中,我们需要在componentDidMount上添加对listByShopfetch 方法的调用,以检索相关产品并将其设置为 state。

mern-marketplace/client/shop/Shop.js

listByShop({
      shopId: this.match.params.shopId
    }).then((data)=>{
      if (data.error) {
        this.setState({error: data.error})
      } else {
        this.setState({products: data})
      }
}) 

Products组件中,如果道具中发送的产品列表包含产品,则该列表将被迭代,并在物料界面GridListTile中呈现每个产品的相关详细信息,其中链接到单个产品视图和AddToCart组件(具体实现在第 7 章中讨论)扩展订单和付款市场

mern-marketplace/client/product/Products.js

{this.props.products.length > 0 ?
   (<div><GridList cellHeight={200} cols={3}>
       {this.props.products.map((product, i) => (
          <GridListTile key={i}>
            <Link to={"/product/"+product._id}>
              <img src={'/api/product/image/'+product._id}
           alt= {product.name} />
            </Link>
            <GridListTileBar
              title={<Link to={"/product/"+product._id}>{product.name}
           </Link>}
              subtitle={<span>$ {product.price}</span>}
              actionIcon={<AddToCart item={tile}/>}
             />
          </GridListTile>
       ))}
    </GridList></div>) : this.props.searched && 
      (<Typography type="subheading" component="h4">
                         No products found! :(</Typography>)}

Products组件用于呈现商店中的产品、按类别划分的产品以及搜索结果中的产品。

店主的 MyProducts 组件

Products组件不同,client/product/MyProducts.js中的MyProducts组件只用于向卖家展示产品,以便卖家管理每家店铺的产品:

MyProducts组件添加到EditShop视图中,因此卖家可以在一个地方管理店铺及其内容。道具中提供了店铺 ID,因此可以获取相关产品。

mern-marketplace/client/shop/EditShop.js

<MyProducts shopId={this.match.params.shopId}/>

MyProducts中,相关产品首先加载在componentDidMount中。

mern-marketplace/client/product/MyProducts.js

componentDidMount = () => {
   this.loadProducts()
}

loadProducts方法使用相同的listByShop提取方法检索商店中的产品,并将其设置为状态。

mern-marketplace/client/product/MyProducts.js

loadProducts = () => {
    listByShop({
      shopId: this.props.shopId
    }).then((data)=>{
      if (data.error) {
        this.setState({error: data.error})
      } else {
        this.setState({products: data})
      }
    })
}

此产品列表被迭代,每个产品在ListItem中呈现,并带有编辑和删除选项,类似于MyShops列表视图。编辑按钮链接到编辑产品视图。DeleteProduct组件处理删除操作,并通过调用MyProducts传递的onRemove方法重新加载列表,用当前店铺的更新后的产品列表更新状态。

MyProducts中定义的removeProduct方法作为DeleteProduct组件的onRemove道具提供。

mern-marketplace/client/product/MyProducts.js

removeProduct = (product) => {
    const updatedProducts = this.state.products
    const index = updatedProducts.indexOf(product)
    updatedProducts.splice(index, 1)
    this.setState({shops: updatedProducts})
}   
...
<DeleteProduct
       product={product}
       shopId={this.props.shopId}
       onRemove={this.removeProduct}/>

列出产品建议

MERN Marketplace 的访问者将看到产品建议,例如添加到市场的最新产品以及与他们当前查看的产品相关的产品。

最新产品

在 MERN Marketplace 的主页上,我们将展示添加到市场中的五种最新产品。为了获取最新的产品,我们将设置一个 API,该 API 将在/api/products/latest接收 GET 请求。

mern-marketplace/server/routes/product.routes.js

router.route('/api/products/latest')
      .get(productCtrl.listLatest)

listLatest控制器方法将按照created日期从最新到最旧对数据库中的产品列表进行排序,并在响应中返回排序后的列表中的前五个。

mern-marketplace/server/controllers/product.controller.js

const listLatest = (req, res) => {
  Product.find({}).sort('-created').limit(5).populate('shop', '_id   
  name').exec((err, products) => {
    if (err) {
      return res.status(400).json({
        error: errorHandler.getErrorMessage(err)
      })
    }
    res.json(products)
  })
}

在前端,我们将为这个最新的productsAPI 在api-product.js中设置一个对应的获取方法,类似于fetch中通过店铺获取列表的方法。检索到的列表将在添加到主页的Suggestions组件中呈现。

相关产品

在每个产品视图中,我们将显示五个相关产品作为建议。为了检索这些相关产品,我们将在/api/products/related设置一个接受请求的 API。

mern-marketplace/server/routes/product.routes.js

router.route('/api/products/related/:productId')
              .get(productCtrl.listRelated)
router.param('productId', productCtrl.productByID)

route URL route 中的:productId参数将调用productByID控制器方法,该方法类似于shopByID控制器方法,并从数据库中检索产品并将其附加到next方法中要使用的请求对象。

mern-marketplace/server/controllers/product.controller.js

const productByID = (req, res, next, id) => {
  Product.findById(id).populate('shop', '_id name').exec((err, product) => {
    if (err || !product)
      return res.status('400').json({
        error: "Product not found"
      })
    req.product = product
    next()
  })
}

listRelated控制器方法查询Product集合,查找与给定产品类别相同的其他产品,不包括给定产品,并返回结果列表中的前五个产品。

mern-marketplace/server/controllers/product.controller.js

const listRelated = (req, res) => {
  Product.find({ "_id": { "$ne": req.product }, 
                "category": req.product.category}).limit(5)
         .populate('shop', '_id name')
         .exec((err, products) => {
            if (err) {
              return res.status(400).json({
              error: errorHandler.getErrorMessage(err)
            })
         }
    res.json(products)
  })
}

为了在前端使用此相关产品 API,我们将在api-product.js中设置相应的获取方法。fetch 方法将在具有产品 ID 的Product组件中调用,以填充在产品视图中呈现的Suggestions组件。

建议部分

Suggestions组件将显示在主页和单个产品页面上,分别显示最新产品和相关产品:

它将收到来自父组件的相关产品列表作为道具,以及列表标题:

<Suggestions  products={this.state.suggestions} title={this.state.suggestionTitle}/>

Suggestions组件中,对接收到的列表进行迭代,并以相关详细信息、单个产品页面链接和AddToCart组件呈现单个产品。

mern-marketplace/client/product/Suggestions.js

<Typography type="title"> {this.props.title} </Typography>
{this.props.products.map((item, i) => { 
  return <span key={i}> 
           <Card>
             <CardMedia image={'/api/product/image/'+item._id} 
                        title={item.name}/>
                <CardContent>
                   <Link to={'/product/'+item._id}>
                     <Typography type="title" component="h3">
                    {item.name}</Typography>
                   </Link>
                   <Link to={'/shops/'+item.shop._id}>
                     <Typography type="subheading">
                        <Icon>shopping_basket</Icon> {item.shop.name}
                     </Typography>
                   </Link>
                   <Typography component="p">
                      Added on {(new 
                     Date(item.created)).toDateString()}
                   </Typography>
                </CardContent>
                <Typography type="subheading" component="h3">$ 
                 {item.price}</Typography>
                <Link to={'/product/'+item._id}>
                  <IconButton color="secondary" dense="dense">
                    <ViewIcon className={classes.iconButton}/>
                  </IconButton>
                </Link>
                <AddToCart item={item}/>
           </Card>
         </span>})}

展示产品

MERN Marketplace 的访问者将能够浏览每个产品,并在单独的视图中显示更多详细信息。

阅读产品 API

在后端,我们将添加一个 GET 路由,该路由使用 ID 查询Product集合,并在响应中返回产品。

mern-marketplace/server/routes/product.routes.js

router.route('/api/products/:productId')
      .get(productCtrl.read) 

:productId参数调用productByID控制器方法,该方法从数据库检索产品并将其附加到请求对象。read控制器方法使用请求对象中的产品响应read请求。

mern-marketplace/server/controllers/product.controller.js

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

api-product.js中,我们将在前端添加一个 fetch 方法来使用这个 read API。

mern-marketplace/client/product/api-product.js

const read = (params) => {
  return fetch('/api/products/' + params.productId, {
    method: 'GET'
  }).then((response) => {
    return response.json()
  }).catch((err) => console.log(err))
}

产品组件

Product组件将呈现产品详细信息,包括添加到购物车选项,并显示相关产品的列表:

在浏览器中可以通过/product/:productID路径访问Product组件,该路径在MainRouter中定义如下。

mern-marketplace/client/MainRouter.js

<Route path="/product/:productId" component={Product}/>

当用户点击相关列表中的另一个产品后,productId在前端路由路径中发生变化时,组件安装时会获取产品详细信息和相关列表数据,或者会收到新的道具。

mern-marketplace/client/product/Product.js

  componentDidMount = () => {
    this.loadProduct(this.match.params.productId)
  }
  componentWillReceiveProps = (props) => {
    this.loadProduct(props.match.params.productId)
  }

loadProduct方法调用readlistRelated获取方法获取产品和相关列表数据,然后将数据设置为状态。

mern-marketplace/client/product/Product.js

loadProduct = (productId) => {
    read({productId: productId}).then((data) => {
      if (data.error) {
        this.setState({error: data.error})
      } else {
        this.setState({product: data})
        listRelated({
          productId: data._id}).then((data) => {
          if (data.error) {
            console.log(data.error)
          } else {
            this.setState({suggestions: data})
          }
        }) 
      }
    }) 
}

组件的 product details 部分在物料 UICard组件中显示产品和AddToCart组件的相关信息。

mern-marketplace/client/product/Product.js

<Card>
  <CardHeader
    action={<AddToCart cartStyle={classes.addCart} 
    item= {this.state.product}/>}
    title={this.state.product.name}
    subheader={this.state.product.quantity > 0? 'In Stock': 'Out of   
   Stock'}
  />
  <CardMedia image={imageUrl} title={this.state.product.name}/>
  <Typography component="p" type="subheading">
    {this.state.product.description}<br/>
    $ {this.state.product.price}
    <Link to={'/shops/'+this.state.product.shop._id}>
      <Icon>shopping_basket</Icon> {this.state.product.shop.name}
    </Link>
  </Typography>
</Card>
...
<Suggestions  products={this.state.suggestions} title='Related Products'/>

Suggestions组件添加到产品视图中,相关列表数据作为道具传递。

编辑和删除产品

如前几节所述,在应用中编辑和删除产品的实现类似于编辑和删除商店。这些功能需要在后端使用相应的 API,在前端使用获取方法,并使用表单和操作对组件视图进行反应

编辑

编辑功能与创建产品非常相似,EditProduct表单组件也只能由/seller/:shopId/:productId/edit上的验证卖家访问。

mern-marketplace/client/MainRouter.js

<PrivateRoute path="/seller/:shopId/:productId/edit" component={EditProduct}/>

EditProduct组件包含与NewProduct相同的表单,使用读取的产品 API 检索产品的填充值,并使用 fetch 方法将包含 PUT 请求的多部分表单数据发送到后端/api/products/by/:shopId的编辑产品 API。

mern-marketplace/server/routes/product.routes.js

router.route('/api/product/:shopId/:productId')
      .put(authCtrl.requireSignin, shopCtrl.isOwner, productCtrl.update)

update控制器类似于产品create方法和车间update方法;它使用formidable处理多部分表单数据,并扩展产品详细信息以保存更新。

删去

如前所述,DeleteProduct组件添加到列表中每个产品的MyProducts组件中。它将product对象shopIDloadProducts方法作为MyProducts的道具。该组件类似于DeleteShop,当用户确认删除意图后,调用取数方法进行删除,向/api/product/:shopId/:productId服务器发出删除请求。

mern-marketplace/server/routes/product.routes.js

router.route('/api/product/:shopId/:productId')
      .delete(authCtrl.requireSignin, shopCtrl.isOwner, productCtrl.remove)

带类别的产品搜索

在 MERN Marketplace 中,访问者可以按名称和特定类别搜索特定产品。

类别 API

为了允许用户选择要搜索的特定类别,我们将设置一个 API 来检索数据库中Product集合中存在的所有不同类别。对/api/products/categories的 GET 请求将返回一个唯一类别数组。

mern-marketplace/server/routes/product.routes.js

router.route('/api/products/categories')
      .get(productCtrl.listCategories)

listCategories控制器方法通过distinct调用category字段查询Product集合。

mern-marketplace/server/controllers/product.controller.js

const listCategories = (req, res) => {
  Product.distinct('category',{},(err, products) => {
    if (err) {
      return res.status(400).json({
        error: errorHandler.getErrorMessage(err)
      })
    }
    res.json(products)
  })
}

此 categories API 可在前端与相应的获取方法一起使用,以检索不同类别的数组并在视图中显示。

搜索产品 API

search products API 将在/api/products?search=value&category=value接受 GET 请求,URL 中有查询参数,用提供的搜索文本和类别值查询Product集合。

mern-marketplace/server/routes/product.routes.js

router.route('/api/products')
      .get(productCtrl.list)

list控制器方法将首先处理请求中的查询参数,然后查找给定类别中的产品(如果有),其名称与提供的搜索文本部分匹配。

mern-marketplace/server/controllers/product.controller.js

const list = (req, res) => {
  const query = {}
  if(req.query.search)
    query.name = {'$regex': req.query.search, '$options': "i"}
  if(req.query.category && req.query.category != 'All')
    query.category = req.query.category
  Product.find(query, (err, products) => {
    if (err) {
      return res.status(400).json({
        error: errorHandler.getErrorMessage(err)
      })
    }
    res.json(products)
  }).populate('shop', '_id name').select('-image')
}

获取视图的搜索结果

为了在前端使用此搜索 API,我们将设置一个方法,该方法使用查询参数构造 URL 并调用 API 的获取。

mern-marketplace/client/product/api-product.js

import queryString from 'query-string'
const list = (params) => {
  const query = queryString.stringify(params)
  return fetch('/api/products?'+query, {
    method: 'GET',
  }).then(response => {
    return response.json()
  }).catch((err) => console.log(err))
}

为了以正确的格式构造查询参数,我们将使用query-stringnpm 模块,这将有助于将 params 对象字符串化为可附加到请求路由的查询字符串。

搜索组件

应用 categories API 和 search API 的第一个用例是Search组件:

Search组件为用户提供了一个简单的表单,其中包含一个搜索input文本字段和从父组件接收的类别选项下拉列表,父组件将使用 distinct categories API 检索列表。

mern-marketplace/client/product/Search.js

<TextField id="select-category" select label="Select category" value={this.state.category}
     onChange={this.handleChange('category')}
     SelectProps={{ MenuProps: { className: classes.menu, } }}>
  <MenuItem value="All"> All </MenuItem>
  {this.props.categories.map(option => (
    <MenuItem key={option} value={option}> {option} </MenuItem>
        ))}
</TextField>
<TextField id="search" label="Search products" type="search" onKeyDown={this.enterKey}
     onChange={this.handleChange('search')}
/>
<Button raised onClick={this.search}> Search </Button>
<Products products={this.state.results} searched={this.state.searched}/>

一旦用户输入搜索文本并点击Enter,就会调用搜索 API 来检索结果。

mern-marketplace/client/product/Search.js

search = () => {
    if(this.state.search){
      list({
        search: this.state.search || undefined, category: 
      this.state.category
      }).then((data) => {
        if (data.error) {
          console.log(data.error) 
        } else {
          this.setState({results: data, searched:true}) 
        }
      }) 
    }
  }

然后将结果数组作为道具传递给Products组件,以呈现搜索表单下方的匹配产品。

类别组件

Categories组件是不同类别和搜索 API 的第二个用例。对于该组件,我们首先获取父组件中的类别列表,并将其作为道具发送,以向用户显示类别:

当用户在显示的列表中选择一个类别时,只使用一个类别值调用搜索 API,后端返回所选类别中的所有产品。然后返回的产品在Products组件中呈现。

在 MERN Marketplace 的第一个版本中,用户可以成为卖家来创建店铺和添加产品,访问者可以浏览店铺和搜索产品,同时应用还向访问者推荐产品。

总结

在本章中,我们开始使用 MERN 堆栈构建一个在线市场应用。MERN 骨架被扩展为向用户添加卖家角色,这样用户就可以创建商店并向每个商店添加产品,以便销售给其他用户。我们还探讨了如何利用堆栈来实现产品浏览、搜索等功能,并为对购买感兴趣的普通用户提供建议。但是,如果没有用于结账、订单管理和付款处理的购物车,市场应用是不完整的。

在下一章中,我们将扩展应用以添加这些功能,并进一步了解如何使用 MERN 堆栈实现电子商务应用的这些核心方面。