Skip to content
索引

待整理笔记

长列表优化

业务开发中存在长列表显示需求,会出现几千条数据的情况,也就意味着要一次性渲染几千条数据,会造成明显的卡顿,这时就有必要对聊天列表进行性能优化来提升用户体验

对于这种需求,可能很多人首先想到的就是使用懒加载进行性能优化,但是对于很长的列表来说懒加载有三个致命的缺点:

  1. 如果一直加载到底, 那么最终还是会出现大量的DOM节点,导致滚动不流畅
  2. 想要定位到某一个位置的数据会非常困难
  3. 滚动条无法正确反映操作者当前浏览的信息在全部列表中的位置。而且大量数据加载,一次给我加载十几条,滚到底太慢了

懒加载无法满足真正的长列表展示,那么如果真正要解决此类问题该怎么办?还有一种思路就是:列表局部渲染,又被称为虚拟滚动

虚拟滚动

vue
<template>
  <div>
    <input type="text" v-model.number="dataLength" />条
    <div class="virtual-scroller" @scroll="onScroll" :style="{ height: 600 + 'px' }">
      <div class="phantom" :style="{ height: dataLength * itemHeight + 'px' }">
        <ul :style="{ 'margin-top': `${scrollTop}px` }">
          <li v-for="item in visibleList" :key="item.name" :style="{ height: `${itemHeight}px`, 'line-height': `${itemHeight}px` }">
            <div>
              <div>{{ item.name }}</div>
            </div>
          </li>
        </ul>
      </div>
    </div>
  </div>
</template>

<script lang="ts" setup>
let itemHeight = ref(60)
let dataLength = ref(500000)
let startIndex = ref(0)
let endIndex = ref(10)
let scrollTop = ref(0)

const dataList = computed(() => {
  const newDataList = [...Array(dataLength.value || 0).keys()].map((v, i) => ({
    name: `第${i + 1}项`,
    height: itemHeight.value
  }))
  return newDataList
})

const visibleList = computed(() => {
  console.log(dataList.value)
  return dataList.value.slice(startIndex.value, endIndex.value)
})

function onScroll(e: any) {
  const s = e.target.scrollTop
  scrollTop.value = s
  startIndex.value = Math.floor(s / itemHeight.value)
  endIndex.value = startIndex.value + 10
}
</script>

<style scoped>
.virtual-scroller {
  height: 600px;
  overflow: auto;
}

.phantom {
  overflow: hidden;
}
</style>
<template>
  <div>
    <input type="text" v-model.number="dataLength" />条
    <div class="virtual-scroller" @scroll="onScroll" :style="{ height: 600 + 'px' }">
      <div class="phantom" :style="{ height: dataLength * itemHeight + 'px' }">
        <ul :style="{ 'margin-top': `${scrollTop}px` }">
          <li v-for="item in visibleList" :key="item.name" :style="{ height: `${itemHeight}px`, 'line-height': `${itemHeight}px` }">
            <div>
              <div>{{ item.name }}</div>
            </div>
          </li>
        </ul>
      </div>
    </div>
  </div>
</template>

<script lang="ts" setup>
let itemHeight = ref(60)
let dataLength = ref(500000)
let startIndex = ref(0)
let endIndex = ref(10)
let scrollTop = ref(0)

const dataList = computed(() => {
  const newDataList = [...Array(dataLength.value || 0).keys()].map((v, i) => ({
    name: `第${i + 1}项`,
    height: itemHeight.value
  }))
  return newDataList
})

const visibleList = computed(() => {
  console.log(dataList.value)
  return dataList.value.slice(startIndex.value, endIndex.value)
})

function onScroll(e: any) {
  const s = e.target.scrollTop
  scrollTop.value = s
  startIndex.value = Math.floor(s / itemHeight.value)
  endIndex.value = startIndex.value + 10
}
</script>

<style scoped>
.virtual-scroller {
  height: 600px;
  overflow: auto;
}

.phantom {
  overflow: hidden;
}
</style>

vue中data是函数而不是对象

JS中的对象是引用类型的数据,当多个实例引用同一个对象的时候,只要其中一个实例对该对象进行操作的话,其他实例中的数据也会发生变化;而在Vue中更多的是组件复用思想,需要每个组件都有该组件自己的数据,这样组件之间才不会相互影响干扰。因此,Vue组件中的数据不能写成对象的形式,要写成函数的形式;组件中data写成一个函数,数据以函数返回值来定义,每次组件复用的时候就会返回一个新的data,这样每个组件都有属于该组件的专有空间,各个组件有各自的数据,这样就不会干扰其他组件的运行操作

vue2双向绑定

Vue数据双向绑定原理是通过数据劫持结合发布者-订阅者模式的方式来实现的,首先是对数据进行监听,然后当监听的属性发生变化时则告诉订阅者是否要更新,若更新就会执行对应的更新函数从而更新视图.

vue数据双向绑定是通过数据劫持结合发布者-订阅者模式的方式来实现的。

在Vue中其实就是通过Object.defineProperty来劫持对象属性的setter和getter操作,并“种下”一个监听器,当数据发生变化的时候发出通知。

我们已经知道实现数据的双向绑定,首先要对数据进行劫持监听,所以我们需要设置一个监听器Observer,用来监听所有属性。如果属性发上变化了,就需要告诉订阅者Watcher看是否需要更新。因为订阅者是有很多个,所以我们需要有一个消息订阅器Dep来专门收集这些订阅者,然后在监听器Observer和订阅者Watcher之间进行统一管理的。接着,我们还需要有一个指令解析器Compile,对每个节点元素进行扫描和解析,将相关指令(如v-model,v-on)对应初始化成一个订阅者Watcher,并替换模板数据或者绑定相应的函数,此时当订阅者Watcher接收到相应属性的变化,就会执行对应的更新函数,从而更新视图。因此接下去我们执行以下3个步骤,实现数据的双向绑定:

1.实现一个监听器Observer,用来劫持并监听所有属性,如果有变动的,就通知订阅者。

2.实现一个订阅者Watcher,每一个Watcher都绑定一个更新函数,watcher可以收到属性的变化通知并执行相应的函数,从而更新视图。

3.实现一个解析器Compile,可以扫描和解析每个节点的相关指令(v-model,v-on等指令),如果节点存在v-model,v-on等指令,则解析器Compile初始化这类节点的模板数据,使之可以显示在视图上,然后初始化相应的订阅者(Watcher)

vue优点?

答:轻量级框架:只关注视图层,是一个构建数据的视图集合,大小只有几十kb; 简单易学:国人开发,中文文档,不存在语言障碍 ,易于理解和学习; 双向数据绑定:保留了angular的特点,在数据操作方面更为简单; 组件化:保留了react的优点,实现了html的封装和重用,在构建单页面应用方面有着独特的优势; 视图,数据,结构分离:使数据的更改更为简单,不需要进行逻辑代码的修改,只需要操作数据就能完成相关操作; 虚拟DOM:dom操作是非常耗费性能的, 不再使用原生的dom操作节点,极大解放dom操作,但具体操作的还是dom不过是换了另一种方式; 运行速度更快:相比较与react而言,同样是操作虚拟dom,就性能而言,vue存在很大的优势。

vue父组件向子组件传递数据?

答:通过props

子组件向父组件传递事件?

答:$emit方法

v-show和v-if指令的共同点和不同点?

答: 共同点:都能控制元素的显示和隐藏; 不同点:实现本质方法不同,v-show本质就是通过控制css中的display设置为none,控制隐藏,只会编译一次;v-if是动态的向DOM树内添加或者删除DOM元素,若初始值为false,就不会编译了。而且v-if不停的销毁和创建比较消耗性能。 总结:如果要频繁切换某节点,使用v-show(切换开销比较小,初始开销较大)。如果不需要频繁切换某节点使用v-if(初始渲染开销较小,切换开销比较大)。

如何让CSS只在当前组件中起作用?

答:在组件中的style前面加上scoped

keep-alive 的作用是什么?

答:keep-alive 是 Vue 内置的一个组件,可以使被包含的组件保留状态,或避免重新渲染。

如何获取dom?

答:ref="domName" 用法:this.$refs.domName

说出几种vue当中的指令和它的用法?

答:v-model双向数据绑定; v-for循环; v-if v-show 显示与隐藏; v-on事件;v-once: 只绑定一次。

vue-loader是什么?使用它的用途有哪些?

答:vue文件的一个加载器,将template/js/style转换成js模块。 用途:js可以写es6、style样式可以scss或less、template可以加jade等

为什么使用key?

答:需要使用key来给每个节点做一个唯一标识,Diff算法就可以正确的识别此节点。 作用主要是为了高效的更新虚拟DOM。

axios及安装?

答:请求后台资源的模块。npm install axios --save装好, js中使用import进来,然后.get或.post。返回在.then函数中如果成功,失败则是在.catch函数中。

v-modal的使用。

答:v-model用于表单数据的双向绑定,其实它就是一个语法糖,这个背后就做了两个操作: v-bind绑定一个value属性; v-on指令给当前元素绑定input事件

请说出vue.cli项目中src目录每个文件夹和文件的用法?

答:assets文件夹是放静态资源;components是放组件;router是定义路由相关的配置; app.vue是一个应用主组件;main.js是入口文件。

分别简述computed和watch的使用场景

答:computed:     当一个属性受多个属性影响的时候就需要用到computed     最典型的栗子: 购物车商品结算的时候 watch:     当一条数据影响多条数据的时候就需要用watch     栗子:搜索数据

v-on可以监听多个方法吗?

答:可以,例子:

$nextTick的使用

答:当你修改了data的值然后马上获取这个dom元素的值,是不能获取到更新后的值, 你需要使用$nextTick这个回调,让修改后的data值渲染更新到dom元素之后在获取,才能成功。

vue组件中data为什么必须是一个函数?

答:因为JavaScript的特性所导致,在component中,data必须以函数的形式存在,不可以是对象。   组件中的data写成一个函数,数据以函数返回值的形式定义,这样每次复用组件的时候,都会返回一份新的data,相当于每个组件实例都有自己私有的数据空间,它们只负责各自维护的数据,不会造成混乱。而单纯的写成对象形式,就是所有的组件实例共用了一个data,这样改一个全都改了。

渐进式框架的理解

答:主张最少;可以根据不同的需求选择不同的层级;

Vue中双向数据绑定是如何实现的?

答:vue 双向数据绑定是通过 数据劫持 结合 发布订阅模式的方式来实现的, 也就是说数据和视图同步,数据发生变化,视图跟着变化,视图变化,数据也随之发生改变; 核心:关于VUE双向数据绑定,其核心是 Object.defineProperty()方法。

单页面应用和多页面应用区别及优缺点

答:单页面应用(SPA),通俗一点说就是指只有一个主页面的应用,浏览器一开始要加载所有必须的 html, js, css。所有的页面内容都包含在这个所谓的主页面中。但在写的时候,还是会分开写(页面片段),然后在交互的时候由路由程序动态载入,单页面的页面跳转,仅刷新局部资源。多应用于pc端。 多页面(MPA),就是指一个应用中有多个页面,页面跳转时是整页刷新 单页面的优点: 用户体验好,快,内容的改变不需要重新加载整个页面,基于这一点spa对服务器压力较小;前后端分离;页面效果会比较炫酷(比如切换页面内容时的专场动画)。 单页面缺点: 不利于seo;导航不可用,如果一定要导航需要自行实现前进、后退。(由于是单页面不能用浏览器的前进后退功能,所以需要自己建立堆栈管理);初次加载时耗时多;页面复杂度提高很多。

