# 消息&热门&个人中心

消息、热门、个人中心这三块的内容重点:

  • 灵活使用UI框架:uview内置样式的综合使用;
  • 统一鉴权跳转提示:用户登录与未登录状态下,页面跳转逻辑;
  • 熟悉前后端开发流程:从前到后的开发逻辑,排查问题从源头开始找问题;
  • 完善登录失效,接口鉴权失败401的页面跳转逻辑;

# 消息模块

最终 完成效果:

# 页面布局和样式

  • 添加 tabs 切换,并设置吸顶
<template>
  <view class="msg">
    <view class="msg" v-if="isLogin">
      <u-sticky>
        <view class="tabs box-shadow">
          <!-- tabs -->
          <u-tabs :list="tabs" :name="'value'" :is-scroll="false" active-color="#02D199" inactive-color="#666" height="88" @change="tabsChange" :current="current"></u-tabs>
        </view>
      <u-sticky>
      <view>
        <!-- 评论列表 -->
        <!-- 点赞列表 -->
      </view>
    </view>
    <view class="info u-flex u-row-center u-col-center flex-column" v-else>
      <view class="center">
        登录过后查看评论&点赞消息
      </view>
      <u-button type="primary" hover-class="none">去登录</u-button>
    </view>
    <view class="bottom-line"></view>
  </view>
</template>

<script>
export default {
  props: {},
  data: () => ({
    current: 0,
    tabs: [
      {
        key: 'comments',
        value: '评论'
      },
      {
        key: 'like',
        value: '点赞'
      }
    ],
  }),
  methods: {
    tabsChange (i) {
      this.current = i
      this.$store.commit('setType', i === 0 ? 'comment' : 'hands')
      this.checkType()
    },
	}
}
</script>

<style lang="scss" scoped>
.flex-column {
  flex-flow: column nowrap;
}

.info {
  flex-flow: column nowrap;
  height: 100vh;
  width: 100vw;
  .center {
    color: #666;
    font-size: 32rpx;
    line-height: 50px;
  }
}
</style>

# 自定义吸顶组件

吸顶效果最关键的属性:

.t-sticky {
  position: sticky;
  top: 0;
}

可以自行创建components/t-sticky/t-sticky.vue组件:

<template>
  <view class="t-sticky" :style="{'top': top + 'px'}">
    <slot></slot>
  </view>
</template>

<script>

export default {
  props: {
    top: {
      default: 0,
      type: Number
    }
  },
  data: () => ({})
}
</script>

<style lang="scss" scoped>
.t-sticky {
  position: sticky;
  // top: 0;
}
</style>

使用方法:

<t-sticky :top="距离顶部的值">
  // ... 组件 
</t-sticky>

# 消息模块的样式

<template>
  <view class="msg">
    <view class="msg" v-if="isLogin">
      <u-sticky>
        <view class="tabs box-shadow">
          <!-- tabs -->
          <u-tabs :list="tabs" :name="'value'" :is-scroll="false" active-color="#02D199" inactive-color="#666" height="88" @change="tabsChange" :current="current"></u-tabs>
        </view>
      </u-sticky>
      <view>
        <!-- 评论列表 -->
        <view v-if="current === 0">
          <view v-for="(item, index) in comments" :key="index">
            <view class="box">
              <!-- 评论用户卡片 -->
              <view class="user u-flex">
                <u-image class="phone" :src="item.cuid.pic" width="72" height="72" shape="circle" error-icon="/static/images/header.jpg"></u-image>
                <view class="user-column u-flex-1 u-flex flex-column u-col-top">
                  <text class="name">{{ item.cuid.name }}</text>
                  <text class="label">{{ item.created | moment }} 回复了你</text>
                </view>
                <view class="reply u-flex u-row-center">
                  <image src="/static/images/advice.png" mode="aspectFit" />
                  回复
                </view>
              </view>
              <!-- 评论内容 -->
              <view class="comment">{{ item.content }}</view>
              <view class="post">
                <view>
                  <!-- 封面图 -->
                  <view v-if="item.tid.shotpic">
                    <view class="img">
                      <u-image :src="item.tid.shotpic" width="192" height="122"></u-image>
                    </view>
                  </view>
                  <!-- 文章标题 + 摘要 -->
                  <view class="post-content u-flex flex-column u-col-top">
                    <text class="title">{{ item.tid.title }}</text>
                    <text class="content">{{ item.tid.content }}</text>
                  </view>
                </view>
              </view>
            </view>
          </view>
        </view>
        <!-- 点赞列表 -->
        <view v-else>
          <view v-for="(item, index) in handUsers" :key="index">
            <view class="box">
              <view class="user u-flex">
                <u-image class="pic" :src="item.huid.pic" width="72" height="72" shape="circle" error-icon="/static/images/header.jpg" />
                <view class="user-column u-flex-1 u-flex flex-column u-col-top">
                  <span class="name">{{ item.huid.name }}</span>
                  <span class="label">{{ item.created | moment }}</span>
                </view>
              </view>
            </view>
            <view class="comment">赞了你的评论 {{item.cid.content}}</view>
          </view>
        </view>
      </view>
    </view>
    <view class="info u-flex u-row-center u-col-center flex-column" v-else>
      <view class="center">
        登录过后查看评论&点赞消息
      </view>
      <u-button type="primary" hover-class="none" @click="navTo">去登录</u-button>
    </view>
    <view class="bottom-line"></view>
  </view>
</template>

<script>
import { mapGetters } from 'vuex'
import { checkAuth } from '@/common/checkAuth'
export default {
  props: {},
  data: () => ({
    current: 0,
    tabs: [
      {
        key: 'comments',
        value: '评论'
      },
      {
        key: 'like',
        value: '点赞'
      }
    ],
    comments: [
    ],
    handUsers: [
    ],
    pageMsg: {
      page: 0,
      limit: 10
    },
    pageHands: {
      page: 0,
      limit: 10
    }
  }),
  computed: {
    ...mapGetters(['isLogin'])
  },
  methods: {
    navTo () {
      uni.navigateTo({
        url: '/subcom-pkg/auth/auth'
      })
    },
    tabsChange (i) {
      this.current = i
      this.$store.commit('setType', i === 0 ? 'comment' : 'hands')
      this.checkType()
    },
    async getMsg () {
      const { data, code } = await this.$u.api.getMsg(this.pageMsg)
      if (code === 200) {
        this.comments = data
      }
    },
    async getHands () {
      const { data, code } = await this.$u.api.getHands(this.pageHands)
      if (code === 200) {
        this.handUsers = data
      }
    },
    async checkType () {
      if (!this.isLogin) return
      const flag = await checkAuth()
      if (!flag) {
        return
      }
      if (this.$store.state.type === 'hands') {
        // 这里肯定已经登录
        this.current = 1
        this.getHands()
      } else {
        this.current = 0
        this.getMsg()
      }
    }
  },
  watch: {},
  // 页面周期函数--监听页面显示(not-nvue)
  onShow () {
    this.checkType()
  },
  // 页面周期函数--监听页面隐藏
  onHide () {
    this.current = 0
  }
}
</script>

