# 文章详情

完成效果:

image-20210527020457329

# 页面布局与样式

基本的步骤

  • 新增页面/subcom-pkg/detail/detail
  • 配置pages.json
  • 调整页面的样式与接口

配置pages.json

"subPackages": [
  {
    "root": "subcom-pkg",
    "pages": [
      ...
      {
        "path": "detail/detail",
        "style": {
          "navigationBarTitleText": "文章详情"
        }
      }
    ]
  }
]

页面样式与基础逻辑

<template>
  <view class="detail" v-show="page._id" @click.stop="showReply = false">
    <view class="header title">
      {{page.title}}
    </view>
    <view class="content">
      <view class="user u-flex">
        <u-image class="photo" :src="page.uid.pic" error-icon="/static/images/header.jpg" width="72" height="72" />
        <view class="user-column u-flex-1">
          <span class="name">{{page.uid.name}}</span>
          <span class="label">{{ page.created | moment }}</span>
        </view>
      </view>
      <u-parse :html="page.content">
        <view class="title"></view>
      </u-parse>
    </view>
    <view class="comments" :style="{'padding-bottom': paddingHeight + 'px'}">
      <view class="title">评论</view>
      <view class="item" v-for="(item) in comments" :key="item._id">
        <view class="u-flex u-col-center">
          <view class="user u-flex-1">
            <u-image class="photo" :src="item.cuid.pic" error-icon="/static/images/header.jpg" width="72" height="72" />
            <view class="user-column u-flex-1">
              <span class="name">{{ item.cuid.name }}</span>
              <span class="label">{{ item.created | moment }} 回复了你</span>
            </view>
          </view>
          <view class="u-flex u-col-center add-hand">
            <view class="reply" :class="{'active': item.handed === '1'}" @click="hand(item)">
              <u-icon name="thumb-up-fill" size="30" v-if="item.handed === '1'"></u-icon>
              <u-icon name="thumb-up" size="30" v-else></u-icon>
              <text>{{item.hands}}</text>
            </view>
            <view v-if="isOwner">
              <view class="caina" v-if="item.isBest === '1'">
                <u-icon name="yicaina" custom-prefix="iconfont" size="70" color="#58a571"></u-icon>
              </view>
              <view class="setBest" v-else-if="parseInt(page.isEnd) === 0 && parseInt(item.isBest) === 0" @click="setBest(item)">
                <u-icon name="caina" custom-prefix="iconfont" size="32"></u-icon>
              </view>
            </view>
          </view>
        </view>
        <view class="comments-content">{{item.content}}</view>
      </view>
      <view v-if="comments.length === 0">
        <view v-if="!loading" class="info">
          暂无评论,赶紧来抢沙发吧~~~
        </view>
        <view class="flex-center-center loading" v-else>
          <u-loading class="loading-icon" mode="circle"></u-loading>
          <text class="loading-text">加载中...</text>
        </view>
      </view>
    </view>
    <view class="footer">
      <view class="box u-flex u-col-center" v-if="!showReply">
        <view class="add-comment" @click.stop="reply()">
          <u-icon name="edit-pen" size="32" color="#cccccc"></u-icon>
          <text class="text">写评论</text>
        </view>
        <view class="ctrls u-flex u-col-center u-row-between">
          <view class="comment u-flex flex-column">
            <u-icon name="chat" size="45"></u-icon>
            <text>评论{{ page.answer > 0 ? page.answer : ''}}</text>
          </view>
          <view class="fav u-flex flex-column" :class="{'active': page.isFav === 1}" @click="setCollect">
            <u-icon name="star-fill" size="45" v-if="page.isFav === 1"></u-icon>
            <u-icon name="star" size="45" v-else></u-icon>
            <text>{{page.isFav === 1 ? '已收藏': '收藏'}}</text>
          </view>
          <view class="like u-flex flex-column" :class="{'active': page.isHand === 1}" @click="handsPost">
            <u-icon name="thumb-up-fill" size="45" v-if="page.isHand === 1"></u-icon>
            <u-icon name="thumb-up" size="45" v-else></u-icon>
            <text>{{page.isHand === 1 ? '已点赞' : '点赞'}}</text>
          </view>
        </view>
      </view>
      <view class="box u-flex u-col-center" v-else>
        <u-input v-model="content" class="reply" placeholder="请输入评论内容" focus @clear="clear"></u-input>
        <button type="primary" plain size="mini" @click.stop="send">发送</button>
      </view>
    </view>
  </view>