v-if和v-for的优先级

答:当 v-if 与 v-for 一起使用时,v-for 具有比 v-if 更高的优先级,这意味着 v-if 将分别重复运行于每个 v-for 循环中。所以,不推荐v-if和v-for同时使用。 如果v-if和v-for一起用的话,vue中的的会自动提示v-if应该放到外层去。

assets和static的区别

答:相同点:assets和static两个都是存放静态资源文件。项目中所需要的资源文件图片,字体图标,样式文件等都可以放在这两个文件下,这是相同点 不相同点:assets中存放的静态资源文件在项目打包时,也就是运行npm run build时会将assets中放置的静态资源文件进行打包上传,所谓打包简单点可以理解为压缩体积,代码格式化。而压缩后的静态资源文件最终也都会放置在static文件中跟着index.html一同上传至服务器。static中放置的静态资源文件就不会要走打包压缩格式化等流程,而是直接进入打包好的目录,直接上传至服务器。因为避免了压缩直接进行上传,在打包时会提高一定的效率,但是static中的资源文件由于没有进行压缩等操作,所以文件的体积也就相对于assets中打包后的文件提交较大点。在服务器中就会占据更大的空间。 建议:将项目中template需要的样式文件js文件等都可以放置在assets中,走打包这一流程。减少体积。而项目中引入的第三方的资源文件如iconfoont.css等文件可以放置在static中,因为这些引入的第三方文件已经经过处理,我们不再需要处理,直接上传。

vue常用的修饰符

答:.stop:等同于JavaScript中的event.stopPropagation(),防止事件冒泡; .prevent:等同于JavaScript中的event.preventDefault(),防止执行预设的行为(如果事件可取消,则取消该事件,而不停止事件的进一步传播); .capture:与事件冒泡的方向相反,事件捕获由外到内; .self:只会触发自己范围内的事件,不包含子元素; .once:只会触发一次。

vue的两个核心点

答:数据驱动、组件系统 数据驱动:ViewModel,保证数据和视图的一致性。 组件系统:应用类UI可以看作全部是由组件树构成的。

vue和jQuery的区别

答:jQuery是使用选择器($)选取DOM对象,对其进行赋值、取值、事件绑定等操作,其实和原生的HTML的区别只在于可以更方便的选取和操作DOM对象,而数据和界面是在一起的。比如需要获取label标签的内容:$("lable").val();,它还是依赖DOM元素的值。 Vue则是通过Vue对象将数据和View完全分离开来了。对数据进行操作不再需要引用相应的DOM对象,可以说数据和View是分离的,他们通过Vue对象这个vm实现相互的绑定。这就是传说中的MVVM。

引进组件的步骤

答: 在template中引入组件; 在script的第一行用import引入路径; 用component中写上组件名称。

delete和Vue.delete删除数组的区别

答:delete只是被删除的元素变成了 empty/undefined 其他的元素的键值还是不变。Vue.delete 直接删除了数组 改变了数组的键值。

SPA首屏加载慢如何解决

答:安装动态懒加载所需插件;使用CDN资源。

Vue-router跳转和location.href有什么区别

vue slot

答:简单来说,假如父组件需要在子组件内放一些DOM,那么这些DOM是显示、不显示、在哪个地方显示、如何显示,就是slot分发负责的活。

vue项目是打包了一个js文件,一个css文件,还是有多个文件?

答:根据vue-cli脚手架规范,一个js文件,一个CSS文件。

Vue里面router-link在电脑上有用,在安卓上没反应怎么解决?

答:Vue路由在Android机上有问题,babel问题,安装babel polypill插件解决。

Vue2中注册在router-link上事件无效解决方法

答: 使用@click.native。原因:router-link会阻止click事件,.native指直接监听一个原生事件。

RouterLink在IE和Firefox中不起作用(路由不跳转)的问题

答: 方法一:只用a标签,不适用button标签;方法二:使用button标签和Router.navigate方法

axios的特点有哪些

答:从浏览器中创建XMLHttpRequests; node.js创建http请求; 支持Promise API; 拦截请求和响应; 转换请求数据和响应数据; 取消请求; 自动换成json。 axios中的发送字段的参数是data跟params两个,两者的区别在于params是跟请求地址一起发送的,data的作为一个请求体进行发送 params一般适用于get请求,data一般适用于post put 请求。

params和query的区别

答:用法:query要用path来引入,params要用name来引入,接收参数都是类似的,分别是this.$route.query.name和this.$route.params.name。 url地址显示:query更加类似于我们ajax中get传参,params则类似于post,说的再简单一点,前者在浏览器地址栏中显示参数,后者则不显示 注意点:query刷新不会丢失query里面的数据 params刷新 会 丢失 params里面的数据。

vue初始化页面闪动问题

答:使用vue开发时,在vue初始化之前,由于div是不归vue管的,所以我们写的代码在还没有解析的情况下会容易出现花屏现象,看到类似于的字样,虽然一般情况下这个时间很短暂,但是我们还是有必要让解决这个问题的。 首先:在css里加上[v-cloak] { display: none; }。 如果没有彻底解决问题,则在根元素加上style="display: none;" :style="{display: 'block'}"

vue更新数组时触发视图更新的方法

答:push();pop();shift();unshift();splice(); sort();reverse()

vue修改打包后静态资源路径的修改

