h5组件

50 阅读5分钟
  • 部分组件基于 vant
  • package.json
 "smooth-signature": "^1.0.15",
 "nprogress": "^0.2.0",
 "pdfh5": "1.4.2",
 "vant": "4.9.10",
 "vconsole": "^3.15.1",
 "weixin-js-sdk": "^1.6.5"

底部按钮 footerButton

  • 底部按钮:支持传入左侧辅助按钮
<template>
  <div class='component-footer-button'>
    <slot v-if='showLeft' name='left'>
      <div class='left-btn' @click.stop='onLeftBtn'>
        {{ leftText }}
      </div>
    </slot>
    <van-button
      color='#2278f5' block native-type='submit' v-bind='$attrs'
      @click='onSubmit'
    >
      {{ text }}
    </van-button>
  </div>
</template>

<script setup lang="ts">
import { THROTTLE_DELAY_TIME } from '@/constants/index.js'

const props = defineProps({
  text: { // 主按钮文案
    type: String,
    default: '提交',
  },
  leftText: { // 左侧按钮展示的时候的文案
    type: String,
    default: '返回',
  },
  showLeft: { // 展示左侧辅助按钮
    type: Boolean,
    default: false,
  },
  // eslint-disable-next-line vue/require-default-prop
  leftFn: {
    type: Function,
  },
})

// 给调用方 加上 节流
const emits = defineEmits(['click'])

/**
   * 主按钮提交
   */
const onSubmit = useThrottleFn(() => {
  console.log('onSubmit')
  emits('click')
}, THROTTLE_DELAY_TIME)

const router = useRouter()
/**
   * 左侧辅助按钮点击时触发
   */
const onLeftBtn = useThrottleFn(() => {
  console.log('onLeftBtn')
  // 若父组件传递了左侧按钮处理函数,就调用父组件传递过来的处理函数
  if (props.leftFn) {
    props.leftFn()
    return
  }
  // 否则 返回到上页
  router.go(-1)
}, THROTTLE_DELAY_TIME)
</script>

<style lang="less" scoped>
.component-footer-button {
  position: fixed;
  bottom: 0;
  left: 0;
  right: 0;
  padding: 5px 25px;
  z-index: 999;
  // background-color: #f7f8f8;
  background-color: #fff;
  border-bottom-width: env(safe-area-inset-bottom);
  border-bottom-style: solid;
  border-bottom-color: transparent;
  display: flex;
  box-shadow: 0px 0px 4px 0px #dedede;
  .left-btn {
    height: var(--van-button-default-height);
    background: #f5f6fa;
    border-radius: 4px;
    min-width: 100px;
    display: flex;
    align-items: center;
    justify-content: center;
    font-size: var(--van-button-normal-font-size);
    margin-right: 10px;
    color: #7f7f7f;
  }
}
</style>
  • 使用
<FooterButton text='完成签署' @click='onFinishSign' />

<!-- native-type='button' 是为了点击按钮 不执行form表单的提交 -->
  <van-form
    colon
    class='apply-form'
    validate-first
    scroll-to-error
    :disabled='[ActionEnum.READ, ActionEnum.UPDATE].includes(action as ActionEnum)'
    @submit='onSubmit'
  >
    <FooterButton
      text='返回' 
      left-text='取消借阅' 
      :left-fn='onCancelBorrow'
      :show-left="['1', '3', '4', '5', '6', '7', '10', '11'].includes(status as string)"
      native-type='button'
      @click='$router.go(-1)'
    />
</van-form>

文件上传

<template>
  <van-uploader
    v-bind='$attrs'
    v-model='fileList'
    class='component-file-uploader'
    multiple
    accept='image/*,.pdf'
    :max-count='maxCount'
    :before-read='beforeRead'
  >
    <!-- 提供 default 插槽,允许使用方自定义中间内容 -->
    <slot>
      <div class='upload'>
        <div class='upload-center'>
          <img
            src='@/assets/images/camera-icon.png'
            alt=''
            class='camera-icon'
          >
          <span>上传影像</span>
        </div>
      </div>
    </slot>
    <template #preview-cover='item'>
      <div
        v-if="['pdf'].includes(item.fileKey?.split('.')[1])"
        class='preview-cover pdf-item'
        @click.stop='onPdfPreview(item)'
      >
        <img src='@/assets/images/pdf.png' alt='' object-fit='cover'>
      </div>
    </template>
  </van-uploader>

  <van-overlay
    :show='showPdfOverlay'
    :lock-scroll='false'
    z-index='99999'
    :custom-style="{ background: 'rgb(0,0,0)' }"
    @click='showPdfOverlay = false'
  >
    <div class='pdf-wrapper'>
      <van-icon name='cross' color='rgb(34, 120, 245)' size='26' />
      <Pdfh5 :pdfurl='pdfurl' />
    </div>
  </van-overlay>