</template>

<script>
import { mapGetters, mapState } from 'vuex'
import { checkToken } from '@/common/checkAuth'

export default {
  components: {},
  data: () => ({
    page: {},
    comments: [],
    params: {
      page: 0,
      limit: 10,
      tid: ''
    },
    content: '',
    showReply: false,
    height: 0,
    paddingHeight: 60,
    loading: false
  }),
  computed: {
    ...mapState(['userInfo']),
    ...mapGetters(['isLogin']),
    isCollect () {
      return typeof this.page.isFav !== 'undefined' && this.page.isFav === 1
    },
    isOwner () {
      let flag = false
      if (this.page.uid && typeof this.page.uid !== 'undefined' && typeof this.userInfo._id !== 'undefined') {
        flag = this.page.uid._id === this.userInfo._id
      }
      return flag
    }
  },
  methods: {
    check: checkToken,
    async getReply () {
      const { data } = await this.$u.api.getComents(this.params)
      const arr = data.reverse()
      if (this.params.page === 0) {
        this.comments = arr
      } else {
        this.comments = [...this.comments, ...arr]
      }
      this.page.answer = this.comments.length || 0
    },
    async handsPost () {
      // 文章点赞
    },
    async hand (item) {
      // 评论点赞
      if (!this.check()) return
      const { msg, data, code } = await this.$u.api.setHands({ cid: item._id })
      if (code === 200 && data) {
        item.handed = '1'
        item.hands++
      } else {
        uni.showToast({
          icon: 'none',
          title: msg,
          duration: 2000
        })
      }
    },
    async setCollect () {
      // 设置收藏
      if (!this.check()) return
      const { msg, isCollect } = await this.$u.api.addCollect({ tid: this.params.tid, isFav: this.isCollect ? 1 : 0 })
      if (isCollect) {
        this.page.isFav = 1
      } else {
        this.page.isFav = 0
      }
      uni.showToast({
        icon: 'none',
        title: msg,
        duration: 2000
      })
    },
    reply () {
      if (!this.check()) return
      this.showReply = true
    },
    async send () {
      // 微信评论
    },
    setBest (item) {
      // 设置最佳
    },
    onShareAppMessage () {
      // 微信分享
    }
  },
  watch: {},
  // 页面周期函数--监听页面加载
  async onLoad (options) {
    this.loading = true
    const { tid } = options
    this.params.tid = tid
    const { data } = await this.$u.api.getDetail({ tid })
    this.page = data
    await this.getReply()
    this.loading = false
  },
  // 页面周期函数--监听页面初次渲染完成
  onPullDownRefresh () {
    uni.stopPullDownRefresh()
  },
  // 页面处理函数--监听用户上拉触底
  onReachBottom () {}
  // 页面处理函数--监听页面滚动(not-nvue)
  /* onPageScroll(event) {}, */
  // 页面处理函数--用户点击右上角分享
  /* onShareAppMessage(options) {}, */
}
</script>

<style lang="scss">
.detail {
  background: #f4f6f8;
  min-height: 100vh;
}

.header,
.content,
.comments {
  background: #fff;
  padding: 32rpx;
}

.header,
.content {
  margin-bottom: 24rpx;
  box-shadow: 0 5rpx 5px rgba($color: black, $alpha: 0.1);
}

.add-hand {
  position: relative;
  .caina {
    position: absolute;
    right: 100rpx;
    top: -20rpx;
  }
  .setBest {
    padding-left: 25rpx;
  }
}

.footer {
  position: fixed;
  bottom: 0;
  left: 0;
  width: 100vw;
  padding: 20rpx 32rpx;
  background-color: #fff;
  // height: 100rpx;
  box-shadow: 0 -5rpx 5px rgba($color: black, $alpha: 0.1);
  .box {
    width: 100%;
  }
  .reply {
    flex: 1;
    border: 1px solid #eee;
    padding: 0 15rpx;
    margin-right: 15rpx;
  }
}

.title {
  font-size: 32rpx;
  color: #333;
  font-weight: bold;
}

