feat(signature): 实现离线签名功能并优化相关页面

- 新增离线签名 API 接口
- 实现离线签名面板页面,包括签名、清空、确认等功能
- 添加屏幕旋转检测和处理逻辑
- 优化签名协议页面,增加同意并签字按钮
- 移除冗余组件和代码
This commit is contained in:
xingyy 2025-02-14 15:01:20 +08:00
parent 0c2b973419
commit 4bb0f318e3
10 changed files with 275 additions and 419 deletions

View File

@ -53,4 +53,12 @@ export async function logSendlog(data) {
method: 'POST',
data
})
}
export async function signOffline(data) {
return await request( {
url:'/api/v1/contract/sign-offline',
method: 'POST',
data
})
}

View File

@ -1,181 +0,0 @@
<template>
<div class="signature-pad-container">
<canvas
ref="canvasRef"
class="signature-pad"
:style="{
width: '100%',
height: '100%',
backgroundColor: '#fff',
border: '1px solid #e5e5e5',
borderRadius: '4px'
}"
@touchstart="handleStart"
@touchmove="handleMove"
@touchend="handleEnd"
@mousedown="handleStart"
@mousemove="handleMove"
@mouseup="handleEnd"
@mouseleave="handleEnd"
></canvas>
<div class="signature-controls">
<van-button
type="default"
size="small"
@click="clearCanvas"
>清除</van-button>
<van-button
type="primary"
size="small"
@click="handleConfirm"
>确认</van-button>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'
const props = defineProps({
modelValue: {
type: String,
default: ''
}
})
const emit = defineEmits(['update:modelValue', 'change'])
const canvasRef = ref(null)
const ctx = ref(null)
const isDrawing = ref(false)
const lastX = ref(0)
const lastY = ref(0)
const LINE_WIDTH = 2 //
//
const initCanvas = () => {
const canvas = canvasRef.value
const dpr = window.devicePixelRatio || 1
const rect = canvas.getBoundingClientRect()
//
canvas.width = rect.width * dpr
canvas.height = rect.height * dpr
ctx.value = canvas.getContext('2d')
//
ctx.value.scale(dpr, dpr)
ctx.value.lineCap = 'round'
ctx.value.lineJoin = 'round'
ctx.value.strokeStyle = '#000'
ctx.value.lineWidth = LINE_WIDTH
}
//
const handleStart = (e) => {
e.preventDefault() //
isDrawing.value = true
const point = getPoint(e)
lastX.value = point.x
lastY.value = point.y
}
//
const handleMove = (e) => {
if (!isDrawing.value) return
e.preventDefault() //
const point = getPoint(e)
ctx.value.beginPath()
ctx.value.moveTo(lastX.value, lastY.value)
ctx.value.lineTo(point.x, point.y)
ctx.value.stroke()
lastX.value = point.x
lastY.value = point.y
}
//
const handleEnd = () => {
isDrawing.value = false
}
//
const getPoint = (e) => {
const canvas = canvasRef.value
const rect = canvas.getBoundingClientRect()
const dpr = window.devicePixelRatio || 1
const event = e.touches ? e.touches[0] : e
//
const x = (event.clientX - rect.left)
const y = (event.clientY - rect.top)
return {
x: x,
y: y
}
}
//
const clearCanvas = () => {
const canvas = canvasRef.value
ctx.value.clearRect(0, 0, canvas.width, canvas.height)
emit('update:modelValue', '')
emit('change', '')
}
//
const handleConfirm = () => {
const canvas = canvasRef.value
const imageData = canvas.toDataURL('image/png')
emit('update:modelValue', imageData)
emit('change', imageData)
}
//
const handleResize = () => {
const canvas = canvasRef.value
const imageData = canvas.toDataURL('image/png')
initCanvas()
//
const img = new Image()
img.onload = () => {
ctx.value.drawImage(img, 0, 0, canvas.width, canvas.height)
}
img.src = imageData
}
onMounted(() => {
initCanvas()
window.addEventListener('resize', handleResize)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', handleResize)
})
</script>
<style scoped>
.signature-pad-container {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
gap: 10px;
}
.signature-pad {
flex: 1;
touch-action: none;
}
.signature-controls {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 16px;
padding: 8px;
}
</style>

View File