答:cli2版本:将 config/index.js 里的 assetsPublicPath 的值改为 './' 。 build: { ... assetsPublicPath: './', ... } cli3版本:在根目录下新建vue.config.js 文件,然后加上以下内容:(如果已经有此文件就直接修改) module.exports = { publicPath: '', // 相对于 HTML 页面(目录相同) }

生命周期函数面试题

1.什么是 vue 生命周期?有什么作用?

答:每个 Vue 实例在被创建时都要经过一系列的初始化过程——例如,需要设置数据监听、编译模板、将实例挂载到 DOM 并在数据变化时更新 DOM 等。同时在这个过程中也会运行一些叫做 生命周期钩子 的函数,这给了用户在不同阶段添加自己的代码的机会。(ps:生命周期钩子就是生命周期函数)例如,如果要通过某些插件操作DOM节点,如想在页面渲染完后弹出广告窗, 那我们最早可在mounted 中进行。

2.第一次页面加载会触发哪几个钩子?

答:beforeCreate, created, beforeMount, mounted

3.简述每个周期具体适合哪些场景

答:beforeCreate:在new一个vue实例后,只有一些默认的生命周期钩子和默认事件,其他的东西都还没创建。在beforeCreate生命周期执行的时候,data和methods中的数据都还没有初始化。不能在这个阶段使用data中的数据和methods中的方法 create:data 和 methods都已经被初始化好了,如果要调用 methods 中的方法,或者操作 data 中的数据,最早可以在这个阶段中操作 beforeMount:执行到这个钩子的时候,在内存中已经编译好了模板了,但是还没有挂载到页面中,此时,页面还是旧的 mounted:执行到这个钩子的时候,就表示Vue实例已经初始化完成了。此时组件脱离了创建阶段,进入到了运行阶段。 如果我们想要通过插件操作页面上的DOM节点,最早可以在和这个阶段中进行 beforeUpdate: 当执行这个钩子时,页面中的显示的数据还是旧的,data中的数据是更新后的, 页面还没有和最新的数据保持同步 updated:页面显示的数据和data中的数据已经保持同步了,都是最新的 beforeDestory:Vue实例从运行阶段进入到了销毁阶段,这个时候上所有的 data 和 methods , 指令, 过滤器 ……都是处于可用状态。还没有真正被销毁 destroyed: 这个时候上所有的 data 和 methods , 指令, 过滤器 ……都是处于不可用状态。组件已经被销毁了。

4.created和mounted的区别

答:created:在模板渲染成html前调用,即通常初始化某些属性值,然后再渲染成视图。 mounted:在模板渲染成html后调用,通常是初始化页面完成后,再对html的dom节点进行一些需要的操作。

5.vue获取数据在哪个周期函数

答:一般 created/beforeMount/mounted 皆可. 比如如果你要操作 DOM , 那肯定 mounted 时候才能操作.

6.请详细说下你对vue生命周期的理解?

答:总共分为8个阶段创建前/后,载入前/后,更新前/后,销毁前/后。 创建前/后: 在beforeCreated阶段,vue实例的挂载元素$el和数据对象data都为undefined,还未初始化。在created阶段,vue实例的数据对象data有了,$el还没有。 载入前/后:在beforeMount阶段,vue实例的$el和data都初始化了,但还是挂载之前为虚拟的dom节点,data.message还未替换。在mounted阶段,vue实例挂载完成,data.message成功渲染。 更新前/后:当data变化时,会触发beforeUpdate和updated方法。 销毁前/后:在执行destroy方法后,对data的改变不会再触发周期函数,说明此时vue实例已经解除了事件监听以及和dom的绑定,但是dom结构依然存在。

vue路由知识

1.mvvm 框架是什么?

答:vue是实现了双向数据绑定的mvvm框架,当视图改变更新模型层,当模型层改变更新视图层。在vue中,使用了双向绑定技术,就是View的变化能实时让Model发生变化,而Model的变化也能实时更新到View。

2.vue-router 是什么?它有哪些组件

答:vue用来写路由一个插件。router-link、router-view

3.active-class 是哪个组件的属性?

答:vue-router模块的router-link组件。children数组来定义子路由

4.怎么定义 vue-router 的动态路由? 怎么获取传过来的值?

答:在router目录下的index.js文件中,对path属性加上/:id。 使用router对象的params.id

5.vue-router 有哪几种导航钩子?

答:三种, 第一种:是全局导航钩子:router.beforeEach(to,from,next),作用:跳转前进行判断拦截。 第二种:组件内的钩子 第三种:单独路由独享组件

6.$route 和 $router 的区别

答:$router是VueRouter的实例,在script标签中想要导航到不同的URL,使用$router.push方法。返回上一个历史history用$router.to(-1) $route为当前router跳转对象。里面可以获取当前路由的name,path,query,parmas等。

7.vue-router的两种模式

答:hash模式:即地址栏 URL 中的 # 符号; history模式:window.history对象打印出来可以看到里边提供的方法和记录长度。利用了 HTML5 History Interface 中新增的 pushState() 和 replaceState() 方法。(需要特定浏览器支持)。

8.vue-router实现路由懒加载( 动态加载路由 )

答:三种方式 第一种:vue异步组件技术 ==== 异步加载,vue-router配置路由 , 使用vue的异步组件技术 , 可以实现按需加载 .但是,这种情况下一个组件生成一个js文件。 第二种:路由懒加载(使用import)。 第三种:webpack提供的require.ensure(),vue-router配置路由,使用webpack的require.ensure技术,也可以实现按需加载。这种情况下,多个路由指定相同的chunkName,会合并打包成一个js文件。

vuex知识

vuex是什么?怎么使用?哪种功能场景使用它?

vue框架中状态管理。在main.js引入store,注入。 新建了一个目录store.js,….. export 。 场景有:单页应用中,组件之间的状态。音乐播放、登录状态、加入购物车

2.vuex有哪几种属性?

有五种,分别是 State、 Getter、Mutation 、Action、 Module state => 基本数据(数据源存放地) getters => 从基本数据派生出来的数据 mutations => 提交更改数据的方法,同步 actions => 像一个装饰器,包裹mutations,使之可以异步。 modules => 模块化Vuex

3.Vue.js中ajax请求代码应该写在组件的methods中还是vuex的actions中?

如果请求来的数据是不是要被其他组件公用,仅仅在请求的组件内使用,就不需要放入vuex 的state里 如果被其他地方复用,这个很大几率上是需要的,如果需要,请将请求放入action里,方便复用

功能实现

前端人脸识别

html
<!-- 人脸识别 -->
<template>
  <!-- v-show="faceSure" -->
  <!-- :class="faceSure?'':'hideFalse'" -->
  <div class="entryFace  selfTestTracking" v-if="face">
    <div class="bgNo" :class="isBg ? 'bg' : ''">
      <div class="title">请将面部对准识别区域</div>
      <div class="ts">
        提示:在采集面人脸时摘掉口罩、帽子等遮挡面部的物品会使识别率更高。
      </div>
    </div>
    <video
      id="videoCamera"
      width="1920"
      height="1080"
      preload
      autoplay
      loop
      muted
      x5-video-player-type="h5-page"
    ></video>
    <canvas id="canvas" ref="canvas" width="1920" height="1080"></canvas>
    <canvas
      id="canvas-face"
      width="1920"
      height="1080"
      style="display: none;"
    ></canvas>
    <div class="bottom" v-show="showBtn">
      <div class="left-btn" @click="exit">返回</div>
    </div>
  </div>
</template>

<script>
  function dataURLtoFile(dataurl, filename) {
    //将base64转换为文件
    var arr = dataurl.split(','),
      mime = arr[0].match(/:(.*?);/)[1],
      bstr = atob(arr[1]),
      n = bstr.length,
      u8arr = new Uint8Array(n)
    while (n--) {
      u8arr[n] = bstr.charCodeAt(n)
    }
    return new File([u8arr], filename, {
      type: mime
    })
  }
  import tracking from '@/assets/js/ytjTracking.js'
  import '@/assets/js/face-min.js'
  import axios from 'axios'
  import { updateFace } from '@/util/api.js'
  import { Toast } from 'vant'
  export default {
    name: 'testTracking',
    data() {
      return {
        trackerTask: null,
        trackering: null,
        mediaStreamTrack: null,
        faceSure: false,
        loading: false,
        clientWidth: '',
        clientHeight: '',
        offsetY: '',
        offsetX: '',
        change: '',
        videoIdArr: [],
        isBg: false,
        userInfo: {},
        showBtn: false
      }
    },
    props: {
      face: Boolean
    },
    mounted() {
      // 获取用户信息
      this.userInfo = JSON.parse(localStorage.getItem('UserInfo'))
      console.log('config.videoId:' + config.videoId)
      this.getCompetence(config.videoId)
    },
    methods: {
      exit() {
        console.log('关闭了啊')
        this.mediaStreamTrack.srcObject.getTracks()[0].stop() // 关闭摄像头
        this.trackerTask.stop() // 停止侦测
        this.$parent.exit()
      },
      getCompetence(videoId) {
        let flag = true
        const _this = this
        const video = (this.mediaStreamTrack =
          document.getElementById('videoCamera'))
        const canvasUpload = document.getElementById('canvas-face')
        const contextUpload = canvasUpload.getContext('2d')
        const tracker = new window.tracking.ObjectTracker('face')
        contextUpload.translate(1920, 0)
        contextUpload.scale(-1, 1)
        tracker.setInitialScale(4)
        tracker.setStepSize(2)
        tracker.setEdgesDensity(0.1)
        // 启动摄像头初始化
        this.trackerTask = window.tracking.track(
          '#videoCamera',
          tracker,
          {
            camera: true
          },
          1920,
          1080,
          videoId
        )

        tracker.on('track', function (event) {
          _this.showBtn = true
          if (event.data.length) {
            // 会不停的去检测人脸,所以这里需要做个锁
            if (flag) {
              // 裁剪出人脸并绘制
              // contextUpload.clearRect(0, 0, 1920, 1080);
              contextUpload.drawImage(video, 0, 0, 1920, 1080)
              // contextUpload.drawImage(video,690,235,1230,700,1230,0,1230,700);
              // contextUpload.drawImage(video,690,235,1230,700,1230,0,1230,700);
              flag = false
              // 人脸的basa64
              const dataURL = canvasUpload.toDataURL('image/jpeg')
              console.log(dataURL)
              // img文件
              var cc = dataURLtoFile(dataURL, 'name.png')
              // 获取FormData对象
              let fd = new FormData()
              // 将图片传入formdata
              fd.append('file', cc)
              axios
                .post(process.env.VUE_APP_URL + 'common/upload', fd, {
                  headers: {
                    'Content-Type': 'application/x-www-form-urlencoded'
                  }
                })
                .then((res) => {
                  _this.userInfo.headImgPath = res.data.data[0].fileUrl
                  if (res.data.status == 200) {
                    // 上传人脸
                    updateFace('post', _this.userInfo).then((res1) => {
                      if (res1.data.status == 200) {
                        console.log('关闭了啊')
                        // 关闭摄像头
                        _this.mediaStreamTrack.srcObject.getTracks()[0].stop()
                        // 停止侦测
                        _this.trackerTask.stop()
                        // console.log(res.data.data[0].fileUrl)
                        _this.$parent.acquisitionSuccess(
                          res.data.data[0].fileUrl
                        )
                      } else {
                        Toast(res1.data.msg)
                        setTimeout(() => {
                          flag = true
                          console.log('关闭了啊')
                          // 关闭摄像头
                          _this.mediaStreamTrack.srcObject.getTracks()[0].stop()
                          // 停止侦测
                          _this.trackerTask.stop()
                          _this.$parent.exit()
                        }, 3000)
                      }
                    })
                  } else {
                    Toast(res.data.message)
                  }
                  // this.imgUrlListValue.push(res.data.urls[0].image) //这里上传到指定的图片服务器,成功后会返回图片的url
                })
                .catch((err) => {})
            }
          }
        })
      }
    },
    destroyed() {
      console.log('关闭了啊')
      // 关闭摄像头
      this.mediaStreamTrack.srcObject.getTracks()[0].stop()
      // 停止侦测
      this.trackerTask.stop()
      const canvasUpload = document.getElementById('canvas-face')
      const contextUpload = canvasUpload.getContext('2d')
      // contextUpload.clearRect(0, 0, this.clientWidth, this.clientHeight);
    }
  }
</script>

<style lang="less" scoped>
  @rem: 192;
  .hideFalse {
    visibility: hidden;
  }
  .bgNo {
    background: url('../assets/imgs/selfService/faceBg.png') no-repeat;
    background-size: 100% 100%;
    width: 100%;
    height: 100%;
    position: absolute;
    top: 0px;
    left: 0px;
    z-index: 99;
    .title {
      font-size: unit(60 / @rem, rem);
      text-align: center;
      color: #333;
      margin-top: unit(64 / @rem, rem);
    }
    .ts {
      font-size: unit(36 / @rem, rem);
      text-align: center;
      color: #f99501;
      margin-top: unit(24 / @rem, rem);
    }
  }
  .bg {
    background: url('../assets/imgs/selfService/faceBgScuess.png') no-repeat;
    background-size: 100% 100%;
  }
  .entryFace {
    width: 100%;
    height: 100vh;
    margin: 0px auto;
    position: fixed;
    top: unit(0 / @rem, rem);
    left: unit(0 / @rem, rem);
    background: #000;
    z-index: 999;
    #canvas {
      display: block;
      position: absolute;
      top: 0px;
      left: 0px;
      z-index: 99;
    }

    #videoCamera {
      display: block;
      position: absolute;
      top: 0px;
      left: 0px;
      background: none;
      transform: rotateY(180deg);
    }

    #canvas-face {
      position: absolute;
      top: 0px;
      left: 0px;
      width: 100%;
      height: 100%;
    }

    .loadingBox {
      background-color: rgba(0, 0, 0, 0.8);
      position: absolute;
      top: 50%;
      left: 50%;
      transform: translate(-50%, -50%);
      padding: unit(20 / @rem, rem) unit(40 / @rem, rem);
      border-radius: unit(10 / @rem, rem);
    }

    button {
      position: absolute;
      top: 20px;
      left: 20px;
    }

    .bottom {
      position: absolute;
      bottom: unit(60 / @rem, rem);
      left: unit(80 / @rem, rem);
      width: unit(1760 / @rem, rem);
      z-index: 99999;
      overflow: hidden;
      .left-btn {
        float: left;
        width: unit(320 / @rem, rem);
        height: unit(80 / @rem, rem);
        background: #d1440d;
        text-align: center;
        line-height: unit(80 / @rem, rem);
        color: #e18e74;
        font-size: unit(48 / @rem, rem);
        font-weight: bold;
        border-radius: unit(20 / @rem, rem);
      }
      .right-btn {
        float: right;
        width: unit(320 / @rem, rem);
        height: unit(80 / @rem, rem);
        text-align: center;
        line-height: unit(80 / @rem, rem);
        color: #fff;
        font-size: unit(48 / @rem, rem);
        font-weight: bold;
        background: #f2ae0d;
        border-radius: unit(20 / @rem, rem);
      }
    }
  }
</style>
<!-- 人脸识别 -->
<template>
  <!-- v-show="faceSure" -->
  <!-- :class="faceSure?'':'hideFalse'" -->
  <div class="entryFace  selfTestTracking" v-if="face">
    <div class="bgNo" :class="isBg ? 'bg' : ''">
      <div class="title">请将面部对准识别区域</div>
      <div class="ts">
        提示:在采集面人脸时摘掉口罩、帽子等遮挡面部的物品会使识别率更高。
      </div>
    </div>
    <video
      id="videoCamera"
      width="1920"
      height="1080"
      preload
      autoplay
      loop
      muted
      x5-video-player-type="h5-page"
    ></video>
    <canvas id="canvas" ref="canvas" width="1920" height="1080"></canvas>
    <canvas
      id="canvas-face"
      width="1920"
      height="1080"
      style="display: none;"
    ></canvas>
    <div class="bottom" v-show="showBtn">
      <div class="left-btn" @click="exit">返回</div>
    </div>
  </div>
</template>