</template>

<script setup lang="ts">
import { showToast } from 'vant'
// eslint-disable-next-line no-duplicate-imports
import type { UploaderFileListItem } from 'vant'
import useCommonStore from '@/stores/modules/useCommonStore'
import { uploadFileRequest } from '@/api/base'

const props = defineProps({
  sigleMaxSize: {
    // 单个文件大小限制, 单位为 M,
    type: Number,
    default: 5 * 1024 * 1024,
  },
  maxCount: {
    // 文件上传数量限制,
    type: Number,
    default: 8,
  },
})

const { normalDictMap } = useCommonStore()

const fileList = defineModel<Array<UploaderFileListItem>>() // { fileKey:string  originalFileName:string, downloadUrl:string, url:string }
let { maxCount, sigleMaxSize } = props
const init = () => {
  const uploadControlInfos = normalDictMap.get('upload_control')
  for (const upload of uploadControlInfos) {
    if (upload.text === 'upload_count') {
      maxCount = Number(upload.value)
    } else if (upload.text === 'upload_size') {
      sigleMaxSize = Number(upload.value)
    }
  }
}
init()
const fileWhiteList = normalDictMap.get('file_white_list').map((item) => item.text)

/**
 * 文件上传至服务器
 * @param  { Object } file:{ name:"", size:15390, type: "image/png", message:"", objectUrl:"", status:"uploading 表示上传中,failed 表示上传失败,done 表示上传完成" }
 */
const beforeRead = (files) => {
  console.log('beforeRead---files', files, fileList.value.length)
  const tempFiles = []
  // 上传 单文件 时为文件对象
  if (!Array.isArray(files)) {
    tempFiles.push(files)
  } else {
    tempFiles.push(...files)
  }
  if (files.length + fileList.value.length > maxCount) {
    showToast(`最多上传${maxCount}个文件`)
    return false
  }
  const formData = new FormData()
  for (const file of tempFiles) {
    const fileType = file.type.split('/')[1]
    if (!fileWhiteList.includes(fileType)) {
      showToast(`请上传 ${fileWhiteList.join('/')} 格式文件`)
      return false
    }
    if (file.size / 1024 / 1024 > sigleMaxSize) {
      showToast(`单个文件大小不能超过${sigleMaxSize}M`)
      return false
    }
    file.status = 'uploading'
    file.message = '上传中...'
    formData.append('files', file)
  }
  // beforeRead钩子限制async语法糖,故用 promise 风格
  uploadFileRequest(formData)
    .then((res) => {
      fileList.value.push(
        ...res.map((fileItem) => ({
          url: fileItem.downloadUrl,
          status: 'done',
          ...fileItem,
        })),
      )
    })
    .catch((err) => {
      console.error('err', err)
    })
}
const showPdfOverlay = ref(false)
const pdfurl = ref('')
const onPdfPreview = (file) => {
  console.log('onPdfPreview---file', file)
  pdfurl.value = file.url ?? file.downloadUrl
  showPdfOverlay.value = true
}

defineExpose({
  fileList,
})
</script>

<style lang="less">
.component-file-uploader {
  width: 100%;
  .van-uploader__wrapper {
    > div {
      width: 50%;
      height: 100px;
      margin: 0;
      padding: 5px;
      overflow: hidden;

      .van-image,
      .van-uploader__file {
        width: 100%;
        height: 100%;
        border-radius: 6px;
      }
      // .van-uploader__preview-delete{
      //   display: none;
      // }
    }
  }

  .pdf-item {
    width: 100%;
    height: 100%;
    img {
      width: 100%;
      height: 100%;
    }
  }

  .upload {
    background: url("../../assets/images/upload-bg.png") no-repeat center;
    background-size: cover;
    border-radius: 6px;
    height: 100%;
    display: flex;
    align-items: center;
    justify-content: center;
    .upload-center {
      display: flex;
      flex-direction: column;
      align-items: center;
      .camera-icon {
        width: 30px;
        height: 30px;
        opacity: 0.4;
        background: #000000;
        border-radius: 50%;
      }
      span {
        margin-top: 5px;
        font-size: 12px;
        font-weight: 400;
        text-align: center;
        color: #333333;
      }
    }
  }
}
.pdf-wrapper {
  height: 100%;
  position: relative;
  .van-icon-cross{
    position: absolute;
    top: 20px;
    right: 20px;
    z-index: 999999;
  }
  #pdfBox {
    padding: 10px;
    background: rgb(0, 0, 0, 0.3);
  }
}
</style>
  • 使用示例