<style lang="scss" scoped>
.flex-column {
  flex-flow: column nowrap;
}

.info {
  flex-flow: column nowrap;
  height: 100vh;
  width: 100vw;
  .center {
    color: #666;
    font-size: 32rpx;
    line-height: 50px;
  }
}
.user {
  margin: 20rpx;
  .name {
    margin-block: 20rpx;
    margin-bottom: 10rpx;
    font-size: 28rpx;
    font-weight: bold;
    color: rgba(51, 51, 51, 1);
  }
  .phone {
    width: 72rpx;
    height: 72rpx;
    border-radius: 50%;
  }
  .user-column {
    margin-left: 20rpx;
  }
  .label {
    font-size: 22rpx;
    font-weight: 500;
    color: rgba(153, 153, 153, 1);
  }
}

.reply {
  color: rgba(153, 153, 153, 1);
  margin-right: 40rpx;
  font-size: 24rpx;
  font-weight: 500;
  line-height: 40rpx;
  image {
    width: 30rpx;
    height: 30rpx;
    margin-right: 10rpx;
  }
}

.comment {
  margin: 0 20rpx 20rpx;
}

.post {
  margin: 0 20rpx 20rpx;
  padding: 20rpx;
  background-color: #f3f3f3;
  border-radius: 15rpx;
  .title {
    margin-bottom: 10rpx;
    font-size: 26rpx;
    font-weight: bold;
    color: rgba(51, 51, 51, 1);
    display: -webkit-box;
    -webkit-line-clamp: 1; /*这个数字是设置要显示省略号的行数*/
    -webkit-box-orient: vertical;
    overflow: hidden;
  }
  .content {
    font-size: 24rpx;
    font-weight: 400;
    color: rgba(102, 102, 102, 1);
    line-height: 30rpx;
    display: -webkit-box;
    -webkit-line-clamp: 2; /*这个数字是设置要显示省略号的行数*/
    -webkit-box-orient: vertical;
    overflow: hidden;
  }
}
</style>

前端接口api/modules/user.js

// 获取点赞数据
const getHands = params => axios.get('/user/getHands', params)

// 获取用户未读消息
const getMsg = (data) => axios.get('/user/getmsg', data)

# 接口对接与联调

调整src/model/CommentsHands.js

import mongoose from '../config/DBHelpler'

const Schema = mongoose.Schema

const CommentsSchema = new Schema({
  cid: { type: String, ref: 'comments' }, // 评论id
  huid: { type: String, ref: 'users' }, // 被点赞用户的id
  uid: { type: String, ref: 'users' }, // 点赞用户id
  created: { type: Date }
})

CommentsSchema.pre('save', function (next) {
  this.created = new Date()
  next()
})

CommentsSchema.post('save', function (error, doc, next) {
  if (error.name === 'MongoError' && error.code === 11000) {
    next(new Error('There was a duplicate key error'))
  } else {
    next(error)
  }
})

CommentsSchema.statics = {
  findByCid: function (id) {
    return this.find({ cid: id })
  },
  getHandsByUid: function (id, page, limit) {
    return this.find({ uid: id })
      .populate({
        path: 'huid',
        select: '_id name pic'
      })
      .populate({
        path: 'cid',
        select: '_id content'
      })
      .skip(page * limit)
      .limit(limit)
      .sort({ created: -1 })
  }
}

const CommentsHands = mongoose.model('comments_hands', CommentsSchema)

export default CommentsHands

获取历史消息src/api/UserController.js

  // 获取历史消息
  // 记录评论之后,给作者发送消息
  async getHands (ctx) {
    const params = ctx.query
    const page = params.page ? params.page : 0
    const limit = params.limit ? parseInt(params.limit) : 0
    // 方法一: 嵌套查询 -> aggregate
    // 方法二: 通过冗余换时间
    const obj = await getJWTPayload(ctx.header.authorization)
    const result = await CommentsHands.getHandsByUid(obj._id, page, limit)

    ctx.body = {
      code: 200,
      data: result
    }
  }

完成效果(点赞):

image-20210527031025147

完成效果(评论):

image-20210527030947602

# 热门模块

这个部分利旧接口,所以只用开发前端页面即可:

# 页面布局和样式

<template>
  <view>
    <u-sticky>
      <view class="tabs box-shadow">
        <u-tabs :list="tabs" :name="'value'" :current="current" @change="tabsChange" :is-scroll="false" active-color="#02D199" inactive-color="#666" height="88"></u-tabs>
      </view>
    </u-sticky>
    <view class="content">
      <view class="tags">
        <uni-tag :text="item.value" v-for="(item, i) in types[tabs[current].key]" :class="{ active: tagCur === i }" :key="i" @click="tagsChange(i)"></uni-tag>
      </view>
      <HotPostList :lists="lists" v-if="tabs[current].key === 'posts'" @click="postDetail"></HotPostList>
      <HotCommentsList :lists="lists" :type="types.comments[tagCur].key" v-else-if="tabs[current].key === 'comments'" @click="commentDetail"></HotCommentsList>
      <HotSignList :lists="lists" :type="types.sign[tagCur].key" v-else></HotSignList>
    </view>
    <view class="bottom-line"></view>
  </view>
</template>

<script>
import HotPostList from './components/HotPostList'
import HotCommentsList from './components/HotCommentsList'
import HotSignList from './components/HotSignList'