.user {
  display: flex;
  align-items: center; /* 垂直居中 */
  margin-right: 20rpx;
  .name {
    margin-bottom: 10rpx;
    font-size: 28rpx;
    font-family: PingFang SC;
    font-weight: bold;
    color: rgba(51, 51, 51, 1);
    white-space: nowrap;
    max-width: 420rpx;
    overflow: hidden;
    text-overflow: ellipsis;
  }
  .photo {
    width: 72rpx;
    height: 72rpx;
    border-radius: 50%;
  }
  .user-column {
    display: flex;
    flex-direction: column;
    margin-left: 20rpx;
  }
  .label {
    font-size: 22rpx;
    font-family: PingFang SC;
    font-weight: 500;
    color: rgba(153, 153, 153, 1);
  }
}

.comments {
  .item {
    padding: 24rpx 0;
    .comments-content {
      padding-top: 32rpx;
    }
    .reply {
      text {
        padding-left: 10rpx;
      }
      &.active {
        color: $u-type-primary;
      }
    }
  }
  .info {
    font-size: 28rpx;
    color: #666;
    line-height: 90rpx;
    text-align: center;
  }
}

.ctrls {
  color: #999;
  font-size: 22rpx;
  width: 35%;
  .fav,
  .like {
    &.active {
      color: $u-type-primary;
    }
  }
}

.add-comment {
  background: #f3f3f3;
  height: 64rpx;
  border-radius: 32rpx;
  line-height: 64rpx;
  padding: 0 32rpx;
  width: 65%;
  margin-right: 40rpx;
  color: #ccc;
  .text {
    padding-left: 10rpx;
  }
}

.loading {
  height: 50px;
  .loading-text {
    padding-left: 15rpx;
  }
}
</style>

App.vue页面中添加公共样式:

.flex-column {
  flex-direction: column;
}

调整api接口:

// 获取文章中的评论列表
const getComents = (params) => {
  const token = store.state.token
  let headers = {}
  if (token !== '') {
    headers = {
      headers: {
        Authorization: 'Bearer ' + store.state.token
      }
    }
  }
  return axios.get('/public/comments', params, headers)
}

// 获取文章详情
const getDetail = (data) => {
  const token = store.state.token
  let headers = {}
  if (token !== '') {
    headers = {
      headers: {
        Authorization: 'Bearer ' + store.state.token
      }
    }
  }
  return axios.get('/public/content/detail', data, headers)
}

# 长屏适配方案

安全区域指的是一个可视窗口范围,处于安全区域的内容不受圆角(corners)、齐刘海(sensor housing)、小黑条(Home Indicator)影响,如下图蓝色区域:

img

也就是说,我们要做好适配,必须保证页面可视、可操作区域是在安全区域内。

解决方案env() 和 constant():

iOS11 新增特性,Webkit 的一个 CSS 函数,用于设定安全区域与边界的距离,有四个预定义的变量:

  • safe-area-inset-left:安全区域距离左边边界距离
  • safe-area-inset-right:安全区域距离右边边界距离
  • safe-area-inset-top:安全区域距离顶部边界距离
  • safe-area-inset-bottom:安全区域距离底部边界距离

这里我们只需要关注 safe-area-inset-bottom 这个变量,因为它对应的就是小黑条的高度(横竖屏时值不一样)。

注意:当 viewport-fit=contain 时 env() 是不起作用的,必须要配合 viewport-fit=cover 使用。对于不支持env() 的浏览器,浏览器将会忽略它。

在这之前,笔者使用的是 constant(),后来,官方文档加了这么一段注释(坑):

The env() function shipped in iOS 11 with the name constant(). Beginning with Safari Technology Preview 41 and the iOS 11.2 beta, constant() has been removed and replaced with env(). You can use the CSS fallback mechanism to support both versions, if necessary, but should prefer env() going forward.

这就意味着,之前使用的 constant() 在 iOS11.2 之后就不能使用的,但我们还是需要做向后兼容,像这样:

padding-bottom: constant(safe-area-inset-bottom); /* 兼容 iOS < 11.2 */
padding-bottom: env(safe-area-inset-bottom); /* 兼容 iOS >= 11.2 */

注意:env() 跟 constant() 需要同时存在,而且顺序不能换。

更详细说明,参考文档: Designing Websites for iPhone X (opens new window)

# 页面分享设置

与页面分享相关的uniapp的API有两个:

小程序中用户点击分享后,在 js 中定义 onShareAppMessage 处理函数(和 onLoad 等生命周期函数同级),设置该页面的分享信息。

  • 用户点击分享按钮的时候会调用。这个分享按钮可能是小程序右上角原生菜单自带的分享按钮,也可能是开发者在页面中放置的分享按钮(<button open-type="share">);
  • 此事件需要 return 一个Object,用于自定义分享内容。