<script>
  function dataURLtoFile(dataurl, filename) {
    //将base64转换为文件
    var arr = dataurl.split(','),
      mime = arr[0].match(/:(.*?);/)[1],
      bstr = atob(arr[1]),
      n = bstr.length,
      u8arr = new Uint8Array(n)
    while (n--) {
      u8arr[n] = bstr.charCodeAt(n)
    }
    return new File([u8arr], filename, {
      type: mime
    })
  }
  import tracking from '@/assets/js/ytjTracking.js'
  import '@/assets/js/face-min.js'
  import axios from 'axios'
  import { updateFace } from '@/util/api.js'
  import { Toast } from 'vant'
  export default {
    name: 'testTracking',
    data() {
      return {
        trackerTask: null,
        trackering: null,
        mediaStreamTrack: null,
        faceSure: false,
        loading: false,
        clientWidth: '',
        clientHeight: '',
        offsetY: '',
        offsetX: '',
        change: '',
        videoIdArr: [],
        isBg: false,
        userInfo: {},
        showBtn: false
      }
    },
    props: {
      face: Boolean
    },
    mounted() {
      // 获取用户信息
      this.userInfo = JSON.parse(localStorage.getItem('UserInfo'))
      console.log('config.videoId:' + config.videoId)
      this.getCompetence(config.videoId)
    },
    methods: {
      exit() {
        console.log('关闭了啊')
        this.mediaStreamTrack.srcObject.getTracks()[0].stop() // 关闭摄像头
        this.trackerTask.stop() // 停止侦测
        this.$parent.exit()
      },
      getCompetence(videoId) {
        let flag = true
        const _this = this
        const video = (this.mediaStreamTrack =
          document.getElementById('videoCamera'))
        const canvasUpload = document.getElementById('canvas-face')
        const contextUpload = canvasUpload.getContext('2d')
        const tracker = new window.tracking.ObjectTracker('face')
        contextUpload.translate(1920, 0)
        contextUpload.scale(-1, 1)
        tracker.setInitialScale(4)
        tracker.setStepSize(2)
        tracker.setEdgesDensity(0.1)
        // 启动摄像头初始化
        this.trackerTask = window.tracking.track(
          '#videoCamera',
          tracker,
          {
            camera: true
          },
          1920,
          1080,
          videoId
        )

        tracker.on('track', function (event) {
          _this.showBtn = true
          if (event.data.length) {
            // 会不停的去检测人脸,所以这里需要做个锁
            if (flag) {
              // 裁剪出人脸并绘制
              // contextUpload.clearRect(0, 0, 1920, 1080);
              contextUpload.drawImage(video, 0, 0, 1920, 1080)
              // contextUpload.drawImage(video,690,235,1230,700,1230,0,1230,700);
              // contextUpload.drawImage(video,690,235,1230,700,1230,0,1230,700);
              flag = false
              // 人脸的basa64
              const dataURL = canvasUpload.toDataURL('image/jpeg')
              console.log(dataURL)
              // img文件
              var cc = dataURLtoFile(dataURL, 'name.png')
              // 获取FormData对象
              let fd = new FormData()
              // 将图片传入formdata
              fd.append('file', cc)
              axios
                .post(process.env.VUE_APP_URL + 'common/upload', fd, {
                  headers: {
                    'Content-Type': 'application/x-www-form-urlencoded'
                  }
                })
                .then((res) => {
                  _this.userInfo.headImgPath = res.data.data[0].fileUrl
                  if (res.data.status == 200) {
                    // 上传人脸
                    updateFace('post', _this.userInfo).then((res1) => {
                      if (res1.data.status == 200) {
                        console.log('关闭了啊')
                        // 关闭摄像头
                        _this.mediaStreamTrack.srcObject.getTracks()[0].stop()
                        // 停止侦测
                        _this.trackerTask.stop()
                        // console.log(res.data.data[0].fileUrl)
                        _this.$parent.acquisitionSuccess(
                          res.data.data[0].fileUrl
                        )
                      } else {
                        Toast(res1.data.msg)
                        setTimeout(() => {
                          flag = true
                          console.log('关闭了啊')
                          // 关闭摄像头
                          _this.mediaStreamTrack.srcObject.getTracks()[0].stop()
                          // 停止侦测
                          _this.trackerTask.stop()
                          _this.$parent.exit()
                        }, 3000)
                      }
                    })
                  } else {
                    Toast(res.data.message)
                  }
                  // this.imgUrlListValue.push(res.data.urls[0].image) //这里上传到指定的图片服务器,成功后会返回图片的url
                })
                .catch((err) => {})
            }
          }
        })
      }
    },
    destroyed() {
      console.log('关闭了啊')
      // 关闭摄像头
      this.mediaStreamTrack.srcObject.getTracks()[0].stop()
      // 停止侦测
      this.trackerTask.stop()
      const canvasUpload = document.getElementById('canvas-face')
      const contextUpload = canvasUpload.getContext('2d')
      // contextUpload.clearRect(0, 0, this.clientWidth, this.clientHeight);
    }
  }
</script>

<style lang="less" scoped>
  @rem: 192;
  .hideFalse {
    visibility: hidden;
  }
  .bgNo {
    background: url('../assets/imgs/selfService/faceBg.png') no-repeat;
    background-size: 100% 100%;
    width: 100%;
    height: 100%;
    position: absolute;
    top: 0px;
    left: 0px;
    z-index: 99;
    .title {
      font-size: unit(60 / @rem, rem);
      text-align: center;
      color: #333;
      margin-top: unit(64 / @rem, rem);
    }
    .ts {
      font-size: unit(36 / @rem, rem);
      text-align: center;
      color: #f99501;
      margin-top: unit(24 / @rem, rem);
    }
  }
  .bg {
    background: url('../assets/imgs/selfService/faceBgScuess.png') no-repeat;
    background-size: 100% 100%;
  }
  .entryFace {
    width: 100%;
    height: 100vh;
    margin: 0px auto;
    position: fixed;
    top: unit(0 / @rem, rem);
    left: unit(0 / @rem, rem);
    background: #000;
    z-index: 999;
    #canvas {
      display: block;
      position: absolute;
      top: 0px;
      left: 0px;
      z-index: 99;
    }

    #videoCamera {
      display: block;
      position: absolute;
      top: 0px;
      left: 0px;
      background: none;
      transform: rotateY(180deg);
    }

    #canvas-face {
      position: absolute;
      top: 0px;
      left: 0px;
      width: 100%;
      height: 100%;
    }

    .loadingBox {
      background-color: rgba(0, 0, 0, 0.8);
      position: absolute;
      top: 50%;
      left: 50%;
      transform: translate(-50%, -50%);
      padding: unit(20 / @rem, rem) unit(40 / @rem, rem);
      border-radius: unit(10 / @rem, rem);
    }

    button {
      position: absolute;
      top: 20px;
      left: 20px;
    }

    .bottom {
      position: absolute;
      bottom: unit(60 / @rem, rem);
      left: unit(80 / @rem, rem);
      width: unit(1760 / @rem, rem);
      z-index: 99999;
      overflow: hidden;
      .left-btn {
        float: left;
        width: unit(320 / @rem, rem);
        height: unit(80 / @rem, rem);
        background: #d1440d;
        text-align: center;
        line-height: unit(80 / @rem, rem);
        color: #e18e74;
        font-size: unit(48 / @rem, rem);
        font-weight: bold;
        border-radius: unit(20 / @rem, rem);
      }
      .right-btn {
        float: right;
        width: unit(320 / @rem, rem);
        height: unit(80 / @rem, rem);
        text-align: center;
        line-height: unit(80 / @rem, rem);
        color: #fff;
        font-size: unit(48 / @rem, rem);
        font-weight: bold;
        background: #f2ae0d;
        border-radius: unit(20 / @rem, rem);
      }
    }
  }
</style>

签字版

js
<template>
  <div class="drop" :class="signature ? '' : 'hide'">
    <div class="signature-box" v-if="showSignatureArea">
      <p class="hint">温馨提示:请填写核对确认信息无误后,在下方手写区域签名,并提交申请。</p>
      <div class="canvas-box" id="canvasBox">
        <canvas id="canvas"></canvas>
        <span>签字区</span>
      </div>
      <div class="canvas-btn-group">
        <div class="btn close" @click="hideSignature">返回检查</div>
        <div class="btn rewriting" @click="clear"><img src="../assets/imgs/selfService/u1398.png" />清除重写</div>
        <div class="btn submit" @click="submit">提交申请</div>
      </div>
    </div>
    <!-- 退役报道显现-->
    <div class="tips" v-show="isSuccess && Ddata.type == 1">
      <img class="icon" src="@/assets/imgs/selfService/success.png" alt />
      <div class="tit">提交成功!</div>
      <p class="txt">尊敬的战友:{{ name }}</p>
      <p class="txt">您好!</p>
      <p class="txt indx">
        您的【信息采集】材料已经提交成功,我们会在24⼩时内进⾏审核,审核通过后会第⼀时间通知到您,
        <span class="g">请保持⼿机畅通!</span>如有疑问可以拨打退役军⼈服务中⼼电话: <span class="y">{{ ServiceTelephone }}</span
        >进⾏咨询。
      </p>
      <p class="txt">我们会竭诚为您服务,欢迎回家!</p>
      <p class="txt right">{{ title }}退役军⼈事务局</p>
      <div class="btn" @click="ok">确认</div>
    </div>
    <!--诉求服务显示-->
    <div class="tips" v-show="isSuccess && Ddata.type == 2">
      <img class="icon" src="@/assets/imgs/selfService/success.png" alt />
      <div class="tit">提交成功!</div>
      <p class="txt">尊敬的战友:{{ name }}</p>
      <p class="txt">您好!</p>
      <p class="txt indx">
        您的【诉求服务】材料已经提交成功,我们会在48⼩时进⾏回复,
        <span class="g">请保持⼿机畅通!</span>如有疑问可以拨打退役军⼈服务中⼼电话: <span class="y">{{ ServiceTelephone }}</span
        >进⾏咨询。
      </p>
      <p class="txt">我们会竭诚为您服务,保障每个退役军⼈合理合规诉求,让崇⾼落地!</p>
      <p class="txt right">{{ title }}退役军⼈事务局</p>
      <div class="btn" @click="ok">确认</div>
    </div>
    <!-- 教育培训显示-->
    <div class="train-tip" v-show="isSuccess && Ddata.type == 3">
      <img class="icon" src="@/assets/imgs/selfService/success.png" />
      <div class="tit">提交成功!</div>
      <p class="txt">
        请近期随时关注您的
        <span class="orange">手机信息</span>
      </p>
      <p class="txt">我们会在信息材料审核结束后通知您审核结果</p>
      <div class="btn" @click="ok">确认</div>
    </div>
    <div class="father">
      <div class="son">
        <div class="grandson"></div>
      </div>
    </div>
    <!-- 审核类业务显示-->
    <div class="tips" v-show="isSuccess && Ddata.type != 1 && Ddata.type != 2 && Ddata.type != 3">
      <img class="icon" src="@/assets/imgs/selfService/success.png" alt />
      <div class="tit">提交成功!</div>
      <p class="txt">尊敬的战友:{{ name }}</p>
      <p class="txt">您好!</p>
      <p class="txt indx">
        您的材料已经提交成功,我们会在24⼩时内进⾏审核,审核通过后会第⼀时间通知到您, <span class="g">请保持⼿机畅通!</span>如有疑问可以拨打退役军⼈服务中⼼电话: <span class="y">{{ ServiceTelephone }}</span
        >进⾏咨询。
      </p>
      <p class="txt">我们会竭诚为您服务!</p>
      <p class="txt right">{{ title }}退役军⼈事务局</p>
      <div class="btn" @click="ok">确认</div>
    </div>
  </div>
</template>

