|
- <template>
- <view v-if="show" class="sign-page">
- <view class="canvas-wrap" @touchmove.stop.prevent>
- <view class="hint-text">
- <text>请在此处手写签名</text>
- <text class="hint-sub">请横屏书写</text>
- </view>
- <view id="signContainer" class="sign-container" />
- </view>
- <view class="bottom-btns">
- <view class="btn-close" @click="handleClose">
- <text>取消</text>
- </view>
- <view class="btn-clear" @click="handleClear">
- <text>清除</text>
- </view>
- <view class="btn-save" @click="handleSave">
- <text>{{ saving ? '上传中' : '保存' }}</text>
- </view>
- </view>
- </view>
- </template>
-
- <script setup>
- import { ref, watch, nextTick } from 'vue'
- import { upload } from '@/utils/request.js'
-
- const props = defineProps({
- show: { type: Boolean, default: false }
- })
- const emit = defineEmits(['close', 'confirm'])
-
- let signature = null
- const hasDrawn = ref(false)
- const saving = ref(false)
- let scriptLoaded = false
-
- const loadScript = () => {
- return new Promise((resolve, reject) => {
- if (window.SmoothSignature) { resolve(); return }
- if (scriptLoaded) {
- const check = setInterval(() => {
- if (window.SmoothSignature) { clearInterval(check); resolve() }
- }, 50)
- return
- }
- scriptLoaded = true
- const s = document.createElement('script')
- s.src = 'https://cdn.csybhelp.com/lib/smooth-signature.umd.min.js'
- s.onload = resolve
- s.onerror = reject
- document.head.appendChild(s)
- })
- }
-
- const initSignature = async () => {
- await loadScript()
- await nextTick()
- setTimeout(() => {
- const container = document.getElementById('signContainer')
- if (!container) return
- container.innerHTML = ''
- const canvas = document.createElement('canvas')
- canvas.style.width = '100%'
- canvas.style.height = '100%'
- container.appendChild(canvas)
- signature = new window.SmoothSignature(canvas, {
- width: container.offsetWidth,
- height: container.offsetHeight,
- minWidth: 2,
- maxWidth: 6,
- color: '#333',
- bgColor: 'transparent'
- })
- canvas.addEventListener('touchstart', () => { hasDrawn.value = true })
- canvas.addEventListener('mousedown', () => { hasDrawn.value = true })
- }, 200)
- }
-
- watch(() => props.show, async (val) => {
- if (val) {
- hasDrawn.value = false
- saving.value = false
- signature = null
- await initSignature()
- }
- })
-
- const handleClear = () => {
- if (signature) signature.clear()
- hasDrawn.value = false
- }
-
- const handleClose = () => { emit('close') }
-
- const handleSave = async () => {
- if (!hasDrawn.value || !signature) {
- return uni.showToast({ title: '请先签名', icon: 'none' })
- }
- if (saving.value) return
- saving.value = true
- uni.showLoading({ title: '保存中...' })
- try {
- // 从 smooth-signature 获取 PNG DataURL
- const dataUrl = signature.getPNG()
- if (!dataUrl) throw { msg: '获取签名失败' }
-
- // 旋转签名(逆时针90度,横屏→竖屏,与小程序端保持一致)
- const rotatedDataUrl = await rotateImage(dataUrl, -90)
- const blob = dataURLtoBlob(rotatedDataUrl)
- const formData = new FormData()
- formData.append('file', blob, 'signature.png')
-
- // H5 下直接用 XMLHttpRequest 上传(uni.uploadFile 需要 filePath)
- const token = uni.getStorageSync('pap-cytx-token') || ''
- const baseUrl = uni.$u && uni.$u.http && uni.$u.http.config
- ? uni.$u.http.config.baseURL
- : '/pro-api'
- const url = baseUrl + '/api/mp/upload'
-
- const result = await new Promise((resolve, reject) => {
- const xhr = new XMLHttpRequest()
- xhr.open('POST', url)
- if (token) xhr.setRequestHeader('Authorization', 'Bearer ' + token)
- xhr.onload = () => {
- try {
- const res = JSON.parse(xhr.responseText)
- if (res.code === 0 && res.data && res.data.url) resolve(res.data.url)
- else reject({ msg: res.msg || '上传失败' })
- } catch (e) { reject({ msg: '上传失败' }) }
- }
- xhr.onerror = () => reject({ msg: '网络错误' })
- xhr.send(formData)
- })
-
- uni.hideLoading()
- emit('confirm', { url: result })
- } catch (err) {
- uni.hideLoading()
- uni.showToast({ title: err.msg || '上传失败', icon: 'none' })
- } finally {
- saving.value = false
- }
- }
-
- const dataURLtoBlob = (dataUrl) => {
- const arr = dataUrl.split(',')
- const mime = arr[0].match(/:(.*?);/)[1]
- const bstr = atob(arr[1])
- let n = bstr.length
- const u8arr = new Uint8Array(n)
- while (n--) u8arr[n] = bstr.charCodeAt(n)
- return new Blob([u8arr], { type: mime })
- }
-
- const rotateImage = (dataUrl, degrees) => {
- return new Promise((resolve) => {
- const img = new Image()
- img.onload = () => {
- const canvas = document.createElement('canvas')
- const rad = (degrees * Math.PI) / 180
- // 旋转 -90 度:宽高互换
- if (Math.abs(degrees) === 90) {
- canvas.width = img.height
- canvas.height = img.width
- } else {
- canvas.width = img.width
- canvas.height = img.height
- }
- const ctx = canvas.getContext('2d')
- ctx.translate(canvas.width / 2, canvas.height / 2)
- ctx.rotate(rad)
- ctx.drawImage(img, -img.width / 2, -img.height / 2)
- resolve(canvas.toDataURL('image/png'))
- }
- img.src = dataUrl
- })
- }
- </script>
-
- <style lang="scss" scoped>
- .sign-page {
- position: fixed;
- top: 30rpx;
- left: 30rpx;
- right: 30rpx;
- bottom: 30rpx;
- z-index: 9999;
- display: flex;
- flex-direction: column;
- background: #edf2fc;
- border-radius: 16rpx;
- overflow: hidden;
- box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.5);
- }
-
- .canvas-wrap {
- flex: 1;
- position: relative;
- overflow: hidden;
-
- .hint-text {
- position: absolute;
- top: 50%;
- left: 50%;
- transform: translate(-50%, -50%) rotate(90deg);
- pointer-events: none;
- z-index: 0;
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: 16rpx;
- width: 80vh;
-
- text {
- font-size: 48rpx;
- color: #c0c8d8;
- font-weight: 300;
- letter-spacing: 16rpx;
- white-space: nowrap;
- }
-
- .hint-sub {
- font-size: 28rpx;
- letter-spacing: 8rpx;
- }
- }
-
- .sign-container {
- width: 100%;
- height: 100%;
- position: relative;
- z-index: 1;
- }
- }
-
- .bottom-btns {
- display: flex;
- height: calc(100rpx + env(safe-area-inset-bottom));
- flex-shrink: 0;
- gap: 12rpx;
- padding: 12rpx;
- padding-bottom: calc(12rpx + env(safe-area-inset-bottom));
- background: #edf2fc;
-
- .btn-close {
- flex: 1;
- display: flex;
- align-items: center;
- justify-content: center;
- background: #8c939d;
- border-radius: 12rpx;
-
- text {
- writing-mode: vertical-rl;
- transform: rotate(90deg);
- font-size: 32rpx;
- color: #fff;
- letter-spacing: 4rpx;
- height: 120rpx;
- text-align: center;
- }
- }
-
- .btn-clear {
- flex: 1;
- display: flex;
- align-items: center;
- justify-content: center;
- background: #ff4040;
- border-radius: 12rpx;
-
- text {
- writing-mode: vertical-rl;
- transform: rotate(90deg);
- font-size: 32rpx;
- color: #fff;
- letter-spacing: 4rpx;
- height: 120rpx;
- text-align: center;
- }
- }
-
- .btn-save {
- flex: 2;
- display: flex;
- align-items: center;
- justify-content: center;
- background: #0e63e3;
- border-radius: 12rpx;
-
- text {
- writing-mode: vertical-rl;
- transform: rotate(90deg);
- font-size: 32rpx;
- color: #fff;
- letter-spacing: 4rpx;
- height: 120rpx;
- text-align: center;
- }
- }
- }
- </style>
|