export default {
  components: {
    HotPostList,
    HotCommentsList,
    HotSignList
  },
  data: () => ({
    tabs: [
      {
        key: 'posts',
        value: '热门帖子'
      },
      {
        key: 'comments',
        value: '热门评论'
      },
      {
        key: 'sign',
        value: '签到排行'
      }
    ],
    types: {
      posts: [
        {
          key: '3',
          value: '全部'
        },
        {
          key: '0',
          value: '3日内'
        },
        {
          key: '1',
          value: '7日内'
        },
        {
          key: '2',
          value: '30日内'
        }
      ],
      comments: [
        {
          key: '1',
          value: '最新评论'
        },
        {
          key: '0',
          value: '热门评论'
        }
      ],
      sign: [
        {
          key: '0',
          value: '总签到榜'
        },
        {
          key: '1',
          value: '今日签到榜'
        }
      ]
    },
    current: 0,
    tagCur: 0,
    lists: [
    ],
    page: {
      page: 0,
      limit: 50
    }
  }),
  onLoad (options) {
    const { scene } = options
    if (scene) {
      this.tabsChange(scene)
    } else {
      this.getHotPost()
    }
  },
  onShow () {
    this.hanldeChange()
  },
  methods: {
    async getHotPost () {
      const { data } = await this.$u.api.getHotPost({
        ...this.page,
        index: this.types.posts[this.tagCur].key
      })
      this.lists = data
    },
    async getHotComments () {
      const { data } = await this.$u.api.getHotComments({
        ...this.page,
        index: this.types.comments[this.tagCur].key
      })
      this.lists = data
    },
    async getHotSignRecord () {
      const { data } = await this.$u.api.getHotSignRecord({
        ...this.page,
        index: this.types.sign[this.tagCur].key
      })
      this.lists = data
    },
    tabsChange (value) {
      this.current = value
      this.tagCur = 0
      this.page = {
        page: 0,
        limit: 50
      }
      this.hanldeChange()
    },
    tagsChange (value) {
      this.tagCur = value
      this.page = {
        page: 0,
        limit: 50
      }
      this.hanldeChange()
    },
    hanldeChange () {
      if (this.current === 0) {
        // 热门帖子
        this.getHotPost()
      } else if (this.current === 1) {
        // 热门评论
        this.getHotComments()
      } else {
        // 签到排行
        this.getHotSignRecord()
      }
    },
    // 跳转文章详情
    postDetail (item) {
      uni.navigateTo({
        url: '/subcom-pkg/detail/detail?tid=' + item._id
      })
    },
    // 评论详情
    commentDetail (item) {
      uni.navigateTo({
        url: `/subcom-pkg/detail/detail?tid=${item.tid}&cid=${item._id}`
      })
    }
  },
  async onPullDownRefresh () {
    this.hanldeChange()
    uni.stopPullDownRefresh()
  },
  // 页面处理函数--监听用户上拉触底
  onReachBottom () {}
  // 页面处理函数--监听页面滚动(not-nvue)
  /* onPageScroll(event) {}, */
  // 页面处理函数--用户点击右上角分享
  /* onShareAppMessage(options) {}, */
}
</script>

<style lang="scss" scoped>
.tags {
  display: flex;
  padding: 20rpx 25rpx;
  width: 100vw;
  background-color: #fff;
  z-index: 200;
  ::v-deep .uni-tag {
    // margin-top: 20rpx;
    margin-right: 25rpx;
    border-radius: 25rpx;
    text {
      color: #999;
      white-space: nowrap;
      font-size: 26rpx;
    }
  }
  .active {
    ::v-deep .uni-tag {
      background-color: #d6f8ef;
      text {
        color: #02d199;
        font-weight: bold;
      }
    }
  }
}

::v-deep .list {
  z-index: 100;
  padding: 0 30rpx 60rpx 30rpx;
  .list-item {
    display: flex;
    flex-flow: row nowrap;
    justify-content: space-between;
    align-items: center;
    border-bottom: 1px solid #ddd;
  }
  .num {
    font-size: 36rpx;
    font-weight: bold;
    &.first {
      color: #ed745e;
    }
    &.second {
      color: #e08435;
    }
    &.third {
      color: #f1ae37;
    }
    &.common {
      color: #999;
    }
  }
  .user {
    width: 90rpx;
    height: 90rpx;
    border-radius: 50%;
    margin-left: 20rpx;
  }
  .column {
    flex: 1;
    display: flex;
    flex-flow: column nowrap;
    justify-content: space-between;
    height: 186rpx;
    padding: 30rpx 24rpx;

    &.no-between {
      justify-content: center;
      .title {
        padding-bottom: 16rpx;
      }
    }
    .title {
      color: #333;
      font-size: 32rpx;
      font-weight: bold;
    }
    .read {
      font-size: 26rpx;
      color: #999;
      text {
        color: #333;
        font-weight: bold;
        padding-right: 10rpx;
      }
    }
  }
  .img {
    width: 200rpx;
    height: 125rpx;
    border-radius: 12rpx;
    overflow: hidden;
    img {
      width: 100%;
      height: 100%;
    }
  }
}
</style>

自定义三个组件,热门帖子HotPostList.vue

<template>
  <view>
    <view class="list" v-for="(item,index) in lists" :key="index">
      <view class="list-item" @click="gotoDetail(item)">
        <view class="num first" v-if="index === 0">01</view>
        <view class="num second" v-else-if="index === 1">02</view>
        <view class="num third" v-else-if="index === 2">03</view>
        <view class="num common" v-else-if="index < 9">{{ '0' + (index+1) }}</view>
        <view class="num common" v-else-if="index < 50 && index >=9">{{ index+1 }}</view>
        <view class="num" v-else></view>
        <view class="column">
          <view class="title">{{item.title}}</view>
          <view class="read">{{parseInt(item.answer) > 1000?parseInt(item.answer/1000).toFixed(1) + 'k': item.answer}} 评论</view>
        </view>
        <view class="img" v-if="item.shotpic">
          <image :src="item.shotpic" mode="aspectFill" />
        </view>
      </view>
    </view>
  </view>
</template>

<script>
export default {
  props: {
    lists: {
      type: Array,
      default: () => []
    }
  },
  methods: {
    gotoDetail (item) {
      this.$emit('click', item)
    }
  }
}
</script>

<style lang="scss" scoped>
</style>

热门评论HotCommentsList.vue