<!-- deletable: false不显示删除按钮  disabled: 禁用-->
<FileUploader ref='fileUploaderRef' v-model='formData.files' :disabled='action === ActionEnum.READ' :deletable='!(action === ActionEnum.READ)' />

const formData = reactive({
  files: [], // 材料 { originalFileName, fileKey }
})

签名smooth-signature

<template>
  <div class='componet-signature'>
    <div v-show='isVertical' class='vertical-wrapper'>
      <p class='tip'>
        {{ tip }}
      </p>
      <ul class='sign-for'>
        <li v-for='(item,index) in signFiles' :key='index'>
          <em /><span>{{ item.fileName }}</span>
        </li>
      </ul>
      <div class='canvas-wrapper'>
        <canvas ref='verticalCanvasRef' />
        <div class='full-screen' @click='isVertical = false'>
          <img src='@/assets/images/full-screen.png' alt=''>
          <span>全屏签名</span>
        </div>
        <div class='reset' @click='verticalSignature.clear()'>
          <img src='@/assets/images/reset.png' alt=''>
          <span>重置</span>
        </div>
      </div>
      <p v-if='notFinish' style='margin-top: 10px; color: #ff5363'>
        请先完成签名
      </p>

      <FooterButton text='完成签署' @click='onFinishSign' />
    </div>
    <div v-show='!isVertical' class='horizontal-wrapper'>
      <div class='btns-wrapper'>
        <div class='btns'>
          <van-button
            plain
            type='primary'
            class='vertical-btn'
            @click='isVertical = true'
          >
            竖屏
          </van-button>
          <van-button
            plain
            type='warning'
            class='reset-btn'
            @click='horizontalSignature.clear()'
          >
            重置
          </van-button>
          <van-button type='primary' @click='onFinishSign'>
            完成签署
          </van-button>
        </div>
        <div v-if='notFinish' class='warn'>
          <p>请先完成签名</p>
        </div>
      </div>

      <canvas ref='horizontalCanvasRef' class='horizontalCanvas' />
    </div>
  </div>
</template>
<script setup lang="ts">
import SmoothSignature from 'smooth-signature'
import FooterButton from '@/components/footerButton/index.vue'

// 签约文件列表 类型
interface SignFilesInterFace {
    fileName: string // 文件名称
}

defineProps({
  tip: { // 签名tip
    type: String,
    default: '您的电子签名以及贵司电子章将使用在以下合同中',
  },
  signFiles: { // 签约文件列表
    type: Array<SignFilesInterFace>,
    required: true,
  },
})
const emits = defineEmits(['finish'])

const verticalCanvasRef = ref(null)
const horizontalCanvasRef = ref(null)
const verticalSignature = ref(null) // 竖屏签名
const horizontalSignature = ref(null) // 全屏下签名
const isVertical = ref(true) // true: 默认竖屏

const initSignature = () => {
  const options1 = {
    width: window.innerWidth - 50,
    height: 300,
    minWidth: 2,
    maxWidth: 6,
    openSmooth: true,
    bgColor: '#f6f6f6',
  }
  const options2 = {
    width: window.innerWidth - 120,
    height: window.innerHeight - 80,
    minWidth: 3,
    maxWidth: 10,
    openSmooth: true,
    bgColor: '#f6f6f6',
  }
  verticalSignature.value = new SmoothSignature(verticalCanvasRef.value, options1)
  horizontalSignature.value = new SmoothSignature(horizontalCanvasRef.value, options2)
}
onMounted(() => {
  initSignature()
})

