238 lines
6.7 KiB
Vue
238 lines
6.7 KiB
Vue
<template>
|
||
<div class="signature-page" :class="{ landscape }">
|
||
<div class="header">电子签名</div>
|
||
<div class="canvas-wrap">
|
||
<canvas
|
||
ref="canvasRef"
|
||
class="canvas"
|
||
@touchstart.prevent="onStart"
|
||
@touchmove.prevent="onMove"
|
||
@touchend.prevent="onEnd"
|
||
@mousedown.prevent="onMouseDown"
|
||
@mousemove.prevent="onMouseMove"
|
||
@mouseup.prevent="onMouseUp"
|
||
></canvas>
|
||
</div>
|
||
<div class="toolbar">
|
||
<el-button @click="clearCanvas">清空</el-button>
|
||
<el-button @click="toggleLandscape">横屏</el-button>
|
||
<el-button type="primary" :loading="submitting" @click="saveSignature">保存</el-button>
|
||
</div>
|
||
<div class="tip">请在画布上手写签名,然后点击保存</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, onMounted } from 'vue'
|
||
import { useRoute } from 'vue-router'
|
||
import { ElMessage } from 'element-plus'
|
||
import { useUploadHeaders } from '@/composables/useUploadHeaders'
|
||
import { CrmService } from '@/api/crmApi'
|
||
import { useUserStore } from '@/store/modules/user'
|
||
|
||
const route = useRoute()
|
||
const userId = route.query.userId as string | undefined
|
||
const userStore = useUserStore()
|
||
// 优先使用URL传入的token,否则取当前应用内的token
|
||
const urlToken = route.query.token as string | undefined
|
||
const effectiveToken = urlToken || userStore.accessToken
|
||
const { uploadHeaders } = useUploadHeaders()
|
||
|
||
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
||
const ctx = ref<CanvasRenderingContext2D | null>(null)
|
||
const drawing = ref(false)
|
||
const submitting = ref(false)
|
||
const landscape = ref(false)
|
||
|
||
const resizeCanvas = () => {
|
||
const canvas = canvasRef.value
|
||
if (!canvas) return
|
||
const dpr = window.devicePixelRatio || 1
|
||
let width = Math.min(window.innerWidth - 32, 800)
|
||
let height = Math.round(width * 0.5)
|
||
if (landscape.value && window.innerHeight > 0) {
|
||
// 横屏时尽量占满高度
|
||
const h = Math.min(window.innerHeight - 120, 500)
|
||
height = h
|
||
width = Math.round(h * 2)
|
||
}
|
||
canvas.style.width = width + 'px'
|
||
canvas.style.height = height + 'px'
|
||
canvas.width = Math.floor(width * dpr)
|
||
canvas.height = Math.floor(height * dpr)
|
||
const c = canvas.getContext('2d')!
|
||
c.scale(dpr, dpr)
|
||
c.lineWidth = 3
|
||
c.lineCap = 'round'
|
||
c.lineJoin = 'round'
|
||
c.strokeStyle = '#111'
|
||
ctx.value = c
|
||
// 背景白色
|
||
c.fillStyle = '#fff'
|
||
c.fillRect(0, 0, width, height)
|
||
}
|
||
|
||
onMounted(() => {
|
||
resizeCanvas()
|
||
window.addEventListener('resize', resizeCanvas)
|
||
})
|
||
|
||
const getPoint = (e: TouchEvent | MouseEvent) => {
|
||
const canvas = canvasRef.value!
|
||
const rect = canvas.getBoundingClientRect()
|
||
let clientX = 0
|
||
let clientY = 0
|
||
if (e instanceof TouchEvent) {
|
||
const t = e.touches[0] || e.changedTouches[0]
|
||
clientX = t.clientX
|
||
clientY = t.clientY
|
||
} else {
|
||
clientX = (e as MouseEvent).clientX
|
||
clientY = (e as MouseEvent).clientY
|
||
}
|
||
return { x: clientX - rect.left, y: clientY - rect.top }
|
||
}
|
||
|
||
const onStart = (e: TouchEvent) => {
|
||
drawing.value = true
|
||
const p = getPoint(e)
|
||
ctx.value!.beginPath()
|
||
ctx.value!.moveTo(p.x, p.y)
|
||
}
|
||
const onMove = (e: TouchEvent) => {
|
||
if (!drawing.value) return
|
||
const p = getPoint(e)
|
||
ctx.value!.lineTo(p.x, p.y)
|
||
ctx.value!.stroke()
|
||
}
|
||
const onEnd = () => {
|
||
drawing.value = false
|
||
}
|
||
|
||
const onMouseDown = (e: MouseEvent) => onStart(e as any)
|
||
const onMouseMove = (e: MouseEvent) => onMove(e as any)
|
||
const onMouseUp = () => onEnd()
|
||
|
||
const clearCanvas = () => {
|
||
resizeCanvas()
|
||
}
|
||
|
||
const dataURLToFile = (dataURL: string, filename: string) => {
|
||
const arr = dataURL.split(',')
|
||
const mime = arr[0].match(/:(.*?);/)?.[1] || 'image/png'
|
||
const bstr = atob(arr[1])
|
||
let n = bstr.length
|
||
const u8arr = new Uint8Array(n)
|
||
while (n--) u8arr[n] = bstr.charCodeAt(n)
|
||
return new File([u8arr], filename, { type: mime })
|
||
}
|
||
|
||
const uploadUrl = `${import.meta.env.VITE_API_URL}/ticket/upload?scene=user_signature`
|
||
|
||
const requestFullscreen = async (el: HTMLElement) => {
|
||
const anyEl = el as any
|
||
if (anyEl.requestFullscreen) return anyEl.requestFullscreen()
|
||
if (anyEl.webkitRequestFullscreen) return anyEl.webkitRequestFullscreen()
|
||
if (anyEl.msRequestFullscreen) return anyEl.msRequestFullscreen()
|
||
}
|
||
|
||
const lockOrientationLandscape = async () => {
|
||
const anyScr = screen as any
|
||
if (anyScr.orientation && anyScr.orientation.lock) {
|
||
try {
|
||
await anyScr.orientation.lock('landscape')
|
||
} catch {
|
||
// ignore
|
||
}
|
||
}
|
||
}
|
||
|
||
const toggleLandscape = async () => {
|
||
landscape.value = !landscape.value
|
||
if (landscape.value) {
|
||
await requestFullscreen(document.documentElement)
|
||
await lockOrientationLandscape()
|
||
}
|
||
setTimeout(resizeCanvas, 200)
|
||
}
|
||
|
||
const saveSignature = async () => {
|
||
if (!userId) {
|
||
ElMessage.error('缺少用户信息')
|
||
return
|
||
}
|
||
try {
|
||
submitting.value = true
|
||
const canvas = canvasRef.value!
|
||
const dataUrl = canvas.toDataURL('image/png')
|
||
const file = dataURLToFile(dataUrl, `signature_${userId}_${Date.now()}.png`)
|
||
|
||
const form = new FormData()
|
||
form.append('file', file)
|
||
|
||
const headers: Record<string, string> = { ...(uploadHeaders.value || {}) }
|
||
if (effectiveToken) {
|
||
headers['Authorization'] = `Bearer ${effectiveToken}`
|
||
}
|
||
const res = await fetch(uploadUrl, {
|
||
method: 'POST',
|
||
headers,
|
||
body: form
|
||
})
|
||
const json = await res.json()
|
||
if (json.code !== 200) throw new Error(json.message || '上传失败')
|
||
|
||
// 将签名保存到个人信息(假设 crmuseredit 接受 signature 和 signature_pic_full 字段)
|
||
await CrmService.crmuseredit({
|
||
id: userId,
|
||
signature: json.data.file_name,
|
||
signature_pic_full: json.data.file_url
|
||
})
|
||
|
||
ElMessage.success('签名已保存')
|
||
// 完成后可提示返回或自动关闭
|
||
setTimeout(() => {
|
||
if (window.history.length > 1) window.history.back()
|
||
}, 800)
|
||
} catch (e: any) {
|
||
ElMessage.error(e?.message || '保存失败')
|
||
} finally {
|
||
submitting.value = false
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.signature-page {
|
||
padding: 16px;
|
||
}
|
||
.header {
|
||
text-align: center;
|
||
font-weight: 600;
|
||
font-size: 18px;
|
||
margin-bottom: 12px;
|
||
}
|
||
.canvas-wrap {
|
||
display: flex;
|
||
justify-content: center;
|
||
}
|
||
.canvas {
|
||
background: #fff;
|
||
border: 1px solid #e5e7eb;
|
||
border-radius: 6px;
|
||
touch-action: none;
|
||
}
|
||
.toolbar {
|
||
display: flex;
|
||
gap: 12px;
|
||
justify-content: center;
|
||
margin-top: 12px;
|
||
}
|
||
.tip {
|
||
text-align: center;
|
||
color: #909399;
|
||
margin-top: 8px;
|
||
font-size: 12px;
|
||
}
|
||
</style>
|