Vue

随动帮助文档实现

朱治龙
2023-06-10 / 0 评论 / 14 阅读 / 正在检测是否收录...
温馨提示:
本文最后更新于2023年06月29日,已超过559天没有更新,若内容或图片失效,请留言反馈。

背景介绍

我们项目提供了在线的项目文档,便于用户在使用过程中有什么问题可以通过直接查文档解决,也在一定程度上减少了公司运营及客服成本。

在实际使用过程中,用户反馈造作文档的使用不是很方便,基本上要打开新Tab页签,不能方便的一遍对照文档,一边操作。

解决方案

为解决以上问题,在参考阿里云及腾讯云相关产品的文档模式后,引入了随动帮助文档机制。

该机制主要实现模式如下:
1、在相关功能指定位置添加文档入口
2、单击文档名称后,在页面左下角显示文档弹出层(一下简称「文档查看器」),主要是系统的大部分功能使用右侧展开的抽屉组件 (Drawer)进行显示。
3、打开的文档查看器,可垂直方向最大,可上下左右四边及四个角落通过拖拽调整大小
4、可拖拽待页面任意位置
5、文档查看器不影响页面中其他组件的交互

该功能是系统每个功能模块都有可能会用到的,所以将起提取为一个公共组件。组件只需传入功能标识,然后调用内容管理系统的接口获取数据,如果有数据则显示文档入口,由于一个功能极有可能会对应有多篇帮助文档,用户从文档入口单击将展开文档列表,用户单击需要查看的文档,则在左下角显示文档。

实现过程

示例中均使用模拟的数据,实际数据在生产环境调用内容管理系统的接口获取。

实现文档查看器组件

HelpModal.vue

文档查看器的拖拽及大小调整均通过指令控制

<template>
  <el-dialog
    ref="dlg"
    v-dragAndResize :modal="false"
    top="auto"
    :visible.sync="isShow" custom-class="help-modal-wrap"
    width="400px"
    :close-on-click-modal="false" append-to-body
  >
    <div slot="title" class="dialog-title">
      <h3>功能指南</h3>
      <div class="panel-actions">
        <em
          class="panel-action console-svg svg-vertical-expand margin-right-xs"
          :class="{
            'svg-vertical-expand': !verticalMaxed,
            'svg-vertical-collapse': verticalMaxed
          }"
          :title="verticalMaxed ? '还原' :'垂直最大化'" @click="setDlgMaxHeight"
        />
      </div>
    </div>
    <div class="dlg-body-inner">
      <div class="content-wrap">
        <h2 class="article-title">
          如何使用一键转区
        </h2>
        <div class="article-subtitle">
          <span class="article-time">更新时间:2022-08-09 10:10</span>
        </div>
        <div class="article-content">
          <p style="line-height:normal">
            <span style="font-family:'arial' , 'helvetica' , sans-serif;font-size:16px">&nbsp;
              &nbsp; 可使用【一键转区】功能,将超算账号的数据转移至指定的超算上进行使用,如超算中心A3分区的sc0001账号(即为源目标)转移至超算分区A6的sc0001(即为目的目标),具体操作如下:</span>
          </p>
          <p style="line-height:normal">
            <span style="font-family:'arial' , 'helvetica' , sans-serif;font-size:16px">&nbsp;
              &nbsp; 1.&nbsp; 单击【一键转区】中的【申请一键转区】</span>
          </p>
          <p>
            <span style="font-size:14px"><img
              src="https://kbs.paratera.com/uploads/1/image/public/202208/20220809100845_a3269g5dky.png" title=""
              alt="image.png"
            ></span>
          </p>
          <p>
            <span style="font-size:14px"><img
              src="https://kbs.paratera.com/uploads/1/image/public/202208/20220809100855_atmxepobl8.png" title=""
              alt="image.png"
            ></span>
          </p>
          <p><span style="font-size:14px">&nbsp;</span></p>
          <p style="line-height:normal">
            <span style="font-family:'arial' , 'helvetica' , sans-serif;font-size:14px">&nbsp;
              <span style="font-family:'arial' , 'helvetica' , sans-serif;font-size:16px">&nbsp; 2.
                在【源目标】处选择可用的超算分区及超算账号,例如A3超算分区的sc0001;</span></span>
          </p>
          <p style="line-height:normal">
            <span style="font-family:'arial' , 'helvetica' , sans-serif;font-size:16px">&nbsp;
              &nbsp; 在【目的目标】处选择匹配的超算分区,不用选择转区后的超算账号,例如A6超算分区;</span>
          </p>
          <p style="line-height:normal">
            <span style="font-family:'arial' , 'helvetica' , sans-serif;font-size:16px">&nbsp;
              &nbsp; 在【源目标账号环境信息】处填软件使用时的环境要求。</span>
          </p>
          <p style="line-height:normal">
            <span style="font-family:'arial' , 'helvetica' , sans-serif;font-size:16px">&nbsp;
              &nbsp; 3. 单击【确定】,会有提示框告知源目标超算账号保存3个月,3个月后超算账号回收的提醒。</span>
          </p>
          <p style="line-height:normal">
            <span style="font-family:'arial' , 'helvetica' , sans-serif;font-size:16px">&nbsp;
              &nbsp; 4. 在【一键转区】的页面查看工单进展。</span>
          </p>
        </div>
      </div>
      <div class="outer-link-wrap">
        <el-button type="primary" class="btn-open-in-window" @click="openDocLink">
          新窗口查看文档
        </el-button>
      </div>
    </div>
  </el-dialog>