微信小程序平台的分享管理比较严格,请参考 小程序分享指引 (opens new window)

在详情页面中加入onShareAppMessage方法:

onShareAppMessage () {
  // 微信分享 -> 这个分享单一的好友
  return {
    title: this.page.title,
    path: '/subcom-pkg/detail/detail?tid=' + this.params.tid
  }
}

目前,微信官方没有提供正式的朋友圈分享的功能,只是在android设备上进行beta测试,参考说明:https://developers.weixin.qq.com/miniprogram/dev/reference/api/Page.html#onShareTimeline

image-20210802212601310

# 富文本显示

# 高亮方案一:自定义的highlight组件

步骤:

  • 定义highlight.vue组件;
  • 安装prismjs库,并使用tokenize方法进行拆分html字符串;
  • 使用normalize库进行转换为对象数组;

安装依赖:

npm i prismjs

自定义的highlight.vue组件

<template>
  <view class="highlight">
    <scroll-view scroll-x>
      <view v-for="(line,i) in tokenLines" :key="i" class="lines">
        <!-- 行数 -->
        <text class="line-number">{{i+1}}</text>
        <!-- 代码块 -->
        <text v-for="(token,index) in line" :key="index" :class="'token--' + token.type">{{token.content}}</text>
      </view>
    </scroll-view>
  </view>
</template>

<script>
import Prism from 'prismjs'
import normalize from '@/common/utils/normalize'

// const code = `
// <template>
//   <view class="list-item">
//     <view class="list-head">
//       <!-- 标题部分 -->
//       <text class="type" :class="['type-'+item.catalog]">{{tabs.filter(o => o.key === item.catalog)[0].value}}</text>
//       <text class="title">{{item.title}}</text>
//     </view>
//     <!-- 用户部分 -->
//     <view class="author u-flex u-m-b-18">
//       <u-image :src="item.uid.pic" class="head" width="40" height="40" shape="circle" error-icon="/static/images/header.jpg"></u-image>
//       <text class="name u-m-l-10">{{item.uid.name}}</text>
//     </view>
//     <!-- 摘要部分 + 右侧的图片 -->
//     <view class="list-body u-m-b-30 u-flex u-col-top">
//       <view class="info u-m-r-20 u-flex-1">{{item.content}}</view>
//       <image class="fmt" :src="item.snapshot" v-if="item.snapshot" mode="aspectFill" />
//     </view>
//     <!-- 回复 + 文章发表的时间 -->
//     <view class="list-footer u-flex">
//       <view class="left">
//         <text class="reply-num u-m-r-25">{{item.answer}} 回复</text>
//         <text class="timer">{{item.created | moment}}</text>
//       </view>
//     </view>
//   </view>
// </template>
// `
export default {
  props: {
    code: {
      type: String,
      default: ''
    },
    language: {
      type: String,
      default: 'js'
    }
  },
  data: () => ({
  }),
  computed: {
    tokenLines () {
      const result = normalize(Prism.tokenize(this.code, Prism.languages[this.language]))
      // console.log('🚀 ~ file: highlight.vue ~ line 54 ~ tokenLines ~ result', result)
      return result
    }
  },
  methods: {}
}
</script>

<style lang="scss" scoped>
.intro {
  margin: 30px;
  text-align: center;
}

:host {
  text-align: left;
  font-family: consolas, monospace;
  line-height: 1.44;
  white-space: nowrap;
}

.lines {
  display: flex;
  flex-flow: row nowrap;
  width: 100%;
  align-items: center;
  justify-content: flex-start;
}

.line-number {
  display: inline-block;
  flex-shrink: 0;
  border-right: 4px solid #d8d8d8;
  min-width: 55px;
  text-align: right;
  margin-right: 10px;
  padding-right: 10px;
  color: #888;
  &.max {
    width: 110px;
  }
}

.token {
  color: #333;
  white-space: pre;
}

.token--plain,
.token--string {
  white-space: pre;
}

.token--keyword {
  color: #00f;
}

.token--number {
  color: #09885a;
}

.token--string {
  color: #a31515;
}

.token--regex {
  color: #811f3f;
}

.token--comment {
  color: #008000;
}
</style>

# 高亮方案二:wxParse

效果展示:

  • 1.将prism.css重命名为prism.wxss
  • 2.复制prism.jsprism.wxssutils文件夹下
  • 3.替换prism.wxss中的code[class*="language-"].wxParse-code[class*="language-"]
  • 4.wxParse.wxss中引用prism.wxss
