"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,
},
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' />
<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'
>
<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'
import type { UploaderFileListItem } from 'vant'
import useCommonStore from '@/stores/modules/useCommonStore'
import { uploadFileRequest } from '@/api/base'
const props = defineProps({
sigleMaxSize: {
type: Number,
default: 5 * 1024 * 1024,
},
maxCount: {
type: Number,
default: 8,
},
})
const { normalDictMap } = useCommonStore()
const fileList = defineModel<Array<UploaderFileListItem>>()
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)
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)
}
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>
<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: {
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)
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
}
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')
</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)
const errorText = ref('')
</script>
pdf展示
<template>
<div class='component-pdfh5'>
<div id='pdfBox' />
</div>
</template>
<script setup lang="ts">
import Pdfh5 from 'pdfh5'
const props = defineProps({
pdfurl: {
type: String,
required: true,
},
startCountDown: {
type: Function,
},
})
const emits = defineEmits(['scan-finish'])
const LoadPdf = () => {
const pdfh5 = new Pdfh5('#pdfBox', {
pdfurl: props.pdfurl,
renderType: 'canvas',
backTop: false,
})
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)
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) {
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([
])
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()
}
}
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')
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()
}
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>