</template>
<script>
import { setStyle, on, off, addClass } from 'element-ui/lib/utils/dom'
export default {
  name: 'HelpModal',
  directives: {
    dragAndResize: {
      bind(el, binding, vnode, oldVnode) {
        addClass(el, 'dlg-wrap-help')
        // 弹框可拉伸最小宽高
        const minWidth = 300
        const minHeight = 300
        // 获取弹框头部(这部分可双击全屏)
        const dialogHeaderEl = el.querySelector('.el-dialog__header')
        // 弹窗
        const dragDom = el.querySelector('.el-dialog')
        dragDom.className = dragDom.className + ' dialog-can-resize'
        // // 给弹窗加上overflow auto;不然缩小时框内的标签可能超出dialog;
        // dragDom.style.overflow = 'auto'
        // 清除选择头部文字效果
        dialogHeaderEl.onselectstart = new Function('return false')
        // 头部加上可拖动cursor
        dialogHeaderEl.style.cursor = 'move'

        // 获取原有属性 ie dom元素.currentStyle 火狐谷歌 window.getComputedStyle(dom元素, null);
        const sty = dragDom.currentStyle || window.getComputedStyle(dragDom, null)

        // 初始设置在页面左下角
        const initialHeight = window.innerHeight * 2 / 3
        setStyle(dragDom, 'height', initialHeight + 'px')
        setStyle(dragDom, 'top', window.innerHeight * 1 / 3 - 10 + 'px')

        const moveDown = (e) => {
          e.preventDefault()
          // 鼠标按下,计算当前元素距离可视区的距离
          const disX = e.clientX - dialogHeaderEl.offsetLeft
          const disY = e.clientY - dialogHeaderEl.offsetTop

          // 获取到的值带px 正则匹配替换
          let styL, styT

          // 注意在ie中 第一次获取到的值为组件自带50% 移动之后赋值为px
          if (sty.left.includes('%')) {
            styL = +document.body.clientWidth * (+sty.left.replace(/%/g, '') / 100)
            styT = +document.body.clientHeight * (+sty.top.replace(/%/g, '') / 100)
          } else {
            styL = +sty.left.replace(/px/g, '')
            styT = +sty.top.replace(/px/g, '')
          }

          document.onmousemove = function(e) {
            e.preventDefault()
            // 通过事件委托,计算移动的距离
            const l = e.clientX - disX
            const t = e.clientY - disY

            // 移动当前元素
            dragDom.style.left = `${l + styL}px`
            dragDom.style.top = `${t + styT}px`

            // 将此时的位置传出去
            // binding.value({x:e.pageX,y:e.pageY})
          }

          document.onmouseup = function(e) {
            document.onmousemove = null
            document.onmouseup = null
          }
        }
        on(dialogHeaderEl, 'mousedown', moveDown)

        // 添加位置调整锚点
        const buildResizeElements = (dragDom) => {
          const directions = ['top', 'right', 'bottom', 'left', 'top-right', 'top-left', 'bottom-right', 'bottom-left']
          for (const direction of directions) {
            const resizeEl = document.createElement('div')
            resizeEl.className = `resize-handle resize-${direction}`
            resizeEl.setAttribute('resize', direction)
            dragDom.appendChild(resizeEl)
          }
        }
        buildResizeElements(dragDom)
        let moveOpts = null
        on(document, 'mousedown', (event) => {
          if (event.which !== 1 && event.type === 'mousedown') {
            return
          }
          const { target } = event
          if (!target.className.includes('resize-handle')) {
            return
          }
          const resizeDirection = target.getAttribute('resize')
          moveOpts = {
            resizeDirection: resizeDirection,
            clientX: event.clientX,
            clientY: event.clientY,
            startWidth: dragDom.offsetWidth,
            startHeight: dragDom.offsetHeight,
            offsetLeft: dragDom.offsetLeft,
            offsetTop: dragDom.offsetTop
          }
          const screenWidth = window.innerWidth
          const screenHeight = window.innerHeight
          const mouseMoveHandler = (event) => {
            event.preventDefault()
            const style = dragDom.style
            const resizeDirection = moveOpts.resizeDirection
            const { startWidth, startHeight } = moveOpts
            let left = moveOpts.offsetLeft
            let top = moveOpts.offsetTop
            let width = moveOpts.startWidth
            let height = moveOpts.startHeight

            let clientX = event.clientX
            let clientY = event.clientY
            let x = (clientX = screenWidth <= clientX ? screenWidth : clientX) <= 0 ? 0 : clientX
            let y = (clientY = screenHeight <= clientY ? screenHeight : clientY) <= 0 ? 0 : clientY
            x = event.clientX - moveOpts.clientX
            y = event.clientY - moveOpts.clientY

            switch (resizeDirection) {
                case 'top':
                  top = y + top
                  height = -y + height
                  break
                case 'right':
                  width = x + width
                  break
                case 'bottom':
                  height = y + height
                  break
                case 'left':
                  left = x + left
                  width = -x + width
                  break
                case 'top-left':
                  left = x + left
                  top = y + top
                  width = -x + width
                  height = -y + height
                  break
                case 'top-right':
                  top = y + top
                  width = x + width
                  height = -y + height
                  break
                case 'bottom-right':
                  width = x + startWidth
                  height = y + startHeight
                  break
                case 'bottom-left':
                  left = x + left
                  width = -x + startWidth
                  height = y + startHeight
            }
            left = left <= 0 ? 0 : left
            top = top <= 0 ? 0 : top
            style.left = left + 'px'
            style.top = top + 'px'
            style.width = Math.max(minWidth, width) + 'px'
            style.height = Math.max(minHeight, height) + 'px'
          }
          const mouseUpHandler = (event) => {
            console.error('mouseUp', event)
            off(document, 'mousemove', mouseMoveHandler)
            off(document, 'mouseup', mouseUpHandler)
          }
          on(document, 'mousemove', mouseMoveHandler)
          on(document, 'mouseup', mouseUpHandler)
        })
      }
    }
  },
  props: {
    show: {
      type: Boolean,
      required: true,
      default: false
    },
    docId: {
      type: Number,
      required: true
    }
  },
  data() {
    return {
      originStyle: null,
      verticalMaxed: false
    }
  },
  computed: {
    isShow: {
      get() {
        return this.show
      },
      set(val) {
        this.$emit('update:show', val)
      }
    }
  },
  created() {
    this.init()
  },

  methods: {
    async init() {
    },
    setDlgMaxHeight() {
      const $dlg = this.$refs['dlg']
      console.log('dlg', $dlg)
      const dlgEl = $dlg.$el.childNodes[0]
      const dlgStyle = dlgEl.style
      if (this.verticalMaxed) {
        setStyle(dlgEl, 'top', this.originStyle.top)
        setStyle(dlgEl, 'height', this.originStyle.height)
      } else {
        this.originStyle = {
          top: dlgStyle.top,
          height: dlgStyle.height
        }
        setStyle(dlgEl, 'top', 0)
        setStyle(dlgEl, 'height', window.innerHeight + 'px')
      }
      this.verticalMaxed = !this.verticalMaxed
    },
    openDocLink() {
      window.open('https://kbs.paratera.com/info/1272')
    }
  }
}
</script>
<style lang="scss">
@import "./modal-resize.scss";
.help-modal-wrap {
  border-radius: 0;
  pointer-events: all;
  margin: 0;
  left: 10px;
  top: calc(100vh - 100% - 10px);
  // transition: all ease 0.2s;
  .el-dialog__header {
    padding: 10px;
    background-image: url(./header-bg-2.jpg);
    background-repeat: no-repeat;
    background-position: 100% 0%;
    background-size:cover;
    background-color: $theme-light-1;
    color:#fff;
    position: relative;
    em.console-svg {
      font-size: 16px;
      cursor: pointer
    }
    .el-dialog__headerbtn {
      top: 15px;
      right: 15px;
    }
    .el-dialog__close {
      color:#fff;
    }
  }

  .el-dialog__body {
    flex: auto;
    overflow: auto;
    height: 100%;
    padding: 0;
  }
  .dlg-body-inner {
    box-sizing: border-box;
    height: 100%;
    // position: relative;

    // overflow: auto;
    // min-height: 352px;
    // max-height: calc(100vh - 60px);
  }

}
</style>
<style lang="scss" scoped>
@import "../../dashboard/modals/ArticleContent.scss";
:deep(.dlg-wrap-help) {
  pointer-events: none;
}
.dialog-title {
  h3 {
    font-size: 16px;
    font-weight:normal;
  }
}
.panel-actions {
  position: absolute;
  right: 30px;
  top: 10px;
}
.panel-action {
  width: 24px;
  height:24px;
  line-height: 24px;
  text-align: center;
  display:inline-block;
  margin: 0 3px;
  cursor: pointer;
  &:hover {
    background-color:rgba(0,0,0,0.15)
  }
}
.content-wrap {
  height: calc(100% - 95px);
  overflow: auto;
  padding: 10px;
}