<script>
import {saveInfo, educationTrainAdd} from "@/util/api.js"
import axios from "axios"
let isSubmit = false // 是否签字
class Draw {
  constructor() {
    const box = document.getElementById("canvasBox") // 获取父元素
    this.clientWidth = box.clientWidth // 获取父元素宽
    this.clientHeight = box.clientHeight // 获取父元素高
    this.canvas = document.getElementById("canvas") // 获取canvas元素
    this.Top = this.canvas.getBoundingClientRect().top // 这里父元素是相对定位,所以不能使用offsetTop和offsetLeft
    this.Left = this.canvas.getBoundingClientRect().left // 而getBoundingClientRect只相对于可见窗口,可以使用
    this.canvas.setAttribute("width", this.clientWidth) // 设置canvas为父元素canvasBox宽度
    this.canvas.setAttribute("height", this.clientHeight) // 设置canvas为父元素canvasBox高度
    this.cxt = this.canvas.getContext("2d") // 获取canvas实例
  }
  init() {
    this.canvas.addEventListener("touchstart", event => {
      this.drawBegin(event)
    })
    this.canvas.addEventListener("touchend", () => {
      this.drawEnd()
    })
    this.clear() // 清空上一次签字
  }
  // 触发签字事件
  drawBegin(e) {
    this.cxt.strokeStyle = "#000"
    this.cxt.beginPath() // 起始一条路径,或重置当前路径
    this.cxt.moveTo(e.changedTouches[0].clientX - this.Left, e.changedTouches[0].clientY - this.Top)
    this.canvas.addEventListener("touchmove", event => {
      this.drawing(event)
    })
  }
  // 滑动签字
  drawing(e) {
    this.cxt.lineTo(e.changedTouches[0].clientX - this.Left, e.changedTouches[0].clientY - this.Top) // 添加一个新点,然后在画布中创建从该点到最后指定点的线条
    this.cxt.stroke() // 绘制已定义的路径
  }
  // 结束滑动
  drawEnd() {
    isSubmit = true
  }
  // 清空签字
  clear() {
    isSubmit = false
    this.cxt.clearRect(0, 0, this.clientWidth, this.clientHeight)
  }
  // 保存canvas为图片
  save() {
    return this.canvas.toDataURL("image/png") // toDataURL用于将canvas对象转换为base64位编码
  }
}

export default {
  props: {
    signature: Boolean, // 是否显示当前组件
    Ddata: Object // 当前模块用户填写信息
  },
  data() {
    return {
      title: "", // 市局名
      ServiceTelephone: "", // 市局联系电话
      type: "", // 判断哪个模块调用签字组件
      name: "", // 用户姓名
      isSubmit: false, // 是否已签字
      isSuccess: false, // 控制显示成功提交的弹窗显示
      showSignatureArea: true // 控制签名区显示
    }
  },
  mounted() {
    this.draw = new Draw("canvas") // 实例Draw类
    this.draw.init() // 初始化
    this.title = config.title // 获取config.js里的市局名
    this.ServiceTelephone = config.ServiceTelephone // 获取config.js里的服务电话
    this.type = sessionStorage.getItem("type") // 判断哪个模块调用签字组件
    this.name = JSON.parse(localStorage.getItem("UserInfo")).name // 用户姓名
  },
  methods: {
    // 点击返回检查,调用父级方法关闭签字区域
    hideSignature() {
      this.$parent.hideSignature()
    },
    // 点击清除重写,清空签字区
    clear() {
      this.draw.clear()
    },
    //将base64转换为文件
    dataURLtoFile(dataurl, filename) {
      var arr = dataurl.split(","),
        mime = arr[0].match(/:(.*?);/)[1],
        bstr = atob(arr[1]),
        n = bstr.length,
        u8arr = new Uint8Array(n)
      while (n--) {
        u8arr[n] = bstr.charCodeAt(n)
      }
      return new File([u8arr], filename, {
        type: mime
      })
    },
    // 点击提交申请->上传签名图片->保存所有信息
    async submit() {
      if (!isSubmit) return this.$Toast("请签名")
      const img = this.draw.save() // 保存成base64 图片格式
      const cc = this.dataURLtoFile(img, "name.png") // 将base64转换为文件
      const fd = new FormData() // new一个FormData对象
      fd.append("file", cc) // 将图片传入Formdata
      const {data: res} = await axios.post(process.env.VUE_APP_URL + "common/upload", fd, {headers: {"Content-Type": "application/x-www-form-urlencoded"}})
      if (res.status == 200) {
        // 上传签名成功
        this.Ddata.jdRetireInfo.signUrl = res.data[0].fileUrl // 上传后的签名地址存到jdRetireInfo中
        this.Ddata.jdRetireInfo.checkStatus = 2 // 0未提交材料 1已提交材料未签名 2已签名 3审核通过 4审核未通过
        if (this.type == "TRAIN_TYPE") {
          // 教育培训模块保存信息
          educationTrainAdd("post", this.Ddata, "json").then(res => {
            if (res.data.status == 200) {
              this.showSignatureArea = false // 关闭签名区
              this.isSuccess = true // 显示成功提交弹窗
            }
          })
        } else {
          // 其他模块保存信息
          saveInfo(this.type, this.Ddata, "json").then(res1 => {
            if (res1.data.status == 200) {
              this.showSignatureArea = false // 关闭签名区
              this.isSuccess = true // 显示成功提交弹窗
            }
          })
        }
      } else {
        // 上传签名不成功的错误信息
        this.$Toast(res.data.message)
      }
    },
    // 提交成功后点击确认回到首页
    ok() {
      this.$parent.hideSignature()
      this.$router.push({name: "selfHome"})
    }
  }
}
</script>

<style lang="less">
@rem: 192;

.hide {
  visibility: hidden;
}

.drop {
  width: 100vw;
  height: 100vh;
  position: fixed;
  top: unit(0 / @rem, rem);
  left: unit(0 / @rem, rem);
  background: rgba(0, 0, 0, 0.5);
  z-index: 999;
  .signature-box {
    width: unit(1100 / @rem, rem);
    height: unit(700 / @rem, rem);
    background: #f2f2f2;
    border-radius: unit(10 / @rem, rem);
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    padding: unit(0 / @rem, rem) unit(50 / @rem, rem);
    padding-top: unit(33 / @rem, rem);
    box-sizing: border-box;

    .hint {
      color: #f99501;
      font-weight: 700;
      font-size: unit(18 / @rem, rem);
      text-align: center;
      margin-bottom: unit(38 / @rem, rem);
    }

    .canvas-box {
      height: unit(460 / @rem, rem);
      border: unit(1 / @rem, rem) solid #797979;
      background: #fff;
      margin-top: unit(15 / @rem, rem);
      position: relative;

      #canvas {
        position: absolute;
        top: 0px;
        left: 0px;
      }

      span {
        font-size: unit(72 / @rem, rem);
        color: #cccccc;
        display: block;
        text-align: center;
        line-height: unit(460 / @rem, rem);
        position: absolute;
        top: unit(0 / @rem, rem);
        left: unit(0 / @rem, rem);
        z-index: 1;
        display: block;
        width: 100%;
      }
    }

    .canvas-btn-group {
      display: flex;
      justify-content: space-between;
      margin-top: unit(40 / @rem, rem);

      .btn {
        width: unit(260 / @rem, rem);
        height: unit(60 / @rem, rem);
        border-radius: unit(10 / @rem, rem);
        font-size: unit(30 / @rem, rem);
        font-weight: bold;
        text-align: center;
        line-height: unit(60 / @rem, rem);
        box-sizing: border-box;
      }

      .close {
        background: #fff;
        color: #f99501;
        box-shadow: 0px 0px unit(13 / @rem, rem) unit(3 / @rem, rem) rgba(62, 31, 32, 0.08);
      }

      .rewriting {
        background: #fff;
        color: #f99501;
        border: unit(3 / @rem, rem) solid #f99501;
        line-height: unit(54 / @rem, rem);
        img {
          width: unit(29 / @rem, rem);
          height: unit(31 / @rem, rem);
          position: relative;
          top: unit(7 / @rem, rem);
          margin-right: unit(13 / @rem, rem);
        }
      }

      .submit {
        background: #f99501;
        color: #fff;
        box-shadow: 0px 0px unit(13 / @rem, rem) unit(3 / @rem, rem) rgba(62, 31, 32, 0.08);
      }
    }
  }
}

.tips {
  width: unit(1226 / @rem, rem);
  height: unit(772 / @rem, rem);
  background: #fff;
  border-radius: unit(24 / @rem, rem);
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  padding: unit(66 / @rem, rem) unit(171 / @rem, rem) unit(48 / @rem, rem) unit(171 / @rem, rem);
  box-sizing: border-box;
  .icon {
    width: unit(377 / @rem, rem);
    height: unit(237 / @rem, rem);
    display: block;
    margin: 0px auto;
  }
  .tit {
    color: #333333;
    font-size: unit(40 / @rem, rem);
    font-weight: bold;
    text-align: center;
    margin-bottom: unit(24 / @rem, rem);
    margin-top: unit(24 / @rem, rem);
  }
  .txt {
    color: #333333;
    font-size: unit(24 / @rem, rem);
    line-height: unit(36 / @rem, rem);
    font-weight: bold;
    text-align: justify;
  }
  .indx {
    text-indent: unit(48 / @rem, rem);
  }
  .g {
    color: #3bba67;
  }
  .y {
    color: #f99501;
  }
  .right {
    text-align: right;
  }
  .btn {
    width: unit(260 / @rem, rem);
    height: unit(60 / @rem, rem);
    background: #f99501;
    box-shadow: 0px 0px unit(13 / @rem, rem) unit(3 / @rem, rem) rgba(62, 31, 32, 0.15);
    border-radius: unit(10 / @rem, rem);
    text-align: center;
    line-height: unit(60 / @rem, rem);
    color: #fff;
    font-size: unit(30 / @rem, rem);
    margin: 0px auto;
    margin-top: unit(37 / @rem, rem);
  }
}