const notFinish = ref(false)
const onFinishSign = () => {
  notFinish.value =
      (isVertical.value && verticalSignature.value.isEmpty()) || (!isVertical.value && horizontalSignature.value.isEmpty())

  if (notFinish.value) {
    return
  }

  // data:image/png;base64  getPNG()
  const signatureImg = (isVertical.value && verticalSignature.value.toDataURL()) || (!isVertical.value && horizontalSignature.value.getRotateCanvas(-90).toDataURL())
  console.log('onFinishSign---signatureImg', signatureImg)
  emits('finish', signatureImg)
}
</script>

  <style lang="less">
  .componet-signature {
    .vertical-wrapper {
      font-size: 14px;
      margin-top: 6px;
      min-height: calc(100vh - 10px);
      background-color: #fff;
      padding: 25px;
      .tip {
        font-weight: 500;
        color: #000000;
      }
      .sign-for {
        color: #666666;
        margin: 20px 0;
        em {
          font-style: normal;
          width: 4px;
          height: 4px;
          background: #666666;
          border-radius: 50%;
          display: inline-block;
          margin-right: 8px;
          vertical-align: middle;
        }
      }
      .canvas-wrapper {
        position: relative;
        font-size: 14px;
        color: #9698a5;
        img {
          width: 16px;
          margin-right: 3px;
        }
        .full-screen {
          position: absolute;
          bottom: 12px;
          left: 10px;
          display: flex;
          align-items: center;
        }
        .reset {
          position: absolute;
          bottom: 12px;
          right: 10px;
          display: flex;
          align-items: center;
        }
      }
    }

    .horizontal-wrapper {
      padding: 15px;
      min-height: 100vh;
      background-color: #fff;
      display: flex;
      justify-content: center;
      .btns-wrapper {
        width: 50px;
        display: flex;
        justify-content: center;
        align-items: center;
      }
      .btns {
        margin-right: 10px;
        white-space: nowrap;
        transform: rotate(90deg);
        .van-button {
          margin-right: 8px;
        }
        .vertical-btn {
          border: 1px solid #1989fa;
        }
        .reset-btn {
          border: 1px solid #ff976a;
        }
      }
      .horizontalCanvas {
        flex: 1;
        margin: auto;
      }
      .warn {
        transform: rotate(90deg) translateX(200px);
        position: absolute;
        width: 8em;
        top: 50%;
        color: #FF5363;
        font-size: 16px;
      }
    }
  }
  </style>

验证码输入框 PasswordSmsCodeInput

<template>
  <van-password-input
    class='component-passwordSmsinpValInput'
    :value='inpVal'
    :mask='false'
    gutter='6'
    :length='length'
    :focused='showKeyboard'
    :error-info='errorText'
    @focus='showKeyboard = true'
  />

  <van-number-keyboard
    v-model='inpVal'
    :show='showKeyboard'
    :maxlength='length'
    @blur='showKeyboard = false'
  />
</template>

<script setup lang="ts">
defineProps({
  errorText: { // 错误提示语
    type: String,
    default: '',
  },
  length: { // 密码或验证码等长度
    type: Number,
    default: 6,
  },
})
const inpVal = defineModel<string>('inpVal') // 密码或验证码值
const showKeyboard = defineModel<boolean>('showKeyboard') //  true: 弹出数字键盘
</script>

<style lang="less">
.component-passwordSmsinpValInput {
  margin: 45px auto 22px 0;
  li {
    border: 1px solid #e0e4eb;
    max-width: 43px;
    height: 43px;
  }
}
</style>
  • 使用示例
<template>
    <PasswordSmsCodeInput v-model:inp-val='inpVal' v-model:show-keyboard='showKeyboard' :error-text='errorText' />
</template>

<script setup lang="ts">
const inpVal = ref('')
const showKeyboard = ref(false) // true: 弹出数字键盘
const errorText = ref('') // 错误提示语
</script>

pdf展示

<template>
  <div class='component-pdfh5'>
    <div id='pdfBox' />
  </div>
</template>
<script setup lang="ts">
import Pdfh5 from 'pdfh5' // https://gitee.com/gjTool/pdfh5

const props = defineProps({
  pdfurl: { // pdf链接
    type: String,
    required: true,
  },
  // eslint-disable-next-line vue/require-default-prop
  startCountDown: { // 开启倒计时
    type: Function,
  },
})

const emits = defineEmits(['scan-finish']) // 浏览pdf完成

const LoadPdf = () => {
  const pdfh5 = new Pdfh5('#pdfBox', {
    pdfurl: props.pdfurl, // 当前默认优先获取浏览器地址栏?file=后面的地址,如果地址栏没有,再拿配置项的pdfurl或者data来渲染pdf
    // zoomEnable: false,
    renderType: 'canvas',
    backTop: false,
    // lazy: true,
  })
  const initTime = 3
  pdfh5.on('complete', (status, msg, time) => {
    console.log('[pdfh5]complete---status', status)
    console.log('[pdfh5]complete---msg, time', msg, time)
    console.log('[pdfh5]complete---totalNum', pdfh5.totalNum)
    if (pdfh5.totalNum <= 1) {
      props.startCountDown?.(initTime) // 当pdf总页数 <= 1, 通知业务组件开启倒计时
      setTimeout(() => {
        emits('scan-finish')
      }, initTime * 1000)
    }
  })

  pdfh5.on('scroll', (scrollTop, currentNum) => {
    console.log('[pdfh5]scroll---currentNum', currentNum, pdfh5.totalNum, scrollTop)
    if (currentNum >= pdfh5.totalNum - 1) {
      // 当预览达到倒数第2页,认为是pdf预览完毕
      emits('scan-finish')
    }
  })
}