article {
  padding-top: 20px;
}

.article-title {
  text-align: left;
  color: #333333;
  padding-top: 10px;
  font-size: 22px;
  font-weight: normal;
}

.article-subtitle {
  position: relative;
  width: 100%;
  padding-top: 10px;
  text-align: left;
  color: #999;
  font-size: 14px;
}
.article-content {
  padding: 10px;

}
.outer-link-wrap {
  padding: 10px;
  position: static;
  background-color: #fff;
  width: 100%;
  bottom: 0;
}
.btn-open-in-window {
  display: block;
  width: 100%;
  box-sizing: border-box;
  margin: 0 auto;
}
</style>
<style>
.dlg-wrap-help {
  pointer-events: none;
  z-index: 9000!important;
}
</style>

modal-resize.scss

.dialog-can-resize .resize-handle {
  position: absolute;
  z-index: 100;
  display: block
}

.dialog-can-resize .resize-top {
  cursor: n-resize;
  top: -3px;
  left: 0px;
  height: 7px;
  width: 100%
}

.dialog-can-resize .resize-bottom {
  cursor: s-resize;
  bottom: -3px;
  left: 0px;
  height: 7px;
  width: 100%
}

.dialog-can-resize .resize-right {
  cursor: e-resize;
  right: -3px;
  top: 0px;
  width: 7px;
  height: 100%
}