@ -1,119 +0,0 @@
<template>
<div
class="draggable-container"
:style="containerStyle"
@mousedown="startDrag"
@touchstart.prevent="startDrag"
>
<slot />
</div>
</template>
<script setup>
const props = defineProps({
fixedSide: {
type: String,
default: 'none',
validator: (v) => ['left', 'right', 'top', 'bottom', 'none'].includes(v)
},
initialPosition: {
type: Object,
default: () => ({ x: 0, y: 0 })
},
width: Number,
height: Number
})
const position = ref({ ...props.initialPosition })
const isDragging = ref(false)
const startPosition = ref({ x: 0, y: 0 })
const containerStyle = computed(() => ({
position: 'fixed',
width: props.width ? `${props.width}px` : 'auto',
height: props.height ? `${props.height}px` : 'auto',
left: `${position.value.x}px`,
top: `${position.value.y}px`,
cursor: isDragging.value ? 'grabbing' : 'grab'
}))
const getClientPos = (e) => ({
x: e.touches ? e.touches[0].clientX : e.clientX,
y: e.touches ? e.touches[0].clientY : e.clientY
})
const startDrag = (e) => {
isDragging.value = true
const { x, y } = getClientPos(e)
startPosition.value = {
x: x - position.value.x,
y: y - position.value.y
}
}
const onDrag = (e) => {
if (!isDragging.value) return
const { x: clientX, y: clientY } = getClientPos(e)
const maxX = window.innerWidth - (props.width || 0)
const maxY = window.innerHeight - (props.height || 0)
let newX = clientX - startPosition.value.x
let newY = clientY - startPosition.value.y
//
switch (props.fixedSide) {
case 'left':
newX = 0
newY = Math.max(0, Math.min(newY, maxY))
break
case 'right':
newX = maxX
newY = Math.max(0, Math.min(newY, maxY))
break
case 'top':
newX = Math.max(0, Math.min(newX, maxX))
newY = 0
break
case 'bottom':
newX = Math.max(0, Math.min(newX, maxX))
newY = maxY
break
default:
newX = Math.max(0, Math.min(newX, maxX))
newY = Math.max(0, Math.min(newY, maxY))
}
position.value = {
x: ['top', 'bottom', 'none'].includes(props.fixedSide) ? newX : position.value.x,
y: ['left', 'right', 'none'].includes(props.fixedSide) ? newY : position.value.y
}
}
const stopDrag = () => {
isDragging.value = false
}
//
onMounted(() => {
document.addEventListener('mousemove', onDrag)
document.addEventListener('mouseup', stopDrag)
document.addEventListener('touchmove', onDrag)
document.addEventListener('touchend', stopDrag)
})
onUnmounted(() => {
document.removeEventListener('mousemove', onDrag)
document.removeEventListener('mouseup', stopDrag)
document.removeEventListener('touchmove', onDrag)
document.removeEventListener('touchend', stopDrag)
})
</script>
<style scoped>
.draggable-container {
touch-action: none;
user-select: none;
z-index: 9999;
}
</style>

View File

