界面效果
【说明】
- 界面中商品的图片来源于闲鱼,若侵权请联系删除
【商品详情】
【评论】
界面实现
工具js
该工具类的作用是,给定一个图片的url地址,计算出图片的高宽比,计算高宽比的作用是让图片可以按照正常比例显示
/** * 获取uuid */ export default { /** * 获取高宽比 乘以 100% */ getAspectRatio(url) { uni.getImageInfo({ src: url, success: function(res) { let aspectRatio = res.height * 100.0 / res.width; // console.log("aspectRatio:" + aspectRatio); return aspectRatio + "%"; } }); }, }
export default { /** * 日期格式化 */ formatDateToString(date) { return new Date(date).toLocaleString(); }, }
页面
<template> <view class="container"> <u-toast ref="uToast"></u-toast> <view class="userItem"> <view class="userProfile"> <u--image :src="productVo.avatar" width="35" height="35" shape="circle"></u--image> <view style="width: 10px;"></view> <view> <view class="nickname">{{productVo.nickname}}</view> <view class="other">10分钟前来过 广东工业大学大学城校区</view> </view> </view> <view class="follow" @click="follow" v-if="hadFollow==false"> <view> <u-icon name="plus" color="#ffffff" style="font-weight: bold;" size="15"></u-icon> </view> <view style="margin-left: 10rpx;font-size: 15px;"> 关 注 </view> </view> <view class="followed" @click="cancelFollow" v-else> <view style="font-size: 15px;color: #C2C2C2;"> 已 关 注 </view> </view> </view> <view class="productItem"> <view class="top"> <view class="price">¥<text class="number">{{productVo.price}}</text>/{{productVo.unit}}</view> <view class="browseInformation"> {{product.starNum}}人想要 | {{product.readNum}}个浏览 </view> </view> <view class="productDetail"> {{productVo.description}} </view> <u--image :showLoading="true" v-for="(pic,index) in productVo.picList" :src="pic" width="100%" :height="getAspectRatio(pic)" radius="10" mode="widthFix"></u--image> </view> <view class="commentView"> <view style="color: #3D3D3D;"> {{commentNum}}条评论 </view> <view v-for="(commentItem,index) in commentVoList"> <view class="commentItem"> <view style="display: flex;"> <u--image :src="commentItem.userAvatar" width="30" height="30" shape="circle"></u--image> <view style="width: 10px;"></view> <view @click="clickShowBottomPopup(1, commentItem.id,commentItem.userNickName)"> <view class="nickname">{{commentItem.userNickName}}</view> <view class="content"> {{commentItem.content}} </view> <view class="dateAndPosition">{{formatDateToString(commentItem.createTime)}}</view> </view> </view> <view style="display: inline-block;text-align: center;"> <u-icon name="thumb-up" size="28" @click="likeComment(commentItem.id,commentItem)" v-if="commentItem.isLike==0"></u-icon> <u-icon name="thumb-up-fill" color="#2B92FF" size="28" @click="cancelLikeComment(commentItem.id,commentItem)" v-else></u-icon> <view style="font-size: 12px;color: #B9B9B9;"> {{commentItem.likeNum}} </view> </view> </view> <view class="sonCommentItem" v-for="(commentItem1,index1) in commentItem.children"> <view style="display: flex;"> <u--image :src="commentItem1.userAvatar" width="30" height="30" shape="circle"></u--image> <view style="width: 10px;"></view> <view @click="clickShowBottomPopup(1, commentItem1.id,commentItem1.userNickName)"> <view class="nickname">{{commentItem1.userNickName}}</view> <view class="content"> <text style="font-size: 14px;"> 回复了<text style="color:#B9B9B9 ;">{{commentItem1.toUserNickName}}</text>: </text> <text> {{ commentItem1.content }} </text> </view> <view class="dateAndPosition">{{formatDateToString(commentItem1.createTime)}}</view> </view> </view> <view style="display: inline-block;text-align: center;"> <u-icon name="thumb-up" size="28" @click="likeComment(commentItem1.id,commentItem1)" v-if="commentItem1.isLike==0"></u-icon> <u-icon name="thumb-up-fill" color="#2B92FF" size="28" @click="cancelLikeComment(commentItem1.id, commentItem1)" v-else></u-icon> <view style="font-size: 12px;color: #B9B9B9;"> {{commentItem1.likeNum}} </view> </view> </view> </view> </view> <view class="footer"> <view> <view class="item" @click="clickShowBottomPopup(0, productVo.id,)"> <u-icon name="chat" size="28"></u-icon> <view class="comment">评论</view> </view> <view class="item" @click="starProduct()" v-if="hadStar==false"> <u-icon name="star" size="28"></u-icon> <view class="comment">我想要</view> </view> <view class="item" @click="cancelStar()" v-if="hadStar==true"> <u-icon name="star-fill" color="#2B92FF" size="28"></u-icon> <view class="comment" style="color: #2B92FF">已收藏</view> </view> </view> <view class="chat"> <u-icon name="chat" color="#ffffff" size="18"></u-icon> <view style="width: 5px;"></view> 私 聊 </view> </view> <!-- 底部弹出框:用于输入评论 --> <!-- @close="this.showBottomPopup=false" 点击遮罩层关闭弹框 --> <u-popup :show="showBottomPopup" mode="bottom" :round="10" @close="this.showBottomPopup=false"> <view class="commentPopup"> <u--textarea v-model="comment.content" :placeholder="commentPlaceHolder" autoHeight height="200" border="surround"></u--textarea> <view class="commentButton" @click="commitComment()"> <u-icon name="chat" color="#ffffff" size="18"></u-icon> <view style="width: 5px;"></view> 评 论 </view> </view> </u-popup> </view> </template> <script> import pictureApi from "@/utils/picture.js"; import { addFollow, hadFollowSomeone, cancelFollowSomeone } from "@/api/market/follow.js"; import { starProduct, cancelStar, hadStar } from "@/api/market/star.js"; import { addComment, listCommentVoOfProduct } from "@/api/market/comment.js"; import dateUtil from "@/utils/date.js"; import { likeComment, cancelLikeComment } from "@/api/market/commentLike.js" import { getProduct } from "@/api/market/prodct.js" export default { data() { return { productVo: {}, product: {}, // 是否已经关注商品主人 hadFollow: false, // 是否已经收藏商品 hadStar: false, // 是否显示底部弹出框 showBottomPopup: false, // 评论 comment: { itemId: undefined, type: undefined, content: '', isTop: 0 }, // 存储商品对应的评论集合 commentVoList: [], // 评论数量 commentNum: undefined, commentPlaceHolder: "", } }, methods: { /** * 获取高宽比 乘以 100% */ getAspectRatio(url) { // uni.getImageInfo({ // src: url, // success: function(res) { // let aspectRatio = res.height * 100.0 / res.width; // // console.log("aspectRatio:" + aspectRatio); // return aspectRatio + "%"; // } // }); return pictureApi.getAspectRatio(url); }, /** * 关注用户 */ follow() { let data = { followedId: this.productVo.userId } addFollow(data).then(res => { this.hadFollow = true; this.$refs.uToast.show({ type: 'success', message: "关注成功", duration: 300 }) }).catch(err => { this.$refs.uToast.show({ type: 'error', message: err.msg, duration: 300 }) }) }, /** * 取消关注 */ cancelFollow() { cancelFollowSomeone(this.productVo.userId).then(res => { this.hadFollow = false; this.$refs.uToast.show({ type: 'success', message: "取消关注成功", duration: 300 }) }) }, /** * 查询是否已经关注了用户 */ searchWhetherFollow() { hadFollowSomeone(this.productVo.userId).then(res => { // console.log("res:" + JSON.stringify(res)); this.hadFollow = res.hadFollow; // console.log("this.hadFollow :" + this.hadFollow); }) }, /** * 收藏商品 */ starProduct() { starProduct(this.productVo.id).then(res => { this.hadStar = true; this.getProduct(); this.$refs.uToast.show({ type: 'success', message: "收藏成功", duration: 300 }) }) }, /** * 取消收藏 */ cancelStar() { cancelStar(this.productVo.id).then(res => { this.hadStar = false; this.getProduct(); this.$refs.uToast.show({ type: 'success', message: "取消收藏成功", duration: 300 }) }) }, /** * 点赞评论 */ likeComment(commentId, comment) { // console.log("comment:" + JSON.stringify(comment)) likeComment(commentId).then(res => { comment.isLike = 1; comment.likeNum += 1; this.$refs.uToast.show({ type: 'success', message: "点赞成功", duration: 300 }) }) }, /** * 取消点赞评论 */ cancelLikeComment(commentId, comment) { cancelLikeComment(commentId).then(res => { comment.isLike = 0; comment.likeNum -= 1; this.$refs.uToast.show({ type: 'success', message: "取消点赞成功", duration: 300 }) }) }, /** * 查询是否已经关注了用户 */ searchWhetherStar() { hadStar(this.productVo.id).then(res => { // console.log("res:" + JSON.stringify(res)); this.hadStar = res.hadStar; // console.log("this.hadFollow :" + this.hadFollow); }) }, /** * 显示底部弹出框 */ clickShowBottomPopup(type, itemId, username = undefined) { this.showBottomPopup = true; this.comment.type = type; this.comment.itemId = itemId; if (type == 0) { this.commentPlaceHolder = "想要了解更多信息,可以评论让商品主人看见哟"; } else { this.commentPlaceHolder = "正在回复" + username + ""; } }, /** * 发表评论 */ commitComment() { // console.log("发送评论,comment:" + JSON.stringify(this.comment)) addComment(this.comment).then(res => { this.showBottomPopup = false; this.comment.content = ''; this.listCommentVoOfProduct(); this.$refs.uToast.show({ type: 'success', message: "评论发送成功", duration: 300 }) }) }, /** * 获取商品对应的所有评论 */ listCommentVoOfProduct() { listCommentVoOfProduct(this.productVo.id).then(res => { // console.log("listCommentVoOfProduct:" + JSON.stringify(res)); this.commentVoList = res.tree; this.commentNum = res.commentNum; }) }, /** * 格式化日期 * @param {Object} date */ formatDateToString(dateStr) { let date = new Date(dateStr); // 月份需要加一 return date.getFullYear() + "-" + (date.getMonth() + 1) + "-" + date.getDate(); }, /** * 获取商品详细信息,同时增加阅读量 */ getProduct() { getProduct(this.productVo.id).then(res => { console.log("product:" + JSON.stringify(res.data)); this.product = res.data; }) } }, onLoad(e) { this.productVo = JSON.parse(decodeURIComponent(e.productVo)); this.searchWhetherFollow(); this.searchWhetherStar(); this.listCommentVoOfProduct(); this.getProduct(); // console.log("productVo:" + JSON.stringify(productVo)); } } </script> <style lang="scss"> .container { // padding: 20rpx; background: #F7F7F7; .userItem { display: flex; align-items: center; justify-content: space-between; background: #ffffff; padding: 20rpx; .userProfile { display: flex; .nickname { color: #202020; font-weight: bold; font-size: 14px; } .other { color: #A6A4A5; font-size: 11px; } } .follow { display: flex; align-items: center; font-weight: bold; color: #ffffff; background: #2B92FF; border-radius: 20px; padding: 4px 8px; } .followed { background: #F6F6F6; border-radius: 20px; padding: 4px 8px; } } .productItem { background: #ffffff; padding: 20rpx; .top { display: flex; align-items: center; justify-content: space-between; .price { color: #F84442; font-weight: bold; .number { font-size: 30px; } } .browseInformation { color: #A6A4A5; font-size: 14px; } } .productDetail { margin-top: 20rpx; margin-bottom: 10rpx; color: #4C4C4C; font-size: 15px; line-height: 30px; font-weight: bold; } } .commentView { margin-top: 10px; // 用来预留展示 footer 的高度,不然footer会挡住评论 margin-bottom: calc(60px + 10rpx); background: #ffffff; padding: 30rpx 30rpx; .nickname { font-size: 14px; color: #B9B9B9; } .content { margin: 5px; // 解决英文字符串、数字不换行的问题 word-break: break-all; word-wrap: break-word; } .dateAndPosition { font-size: 11px; color: #B9B9B9; } .commentItem { display: flex; margin: 10px; justify-content: space-between; } .sonCommentItem { display: flex; margin: 10px 10px 10px 50px; justify-content: space-between; } } .footer { padding: 20rpx; position: fixed; // right: 20rpx; bottom: 0rpx; background: #ffffff; height: 60px; width: 710rpx; padding-top: 2px; display: flex; align-items: center; justify-content: space-between; .item { display: inline-block; text-align: center; margin-right: 10px; .comment { font-size: 10px; } } .chat { display: flex; align-items: center; background-color: #2B92FF; border-radius: 20px; padding: 7px; color: #ffffff; // margin-right: 20px; font-size: 12px; } } .commentPopup { display: flex; padding: 10px; min-height: 200rpx; .commentButton { background-color: #2B92FF; border-radius: 5px; padding: 7px; color: #ffffff; font-size: 12px; height: 20px; display: flex; align-items: center; } } } </style>
日期格式化
有时候后端传递过来的日期格式直接在前端页面中展示不太美观或简洁,那就可以自己写一个日期格式化方法,将日期转化为我们需要的格式来显示
/** * 格式化日期 * @param {Object} date */ formatDateToString(dateStr) { let date = new Date(dateStr); // 月份需要加一 return date.getFullYear() + "-" + (date.getMonth() + 1) + "-" + date.getDate(); },
英文自动换行显示
.content { margin: 5px; // 解决英文字符串、数字不换行的问题 word-break: break-all; word-wrap: break-word; }
后端
收藏
Controller
为了便于商品数据的查询,我在数据库设计的时候给商品表增加了收藏数的冗余字段,因此每次收藏商品或者取消商品的收藏的同时,需要更新商品表的收藏数
/** * 收藏商品 */ @PreAuthorize("@ss.hasPermi('market:star:star')") @GetMapping("/starProduct/{productId}") public AjaxResult starProduct(@PathVariable("productId") Long productId) { Star star = new Star(); star.setUserId(getLoginUser().getUserId()); star.setProductId(productId); boolean isStar = starService.addStar(star); if (isStar){ // 需要将商品的收藏量+1 productService.starNumPlusOne(productId); } return AjaxResult.success(); }
Service
package com.shm.service.impl; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.ruoyi.common.core.domain.entity.Star; import com.shm.mapper.StarMapper; import com.shm.service.IStarService; import org.springframework.stereotype.Service; /** * @author dam * @description 针对表【collection(收藏表)】的数据库操作Service实现 * @createDate 2023-08-09 19:41:23 */ @Service public class IStarServiceImpl extends ServiceImpl<StarMapper, Star> implements IStarService { @Override public boolean addStar(Star star) { return baseMapper.addStar(star); } }
mapper
public interface StarMapper extends BaseMapper<Star> { boolean addStar(@Param("star") Star star); }
将商品添加收藏的时候,需要先判断同样的收藏数据不存在于数据库中才执行插入操作,否则如果用户网络卡顿并多次发送收藏请求,数据库会出现冗余的脏数据
<insert id="addStar"> INSERT INTO `star` (`user_id`, `product_id`) SELECT #{star.userId},#{star.productId} FROM DUAL WHERE NOT EXISTS ( SELECT 1 FROM `star` WHERE `user_id` = #{star.productId} AND `product_id` = #{star.productId} limit 1 ); </insert>
评论
Controller
/** * 获取商品对应的所有评论 * * @param productId * @return */ @PreAuthorize("@ss.hasPermi('market:comment:list')") @GetMapping("/listCommentVoOfProduct/{productId}") public AjaxResult listCommentVoOfProduct(@PathVariable("productId") Long productId) { // 查询出商品对应的所有评论数据 List<CommentVo> commentVoList = commentService.listCommentVoOfProduct(productId, getLoginUser().getUserId()); int commentNum = commentVoList.size(); // 将评论数据封装成树形结构 List<CommentVo> tree = commentService.buildTree(commentVoList); return AjaxResult.success().put("tree", tree).put("commentNum", commentNum); }
Service
需要注意的是,这里的树形结构只有两层数据(针对商品的评论为一层,针对评论的所有评论为一层),因为小程序不方便显示太多层数据,否则宽度会非常大,用户需要反复滑动来查看完整的评论
@Override public List<CommentVo> listCommentVoOfProduct(Long productId, Long userId) { return commentMapper.listCommentVoOfProduct(productId, userId); } /** * 将评论数据封装成树形结构 * * @param commentVoList * @return */ @Override public List<CommentVo> buildTree(List<CommentVo> commentVoList) { // 将所有父级评论过滤出来 List<CommentVo> fatherList = commentVoList.stream().filter((item) -> { return item.getType() == 0; }).collect(Collectors.toList()); commentVoList.removeAll(fatherList); // 为所有父级评论寻找孩子 for (CommentVo father : fatherList) { father.setChildren(new ArrayList<>()); this.searchSon(father.getId(), father.getUserNickName(), father.getChildren(), commentVoList); } return fatherList; } /** * 寻找孩子 * * @param fatherId * @param children * @param commentVoList */ private void searchSon(Long fatherId, String fatherNickName, List<CommentVo> children, List<CommentVo> commentVoList) { for (CommentVo commentVo : commentVoList) { if (commentVo.getItemId().equals(fatherId)) { commentVo.setToUserNickName(fatherNickName); children.add(commentVo); this.searchSon(commentVo.getId(), commentVo.getUserNickName(), children, commentVoList); } } }
Mapper
这段sql非常复杂,一次性将评论的主人昵称、头像、评论的点赞数量查出来了,同时还使用递归查询来不断查询出评论的子评论。我目前不能保证这段sql的效率,只是实现了功能,后面如果性能不足,我再想办法优化
<select id="listCommentVoOfProduct" resultType="com.ruoyi.common.core.domain.vo.CommentVo"> SELECT ct.id, ct.user_id, ct.item_id, ct.type, ct.content, ct.create_time, u.nick_name AS userNickName, u.avatar AS userAvatar, CASE WHEN cl.user_id IS NULL THEN 0 ELSE 1 END AS isLike, ct.LEVEL, COALESCE ( likeNum, 0 ) AS likeNum FROM ( WITH RECURSIVE comment_tree AS ( SELECT id, user_id, item_id, type, content, create_time, 0 AS LEVEL FROM COMMENT WHERE item_id = #{productId} and type=0 UNION ALL SELECT c.id, c.user_id, c.item_id, c.type, c.content, c.create_time, ct.LEVEL + 1 AS LEVEL FROM COMMENT c INNER JOIN comment_tree ct ON c.item_id = ct.id WHERE c.type = 1 ) SELECT * FROM comment_tree ) ct LEFT JOIN ( SELECT comment_id, COUNT(*) AS likeNum FROM comment_like WHERE is_deleted = 0 GROUP BY comment_id ) pc ON ct.id = pc.comment_id LEFT JOIN sys_user AS u ON ct.user_id = u.user_id LEFT JOIN comment_like cl ON ct.id = cl.comment_id AND cl.user_id = #{userId} and cl.is_deleted =0 </select>
商品
Controller
/** * 获取商品详细信息 */ @PreAuthorize("@ss.hasPermi('market:product:query')") @GetMapping(value = "/{id}") @Transactional // 同时处理多个表,添加事务 public AjaxResult getInfo(@PathVariable("id") Long id) { // 首先判断用户有没有阅读该商品 boolean isAdd = productReadService.addRead(new ProductRead(getLoginUser().getUserId(), id)); if (isAdd) { // 需要将商品的阅读量+1 productService.readNumPlusOne(id); } return success(productService.getById(id)); }
阅读
Service
<insert id="addRead"> INSERT INTO `product_read` (`user_id`, `product_id`) SELECT #{productRead.userId},#{productRead.productId} FROM DUAL WHERE NOT EXISTS ( SELECT 1 FROM `product_read` WHERE `user_id` = #{productRead.userId} AND `product_id` = #{productRead.productId} limit 1 ); </insert>