2025-09-08 19:09:55 +08:00
|
|
|
<template>
|
|
|
|
|
<div class="signature-page" :class="{ landscape }">
|
|
|
|
|
<div class="header">电子签名</div>
|
|
|
|
|
<div class="canvas-wrap">
|
|
|
|
|
<canvas
|
|
|
|
|
ref="canvasRef"
|
|
|
|
|
class="canvas"
|
2025-09-09 20:53:30 +08:00
|
|
|
:style="{ pointerEvents: locked ? 'none' : 'auto' }"
|
2025-09-08 19:09:55 +08:00
|
|
|
@touchstart.prevent="onStart"
|
|
|
|
|
@touchmove.prevent="onMove"
|
|
|
|
|
@touchend.prevent="onEnd"
|
|
|
|
|
@mousedown.prevent="onMouseDown"
|
|
|
|
|
@mousemove.prevent="onMouseMove"
|
|
|
|
|
@mouseup.prevent="onMouseUp"
|
|
|
|
|
></canvas>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="toolbar">
|
2025-09-09 20:53:30 +08:00
|
|
|
<el-button :disabled="locked" @click="clearCanvas">清空</el-button>
|
|
|
|
|
<el-button :disabled="locked" @click="toggleLandscape">横屏</el-button>
|
|
|
|
|
<el-button type="primary" :disabled="locked" :loading="submitting" @click="saveSignature">
|
|
|
|
|
保存
|
|
|
|
|
</el-button>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="tip">
|
|
|
|
|
{{ locked ? '签名已保存,页面已锁定' : '请在画布上手写签名,然后点击保存' }}
|
2025-09-08 19:09:55 +08:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
2025-09-09 20:53:30 +08:00
|
|
|
import { ref, onMounted, computed } from 'vue'
|
2025-09-08 19:09:55 +08:00
|
|
|
import { useRoute } from 'vue-router'
|
|
|
|
|
import { ElMessage } from 'element-plus'
|
|
|
|
|
import { useUploadHeaders } from '@/composables/useUploadHeaders'
|
2025-09-09 20:53:30 +08:00
|
|
|
// import { CrmService } from '@/api/crmApi'
|
|
|
|
|
import { commonService } from '@/api/commonApi'
|
2025-09-08 19:09:55 +08:00
|
|
|
import { useUserStore } from '@/store/modules/user'
|
|
|
|
|
|
|
|
|
|
const route = useRoute()
|
|
|
|
|
const userId = route.query.userId as string | undefined
|
|
|
|
|
const userStore = useUserStore()
|
|
|
|
|
// 优先使用URL传入的token,否则取当前应用内的token
|
2025-09-09 20:53:30 +08:00
|
|
|
const urlToken =
|
|
|
|
|
(route.query.accessToken as string | undefined) || (route.query.token as string | undefined)
|
|
|
|
|
// 其他头部信息从URL透传(便于直达页调用接口)
|
|
|
|
|
const urlLang = route.query.lang as string | undefined
|
|
|
|
|
const urlCorp = (route.query.companyabbr as string | undefined) || userStore.companyabbr
|
|
|
|
|
const urlLoginType = route.query.loginType as string | undefined
|
|
|
|
|
const urlSys = (route.query.systemsabbr as string | undefined) || userStore.systemsabbr || 'fdesk'
|
2025-09-08 19:09:55 +08:00
|
|
|
const effectiveToken = urlToken || userStore.accessToken
|
2025-09-09 20:53:30 +08:00
|
|
|
|
|
|
|
|
// 从URL参数获取公司信息并存储到userStore(如果路由守卫没有处理)
|
|
|
|
|
const companyid = route.query.companyid as string | undefined
|
|
|
|
|
const companyabbr = route.query.companyabbr as string | undefined
|
|
|
|
|
if (companyid && companyabbr && (!userStore.companyid || !userStore.companyabbr)) {
|
|
|
|
|
userStore.setCompany(companyid, companyabbr)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 从URL参数获取系统信息并存储到userStore(如果路由守卫没有处理)
|
|
|
|
|
const systemsabbr = route.query.systemsabbr as string | undefined
|
|
|
|
|
const systemsid = route.query.systemsid as string | undefined
|
|
|
|
|
if (systemsid && systemsabbr && (!userStore.systemsid || !userStore.systemsabbr)) {
|
|
|
|
|
userStore.setSystems(systemsid, systemsabbr)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 从URL参数获取登录类型并存储到userStore(如果路由守卫没有处理)
|
|
|
|
|
if (urlLoginType && !userStore.loginType) {
|
|
|
|
|
userStore.setLoginType(urlLoginType as 'feishu' | 'upwd')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 从URL参数获取token并存储到userStore(如果路由守卫没有处理)
|
|
|
|
|
if (urlToken && !userStore.accessToken) {
|
|
|
|
|
userStore.setToken(urlToken)
|
|
|
|
|
}
|
|
|
|
|
// 调试信息:打印初始化时的userStore状态
|
|
|
|
|
// console.log('Signature page initialized:', {
|
|
|
|
|
// urlParams: {
|
|
|
|
|
// userId,
|
|
|
|
|
// urlToken: urlToken ? 'exists' : 'missing',
|
|
|
|
|
// urlLang,
|
|
|
|
|
// urlCorp,
|
|
|
|
|
// urlLoginType,
|
|
|
|
|
// urlSys
|
|
|
|
|
// },
|
|
|
|
|
// userStore: {
|
|
|
|
|
// companyabbr: userStore.companyabbr,
|
|
|
|
|
// companyid: userStore.companyid,
|
|
|
|
|
// systemsabbr: userStore.systemsabbr,
|
|
|
|
|
// systemsid: userStore.systemsid,
|
|
|
|
|
// language: userStore.language,
|
|
|
|
|
// loginType: userStore.loginType,
|
|
|
|
|
// accessToken: userStore.accessToken ? 'exists' : 'missing',
|
|
|
|
|
// isLogin: userStore.isLogin
|
|
|
|
|
// }
|
|
|
|
|
// })
|
|
|
|
|
|
2025-09-08 19:09:55 +08:00
|
|
|
const { uploadHeaders } = useUploadHeaders()
|
2025-09-09 20:53:30 +08:00
|
|
|
// 基于URL参数动态构造上传头,优先使用URL透传值
|
|
|
|
|
const dynamicHeaders = computed(() => {
|
|
|
|
|
const headers: Record<string, string> = { ...(uploadHeaders.value || {}) }
|
|
|
|
|
|
|
|
|
|
// 语言设置
|
|
|
|
|
if (urlLang) headers['Fdesk-Language'] = urlLang
|
|
|
|
|
|
|
|
|
|
// 公司标识 - 优先使用URL参数,其次使用userStore,最后使用默认值
|
|
|
|
|
const corpAbbr = urlCorp || userStore.companyabbr || 'default'
|
|
|
|
|
headers['Fdesk-Corp-Abbr'] = corpAbbr
|
|
|
|
|
|
|
|
|
|
// 登录类型
|
|
|
|
|
if (urlLoginType) headers['Fdesk-Login-Type'] = urlLoginType
|
|
|
|
|
|
|
|
|
|
// 系统标识
|
|
|
|
|
headers['Fdesk-System-Code'] = urlSys
|
|
|
|
|
|
|
|
|
|
// 认证令牌
|
|
|
|
|
if (effectiveToken) headers['Fdesk-Auth-Token'] = 'Bearer ' + effectiveToken
|
|
|
|
|
|
|
|
|
|
// 调试信息:打印请求头
|
|
|
|
|
// console.log('Signature page headers:', headers)
|
|
|
|
|
// console.log('URL params:', {
|
|
|
|
|
// urlLang,
|
|
|
|
|
// urlCorp,
|
|
|
|
|
// urlLoginType,
|
|
|
|
|
// urlSys,
|
|
|
|
|
// effectiveToken
|
|
|
|
|
// })
|
|
|
|
|
// console.log('Final corp abbr:', corpAbbr)
|
|
|
|
|
|
|
|
|
|
return headers
|
|
|
|
|
})
|
2025-09-08 19:09:55 +08:00
|
|
|
|
|
|
|
|
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
|
|
|
|
const ctx = ref<CanvasRenderingContext2D | null>(null)
|
|
|
|
|
const drawing = ref(false)
|
|
|
|
|
const submitting = ref(false)
|
|
|
|
|
const landscape = ref(false)
|
2025-09-09 20:53:30 +08:00
|
|
|
const locked = ref(false)
|
2025-09-08 19:09:55 +08:00
|
|
|
|
|
|
|
|
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) => {
|
2025-09-09 20:53:30 +08:00
|
|
|
if (locked.value) return
|
2025-09-08 19:09:55 +08:00
|
|
|
drawing.value = true
|
|
|
|
|
const p = getPoint(e)
|
|
|
|
|
ctx.value!.beginPath()
|
|
|
|
|
ctx.value!.moveTo(p.x, p.y)
|
|
|
|
|
}
|
|
|
|
|
const onMove = (e: TouchEvent) => {
|
2025-09-09 20:53:30 +08:00
|
|
|
if (locked.value) return
|
2025-09-08 19:09:55 +08:00
|
|
|
if (!drawing.value) return
|
|
|
|
|
const p = getPoint(e)
|
|
|
|
|
ctx.value!.lineTo(p.x, p.y)
|
|
|
|
|
ctx.value!.stroke()
|
|
|
|
|
}
|
|
|
|
|
const onEnd = () => {
|
2025-09-09 20:53:30 +08:00
|
|
|
if (locked.value) return
|
2025-09-08 19:09:55 +08:00
|
|
|
drawing.value = false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const onMouseDown = (e: MouseEvent) => onStart(e as any)
|
|
|
|
|
const onMouseMove = (e: MouseEvent) => onMove(e as any)
|
|
|
|
|
const onMouseUp = () => onEnd()
|
|
|
|
|
|
|
|
|
|
const clearCanvas = () => {
|
2025-09-09 20:53:30 +08:00
|
|
|
if (locked.value) return
|
2025-09-08 19:09:55 +08:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-09 20:53:30 +08:00
|
|
|
const isLimitedWebview = () => /MicroMessenger|Lark|Feishu|DingTalk/i.test(navigator.userAgent)
|
|
|
|
|
|
2025-09-08 19:09:55 +08:00
|
|
|
const toggleLandscape = async () => {
|
|
|
|
|
landscape.value = !landscape.value
|
|
|
|
|
if (landscape.value) {
|
2025-09-09 20:53:30 +08:00
|
|
|
// 在微信/飞书等内置浏览器中,强制横屏普遍受限,采用 CSS 旋转兜底
|
|
|
|
|
if (!isLimitedWebview()) {
|
|
|
|
|
await requestFullscreen(document.documentElement)
|
|
|
|
|
await lockOrientationLandscape()
|
|
|
|
|
}
|
2025-09-08 19:09:55 +08:00
|
|
|
}
|
|
|
|
|
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)
|
|
|
|
|
|
2025-09-09 20:53:30 +08:00
|
|
|
const headers: Record<string, string> = { ...(dynamicHeaders.value || {}) }
|
|
|
|
|
// 兼容后端可能验 Authorization 的情况
|
|
|
|
|
if (effectiveToken) headers['Authorization'] = `Bearer ${effectiveToken}`
|
|
|
|
|
|
|
|
|
|
// 调试信息:打印上传请求详情
|
|
|
|
|
// console.log('Upload URL:', uploadUrl)
|
|
|
|
|
// console.log('Upload headers:', headers)
|
|
|
|
|
// console.log('UserStore state:', {
|
|
|
|
|
// systemsabbr: userStore.systemsabbr,
|
|
|
|
|
// systemsid: userStore.systemsid,
|
|
|
|
|
// companyabbr: userStore.companyabbr,
|
|
|
|
|
// companyid: userStore.companyid,
|
|
|
|
|
// language: userStore.language,
|
|
|
|
|
// loginType: userStore.loginType
|
|
|
|
|
// })
|
|
|
|
|
|
2025-09-08 19:09:55 +08:00
|
|
|
const res = await fetch(uploadUrl, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers,
|
|
|
|
|
body: form
|
|
|
|
|
})
|
|
|
|
|
const json = await res.json()
|
2025-09-09 20:53:30 +08:00
|
|
|
// console.log('Upload response:', json)
|
2025-09-08 19:09:55 +08:00
|
|
|
if (json.code !== 200) throw new Error(json.message || '上传失败')
|
|
|
|
|
|
|
|
|
|
// 将签名保存到个人信息(假设 crmuseredit 接受 signature 和 signature_pic_full 字段)
|
2025-09-09 20:53:30 +08:00
|
|
|
const res1 = await commonService.signatureEdit({
|
|
|
|
|
sign_img: json.data.file_name
|
2025-09-08 19:09:55 +08:00
|
|
|
})
|
|
|
|
|
|
2025-09-09 20:53:30 +08:00
|
|
|
ElMessage.success(res1?.message || '签名已保存')
|
|
|
|
|
locked.value = true
|
2025-09-08 19:09:55 +08:00
|
|
|
// 完成后可提示返回或自动关闭
|
2025-09-09 20:53:30 +08:00
|
|
|
// setTimeout(() => {
|
|
|
|
|
// if (window.history.length > 1) window.history.back()
|
|
|
|
|
// }, 800)
|
2025-09-08 19:09:55 +08:00
|
|
|
} 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>
|