@ -2,8 +2,7 @@
import { ref } from 'vue'
import { goodStore } from "@/stores/goods"
import DetailPopup from '../DetailPopup/index.vue'
import MasonryWall from '@yeger/vue-masonry-wall'
import WaterfallFlow from '@/components/WaterfallFlow.vue'
import WaterfallFlow from '@/components/waterfallFlow/index.vue'
const {
itemList,
pageRef,

View File

@ -51,7 +51,7 @@ const onRefresh = async () => {
}
//
const goPay = () => router.push('/signature/personal-Info')
const goPay = () => router.push('/signature/protocol')
const goDetail = (item) => router.push({ path: '/artDetail', query: { uuid: item.uuid } })
//

View File

@ -1,18 +1,173 @@
<script setup>
const image = ref('');
import { showToast } from 'vant';
import {showToast} from 'vant';
import {onMounted, onUnmounted, ref} from 'vue';
import {signOffline} from "~/api/goods/index.js";
const router = useRouter();
definePageMeta({
layout: ''
})
const onSubmit = (data) => {
image.value = data.image;
const signaturePadRef = ref(null);
const isLandscapeMode = ref(false);
const checkScreenOrientation = () => {
const orientation = screen.orientation?.type || window.orientation;
if (orientation === 'landscape-primary' || orientation === 'landscape-secondary' ||
orientation === 90 || orientation === -90) {
isLandscapeMode.value = true;
} else {
isLandscapeMode.value = false;
showToast('请将手机横屏使用');
}
};
const onClear = () => showToast('clear');
onMounted(() => {
nextTick(() => {
checkScreenOrientation();
});
window.addEventListener('orientationchange', checkScreenOrientation);
screen.orientation?.addEventListener('change', checkScreenOrientation);
});
onUnmounted(() => {
window.removeEventListener('orientationchange', checkScreenOrientation);
screen.orientation?.removeEventListener('change', checkScreenOrientation);
});
const imgUrl = ref('')
const show = ref(false)
const clearSignature = () => {
signaturePadRef.value?.resize();
signaturePadRef.value?.clear();
};
const submitSignature = () => {
signaturePadRef.value?.submit();
};
const handleSignatureSubmit = async (data) => {
imgUrl.value = data.image
show.value = true
nextTick(() => {
const overlay = document.querySelector('.signature-container .van-overlay');
if (overlay) {
overlay.style.width = '100vw';
overlay.style.left = '0';
overlay.style.right = '0';
}
})
};
const confirm = async () => {
const res = await signOffline({
signImgFileData: data.image
})
console.log('res', res)
}
const goBack = () => {
router.back()
}
</script>
<template>
<van-signature @submit="onSubmit" @clear="onClear" />
<van-image v-if="image" :src="image" />
<div class="signature-container">
<template v-if="isLandscapeMode">
<div class="signature-content">
<van-signature
class="signature-pad"
ref="signaturePadRef"
@submit="handleSignatureSubmit"
/>
<div class="control-buttons">
<van-button
class="control-button"
size="mini"
type="primary"
@click="goBack"
>
返回
</van-button>
<van-button
class="control-button"
size="mini"
type="warning"
@click="clearSignature"
>
清空
</van-button>
<van-button
class="control-button"
size="mini"
type="primary"
@click="submitSignature"
>
确认
</van-button>
</div>
</div>
</template>
<template v-else>
<div class="orientation-hint">
<p>请将手机横屏使用</p>
</div>
</template>
<van-dialog v-model:show="show" class="signature-dialog" show-cancel-button @confirm="confirm">
<img class="h-100px" :src="imgUrl"/>
</van-dialog>
</div>
</template>
<style scoped>
.signature-container {
position: fixed;
inset: 0;
background-color: #fff;
padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) 0;
}
:deep(.van-button--mini+.van-button--mini) {
margin: 0;
}
:deep(.van-dialog__content) {
display: flex;
justify-content: center;
}
.signature-content {
display: flex;
height: 100%;
}
:deep(.van-overlay) {
/* left: initial;*/
}
.signature-pad {
flex-grow: 1;
}
.control-buttons {
display: flex;
flex-direction: column;
padding: 20px 10px 0;
gap: 10px;
}
.control-button {
width: 40px;
}
.orientation-hint {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background-color: #fff;
font-size: 18px;
}
:deep(.van-signature__footer) {
display: none;
}
</style>

View File

@ -4,6 +4,12 @@ definePageMeta({
layout: 'default',
title: '签署内容'
})
const router = useRouter();
const goSignature=()=>{
router.push({
path:'/signature/panel'
})
}
</script>
<template>
@ -30,7 +36,7 @@ definePageMeta({
</van-collapse-item>
</van-collapse>
<div class="h-81px bg-#fff flex justify-center pt-7px border-t">
<van-button color="#2B53AC" class="w-213px van-btn-h-38px">同意并签字</van-button>
<van-button color="#2B53AC" class="w-213px van-btn-h-38px" @click="goSignature">同意并签字</van-button>
</div>
</div>
</template>

View File

@ -109,6 +109,46 @@ export const liveStore = createGlobalState(() => {
}
// 定义常量
const WS_TYPES = {
TIP: 'tip',
STOP_ARTWORK: 'stopArtwor',
OVER: 'over'
}
const TIP_TYPES = {
FALLING: 'falling',
OTHERS_BID: 'othersBid',
SUCCESS_BID: 'successBid',
ARTWORK_OVER: 'artworkOver',
FAIL_BID: 'failBid'
}
// 基础消息配置
const BASE_MESSAGE_STYLE = {
width: '151px',
bottom: '265px'
}
const createMessageConfig = (text, color, subText = '', extraStyle = {}) => ({
title: {
text,
color,
align: 'center'
},
icon: false,
...(subText && {
subTitle: {
text: subText,
color: '#939393',
align: 'center'
}
}),
style: {
...BASE_MESSAGE_STYLE,
...extraStyle
}
})
const getSocketData = async () => {
const wsClient = new WebSocketClient(
config.public.NUXT_PUBLIC_SOCKET_URL,
@ -118,119 +158,67 @@ export const liveStore = createGlobalState(() => {
auctionUuid: auctionDetail.value.uuid,
})
// 处理消息提示
const handleTipMessage = (tipType) => {
const tipConfigs = {
[TIP_TYPES.FALLING]: () =>
message.warning(createMessageConfig(t('live_room.text1'), '#F09F1F')),
[TIP_TYPES.OTHERS_BID]: () =>
message.error(createMessageConfig(t('live_room.text2'), '#CF3050', t('live_room.text3'))),
[TIP_TYPES.SUCCESS_BID]: () =>
message.success(createMessageConfig(t('live_room.text4'), '#18A058', t('live_room.text5'))),
[TIP_TYPES.ARTWORK_OVER]: () =>
message.success(createMessageConfig(
t('live_room.text6'),
'#575757',
t('live_room.text7'),
{ backgroundColor: '#fff', borderColor: '#fff' }
)),
[TIP_TYPES.FAIL_BID]: () =>
message.error(createMessageConfig(
t('live_room.text8'),
'#CF3050',
t('live_room.text9'),
{ width: '186px' }
))
}
const handler = tipConfigs[tipType]
if (handler) handler()
}
// WebSocket 事件处理
ws.onOpen(() => {
console.log('WebSocket connected')
})
ws.onMessage((data) => {
auctionData.value = data.data
if (data.data?.wsType === 'tip' ) {
if (data.data?.tip?.tipType === 'falling'){
message.warning({
title: {
text: t('live_room.text1'),
color: '#F09F1F',
align: 'center',
},
style: {
width: '151px',
bottom: '230px',
},
})
}else if (data.data?.tip?.tipType === 'othersBid'){
message.error({
title: {
text: t('live_room.text2'),
color: '#CF3050',
align: 'center',
},
icon:false,
subTitle:{
text:t('live_room.text3'),
color: '#939393',
align: 'center',
},
style: {
width: '151px',
bottom: '230px'
},
})
}else if (data.data?.tip?.tipType === 'successBid'){
message.success({
title: {
text: t('live_room.text4'),
color: '#18A058',
align: 'center',
},
icon:false,
subTitle:{
text:t('live_room.text5'),
color: '#939393',
align: 'center',
},
style: {
width: '151px',
bottom: '230px'
},
})
}else if (data.data?.tip?.tipType === 'artworkOver'){
message.success({
title: {
text: t('live_room.text6'),
color: '#575757',
align: 'center',
const { wsType, tip } = data.data || {}
},
icon:false,
subTitle:{
text:t('live_room.text7'),
color: '#939393',
align: 'center',
},
style: {
width: '151px',
bottom: '230px',
switch (wsType) {
case WS_TYPES.TIP:
handleTipMessage(tip?.tipType)
break
case WS_TYPES.STOP_ARTWORK:
quoteStatus.value = false
break
case WS_TYPES.OVER:
message.success(createMessageConfig(
t('live_room.text10'),
'#575757',
'',
{
width: '195px',
backgroundColor: '#fff',
borderColor:'#fff'
},
})
}else if (data.data?.tip?.tipType === 'failBid'){
message.error({
title: {
text: t('live_room.text8'),
color: '#CF3050',
align: 'center',
},
icon:false,
subTitle:{
text: t('live_room.text9'),
color: '#939393',
align: 'center',
},
style: {
width: '186px',
bottom: '230px'
},
})
}
}else if (data.data?.wsType==='stopArtwor'){
quoteStatus.value=false
}else if (data.data?.wsType==='over'){
message.success({
title: {
text: t('live_room.text10'),
color: '#575757',
align: 'center',
},
icon:false,
style: {
width: '195px',
bottom: '230px',
backgroundColor: '#fff',
borderColor:'#fff'
},
})
borderColor: '#fff'
}
))
break
}
console.log('onmessage', data)

View File

@ -49,6 +49,7 @@ export default defineNuxtConfig({
rootContainingBlockSelectorList: [
'van-tabbar',
'van-popup',
'van-overlay',
],
},
},

View File

@ -22,7 +22,6 @@
"@fingerprintjs/fingerprintjs": "^4.5.1",
"@nuxtjs/i18n": "^9.1.1",
"@vueuse/core": "^12.4.0",
"@yeger/vue-masonry-wall": "^5.0.17",
"aliyun-aliplayer": "^2.28.5",
"axios": "^1.7.9",
"crypto-js": "^4.2.0",
@ -32,10 +31,10 @@
"pinyin": "4.0.0-alpha.2",
"qrcode": "^1.5.4",
"segmentit": "^2.0.3",
"tslib": "^2.6.0",
"vconsole": "^3.15.1",
"vue": "^3.5.13",
"vue-router": "^4.5.0",
"tslib": "^2.6.0"
"vue-router": "^4.5.0"
},
"devDependencies": {
"@iconify-json/carbon": "^1.2.5",