<template>
  <view>
    <view class="list" v-for="(item,index) in lists" :key="index">
      <!-- 评论 -->
      <view class="list-item" @click="gotoDetail(item)">
        <view class="num first" v-if="index === 0">01</view>
        <view class="num second" v-else-if="index === 1">02</view>
        <view class="num third" v-else-if="index === 2">03</view>
        <view class="num common" v-else-if="index < 9">{{ '0' + (index+1) }}</view>
        <view class="num common" v-else-if="index < 50 && index >=9">{{ index+1 }}</view>
        <view class="num" v-else></view>
        <u-image width="88" height="88" class="user" :src="item.cuid? item.cuid.pic : ''" mode="aspectFit" shape="circle" error-icon="/static/images/header.jpg" />
        <view class="column no-between">
          <view class="title">{{item.cuid && item.cuid.name? item.cuid.name : 'imooc'}}</view>
          <view class="read" v-if="parseInt(type) === 0">
            <text>{{item.count}}</text> 条评论
          </view>
          <view class="read" v-else>{{item.created | moment}} 发表了评论</view>
        </view>
      </view>
    </view>
  </view>
</template>

<script>
export default {
  props: {
    lists: {
      type: Array,
      default: () => []
    },
    type: {
      type: [Number, String],
      default: 0
    }
  },
  methods: {
    gotoDetail (item) {
      this.$emit('click', item)
    }
  }
}
</script>

<style lang="scss" scoped>
</style>

签到排行HotSignList.vue

<template>
  <view>
    <view class="list" v-for="(item, index) in lists" :key="index">
      <!-- 签到 -->
      <view
        class="list-item"
        v-if="item.count && item.count > 0"
        @click="gotoDetail(item)"
      >
        <view class="num first" v-if="index === 0">01</view>
        <view class="num second" v-else-if="index === 1">02</view>
        <view class="num third" v-else-if="index === 2">03</view>
        <view class="num common" v-else-if="index < 9">{{
          '0' + (index + 1)
        }}</view>
        <view class="num common" v-else-if="index < 50 && index >= 9">{{
          index + 1
        }}</view>
        <view class="num" v-else></view>
        <u-image
          width="88"
          height="88"
          class="user"
          :src="parseInt(type) === 0 ? item.pic : item.uid.pic"
          mode="aspectFit"
          shape="circle"
          error-icon="/static/images/header.jpg"
        />
        <view class="column no-between">
          <view class="title">{{
            (item.uid ? item.uid.name : item.name) || 'imooc'
          }}</view>
          <view class="read" v-if="parseInt(type) === 0">
            已经连续签到
            <span>{{ item.count }}</span></view>
          <view class="read" v-else>{{ item.created | hours }}</view>
        </view>
      </view>
      <view class="list-item" v-else>
        <view class="num first" v-if="index === 0">01</view>
        <view class="num second" v-else-if="index === 1">02</view>
        <view class="num third" v-else-if="index === 2">03</view>
        <view class="num common" v-else-if="index < 9">{{
          '0' + (index + 1)
        }}</view>
        <view class="num common" v-else-if="index < 50 && index >= 9">{{
          index + 1
        }}</view>
        <view class="num" v-else></view>
        <u-image
          width="88"
          height="88"
          class="user"
          :src="parseInt(type) === 0 ? item.pic : item.uid.pic"
          mode="aspectFit"
          shape="circle"
          error-icon="/static/images/header.jpg"
        />
        <view class="column no-between">
          <view class="title">{{ item.uid ? item.uid.name : 'imooc' }}</view>
          <view class="read">
            今日签到时间<span class="text">{{ item.created | hours }}</span>
          </view>
          <!-- <view class="read" v-else>{{item.created | hours}}</view> -->
        </view>
      </view>
    </view>
  </view>
</template>

<script>
export default {
  props: {
    lists: {
      type: Array,
      default: () => []
    },
    type: {
      type: [Number, String],
      default: 0
    }
  },
  methods: {
    gotoDetail (item) {
      this.$emit('click', item)
    }
  }
}
</script>

<style lang="scss" scoped>
.text {
  padding-left: 15rpx;
}
</style>

# 接口对接与联调

PublicController.js文件:

  async getHotPost (ctx) {
    // page limit
    // type index 0-3日内, 1-7日内, 2-30日内, 3-全部
    const params = ctx.query
    const page = params.page ? parseInt(params.page) : 0
    const limit = params.limit ? parseInt(params.limit) : 10
    const index = params.index ? params.index : '0'
    let startTime = ''
    let endTime = ''
    if (index === '0') {
      startTime = moment().subtract(2, 'day').format('YYYY-MM-DD 00:00:00')
    } else if (index === '1') {
      startTime = moment().subtract(6, 'day').format('YYYY-MM-DD 00:00:00')
    } else if (index === '2') {
      startTime = moment().subtract(29, 'day').format('YYYY-MM-DD 00:00:00')
    }
    endTime = moment().add(1, 'day').format('YYYY-MM-DD 00:00:00')
    const result = await Post.getHotPost(page, limit, startTime, endTime)
    const total = await Post.getHotPostCount(page, limit, startTime, endTime)
    ctx.body = {
      code: 200,
      total,
      data: result,
      msg: '获取热门文章成功'
    }
  }

  async getHotComments (ctx) {
    // 0-热门评论,1-最新评论
    const params = ctx.query
    const page = params.page ? parseInt(params.page) : 0
    const limit = params.limit ? parseInt(params.limit) : 10
    const index = params.index ? params.index : '0'
    const result = await Comments.getHotComments(page, limit, index)
    const total = await Comments.getHotCommentsCount(index)
    ctx.body = {
      code: 200,
      data: result,
      total,
      msg: '获取热门评论成功'
    }
  }

  async getHotSignRecord (ctx) {
    // 0-总签到榜,1-最新签到
    const params = ctx.query
    const page = params.page ? parseInt(params.page) : 0
    const limit = params.limit ? parseInt(params.limit) : 10
    const index = params.index ? params.index : '0'
    let result
    let total = 0
    if (index === '0') {
      // 总签到榜
      result = await User.getTotalSign(page, limit)
      total = await User.getTotalSignCount()
    } else if (index === '1') {
      // 今日签到
      result = await SignRecord.getTopSign(page, limit)
      total = await SignRecord.getTopSignCount()
    } else if (index === '2') {
      // 最新签到
      result = await SignRecord.getLatestSign(page, limit)
      total = await SignRecord.getSignCount()
    }
    ctx.body = {
      code: 200,
      data: result,
      total,
      msg: '获取签到排行成功'
    }
  }