.train-tip {
  width: unit(750 / @rem, rem);
  height: unit(520 / @rem, rem);
  background: #fff;
  border-radius: unit(24 / @rem, rem);
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  box-sizing: border-box;
  .icon {
    width: unit(277 / @rem, rem);
    height: unit(177 / @rem, rem);
    display: block;
    margin: 0px auto;
    margin-top: unit(54 / @rem, rem);
  }
  .tit {
    color: #333333;
    font-size: unit(36 / @rem, rem);
    font-weight: bold;
    text-align: center;
    padding-left: unit(45 / @rem, rem);
    letter-spacing: unit(5 / @rem, rem);
    margin-bottom: unit(18 / @rem, rem);
    margin-top: unit(10 / @rem, rem);
  }
  .txt {
    color: #333333;
    padding-left: unit(5 / @rem, rem);
    letter-spacing: unit(2 / @rem, rem);
    font-size: unit(24 / @rem, rem);
    font-weight: bold;
    text-align: center;
    margin-bottom: unit(4 / @rem, rem);
    .orange {
      color: #f99501;
    }
  }
  .btn {
    width: unit(260 / @rem, rem);
    height: unit(60 / @rem, rem);
    background: #f99501;
    padding-left: unit(5 / @rem, rem);
    letter-spacing: unit(5 / @rem, rem);
    box-shadow: 0px 0px unit(13 / @rem, rem) unit(3 / @rem, rem) rgba(62, 31, 32, 0.15);
    border-radius: unit(10 / @rem, rem);
    text-align: center;
    line-height: unit(60 / @rem, rem);
    color: #fff;
    font-size: unit(30 / @rem, rem);
    margin: 0px auto;
    margin-top: unit(37 / @rem, rem);
  }
}
</style>
<template>
  <div class="drop" :class="signature ? '' : 'hide'">
    <div class="signature-box" v-if="showSignatureArea">
      <p class="hint">温馨提示:请填写核对确认信息无误后,在下方手写区域签名,并提交申请。</p>
      <div class="canvas-box" id="canvasBox">
        <canvas id="canvas"></canvas>
        <span>签字区</span>
      </div>
      <div class="canvas-btn-group">
        <div class="btn close" @click="hideSignature">返回检查</div>
        <div class="btn rewriting" @click="clear"><img src="../assets/imgs/selfService/u1398.png" />清除重写</div>
        <div class="btn submit" @click="submit">提交申请</div>
      </div>
    </div>
    <!-- 退役报道显现-->
    <div class="tips" v-show="isSuccess && Ddata.type == 1">
      <img class="icon" src="@/assets/imgs/selfService/success.png" alt />
      <div class="tit">提交成功!</div>
      <p class="txt">尊敬的战友:{{ name }}</p>
      <p class="txt">您好!</p>
      <p class="txt indx">
        您的【信息采集】材料已经提交成功,我们会在24⼩时内进⾏审核,审核通过后会第⼀时间通知到您,
        <span class="g">请保持⼿机畅通!</span>如有疑问可以拨打退役军⼈服务中⼼电话: <span class="y">{{ ServiceTelephone }}</span
        >进⾏咨询。
      </p>
      <p class="txt">我们会竭诚为您服务,欢迎回家!</p>
      <p class="txt right">{{ title }}退役军⼈事务局</p>
      <div class="btn" @click="ok">确认</div>
    </div>
    <!--诉求服务显示-->
    <div class="tips" v-show="isSuccess && Ddata.type == 2">
      <img class="icon" src="@/assets/imgs/selfService/success.png" alt />
      <div class="tit">提交成功!</div>
      <p class="txt">尊敬的战友:{{ name }}</p>
      <p class="txt">您好!</p>
      <p class="txt indx">
        您的【诉求服务】材料已经提交成功,我们会在48⼩时进⾏回复,
        <span class="g">请保持⼿机畅通!</span>如有疑问可以拨打退役军⼈服务中⼼电话: <span class="y">{{ ServiceTelephone }}</span
        >进⾏咨询。
      </p>
      <p class="txt">我们会竭诚为您服务,保障每个退役军⼈合理合规诉求,让崇⾼落地!</p>
      <p class="txt right">{{ title }}退役军⼈事务局</p>
      <div class="btn" @click="ok">确认</div>
    </div>
    <!-- 教育培训显示-->
    <div class="train-tip" v-show="isSuccess && Ddata.type == 3">
      <img class="icon" src="@/assets/imgs/selfService/success.png" />
      <div class="tit">提交成功!</div>
      <p class="txt">
        请近期随时关注您的
        <span class="orange">手机信息</span>
      </p>
      <p class="txt">我们会在信息材料审核结束后通知您审核结果</p>
      <div class="btn" @click="ok">确认</div>
    </div>
    <div class="father">
      <div class="son">
        <div class="grandson"></div>
      </div>
    </div>
    <!-- 审核类业务显示-->
    <div class="tips" v-show="isSuccess && Ddata.type != 1 && Ddata.type != 2 && Ddata.type != 3">
      <img class="icon" src="@/assets/imgs/selfService/success.png" alt />
      <div class="tit">提交成功!</div>
      <p class="txt">尊敬的战友:{{ name }}</p>
      <p class="txt">您好!</p>
      <p class="txt indx">
        您的材料已经提交成功,我们会在24⼩时内进⾏审核,审核通过后会第⼀时间通知到您, <span class="g">请保持⼿机畅通!</span>如有疑问可以拨打退役军⼈服务中⼼电话: <span class="y">{{ ServiceTelephone }}</span
        >进⾏咨询。
      </p>
      <p class="txt">我们会竭诚为您服务!</p>
      <p class="txt right">{{ title }}退役军⼈事务局</p>
      <div class="btn" @click="ok">确认</div>
    </div>
  </div>
</template>

<script>
import {saveInfo, educationTrainAdd} from "@/util/api.js"
import axios from "axios"
let isSubmit = false // 是否签字
class Draw {
  constructor() {
    const box = document.getElementById("canvasBox") // 获取父元素
    this.clientWidth = box.clientWidth // 获取父元素宽
    this.clientHeight = box.clientHeight // 获取父元素高
    this.canvas = document.getElementById("canvas") // 获取canvas元素
    this.Top = this.canvas.getBoundingClientRect().top // 这里父元素是相对定位,所以不能使用offsetTop和offsetLeft
    this.Left = this.canvas.getBoundingClientRect().left // 而getBoundingClientRect只相对于可见窗口,可以使用
    this.canvas.setAttribute("width", this.clientWidth) // 设置canvas为父元素canvasBox宽度
    this.canvas.setAttribute("height", this.clientHeight) // 设置canvas为父元素canvasBox高度
    this.cxt = this.canvas.getContext("2d") // 获取canvas实例
  }
  init() {
    this.canvas.addEventListener("touchstart", event => {
      this.drawBegin(event)
    })
    this.canvas.addEventListener("touchend", () => {
      this.drawEnd()
    })
    this.clear() // 清空上一次签字
  }
  // 触发签字事件
  drawBegin(e) {
    this.cxt.strokeStyle = "#000"
    this.cxt.beginPath() // 起始一条路径,或重置当前路径
    this.cxt.moveTo(e.changedTouches[0].clientX - this.Left, e.changedTouches[0].clientY - this.Top)
    this.canvas.addEventListener("touchmove", event => {
      this.drawing(event)
    })
  }
  // 滑动签字
  drawing(e) {
    this.cxt.lineTo(e.changedTouches[0].clientX - this.Left, e.changedTouches[0].clientY - this.Top) // 添加一个新点,然后在画布中创建从该点到最后指定点的线条
    this.cxt.stroke() // 绘制已定义的路径
  }
  // 结束滑动
  drawEnd() {
    isSubmit = true
  }
  // 清空签字
  clear() {
    isSubmit = false
    this.cxt.clearRect(0, 0, this.clientWidth, this.clientHeight)
  }
  // 保存canvas为图片
  save() {
    return this.canvas.toDataURL("image/png") // toDataURL用于将canvas对象转换为base64位编码
  }
}

export default {
  props: {
    signature: Boolean, // 是否显示当前组件
    Ddata: Object // 当前模块用户填写信息
  },
  data() {
    return {
      title: "", // 市局名
      ServiceTelephone: "", // 市局联系电话
      type: "", // 判断哪个模块调用签字组件
      name: "", // 用户姓名
      isSubmit: false, // 是否已签字
      isSuccess: false, // 控制显示成功提交的弹窗显示
      showSignatureArea: true // 控制签名区显示
    }
  },
  mounted() {
    this.draw = new Draw("canvas") // 实例Draw类
    this.draw.init() // 初始化
    this.title = config.title // 获取config.js里的市局名
    this.ServiceTelephone = config.ServiceTelephone // 获取config.js里的服务电话
    this.type = sessionStorage.getItem("type") // 判断哪个模块调用签字组件
    this.name = JSON.parse(localStorage.getItem("UserInfo")).name // 用户姓名
  },
  methods: {
    // 点击返回检查,调用父级方法关闭签字区域
    hideSignature() {
      this.$parent.hideSignature()
    },
    // 点击清除重写,清空签字区
    clear() {
      this.draw.clear()
    },
    //将base64转换为文件
    dataURLtoFile(dataurl, filename) {
      var arr = dataurl.split(","),
        mime = arr[0].match(/:(.*?);/)[1],
        bstr = atob(arr[1]),
        n = bstr.length,
        u8arr = new Uint8Array(n)
      while (n--) {
        u8arr[n] = bstr.charCodeAt(n)
      }
      return new File([u8arr], filename, {
        type: mime
      })
    },
    // 点击提交申请->上传签名图片->保存所有信息
    async submit() {
      if (!isSubmit) return this.$Toast("请签名")
      const img = this.draw.save() // 保存成base64 图片格式
      const cc = this.dataURLtoFile(img, "name.png") // 将base64转换为文件
      const fd = new FormData() // new一个FormData对象
      fd.append("file", cc) // 将图片传入Formdata
      const {data: res} = await axios.post(process.env.VUE_APP_URL + "common/upload", fd, {headers: {"Content-Type": "application/x-www-form-urlencoded"}})
      if (res.status == 200) {
        // 上传签名成功
        this.Ddata.jdRetireInfo.signUrl = res.data[0].fileUrl // 上传后的签名地址存到jdRetireInfo中
        this.Ddata.jdRetireInfo.checkStatus = 2 // 0未提交材料 1已提交材料未签名 2已签名 3审核通过 4审核未通过
        if (this.type == "TRAIN_TYPE") {
          // 教育培训模块保存信息
          educationTrainAdd("post", this.Ddata, "json").then(res => {
            if (res.data.status == 200) {
              this.showSignatureArea = false // 关闭签名区
              this.isSuccess = true // 显示成功提交弹窗
            }
          })
        } else {
          // 其他模块保存信息
          saveInfo(this.type, this.Ddata, "json").then(res1 => {
            if (res1.data.status == 200) {
              this.showSignatureArea = false // 关闭签名区
              this.isSuccess = true // 显示成功提交弹窗
            }
          })
        }
      } else {
        // 上传签名不成功的错误信息
        this.$Toast(res.data.message)
      }
    },
    // 提交成功后点击确认回到首页
    ok() {
      this.$parent.hideSignature()
      this.$router.push({name: "selfHome"})
    }
  }
}
</script>

<style lang="less">
@rem: 192;

.hide {
  visibility: hidden;
}

.drop {
  width: 100vw;
  height: 100vh;
  position: fixed;
  top: unit(0 / @rem, rem);
  left: unit(0 / @rem, rem);
  background: rgba(0, 0, 0, 0.5);
  z-index: 999;
  .signature-box {
    width: unit(1100 / @rem, rem);
    height: unit(700 / @rem, rem);
    background: #f2f2f2;
    border-radius: unit(10 / @rem, rem);
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    padding: unit(0 / @rem, rem) unit(50 / @rem, rem);
    padding-top: unit(33 / @rem, rem);
    box-sizing: border-box;

    .hint {
      color: #f99501;
      font-weight: 700;
      font-size: unit(18 / @rem, rem);
      text-align: center;
      margin-bottom: unit(38 / @rem, rem);
    }

    .canvas-box {
      height: unit(460 / @rem, rem);
      border: unit(1 / @rem, rem) solid #797979;
      background: #fff;
      margin-top: unit(15 / @rem, rem);
      position: relative;

      #canvas {
        position: absolute;
        top: 0px;
        left: 0px;
      }

      span {
        font-size: unit(72 / @rem, rem);
        color: #cccccc;
        display: block;
        text-align: center;
        line-height: unit(460 / @rem, rem);
        position: absolute;
        top: unit(0 / @rem, rem);
        left: unit(0 / @rem, rem);
        z-index: 1;
        display: block;
        width: 100%;
      }
    }

    .canvas-btn-group {
      display: flex;
      justify-content: space-between;
      margin-top: unit(40 / @rem, rem);

      .btn {
        width: unit(260 / @rem, rem);
        height: unit(60 / @rem, rem);
        border-radius: unit(10 / @rem, rem);
        font-size: unit(30 / @rem, rem);
        font-weight: bold;
        text-align: center;
        line-height: unit(60 / @rem, rem);
        box-sizing: border-box;
      }

      .close {
        background: #fff;
        color: #f99501;
        box-shadow: 0px 0px unit(13 / @rem, rem) unit(3 / @rem, rem) rgba(62, 31, 32, 0.08);
      }

      .rewriting {
        background: #fff;
        color: #f99501;
        border: unit(3 / @rem, rem) solid #f99501;
        line-height: unit(54 / @rem, rem);
        img {
          width: unit(29 / @rem, rem);
          height: unit(31 / @rem, rem);
          position: relative;
          top: unit(7 / @rem, rem);
          margin-right: unit(13 / @rem, rem);
        }
      }

      .submit {
        background: #f99501;
        color: #fff;
        box-shadow: 0px 0px unit(13 / @rem, rem) unit(3 / @rem, rem) rgba(62, 31, 32, 0.08);
      }
    }
  }
}