.dialog-can-resize .resize-left {
  cursor: w-resize;
  left: -3px;
  top: 0px;
  width: 7px;
  height: 100%
}

.dialog-can-resize .resize-bottom-right {
  cursor: se-resize;
  width: 20px;
  height: 20px;
  right: -8px;
  bottom: -8px;
  background: url('./resize_corner.png') no-repeat;
  opacity: .4;
  filter: alpha(opacity=40)
}

.dialog-can-resize .resize-bottom-left {
  cursor: sw-resize;
  width: 16px;
  height: 16px;
  left: -8px;
  bottom: -8px
}

.dialog-can-resize .resize-top-left {
  cursor: nw-resize;
  width: 16px;
  height: 16px;
  left: -8px;
  top: -8px
}

.dialog-can-resize .resize-top-right {
  cursor: ne-resize;
  width: 16px;
  height: 16px;
  right: -8px;
  top: -8px
}

.dialog-can-resize .aui-min,.dialog-can-resize .aui-max {
  display: block
}

.dialog-can-resize .resize-top-right:before,.dialog-can-resize .resize-bottom-right:before,.dialog-can-resize .resize-bottom-left:before,.dialog-can-resize .resize-top-left:before {
  position: absolute;
  content: "";
  -ms-transition: all .2s;
  -webkit-transition: all .2s;
  -moz-transition: all .2s;
  -o-transition: all .2s;
  transition: all .2s;
  width: 15px;
  height: 15px;
  opacity: 0;
  pointer-events: none;
  border: 1px solid #1890ff;
  border-radius: 0
}