完成效果(热门帖子):

image-20210527032522285

完成效果(热门评论):

image-20210527032608703

完成效果(签到排行):

image-20210527032652755

# 个人中心模块

# 页面布局和样式

整体的页面分为:

  • 背景
  • 个人信息 + 统计信息
  • 功能区
  • 快速跳转区
<template>
  <view>
    <view class="grey">
      <view class="bg"></view>
      <view class="wrapper">
        <!-- 个人信息卡片 -->
        <view class="profile">
          <view class="info">
            <u-image class="pic" :src="isLogin ? userInfo.pic: ''" width="120" height="120" shape="circle" error-icon="/static/images/header.jpg" />
            <!-- 用户昵称 + VIP -->
            <view class="user" @click="navTo">
              <view class="name">{{isLogin ?userInfo.name : '请登录'}}</view>
              <view class="fav">
                <!-- <van-icon name="fav2" class-prefix="iconfont" size="14"></van-icon> -->
                积分:{{userInfo && userInfo.favs ? userInfo.favs:0}}
              </view>
            </view>
            <view class="link" @click="gotoGuard('/sub-pkg/user-info/user-info')">个人主页 ></view>
          </view>
          <!-- 统计信息 -->
          <view class="stats" v-if="isLogin">
            <view class="item">
              <navigator :url="'/sub-pkg/posts/posts?uid=' + uid + '&type=p'">
                <view>{{ countMyPost }}</view>
                <view class="title">我的帖子</view>
              </navigator>
            </view>
            <view class="item">
              <navigator :url="'/sub-pkg/posts/posts?uid=' + uid+ '&type=c'">
                <view>{{ countMyCollect }}</view>
                <view class="title">收藏夹</view>
              </navigator>
            </view>
            <view class="item">
              <navigator :url="'/sub-pkg/posts/posts?uid=' + uid+ '&type=h'">
                <view>{{ countMyHistory }}</view>
                <view class="title">最近浏览</view>
              </navigator>
            </view>
          </view>
        </view>
      </view>
      <!-- 功能区 -->
      <view class="center-wraper">
        <view class="center-list first">
          <li v-for="(item,index) in lists" :key="index">
            <view @click="gotoGuardHandler(item)">
              <i :class="item.icon"></i>
              <span>{{item.name}}</span>
            </view>
          </li>
        </view>
        <!-- 首页 -> 分类标签 快速跳转 -->
        <view class="center-list">
          <li v-for="(item,index) in routes" :key="index" @click="gotoHome(item.tab)">
            <i :class="item.icon"></i>
            <span>{{item.name}}</span>
          </li>
        </view>
      </view>
    </view>
    <view class="bottom-line"></view>
  </view>
</template>

<script>
import { gotoGuard } from '@/common/checkAuth'
import { mapGetters, mapState, mapMutations } from 'vuex'
export default {
  data: () => ({
    lists: [
      {
        name: '我的帖子',
        icon: 'icon-teizi',
        routeName: '/sub-pkg/posts/posts'
      },
      {
        name: '修改设置',
        icon: 'icon-setting',
        routeName: '/sub-pkg/settings/settings'
      },
      {
        name: '签到中心',
        icon: 'icon-qiandao',
        routeName: '/sub-pkg/sign/sign'
      },
      {
        name: '电子书',
        icon: 'icon-book',
        routeName: '/sub-pkg/books/books'
      },
      {
        name: '关于我们',
        icon: 'icon-about',
        routeName: '/sub-pkg/about/about'
      },
      {
        name: '人工客服',
        icon: 'icon-support',
        routeName: '/sub-pkg/suggest/suggest'
      },
      {
        name: '意见反馈',
        icon: 'icon-lock2',
        routeName: '/sub-pkg/suggest/survey'
      }
    ],
    routes: [
      {
        name: '提问',
        icon: 'icon-question',
        tab: 'ask'
      },
      {
        name: '分享',
        icon: 'icon-share',
        tab: 'share'
      },
      {
        name: '讨论',
        icon: 'icon-taolun',
        tab: 'discuss'
      },
      {
        name: '建议',
        icon: 'icon-advise',
        tab: 'advise'
      }
    ],
    countMyPost: 0,
    countMyCollect: 0,
    countMyHistory: 0
    // isLogin: true
  }),
  computed: {
    ...mapGetters(['isLogin']),
    ...mapState(['userInfo']),
    uid () {
      return this.userInfo._id
    }
  },
  onShow () {
  },
  methods: {
    ...mapMutations(['setUserInfo']),
    gotoGuard,
    gotoGuardHandler (item) {
      const { name, routeName } = item
      if (name === '我的帖子') {
        gotoGuard(routeName + `?uid=${this.uid}&type=p`)
      } else {
        gotoGuard(routeName)
      }
    },
    gotoHome (tab) {
      uni.switchTab({
        url: '/pages/home/home'
      })
    },
    navTo () {
      if (!this.isLogin) {
        uni.navigateTo({
          url: '/subcom-pkg/auth/auth'
        })
      }
    },

  }

}
</script>