.tips {
  width: unit(1226 / @rem, rem);
  height: unit(772 / @rem, rem);
  background: #fff;
  border-radius: unit(24 / @rem, rem);
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  padding: unit(66 / @rem, rem) unit(171 / @rem, rem) unit(48 / @rem, rem) unit(171 / @rem, rem);
  box-sizing: border-box;
  .icon {
    width: unit(377 / @rem, rem);
    height: unit(237 / @rem, rem);
    display: block;
    margin: 0px auto;
  }
  .tit {
    color: #333333;
    font-size: unit(40 / @rem, rem);
    font-weight: bold;
    text-align: center;
    margin-bottom: unit(24 / @rem, rem);
    margin-top: unit(24 / @rem, rem);
  }
  .txt {
    color: #333333;
    font-size: unit(24 / @rem, rem);
    line-height: unit(36 / @rem, rem);
    font-weight: bold;
    text-align: justify;
  }
  .indx {
    text-indent: unit(48 / @rem, rem);
  }
  .g {
    color: #3bba67;
  }
  .y {
    color: #f99501;
  }
  .right {
    text-align: right;
  }
  .btn {
    width: unit(260 / @rem, rem);
    height: unit(60 / @rem, rem);
    background: #f99501;
    box-shadow: 0px 0px unit(13 / @rem, rem) unit(3 / @rem, rem) rgba(62, 31, 32, 0.15);
    border-radius: unit(10 / @rem, rem);
    text-align: center;
    line-height: unit(60 / @rem, rem);
    color: #fff;
    font-size: unit(30 / @rem, rem);
    margin: 0px auto;
    margin-top: unit(37 / @rem, rem);
  }
}

.train-tip {
  width: unit(750 / @rem, rem);
  height: unit(520 / @rem, rem);
  background: #fff;
  border-radius: unit(24 / @rem, rem);
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  box-sizing: border-box;
  .icon {
    width: unit(277 / @rem, rem);
    height: unit(177 / @rem, rem);
    display: block;
    margin: 0px auto;
    margin-top: unit(54 / @rem, rem);
  }
  .tit {
    color: #333333;
    font-size: unit(36 / @rem, rem);
    font-weight: bold;
    text-align: center;
    padding-left: unit(45 / @rem, rem);
    letter-spacing: unit(5 / @rem, rem);
    margin-bottom: unit(18 / @rem, rem);
    margin-top: unit(10 / @rem, rem);
  }
  .txt {
    color: #333333;
    padding-left: unit(5 / @rem, rem);
    letter-spacing: unit(2 / @rem, rem);
    font-size: unit(24 / @rem, rem);
    font-weight: bold;
    text-align: center;
    margin-bottom: unit(4 / @rem, rem);
    .orange {
      color: #f99501;
    }
  }
  .btn {
    width: unit(260 / @rem, rem);
    height: unit(60 / @rem, rem);
    background: #f99501;
    padding-left: unit(5 / @rem, rem);
    letter-spacing: unit(5 / @rem, rem);
    box-shadow: 0px 0px unit(13 / @rem, rem) unit(3 / @rem, rem) rgba(62, 31, 32, 0.15);
    border-radius: unit(10 / @rem, rem);
    text-align: center;
    line-height: unit(60 / @rem, rem);
    color: #fff;
    font-size: unit(30 / @rem, rem);
    margin: 0px auto;
    margin-top: unit(37 / @rem, rem);
  }
}
</style>

打印 html 内容

html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>
<table border="1">
  <tr>
    <th>Month</th>
    <th>Savings</th>
  </tr>
<!--startprint-->
  <tr>
    <td>January</td>
    <td>$100</td>
  </tr>
<!--endprint-->
</table>
</p>
  <script>
function doPrint() {
    const html = window.document.body.innerHTML;
    const start = "<!--startprint-->"; //开始打印标识字符串有17个字符
    const end = "<!--endprint-->"; //结束打印标识字符串
    let printHtml= html.substring(html.indexOf(start) + 17); //从开始打印标识之后的内容
    printHtml = printHtml.substring(0, printHtml.indexOf(end)); //截取开始标识和结束标识之间的内容
    window.document.body.innerHTML = printHtml //把需要打印的指定内容赋给body.innerHTML
    window.print() //调用浏览器的打印功能打印指定区域
    window.document.body.innerHTML = printHtml//重新给页面内容赋值;
}
doPrint()
</script>
</body>
</html>
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>
<table border="1">
  <tr>
    <th>Month</th>
    <th>Savings</th>
  </tr>
<!--startprint-->
  <tr>
    <td>January</td>
    <td>$100</td>
  </tr>
<!--endprint-->
</table>
</p>
  <script>
function doPrint() {
    const html = window.document.body.innerHTML;
    const start = "<!--startprint-->"; //开始打印标识字符串有17个字符
    const end = "<!--endprint-->"; //结束打印标识字符串
    let printHtml= html.substring(html.indexOf(start) + 17); //从开始打印标识之后的内容
    printHtml = printHtml.substring(0, printHtml.indexOf(end)); //截取开始标识和结束标识之间的内容
    window.document.body.innerHTML = printHtml //把需要打印的指定内容赋给body.innerHTML
    window.print() //调用浏览器的打印功能打印指定区域
    window.document.body.innerHTML = printHtml//重新给页面内容赋值;
}
doPrint()
</script>
</body>
</html>

图片预览

html
<!DOCTYPE html>
<html>
  <head>
    <meta
      name="viewport"
      content="width=device-width,initial-scale=1, user-scalable=no"
    />
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
    <meta name="format-detection" content="telephone=no" />
    <title>test</title>
    <script>
      //选择图片时预览功能
      function imageshow(source) {
        var file = source.files[0]
        var imageid = source.id
        if (window.FileReader) {
          var fr = new FileReader()
          fr.onloadend = function (e) {
            document.getElementById('portrait' + imageid).src = e.target.result
          }
          fr.readAsDataURL(file)
        }
        document.getElementById('image' + imageid).style.display = 'none'
        document.getElementById('show' + imageid).style.display = 'block'
      }
    </script>
  </head>
  <body>
    <div>
      <div id="image1">
              
        <p>上传截图</p>
        <input
          type="file"
          name="screenshot1"
          id="1"
          onchange="imageshow(this)"
        />
      </div>
          
      <div id="show1" style="display:none;">
              <img src="" id="portrait1" width="100" height="70" />
            
      </div>
          
      <div id="image2">
        <p>上传截图</p>
        <input
          type="file"
          name="screenshot2"
          id="2"
          onchange="imageshow(this)"
        />
      </div>
         
      <div id="show2" style="display:none;">
              <img src="" id="portrait2" width="100" height="70" />    
      </div>
      <div id="image3">
        <p>上传截图</p>
        <input
          type="file"
          name="screenshot3"
          id="3"
          onchange="imageshow(this)"
        />
      </div>
        
      <div id="show3" style="display:none;">
              <img src="" id="portrait3" width="100" height="70" />
            
      </div>
    </div>
  </body>
</html>
<!DOCTYPE html>
<html>
  <head>
    <meta
      name="viewport"
      content="width=device-width,initial-scale=1, user-scalable=no"
    />
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
    <meta name="format-detection" content="telephone=no" />
    <title>test</title>
    <script>
      //选择图片时预览功能
      function imageshow(source) {
        var file = source.files[0]
        var imageid = source.id
        if (window.FileReader) {
          var fr = new FileReader()
          fr.onloadend = function (e) {
            document.getElementById('portrait' + imageid).src = e.target.result
          }
          fr.readAsDataURL(file)
        }
        document.getElementById('image' + imageid).style.display = 'none'
        document.getElementById('show' + imageid).style.display = 'block'
      }
    </script>
  </head>
  <body>
    <div>
      <div id="image1">
              
        <p>上传截图</p>
        <input
          type="file"
          name="screenshot1"
          id="1"
          onchange="imageshow(this)"
        />
      </div>
          
      <div id="show1" style="display:none;">
              <img src="" id="portrait1" width="100" height="70" />
            
      </div>
          
      <div id="image2">
        <p>上传截图</p>
        <input
          type="file"
          name="screenshot2"
          id="2"
          onchange="imageshow(this)"
        />
      </div>
         
      <div id="show2" style="display:none;">
              <img src="" id="portrait2" width="100" height="70" />    
      </div>
      <div id="image3">
        <p>上传截图</p>
        <input
          type="file"
          name="screenshot3"
          id="3"
          onchange="imageshow(this)"
        />
      </div>
        
      <div id="show3" style="display:none;">
              <img src="" id="portrait3" width="100" height="70" />
            
      </div>
    </div>
  </body>
</html>

input 设置图标

方法一

将图标设置为 input 的背景

html
 <style>
            input{
                border: none;
            }
            .box{
                height: 50px;
                background: yellow;
            }
            .box input{
                width: 200px;
                height: 30px;
                border-radius: 15px;
                margin: 10px 0;
                background: url(image/search.gif) no-repeat;
                background-color: white;
                background-position: 3px;
                padding-left: 30px;
                border: 1px solid black;
                outline: none;
            }
        </style>
    </head>
    <body>
        <div class="box">
            <input type="text" class="username" value="搜索"/>
        </div>
    </body>
 <style>
            input{
                border: none;
            }
            .box{
                height: 50px;
                background: yellow;
            }
            .box input{
                width: 200px;
                height: 30px;
                border-radius: 15px;
                margin: 10px 0;
                background: url(image/search.gif) no-repeat;
                background-color: white;
                background-position: 3px;
                padding-left: 30px;
                border: 1px solid black;
                outline: none;
            }
        </style>
    </head>
    <body>
        <div class="box">
            <input type="text" class="username" value="搜索"/>
        </div>
    </body>

方法二 使用绝对定位,相对定位

html
<style >
            .box{
                width: 200px;
                position: relative;
            }
            .box .icon-search{
                background: url(image/search.gif) no-repeat;
                width: 20px;
                height: 20px;
                position: absolute;
                top: 6px;
                left: 0;
            }
            .box .username{
                padding-left: 30px;
                height: 25px;
            }
        </style>
    </head>
    <body>
        <div class="box">
            <i class="icon-search"></i>
            <input type="text" class="username" value="搜索"/>
        </div>
    </body>