.dialog-can-resize .resize-top-right:hover:before,.dialog-can-resize .resize-bottom-right:hover:before,.dialog-can-resize .resize-bottom-left:hover:before,.dialog-can-resize .resize-top-left:hover:before,.dialog-can-resize .resize-top-right:active:before,.dialog-can-resize .resize-bottom-right:active:before,.dialog-can-resize .resize-bottom-left:active:before,.dialog-can-resize .resize-top-left:active:before {
  opacity: 1
}

.dialog-can-resize .resize-top-right:before {
  border-left: none;
  border-bottom: none;
  border-radius: 0 4px 0 0;
  left: -8px;
  top: 8px
}

.dialog-can-resize .resize-bottom-right:before {
  border-left: none;
  border-top: none;
  border-radius: 0 0 4px 0;
  left: -4px;
  top: -4px
}

.dialog-can-resize .resize-bottom-left:before {
  border-right: none;
  border-top: none;
  border-radius: 0 0 0 4px;
  left: 8px;
  top: -8px
}

.dialog-can-resize .resize-top-left:before {
  border-right: none;
  border-bottom: none;
  border-radius: 4px 0 0 0;
  left: 8px;
  top: 8px
}

实现文档入口组件

<template>
  <div v-if="docList.length > 0" class="fn-doc-wrap">
    <el-popover
      position="bottom"
      trigger="hover"
    >
      <el-button slot="reference" type="text" class="padding-xs">
        <em class="console-svg svg-fn-help" />
      </el-button>
      <ul class="doc-list">
        <li v-for="docItem in docList" :key="docItem.id" @click="showHelpModal(docItem.id)">
          <p v-text="docItem.title" /><svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg" class="icon-tool-arrow" data-v-68bcb17a=""><path d="M13.7031 11.5259L13.7031 4.77588L6.95314 4.77589M4.30093 14.177L13.3949 5.08301" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path></svg>
        </li>
      </ul>
    </el-popover>
  </div>
</template>
<script>
export default {
  name: 'ParaFnDoc',
  desc: '随动帮助文档入口组件,使用过程中只需要传入关键词即可',
  props: {
    keyword: {
      type: String,
      required: true
    }
  },
  data() {
    return {
      docList: []
    }
  },
  created() {
    this.init()
  },
  methods: {
    init() {
      // TODO 根据关键词获取文档列表
      this.docList = [
        {
          id: 1,
          title: '一键转区功能说明'
        },
        {
          id: 2,
          title: '为什么一键转区功能不可用?'
        },
        {
          id: 3,
          title: '为什么一键转区提交后没有及时生效?'
        }
      ]
    },
    showHelpModal(docId) {
      window.ParaCommon.showHelpModal(docId)
    }
  }
}
</script>
<style lang="scss" scoped>
.fn-doc-wrap {
  display: inline-block;
  vertical-align: middle;
}
.doc-list li {
  line-height: 22px;
  margin-bottom: 5px;
  padding: 7px 10px;
  list-style:none;
  display: flex;
  align-items: center;
  justify-content: center;
  &:hover {
    background-color: #f7f8fa;
    cursor: pointer;
    .icon-tool-arrow {
      stroke: #1e80ff;
    }
  }
  p {
    flex: 1
  }
  .link-arrow {
    color:blue
  }
}
</style>

实现后的效果

0

评论 (0)

取消