<style lang="scss">
.grey {
  position: fixed;
  width: 100%;
  height: 100%;
  left: 0;
  top: 0;
  z-index: 30;
}
a {
  color: #666;
  text-decoration: none;
}
.bg {
  background-image: url("/static/images/my_bg.png");
  background-repeat: no-repeat;
  background-size: contain;
  position: relative;
  left: 0;
  top: 0;
  width: 100%;
  height: 280rpx;
  background-position: 0 0;
  z-index: 100;
}
.wrapper {
  width: 100%;
  height: 370rpx;
  padding: 25rpx;
  position: absolute;
  left: 0;
  top: 0;
  z-index: 100;
  box-sizing: border-box;
  color: #333;
  .profile {
    background: #fff;
    border-radius: 12rpx;
    box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
    width: 100%;
    height: 100%;
    padding: 30rpx;
    box-sizing: border-box;
    .name {
      font-size: 36rpx;
      font-weight: 700;
      margin-bottom: 10rpx;
      margin-top: 0;
      width: 370rpx;
      overflow: hidden;
      text-overflow: ellipsis;
      white-space: nowrap;
    }
    .link {
      font-size: 24rpx;
      color: #999;
    }
    .fav {
      display: inline-block;
      padding: 8px 12rpx;
      background: rgba(2, 209, 153, 0.16);
      border-radius: 12rpx;
      color: #02d199;
      margin: 0;
      font-size: 22rpx;
      .icon-fav {
        padding-right: 10rpx;
      }
    }
    .info,
    .stats {
      display: flex;
      flex-flow: row nowrap;
      justify-content: space-between;
      align-items: center;
    }
    .info {
      margin-bottom: 24rpx;
    }
    .stats {
      justify-content: space-around;
    }
    .user {
      flex: 1;
      padding-left: 20rpx;
    }
    .pic {
      width: 120rpx;
      height: 120rpx;
      border-radius: 50%;
    }
    .item {
      text-align: center;
      position: relative;
      p {
        margin-top: 14rpx;
        margin-bottom: 0;
      }
      &:after {
        width: 2rpx;
        height: 80rpx;
        background: #ddd;
        content: "";
        position: absolute;
        right: -60rpx;
        top: 20rpx;
      }
      &:last-child {
        &:after {
          width: 0;
        }
      }
      .title {
        color: #666;
      }
    }
  }
}
.center-wraper {
  background: #f6f5f8;
  position: relative;
  width: 100%;
  // height: 100%;
  z-index: 10;
  .center-list {
    background: #fff;
    margin-bottom: 30rpx;
    display: flex;
    flex-flow: wrap;
    padding-top: 40rpx;
    &.first {
      padding-top: 100rpx;
    }
    li {
      width: 25%;
      text-align: center;
      color: #666;
      margin-bottom: 40rpx;
      font-size: 26rpx;
    }
    i {
      display: block;
      margin: 0 auto;
      font-size: 40rpx;
      width: 56rpx;
      height: 56rpx;
      margin-bottom: 20rpx;
      color: #888;
      background-size: contain;
    }
    .icon-teizi {
      background-image: url("/static/images/teizi@2x.png");
    }
    .icon-setting {
      background-image: url("/static/images/setting@2x.png");
    }
    .icon-lock2 {
      background-image: url("/static/images/lock2@2x.png");
    }
    .icon-support {
      background-image: url("/static/images/support.png");
    }
    .icon-qiandao {
      background-image: url("/static/images/sign1.png");
    }
    .icon-book {
      background-image: url("/static/images/books.png");
    }
    .icon-record {
      background-image: url("/static/images/record@2x.png");
    }
    .icon-about {
      background-image: url("/static/images/about.png");
    }
    // 快捷访问
    .icon-question {
      background-image: url("/static/images/question@2x.png");
    }
    .icon-share {
      background-image: url("/static/images/share@2x.png");
    }
    .icon-taolun {
      background-image: url("/static/images/taolun@2x.png");
    }
    .icon-advise {
      background-image: url("/static/images/advice@2x.png");
    }
  }
}
</style>

# 最近浏览功能

创建PostHistory模型:

import mongoose from '../config/DBHelpler'

const Schema = mongoose.Schema

const PostHistorySchema = new Schema(
  {
    uid: { type: String, ref: 'user', required: true },
    tid: { type: String, ref: 'post', required: true }
  },
  { timestamps: { createdAt: 'created', updatedAt: 'updated' } }
)

PostHistorySchema.statics = {
  queryCount (options) {
    return this.find(options).countDocuments()
  },
  addOrUpdate (uid, tid) {
    // 增加浏览记录,如果有则更新浏览时间
    return this.findOne({ uid, tid }).exec((err, doc) => {
      if (err) {
        console.log(err)
      }
      if (doc) {
        doc.created = new Date()
        doc.save()
      } else {
        this.create({ uid, tid })
      }
    })
  },
  delOne (uid, tid) {
    return this.deleteOne({ uid, tid })
  },
  getListByUid (uid, skip, limit) {
    // 获取用户的浏览记录
    return this.find({ uid })
      .populate({
        path: 'tid',
        select: 'uid catalog title content answer created',
        populate: {
          path: 'uid',
          select: 'name pic'
        }
      })
      .sort({ created: -1 })
      .skip(skip)
      .limit(limit)
  },
  deleteByPostId: function (tid) {
    return this.deleteMany({ tid })
  }
}

const PostHistory = mongoose.model('post_history', PostHistorySchema)

export default PostHistory

创建src/api/StaticsController.js

  • 统计最近浏览、我的帖子、收藏夹、我的评论、我的点赞、我的获赞、个人积分、用户的签到日期;
  • 个人积分需要单独写方法,其他的可以使用mongoose中的countDocuments方法;
// import { getJWTPayload } from '@/common/Utils'
import Post from '@/model/Post'
import PostHistory from '@/model/PostHistory'
import User from '@/model/User'
import UserCollect from '@/model/UserCollect'
import Comments from '@/model/Comments'
import CommentsHands from '@/model/CommentsHands'
import SignRecord from '@/model/SignRecord'

/**
 * 统计相关的 api 放在这里
 */
class StatisticsController {
  // 统计数据:最近浏览、我的帖子、收藏夹、我的评论、我的点赞、获赞、个人积分
  async wxUserCount (ctx) {
    const body = ctx.query
    // const obj = await getJWTPayload(ctx.header.authorization)
    const { _id: uid } = ctx
    const { reqAll } = body
    // console.log('🚀 ~ file: StatisticsController.js ~ line 20 ~ StatisticsController ~ wxUserCount ~ reqAll', reqAll)
    if (uid) {
      const countMyHistory =
        (body.reqHistory || reqAll) && (await PostHistory.countDocuments({ uid })) // 最近浏览
      const countMyPost =
        (body.reqPost || reqAll) && (await Post.countDocuments({ uid })) // 我的帖子
      const countMyCollect =
        (body.reqCollect || reqAll) &&
        (await UserCollect.countDocuments({ uid })) // 收藏夹
      const countMyComment =
        (body.reqComment || reqAll) &&
        (await Comments.countDocuments({ cuid: uid })) // 我的评论
      const countMyHands =
        (body.reqHands || reqAll) &&
        (await CommentsHands.countDocuments({ uid })) // 我的点赞
      const countHandsOnMe =
        (body.reqHandsOnMe || reqAll) &&
        (await CommentsHands.countDocuments({ huid: uid })) // 获赞
      const countFavs = (body.reqFavs || reqAll) && (await User.getFavs(uid)) // 个人积分
      const countSign =
        (body.reqSign || reqAll) && (await SignRecord.countDocuments({ uid })) // 签到次数
      const lastSigned =
        (body.reqLastSigned || reqAll) && (await SignRecord.countDocuments({ uid })) // 获取用户最新的签到日期

      ctx.body = {
        code: 200,
        data: {
          countMyPost,
          countMyCollect,
          countMyComment,
          countMyHands,
          countHandsOnMe,
          countMyHistory,
          countFavs,
          lastSigned,
          countSign
        }
      }
    } else {
      ctx.body = {
        code: 500,
        msg: '查询失败'
      }
    }
  }
}