<style >
            .box{
                width: 200px;
                position: relative;
            }
            .box .icon-search{
                background: url(image/search.gif) no-repeat;
                width: 20px;
                height: 20px;
                position: absolute;
                top: 6px;
                left: 0;
            }
            .box .username{
                padding-left: 30px;
                height: 25px;
            }
        </style>
    </head>
    <body>
        <div class="box">
            <i class="icon-search"></i>
            <input type="text" class="username" value="搜索"/>
        </div>
    </body>

input 正则校验

css
<template>
    <form>
        <input type="text" maxlength="10" placeholder="请输入用户名" v-model="user" required @input="checkUser">
        <p v-if="userCorrect">{{userMes}}</p>
        <input type="password" maxlength="10" placeholder="请输入密码" v-model="password" required @input="checkPassword">
        <p v-if="passwordCorrect">{{passwordMes}}</p>
    </form>
</template>

<script>
export default {
    data () {
        return {
            user: '',
            userCorrect: false,
            userMes: '请输入正确用户名,长度在4-10个字符',
            password: '',
            passwordCorrect: false,
            passwordMes: '请输入正确密码,长度在4-10个字符',
        }
    },
    methods: {
        checkUser () {
            const pattern = /^[0-9]{4,10}$/
            this.userCorrect = !pattern.test(this.user)
        },
        checkPassword () {
            const pattern = /^[0-9]{4,10}$/
            this.passwordCorrect = !pattern.test(this.password)
        }
    }
}
</script>

<style>
input {
    display: block;
    padding: 0 20px;
    outline: none;
    border: 1px solid #ccc;
    width: 150px;
    height: 40px;
    transition: all 300ms;
}
/*input内容合法时:边框颜色是绿色*/
 input:valid {
    border-color: green;
    box-shadow: inset 5px 0 0 green;
}
/* input内容非法时:边框颜色是红色*/
 input:invalid {
    border-color: red;
    box-shadow: inset 5px 0 0 red;
}
button {
    width: 190px;
    height: 40px;
    background-color: green;
    color: white;
    margin-top: 20px;
}
</style>
<template>
    <form>
        <input type="text" maxlength="10" placeholder="请输入用户名" v-model="user" required @input="checkUser">
        <p v-if="userCorrect">{{userMes}}</p>
        <input type="password" maxlength="10" placeholder="请输入密码" v-model="password" required @input="checkPassword">
        <p v-if="passwordCorrect">{{passwordMes}}</p>
    </form>
</template>

<script>
export default {
    data () {
        return {
            user: '',
            userCorrect: false,
            userMes: '请输入正确用户名,长度在4-10个字符',
            password: '',
            passwordCorrect: false,
            passwordMes: '请输入正确密码,长度在4-10个字符',
        }
    },
    methods: {
        checkUser () {
            const pattern = /^[0-9]{4,10}$/
            this.userCorrect = !pattern.test(this.user)
        },
        checkPassword () {
            const pattern = /^[0-9]{4,10}$/
            this.passwordCorrect = !pattern.test(this.password)
        }
    }
}
</script>

<style>
input {
    display: block;
    padding: 0 20px;
    outline: none;
    border: 1px solid #ccc;
    width: 150px;
    height: 40px;
    transition: all 300ms;
}
/*input内容合法时:边框颜色是绿色*/
 input:valid {
    border-color: green;
    box-shadow: inset 5px 0 0 green;
}
/* input内容非法时:边框颜色是红色*/
 input:invalid {
    border-color: red;
    box-shadow: inset 5px 0 0 red;
}
button {
    width: 190px;
    height: 40px;
    background-color: green;
    color: white;
    margin-top: 20px;
}
</style>

滚动底部请求数据

js
this.$nextTick(() => {
  const body = this.$refs.instructions
  body.onscroll = () => {
    const scrollTop = body.scrollTop
    const clientHeight = body.clientHeight
    const scrollHeight = body.scrollHeight
    if (scrollTop + clientHeight >= scrollHeight - 50) {
      // 如果还有新的数据
      if (!this.finished) {
        this.page++ // 请求的页数
        this.getList() // 发起网络请求
      }
    }
  }
})
this.$nextTick(() => {
  const body = this.$refs.instructions
  body.onscroll = () => {
    const scrollTop = body.scrollTop
    const clientHeight = body.clientHeight
    const scrollHeight = body.scrollHeight
    if (scrollTop + clientHeight >= scrollHeight - 50) {
      // 如果还有新的数据
      if (!this.finished) {
        this.page++ // 请求的页数
        this.getList() // 发起网络请求
      }
    }
  }
})

请求方法

js
getList () {
            NewsList("post", {
                pageSize: 10,
                pageNum: this.page, // 页数参数
            }).then(res => {
                if (res.data.status == 200) {
                    this.con = [...this.con, ...res.data.data] // 合并新数据和旧数据
                    // 没有更多的数据了
                    if (res.data.data.length == 0) {
                        this.finished = true
                    }
                }
            })
        }
getList () {
            NewsList("post", {
                pageSize: 10,
                pageNum: this.page, // 页数参数
            }).then(res => {
                if (res.data.status == 200) {
                    this.con = [...this.con, ...res.data.data] // 合并新数据和旧数据
                    // 没有更多的数据了
                    if (res.data.data.length == 0) {
                        this.finished = true
                    }
                }
            })
        }

轮播图

bash
npm i -s swiper
npm i -s swiper
vue
<template>
  <div class="swiper-box">
    <div class="swiper">
      <div class="swiper-wrapper">
        <div
          class="swiper-slide"
          v-for="(item, index) in swiperArr"
          :key="index"
        >
          <img :src="item.clientFileUrl" />
        </div>
      </div>
      <div class="swiper-pagination"></div>
    </div>
    <div class="bottom">
      <div class="text">
        <span>{{ swiperArr[swiperIndex].tittle }}</span>
      </div>
      <div class="dots">
        <div
          class="dot"
          :class="swiperIndex == index ? 'active' : ''"
          v-for="(item, index) in swiperArr"
          :key="index"
        ></div>
      </div>
    </div>
  </div>
</template>
<script>
import { BannerList } from '@/util/api.js'
import Swiper from 'swiper'
import 'swiper/swiper.min.css'
export default {
  data() {
    return {
      swiperArr: [{ tittle: '' }], // 轮播数组
      swiperIndex: 0
    }
  },
  mounted() {
    this.getList()
  },
  methods: {
    async getList() {
      const { data: res } = await BannerList(
        'post',
        {
          type: 4
        },
        'json'
      )
      if (res.status == 200) {
        const images = res.data.filter(
          (item) =>
            item.clientFileUrl.charAt(item.clientFileUrl.length - 1) == 'g'
        )
        this.swiperArr = images
        const that = this
        this.$nextTick(() => {
          new Swiper('.swiper', {
            autoplay: true, // 自动滑动,
            loop: true, // 循环
            observer: true, //修改swiper自己或子元素时,自动初始化swiper
            observeParents: true, //修改swiper的父元素时,自动初始化swiper
            pagination: {
              el: '.swiper-pagination'
            },
            on: {
              slideChangeTransitionStart: function () {
                if (this.activeIndex > that.swiperArr.length) {
                  return (that.swiperIndex = 0)
                }
                if (this.activeIndex == 0) {
                  return (that.swiperIndex = that.swiperArr.length - 1)
                }
                if (that.swiperIndex <= that.swiperArr.length) {
                  that.swiperIndex = this.activeIndex - 1
                }
              }
            }
          })
        })
      }
    }
  }
}
</script>

<style lang="less">
.swiper-box {
  width: 100%;
  height: 100vh;
  position: absolute;
  top: 0;
  overflow: hidden;

  .swiper-slide {
    overflow: hidden;

    img {
      width: 100%;
      height: 100vh;
    }
  }

  .bottom {
    position: absolute;
    bottom: 0px;
    left: 0px;
    height: 40px;
    padding: 0px 40px;
    width: 100%;
    z-index: 99;
    box-sizing: border-box;
    background-color: #a70003;

    .text {
      float: left;
      line-height: 40px;
      color: #fff;
      font-size: 20px;
    }

    .dots {
      float: right;
      display: flex;
      align-items: center;
      height: 40px;

      .dot {
        width: 10px;
        height: 10px;
        background: #909090;
        border-radius: 50%;
        margin-left: 10px;
      }

      .active {
        background: #fff;
      }
    }
  }
}
</style>
<template>
  <div class="swiper-box">
    <div class="swiper">
      <div class="swiper-wrapper">
        <div
          class="swiper-slide"
          v-for="(item, index) in swiperArr"
          :key="index"
        >
          <img :src="item.clientFileUrl" />
        </div>
      </div>
      <div class="swiper-pagination"></div>
    </div>
    <div class="bottom">
      <div class="text">
        <span>{{ swiperArr[swiperIndex].tittle }}</span>
      </div>
      <div class="dots">
        <div
          class="dot"
          :class="swiperIndex == index ? 'active' : ''"
          v-for="(item, index) in swiperArr"
          :key="index"
        ></div>
      </div>
    </div>
  </div>
</template>
<script>
import { BannerList } from '@/util/api.js'
import Swiper from 'swiper'
import 'swiper/swiper.min.css'
export default {
  data() {
    return {
      swiperArr: [{ tittle: '' }], // 轮播数组
      swiperIndex: 0
    }
  },
  mounted() {
    this.getList()
  },
  methods: {
    async getList() {
      const { data: res } = await BannerList(
        'post',
        {
          type: 4
        },
        'json'
      )
      if (res.status == 200) {
        const images = res.data.filter(
          (item) =>
            item.clientFileUrl.charAt(item.clientFileUrl.length - 1) == 'g'
        )
        this.swiperArr = images
        const that = this
        this.$nextTick(() => {
          new Swiper('.swiper', {
            autoplay: true, // 自动滑动,
            loop: true, // 循环
            observer: true, //修改swiper自己或子元素时,自动初始化swiper
            observeParents: true, //修改swiper的父元素时,自动初始化swiper
            pagination: {
              el: '.swiper-pagination'
            },
            on: {
              slideChangeTransitionStart: function () {
                if (this.activeIndex > that.swiperArr.length) {
                  return (that.swiperIndex = 0)
                }
                if (this.activeIndex == 0) {
                  return (that.swiperIndex = that.swiperArr.length - 1)
                }
                if (that.swiperIndex <= that.swiperArr.length) {
                  that.swiperIndex = this.activeIndex - 1
                }
              }
            }
          })
        })
      }
    }
  }
}
</script>

<style lang="less">
.swiper-box {
  width: 100%;
  height: 100vh;
  position: absolute;
  top: 0;
  overflow: hidden;

  .swiper-slide {
    overflow: hidden;

    img {
      width: 100%;
      height: 100vh;
    }
  }

  .bottom {
    position: absolute;
    bottom: 0px;
    left: 0px;
    height: 40px;
    padding: 0px 40px;
    width: 100%;
    z-index: 99;
    box-sizing: border-box;
    background-color: #a70003;

    .text {
      float: left;
      line-height: 40px;
      color: #fff;
      font-size: 20px;
    }

    .dots {
      float: right;
      display: flex;
      align-items: center;
      height: 40px;

      .dot {
        width: 10px;
        height: 10px;
        background: #909090;
        border-radius: 50%;
        margin-left: 10px;
      }

      .active {
        background: #fff;
      }
    }
  }
}
</style>

Released under the MIT License.