@import "../utils/prism.wxss";
  • 5.新增highlight.js高亮工具类(代码放置在wxParse文件夹下)
var Prism = require('../utils/prism.js')
function highlight(data, that) {
  // Prism 所支持的代码语言数组
  let langArr = new Array();
  langArr = listLanguages();
  // console.log('all-language:'+langArr)
  let html = data;
  //匹配到的所有标签<\/code>
  let tagArr = data.match(/<\/?code[^>]*>/g);
  if (tagArr == null) {
    return html;// 如果没有 pre 标签就直接返回原来的内容,不做代码高亮处理
  }
  //记录每一个 code 标签在data中的索引位置
  let indexArr = [];
  //计算索引位置
  for (let i = 0; i < tagArr.length; i++) {
    //添加索引值
    if (i == 0) {
      indexArr.push(data.indexOf(tagArr[i]));
    }
    else {
      indexArr.push(data.indexOf(tagArr[i], indexArr[i - 1]));
    }
  }

  //记录基本的class信息
  let cls;

  // 开始循环处理 code 标签
  let i = 0;
  while (i < tagArr.length - 1) {// 这里减一是因为不处理最后的 code 标签
    // 调用函数来获取 class 信息
    getStartInfo(tagArr[i])
    // 获取标签的值
    var label = tagArr[i].match(/<*([^> ]*)/)[1];
    // console.log('label:'+label)
    if (tagArr[i + 1] === '</' + label + '>') {//判断紧跟它的下一个标签是否为它的闭合标签
      if (label === 'code') {
        // 代码语言判断,根据类进行判断,自定义,比如 lang-语言,language-语言。
        let lang = cls.split(' ')[0];
        if (/lang-(.*)/i.test(lang)) {// 代码语言定义是 lang-XXX 的样式
          lang = lang.replace(/lang-(.*)/i, '$1');
        }
        else if (/languages?-(.*)/i.test(lang)) {
          lang = lang.replace(/languages?-(.*)/i, '$1');// 代码语言定义是 language(s)-XXX 的样式
        }
        // 如果代码语言不在 Prism 存在的语言,或者 class 值是 null,则不执行代码高亮函数
        if (langArr.indexOf(lang) == -1 || lang == null || lang == 'none' || lang == 'null') {
        }
        else {
          // 获取代码段内容为 code
          let code = data.substring(indexArr[i], indexArr[i + 1]).replace(/<code[^>]*>/, '');

          // 执行 Prism 的代码高亮函数
          let hcode = Prism.highlight(code, Prism.languages[lang], lang);
          html = html.replace(code, hcode);
        }

      }
      // 指向下一个标签(闭合标签)索引
      i++;
    } else {
      //onsole.log('不是闭包')
    }
    // 指向下一个标签(开始标签)的索引
    i++;
  }
  return html;

  function getStartInfo(str) {
    cls = matchRule(str, 'class');
  }

  //获取部分属性的值
  function matchRule(str, rule) {
    let value = '';
    let re = new RegExp(rule + '=[\'"]?([^\'"]*)');
    //console.log('regexp:'+re)
    if (str.match(re) !== null) {
      value = str.match(re)[1];
      //console.log('value:'+value)
    }
    return value;
  }


  // 列出当前 Prism.js 中已有的代码语言,可以自己在 Prism 的下载页面选择更多的语言。
  function listLanguages() {
    var langs = new Array();
    let i = 0;
    for (let language in Prism.languages) {
      if (Object.prototype.toString.call(Prism.languages[language]) !== '[object Function]') {
        langs[i] = language;
        i++;
      }
    }
    return langs;
  }
}

module.exports = {
  highlight: highlight
};
  • 6.在wxParse文件夹下的html2json.js中引用highlight.js工具类的highligh高亮函数
//引用`highlight.js`工具类
var highlight = require('./highlight.js');

function html2json(html, bindName, that) {
    html = removeDOCTYPE(html);
    html = trimHtml(html);
    html = wxDiscode.strDiscode(html);
    //引用高亮函数
    html = highlight.highlight(html, that); 

    //省略了后续代码
    ...

}

参考链接 :

https://blog.sunriseydy.top/technology/server-blog/wordpress/wordpress-miniapp-code-highlight

https://blog.csdn.net/qq_41107410/article/details/89042212