export default new StatisticsController()

更新用户获取文章详情的方法getPostDetail,在文件src/api/ContentController.js中:

  // 获取文章详情
  async getPostDetail (ctx) {
    const params = ctx.query
    if (!params.tid) {
      ctx.body = {
        code: 500,
        msg: '文章id为空'
      }
      return
    }
    const post = await Post.findByTid(params.tid)
    if (!post) {
      ctx.body = {
        code: 200,
        data: {},
        msg: '查询文章详情成功'
      }
      return
    }
    let isFav = 0
    // 判断用户是否传递Authorization的数据,即是否登录
    if (
      typeof ctx.header.authorization !== 'undefined' &&
      ctx.header.authorization !== ''
    ) {
      const obj = await getJWTPayload(ctx.header.authorization)
      const userCollect = await UserCollect.findOne({
        uid: obj._id,
        tid: params.tid
      })
      if (userCollect && userCollect.tid) {
        isFav = 1
      }
      await PostHistory.addOrUpdate(ctx._id, params.tid) // 添加浏览记录
    }
    const newPost = post.toJSON()
    newPost.isFav = isFav
    // 更新文章阅读记数
    const result = await Post.updateOne(
      { _id: params.tid },
      { $inc: { reads: 1 } }
    )
    if (post._id && result.ok === 1) {
      ctx.body = {
        code: 200,
        data: newPost,
        msg: '查询文章详情成功'
      }
    } else {
      ctx.body = {
        code: 500,
        msg: '获取文章详情失败'
      }
    }
  }

# 接口对接与联调

前端添加接口:

// 个人中心的统计数字
const wxUserCount = params => axios.get('/user/wxUserCount', params)

前端页面添加请求:

async getUserCount () {
  const { _id: uid } = this.userInfo
  if (!uid) return
  await this.getUserInfo()
  const { data, code } = await this.$u.api.wxUserCount({ uid, reqAll: 1 })
  if (code === 200) {
    const { countMyPost, countMyCollect, countMyHistory } = data
    this.countMyPost = countMyPost
    this.countMyCollect = countMyCollect
    this.countMyHistory = countMyHistory
  }
}

完整的页面代码:

<template>
  <view>
    <view class="grey">
      <view class="bg"></view>
      <view class="wrapper">
        <!-- 个人信息卡片 -->
        <view class="profile">
          <view class="info">
            <u-image class="pic" :src="isLogin ? userInfo.pic: ''" width="120" height="120" shape="circle" error-icon="/static/images/header.jpg" />
            <!-- 用户昵称 + VIP -->
            <view class="user" @click="navTo">
              <view class="name">{{isLogin ?userInfo.name : '请登录'}}</view>
              <view class="fav">
                <!-- <van-icon name="fav2" class-prefix="iconfont" size="14"></van-icon> -->
                积分:{{userInfo && userInfo.favs ? userInfo.favs:0}}
              </view>
            </view>
            <view class="link" @click="gotoGuard('/sub-pkg/user-info/user-info')">个人主页 ></view>
            <!-- <navigator class="link" url="/subcom-pkg/auth/auth">个人主页 ></navigator> -->
            <!-- <navigator class="link" url="/sub-pkg/user-info/user-info">个人主页 ></navigator> -->
          </view>
          <view class="stats" v-if="isLogin">
            <view class="item">
              <navigator :url="'/sub-pkg/posts/posts?uid=' + uid + '&type=p'">
                <view>{{ countMyPost }}</view>
                <view class="title">我的帖子</view>
              </navigator>
            </view>
            <view class="item">
              <navigator :url="'/sub-pkg/posts/posts?uid=' + uid+ '&type=c'">
                <view>{{ countMyCollect }}</view>
                <view class="title">收藏夹</view>
              </navigator>
            </view>
            <view class="item">
              <navigator :url="'/sub-pkg/posts/posts?uid=' + uid+ '&type=h'">
                <view>{{ countMyHistory }}</view>
                <view class="title">最近浏览</view>
              </navigator>
            </view>
          </view>
        </view>
      </view>
      <view class="center-wraper">
        <view class="center-list first">
          <li v-for="(item,index) in lists" :key="index">
            <view @click="gotoGuardHandler(item)">
              <i :class="item.icon"></i>
              <span>{{item.name}}</span>
            </view>
          </li>
        </view>
        <view class="center-list">
          <li v-for="(item,index) in routes" :key="index" @click="gotoHome(item.tab)">
            <i :class="item.icon"></i>
            <span>{{item.name}}</span>
          </li>
        </view>
      </view>
    </view>
    <view class="bottom-line"></view>
  </view>
</template>

