Files
art-design/src/views/auth/signature/mobile.vue
T
2025-09-10 18:11:39 +08:00

363 lines
12 KiB
Vue

<template>
<div class="signature-page" :class="{ landscape }">
<div class="header">电子签名</div>
<div class="canvas-wrap">
<canvas
ref="canvasRef"
class="canvas"
:style="{ pointerEvents: locked ? 'none' : 'auto' }"
@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 :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 ? '签名已保存,页面已锁定' : '请在画布上手写签名,然后点击保存' }}
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import { useUploadHeaders } from '@/composables/useUploadHeaders'
// import { CrmService } from '@/api/crmApi'
import { commonService } from '@/api/commonApi'
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.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) ||
(route.query.corp as string | undefined) ||
userStore.companyabbr
const urlLoginType =
(route.query.loginType as string | undefined) || (route.query.type as string | undefined)
const urlSys =
(route.query.systemsabbr as string | undefined) ||
(route.query.sys as string | undefined) ||
userStore.systemsabbr ||
'fdesk'
const effectiveToken = urlToken || userStore.accessToken
// 从URL参数获取公司信息并存储到userStore(如果路由守卫没有处理)
const companyid =
(route.query.companyid as string | undefined) || (route.query.corpid as string | undefined)
const companyabbr =
(route.query.companyabbr as string | undefined) || (route.query.corp as string | undefined)
if (companyid && companyabbr && (!userStore.companyid || !userStore.companyabbr)) {
userStore.setCompany(companyid, companyabbr)
}
// 从URL参数获取系统信息并存储到userStore(如果路由守卫没有处理)
const systemsabbr =
(route.query.systemsabbr as string | undefined) || (route.query.sys as string | undefined)
const systemsid =
(route.query.systemsid as string | undefined) || (route.query.sysid 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
// }
// })
const { uploadHeaders } = useUploadHeaders()
// 基于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
})
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 locked = 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) => {
if (locked.value) return
drawing.value = true
const p = getPoint(e)
ctx.value!.beginPath()
ctx.value!.moveTo(p.x, p.y)
}
const onMove = (e: TouchEvent) => {
if (locked.value) return
if (!drawing.value) return
const p = getPoint(e)
ctx.value!.lineTo(p.x, p.y)
ctx.value!.stroke()
}
const onEnd = () => {
if (locked.value) return
drawing.value = false
}
const onMouseDown = (e: MouseEvent) => onStart(e as any)
const onMouseMove = (e: MouseEvent) => onMove(e as any)
const onMouseUp = () => onEnd()
const clearCanvas = () => {
if (locked.value) return
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 isLimitedWebview = () => /MicroMessenger|Lark|Feishu|DingTalk/i.test(navigator.userAgent)
const toggleLandscape = async () => {
landscape.value = !landscape.value
if (landscape.value) {
// 在微信/飞书等内置浏览器中,强制横屏普遍受限,采用 CSS 旋转兜底
if (!isLimitedWebview()) {
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> = { ...(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
// })
const res = await fetch(uploadUrl, {
method: 'POST',
headers,
body: form
})
const json = await res.json()
// console.log('Upload response:', json)
if (json.code !== 200) throw new Error(json.message || '上传失败')
// 将签名保存到个人信息(假设 crmuseredit 接受 signature 和 signature_pic_full 字段)
const res1 = await commonService.signatureEdit({
sign_img: json.data.file_name
})
ElMessage.success(res1?.message || '签名已保存')
locked.value = true
// 完成后可提示返回或自动关闭
// 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>