onMounted(() => {
  LoadPdf()
})
</script>

<style>
@import "pdfh5/css/pdfh5.css";
</style>
<style lang="less" scoped>
.component-pdfh5 {
  padding-bottom: calc(70px + env(safe-area-inset-bottom));
  height: 100vh;
  overflow: auto;
  #pdfBox {
    width: 100%;
    // min-height: 100vh;
  }
}
</style>

省市选择器 provinceCityCascader

<template>
  <van-popup v-model:show='show' round position='bottom'>
    <van-cascader
      v-model='cascaderValue'
      :title='title'
      :options='options'
      @close='show = false'
      @change='onChange'
      @finish='onFinish'
    />
  </van-popup>
</template>

<script setup lang='ts'>
import { closeToast, showLoadingToast } from 'vant'
import { selectProvince, selectCity } from '@/api/base'
import useCommonStore from '@/stores/modules/useCommonStore'
import { storeToRefs } from 'pinia'

defineProps({
  title: {
    type: String,
    default: '请选择省市',
  },
})
const emits = defineEmits(['finish'])
const show = ref(false)
const cascaderValue = ref('')
const options = ref([
//   {
//     text: '浙江省',
//     value: '330000',
//     children: [],
//   },
])

/**
   * 选中项变化时触发
   * @param  { string | number } value:选中项的value
   * @param  {  number } tabIndex:选中项的索引, 省-0  市-1
   */
const onChange = async ({ value, tabIndex }) => {
  console.log('onChange---value:tabIndex', value, tabIndex)
  // 暂时只考虑省市两级,故 "市" 的变动不做处理
  if (tabIndex !== 0) return
  const findOpt = options.value.find(option => option.value === value)
  if (!findOpt.children.length) {
    showLoadingToast('加载中...')
    const cities = await selectCity({ provinceCode: value })
    findOpt.children = cities.map(city => ({
      text: city.cityName,
      value: city.cityCode,
    }))
    closeToast()
  }
}

/**
   * 省市 选择器完成选择时触发
   * @param  { Object } selectedOptions:选中项 { text:"", value:"" }
   */
const onFinish = ({ selectedOptions }) => {
  show.value = false
  console.log('onFinish---selectedOptions', selectedOptions)
  const texts = []
  const values = []
  for (const option of selectedOptions) {
    texts.push(option.text)
    values.push(option.value)
  }
  console.log('cascaderValue', cascaderValue)
  emits('finish', texts, values, selectedOptions)
}

const commonStore = useCommonStore()
const { provinces } = storeToRefs(commonStore)
/**
   *  展示 省市 选择器
   */
const showProvinceCityCascader = async () => {
  console.log('showProvinceCityCascader---start')
  // pinia中若没值就去请求接口,若有值就直接取出
  if (!provinces?.value?.length) {
    const provincesRes = await selectProvince()
    const datas = provincesRes.map(province => ({
      text: province.provinceName,
      value: province.provinceCode,
      children: [],
    }))
    commonStore.setProvinces(datas)
  }
  options.value = provinces.value
  show.value = true
}

defineExpose({
  showProvinceCityCascader,
})
</script>
  • 使用示例
<template>
  <ProvinceCityCascader ref='provinceCityCascaderRef' @finish='onProvinceCityCascaderFinish' />
</template>

<script setup lang="ts">

// 省市
const provinceCityCascaderRef = ref(null)
const onShowProvinceCityCascader = () => {
  provinceCityCascaderRef.value?.showProvinceCityCascader()
}

/**
   * 省市 Cascader 完成时触发
   * @param  { Array } texts:选中的省市
   * @param  { Array } values:选中的省市value
   * @param  { Array } selectedOptions:选中的省市对象
   */
const onProvinceCityCascaderFinish = (texts, values, selectedOptions) => {
  console.log('onProvinceCityCascaderFinish', texts, values, selectedOptions)
  formData.value.provinceCityName = texts.join('')
  formData.value.provinceCode = values[0]
  formData.value.cityCode = values[1]
}
</script>