<script>
import { gotoGuard } from '@/common/checkAuth'
import { mapGetters, mapState, mapMutations } from 'vuex'
export default {
  data: () => ({
    lists: [
      {
        name: '我的帖子',
        icon: 'icon-teizi',
        routeName: '/sub-pkg/posts/posts'
      },
      {
        name: '修改设置',
        icon: 'icon-setting',
        routeName: '/sub-pkg/settings/settings'
      },
      {
        name: '签到中心',
        icon: 'icon-qiandao',
        routeName: '/sub-pkg/sign/sign'
      },
      {
        name: '电子书',
        icon: 'icon-book',
        routeName: '/sub-pkg/books/books'
      },
      {
        name: '关于我们',
        icon: 'icon-about',
        routeName: '/sub-pkg/about/about'
      },
      {
        name: '人工客服',
        icon: 'icon-support',
        routeName: '/sub-pkg/suggest/suggest'
      },
      {
        name: '意见反馈',
        icon: 'icon-lock2',
        routeName: '/sub-pkg/suggest/survey'
      }
    ],
    routes: [
      {
        name: '提问',
        icon: 'icon-question',
        tab: 'ask'
      },
      {
        name: '分享',
        icon: 'icon-share',
        tab: 'share'
      },
      {
        name: '讨论',
        icon: 'icon-taolun',
        tab: 'discuss'
      },
      {
        name: '建议',
        icon: 'icon-advise',
        tab: 'advise'
      }
    ],
    countMyPost: 0,
    countMyCollect: 0,
    countMyHistory: 0
    // isLogin: true
  }),
  computed: {
    ...mapGetters(['isLogin']),
    ...mapState(['userInfo']),
    uid () {
      return this.userInfo._id
    }
  },
  onShow () {
    this.getUserCount()
  },
  methods: {
    ...mapMutations(['setTab', 'setUserInfo']),
    gotoGuard,
    gotoGuardHandler (item) {
      const { name, routeName } = item
      if (name === '我的帖子') {
        gotoGuard(routeName + `?uid=${this.uid}&type=p`)
      } else {
        gotoGuard(routeName)
      }
    },
    gotoHome (tab) {
      this.setTab(tab)
      uni.switchTab({
        url: '/pages/home/home'
      })
    },
    navTo () {
      if (!this.isLogin) {
        uni.navigateTo({
          url: '/subcom-pkg/auth/auth'
        })
      }
    },
    async getUserInfo () {
      const { _id: uid } = this.userInfo
      const { code, data } = await this.$u.api.getBasic({ uid })
      if (code === 200) {
        this.setUserInfo(data)
      }
    },
    async getUserCount () {
      const { _id: uid } = this.userInfo
      if (!uid) return
      await this.getUserInfo()
      const { data, code } = await this.$u.api.wxUserCount({ uid, reqAll: 1 })
      if (code === 200) {
        const { countMyPost, countMyCollect, countMyHistory } = data
        this.countMyPost = countMyPost
        this.countMyCollect = countMyCollect
        this.countMyHistory = countMyHistory
      }
    }
  }

}
</script>

<style lang="scss">
.grey {
  position: fixed;
  width: 100%;
  height: 100%;
  left: 0;
  top: 0;
  z-index: 30;
}
a {
  color: #666;
  text-decoration: none;
}
// .bg {
//   height: 260rpx;
//   // 4个参数: 左上 右上 右下 左下
//   border-radius: 0 0 50% 50%;
//   background-color: #16d1a2;
//   position: relative;
//   z-index: 50;
// }
.bg {
  background-image: url("/static/images/my_bg.png");
  background-repeat: no-repeat;
  background-size: contain;
  position: relative;
  left: 0;
  top: 0;
  width: 100%;
  height: 280rpx;
  background-position: 0 0;
  z-index: 100;
}
.wrapper {
  width: 100%;
  height: 370rpx;
  padding: 25rpx;
  position: absolute;
  left: 0;
  top: 0;
  z-index: 100;
  box-sizing: border-box;
  color: #333;
  .profile {
    background: #fff;
    border-radius: 12rpx;
    box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
    width: 100%;
    height: 100%;
    padding: 30rpx;
    box-sizing: border-box;
    .name {
      font-size: 36rpx;
      font-weight: 700;
      margin-bottom: 10rpx;
      margin-top: 0;
      width: 370rpx;
      overflow: hidden;
      text-overflow: ellipsis;
      white-space: nowrap;
    }
    .link {
      font-size: 24rpx;
      color: #999;
    }
    .fav {
      display: inline-block;
      padding: 8px 12rpx;
      background: rgba(2, 209, 153, 0.16);
      border-radius: 12rpx;
      color: #02d199;
      margin: 0;
      font-size: 22rpx;
      .icon-fav {
        padding-right: 10rpx;
      }
    }
    .info,
    .stats {
      display: flex;
      flex-flow: row nowrap;
      justify-content: space-between;
      align-items: center;
    }
    .info {
      margin-bottom: 24rpx;
    }
    .stats {
      justify-content: space-around;
    }
    .user {
      flex: 1;
      padding-left: 20rpx;
    }
    .pic {
      width: 120rpx;
      height: 120rpx;
      border-radius: 50%;
    }
    .item {
      text-align: center;
      position: relative;
      p {
        margin-top: 14rpx;
        margin-bottom: 0;
      }
      &:after {
        width: 2rpx;
        height: 80rpx;
        background: #ddd;
        content: "";
        position: absolute;
        right: -60rpx;
        top: 20rpx;
      }
      &:last-child {
        &:after {
          width: 0;
        }
      }
      .title {
        color: #666;
      }
    }
  }
}
.center-wraper {
  background: #f6f5f8;
  position: relative;
  width: 100%;
  // height: 100%;
  z-index: 10;
  .center-list {
    background: #fff;
    margin-bottom: 30rpx;
    display: flex;
    flex-flow: wrap;
    padding-top: 40rpx;
    &.first {
      padding-top: 100rpx;
    }
    li {
      width: 25%;
      text-align: center;
      color: #666;
      margin-bottom: 40rpx;
      font-size: 26rpx;
    }
    i {
      display: block;
      margin: 0 auto;
      font-size: 40rpx;
      width: 56rpx;
      height: 56rpx;
      margin-bottom: 20rpx;
      color: #888;
      background-size: contain;
    }
    .icon-teizi {
      background-image: url("/static/images/teizi@2x.png");
    }
    .icon-setting {
      background-image: url("/static/images/setting@2x.png");
    }
    .icon-lock2 {
      background-image: url("/static/images/lock2@2x.png");
    }
    .icon-support {
      background-image: url("/static/images/support.png");
    }
    .icon-qiandao {
      background-image: url("/static/images/sign1.png");
    }
    .icon-book {
      background-image: url("/static/images/books.png");
    }
    .icon-record {
      background-image: url("/static/images/record@2x.png");
    }
    .icon-about {
      background-image: url("/static/images/about.png");
    }
    // 快捷访问
    .icon-question {
      background-image: url("/static/images/question@2x.png");
    }
    .icon-share {
      background-image: url("/static/images/share@2x.png");
    }
    .icon-taolun {
      background-image: url("/static/images/taolun@2x.png");
    }
    .icon-advise {
      background-image: url("/static/images/advice@2x.png");
    }
  }
}
</style>

最终效果:

image-20210801235142625