Files
QSY-lvjuxcx/components/ContractSignDialog.vue
2026-06-29 09:42:53 +08:00

1029 lines
29 KiB
Vue

<template>
<view v-if="show" class="contract-sign-dialog" @touchmove.stop.prevent="noop">
<view class="contract-sign-dialog__mask" @tap="handleRequestClose"></view>
<view
class="contract-sign-dialog__panel tn-safe-area-inset-bottom"
:class="{ 'is-landscape': isLandscape }"
@touchmove.stop.prevent="noop"
>
<view class="contract-sign-dialog__canvas-card">
<view class="contract-sign-dialog__canvas-tip">
<view class="contract-sign-dialog__canvas-tip-text">
<text class="ri-edit-2-line"></text>
<text>{{ writingHint }}</text>
</view>
<view class="contract-sign-dialog__inline-name">
<text class="ri-user-3-fill"></text>
<text>{{ displaySignerName }}</text>
</view>
</view>
<view class="contract-sign-dialog__canvas-box" @touchmove.stop.prevent="noop">
<view class="contract-sign-dialog__canvas-guide" :class="{ 'is-chinese': isChineseName }">
<view v-if="isChineseName" class="contract-sign-dialog__canvas-guide-grid" :style="guideGridStyle">
<view
v-for="(char, index) in chineseGuideChars"
:key="`${char}-${index}`"
class="contract-sign-dialog__canvas-guide-cell"
:style="guideCellStyle"
>
<view class="contract-sign-dialog__guide-cross contract-sign-dialog__guide-cross--horizontal"></view>
<view class="contract-sign-dialog__guide-cross contract-sign-dialog__guide-cross--vertical"></view>
<text class="contract-sign-dialog__canvas-guide-char" :style="{ fontSize: guideFontSize, fontFamily: guideFontFamily }">
{{ char }}
</text>
</view>
</view>
<view v-else class="contract-sign-dialog__canvas-guide-line" :style="guideAreaStyle">
<view class="contract-sign-dialog__guide-baseline"></view>
<text
class="contract-sign-dialog__canvas-guide-text is-en"
:style="{ fontSize: guideFontSize, fontFamily: guideFontFamily }"
>
{{ displaySignerName }}
</text>
</view>
</view>
<canvas
class="contract-sign-dialog__canvas"
canvas-id="contract-sign-canvas"
disable-scroll="true"
@touchstart="handleTouchStart"
@touchmove="handleTouchMove"
@touchend="handleTouchEnd"
></canvas>
</view>
</view>
<view class="contract-sign-dialog__toolbar">
<view class="tool-btn tool-btn--soft" :class="{ disabled: !canUndo }" @tap="handleUndo">
<text class="ri-play-reverse-line"></text>
<text>{{ t('undo') }}</text>
</view>
<view class="tool-btn tool-btn--soft" :class="{ disabled: !hasSignature }" @tap="handleClear">
<text class="ri-delete-bin-line"></text>
<text>{{ t('rewrite') }}</text>
</view>
<view class="tool-btn tool-btn--ghost" @tap="handleRequestClose">
<text class="ri-close-line"></text>
<text>{{ t('close') }}</text>
</view>
<view class="tool-btn tool-btn--primary" :class="{ disabled: !hasSignature }" @tap="handleSave">
<text class="ri-check-line-circle"></text>
{{ t('confirmSignature') }}
</view>
</view>
<view v-if="!isLandscape" class="contract-sign-dialog__footer-tip">
<text class="ri-shield-check-line-fill"></text>
<text>{{ t('footerTip') }}</text>
</view>
</view>
<view
v-if="showCloseConfirm"
class="contract-sign-dialog__confirm-layer"
@touchmove.stop.prevent="noop"
>
<view class="contract-sign-dialog__confirm-mask"></view>
<view class="contract-sign-dialog__confirm-card">
<view class="contract-sign-dialog__confirm-title">{{ t('closeBoardTitle') }}</view>
<view class="contract-sign-dialog__confirm-desc">
{{ t('closeBoardDesc') }}
</view>
<view class="contract-sign-dialog__confirm-actions">
<view class="contract-sign-dialog__confirm-btn is-cancel" @tap="cancelRequestClose">{{ t('continueSigning') }}</view>
<view class="contract-sign-dialog__confirm-btn is-confirm" @tap="confirmRequestClose">{{ t('continueClosing') }}</view>
</view>
</view>
</view>
<view class="contract-sign-dialog__export-canvas-wrap">
<canvas
canvas-id="contract-sign-export-canvas"
:style="{ width: `${exportCanvasWidth}px`, height: `${exportCanvasHeight}px` }"
></canvas>
</view>
</view>
</template>
<script>
export default {
name: 'ContractSignDialog',
props: {
show: {
type: Boolean,
default: false
},
signerName: {
type: String,
default: ''
}
},
i18n: {
zh: {
undo: '撤销',
rewrite: '重写',
close: '关闭',
confirmSignature: '确认签名',
footerTip: '签名前请确认您已完整阅读合同内容',
closeBoardTitle: '关闭提示',
closeBoardDesc: '您还未完成签名,确认要关闭吗?',
continueSigning: '继续签名',
continueClosing: '确认关闭',
missingSignerName: '签名人缺失',
writingHintZh: '请在此处手写中文正楷签名',
writingHintEn: 'Please write your signature here',
signerNameMissingToast: '签名人信息缺失',
signatureRequiredToast: '请先完成签名',
boundsFailedToast: '获取签名范围失败',
signatureTooSmallToast: '签名范围太小,请重新签名',
exportFailedToast: '导出签名失败,请重试'
},
en: {
undo: 'Undo',
rewrite: 'Rewrite',
close: 'Close',
confirmSignature: 'Confirm',
footerTip: 'Please confirm that you have fully read the contract before signing',
closeBoardTitle: 'Notice',
closeBoardDesc: 'You have not finished signing. Are you sure to close?',
continueSigning: 'Continue Signing',
continueClosing: 'Close',
missingSignerName: 'Missing Signer',
writingHintZh: 'Please write your Chinese signature here',
writingHintEn: 'Please write your signature here',
signerNameMissingToast: 'Missing signer name',
signatureRequiredToast: 'Please sign first',
boundsFailedToast: 'Failed to get signature bounds',
signatureTooSmallToast: 'Signature is too small, please try again',
exportFailedToast: 'Failed to export signature, please try again'
}
},
data() {
return {
canvasId: 'contract-sign-canvas',
ctx: null,
canvasWidth: 0,
canvasHeight: 0,
isLandscape: false,
ready: false,
drawing: false,
strokes: [],
currentStroke: [],
showCloseConfirm: false,
lineWidth: 5,
minPointDistance: 2,
strokeColor: '#0F172A'
}
},
computed: {
language() {
return this.$getLanguage ? this.$getLanguage() : 'zh'
},
displaySignerName() {
const name = String(this.signerName || '').trim()
return name || this.t('missingSignerName')
},
hasSignerName() {
return !!String(this.signerName || '').trim()
},
isChineseName() {
return /^[\u4E00-\u9FA5·]+$/.test(this.displaySignerName)
},
chineseGuideChars() {
return this.isChineseName
? this.displaySignerName.split('').filter(char => char && char.trim())
: []
},
hasSignature() {
return this.strokes.length > 0
},
canUndo() {
return this.strokes.length > 0
},
writingHint() {
return this.isChineseName ? this.t('writingHintZh') : this.t('writingHintEn')
},
guideFontSize() {
const length = this.displaySignerName.length
if (this.isChineseName) {
if (length <= 2) return this.isLandscape ? '176rpx' : '160rpx'
if (length === 3) return this.isLandscape ? '156rpx' : '142rpx'
if (length === 4) return this.isLandscape ? '138rpx' : '128rpx'
if (length <= 6) return this.isLandscape ? '122rpx' : '112rpx'
return this.isLandscape ? '108rpx' : '100rpx'
}
if (length >= 18) return this.isLandscape ? '88rpx' : '78rpx'
if (length >= 12) return this.isLandscape ? '100rpx' : '90rpx'
if (length >= 8) return this.isLandscape ? '116rpx' : '102rpx'
return this.isLandscape ? '132rpx' : '116rpx'
},
guideFontFamily() {
if (this.isChineseName) {
return '"STKaiti", "KaiTi", "Kaiti SC", "DFKai-SB", serif'
}
return '"Segoe Script", "Brush Script MT", "Lucida Handwriting", cursive'
},
guideCellStyle() {
const count = Math.max(this.chineseGuideChars.length, 1)
const gapRpx = this.isLandscape ? 12 : 8
const gapPx = uni.upx2px(gapRpx)
const totalGapPx = gapPx * Math.max(count - 1, 0)
const availableWidth = this.canvasWidth ? this.canvasWidth * 0.9 : 0
const availableHeight = this.canvasHeight ? this.canvasHeight * 0.9 : 0
if (!availableWidth || !availableHeight) {
return {}
}
const cellPx = Math.max(
Math.min((availableWidth - totalGapPx) / count, availableHeight),
0
)
return {
width: `${cellPx}px`,
height: `${cellPx}px`
}
},
guideAreaStyle() {
return {
width: '90%',
height: '90%'
}
},
guideGridStyle() {
return {
width: '90%',
height: '90%'
}
},
exportCanvasWidth() {
return this.isLandscape ? this.canvasHeight || 1 : this.canvasWidth || 1
},
exportCanvasHeight() {
return this.isLandscape ? this.canvasWidth || 1 : this.canvasHeight || 1
}
},
watch: {
show(value) {
if (value) {
this.isLandscape = true
this.applyLandscapeOrientation()
this.$nextTick(() => {
this.initCanvas()
})
} else {
this.restorePortraitOrientation()
}
},
isLandscape(value, oldValue) {
if (value === oldValue) return
this.clearSignatureContent()
}
},
beforeDestroy() {
this.restorePortraitOrientation()
this.ctx = null
},
methods: {
t(key) {
return this.$translate(this.$options.i18n, key)
},
clearSignatureContent() {
this.strokes = []
this.currentStroke = []
this.drawing = false
if (this.ready) {
this.resetCanvas()
}
},
initCanvas() {
this.clearSignatureContent()
const query = uni.createSelectorQuery().in(this)
query.select('.contract-sign-dialog__canvas-box').boundingClientRect(rect => {
if (!rect) return
this.canvasWidth = rect.width
this.canvasHeight = rect.height
this.ctx = uni.createCanvasContext(this.canvasId, this)
this.ready = true
this.resetCanvas()
}).exec()
},
resetCanvas() {
if (!this.ctx || !this.ready) return
this.ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight)
this.ctx.setLineCap('round')
this.ctx.setLineJoin('round')
this.ctx.draw()
},
redrawCanvas() {
if (!this.ctx || !this.ready) return
this.ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight)
this.ctx.setLineCap('round')
this.ctx.setLineJoin('round')
this.strokes.forEach(stroke => {
this.drawStroke(stroke)
})
this.ctx.draw()
},
drawStroke(stroke) {
if (!Array.isArray(stroke) || stroke.length === 0) return
if (stroke.length === 1) {
const point = stroke[0]
this.ctx.beginPath()
this.ctx.arc(point.x, point.y, this.lineWidth / 2, 0, Math.PI * 2)
this.ctx.setFillStyle(this.strokeColor)
this.ctx.fill()
return
}
if (stroke.length === 2) {
this.ctx.beginPath()
this.ctx.setStrokeStyle(this.strokeColor)
this.ctx.setLineWidth(this.lineWidth)
this.ctx.moveTo(stroke[0].x, stroke[0].y)
this.ctx.lineTo(stroke[1].x, stroke[1].y)
this.ctx.stroke()
return
}
this.ctx.beginPath()
this.ctx.setStrokeStyle(this.strokeColor)
this.ctx.setLineWidth(this.lineWidth)
this.ctx.moveTo(stroke[0].x, stroke[0].y)
for (let index = 1; index < stroke.length - 1; index += 1) {
const currentPoint = stroke[index]
const nextPoint = stroke[index + 1]
const midPoint = this.getMidPoint(currentPoint, nextPoint)
this.ctx.quadraticCurveTo(currentPoint.x, currentPoint.y, midPoint.x, midPoint.y)
}
const secondLastPoint = stroke[stroke.length - 2]
const lastPoint = stroke[stroke.length - 1]
this.ctx.quadraticCurveTo(secondLastPoint.x, secondLastPoint.y, lastPoint.x, lastPoint.y)
this.ctx.stroke()
},
getMidPoint(pointA, pointB) {
return {
x: (pointA.x + pointB.x) / 2,
y: (pointA.y + pointB.y) / 2
}
},
getDistance(pointA, pointB) {
const dx = pointA.x - pointB.x
const dy = pointA.y - pointB.y
return Math.sqrt(dx * dx + dy * dy)
},
applyLandscapeOrientation() {
try {
if (typeof plus !== 'undefined' && plus.screen && plus.screen.lockOrientation) {
plus.screen.lockOrientation('landscape-primary')
return
}
} catch (error) {
console.error('切换系统横屏失败', error)
}
try {
if (
typeof window !== 'undefined' &&
window.screen &&
window.screen.orientation &&
window.screen.orientation.lock
) {
window.screen.orientation.lock('landscape').catch(() => {})
}
} catch (error) {
console.error('浏览器横屏锁定失败', error)
}
},
restorePortraitOrientation() {
try {
if (typeof plus !== 'undefined' && plus.screen && plus.screen.lockOrientation) {
plus.screen.lockOrientation('portrait-primary')
return
}
} catch (error) {
console.error('恢复竖屏失败', error)
}
try {
if (
typeof window !== 'undefined' &&
window.screen &&
window.screen.orientation &&
window.screen.orientation.lock
) {
window.screen.orientation.lock('portrait').catch(() => {})
}
} catch (error) {
console.error('浏览器竖屏锁定失败', error)
}
},
handleRequestClose() {
if (this.hasSignature) {
this.showCloseConfirm = true
return
}
this.handleClose()
},
cancelRequestClose() {
this.showCloseConfirm = false
},
confirmRequestClose() {
this.showCloseConfirm = false
this.handleClose()
},
noop() {},
buildPoint(event, key) {
const touches = event[key]
if (!touches || !touches.length) return null
return {
x: touches[0].x,
y: touches[0].y
}
},
handleTouchStart(event) {
if (!this.ready) return
const point = this.buildPoint(event, 'touches')
if (!point) return
this.drawing = true
this.currentStroke = [point]
this.strokes.push(this.currentStroke)
this.redrawCanvas()
},
handleTouchMove(event) {
if (!this.ready || !this.drawing) return
if (event.cancelable && !event.defaultPrevented) {
event.preventDefault()
}
const point = this.buildPoint(event, 'touches')
if (!point) return
const lastPoint = this.currentStroke[this.currentStroke.length - 1]
if (lastPoint && this.getDistance(lastPoint, point) < this.minPointDistance) {
return
}
this.currentStroke.push(point)
this.redrawCanvas()
},
handleTouchEnd(event) {
if (!this.ready || !this.drawing) return
const point = this.buildPoint(event, 'changedTouches')
if (point) {
this.currentStroke.push(point)
}
this.drawing = false
this.currentStroke = []
this.redrawCanvas()
},
handleUndo() {
if (!this.canUndo) return
this.strokes.pop()
this.redrawCanvas()
},
handleClear() {
this.clearSignatureContent()
},
handleClose() {
this.showCloseConfirm = false
this.isLandscape = false
this.restorePortraitOrientation()
this.$emit('close')
},
getSignatureBounds() {
if (!this.hasSignature) return null
let minX = Number.POSITIVE_INFINITY
let minY = Number.POSITIVE_INFINITY
let maxX = 0
let maxY = 0
this.strokes.forEach(stroke => {
;(stroke || []).forEach(point => {
if (!point) return
minX = Math.min(minX, point.x)
minY = Math.min(minY, point.y)
maxX = Math.max(maxX, point.x)
maxY = Math.max(maxY, point.y)
})
})
if (!Number.isFinite(minX) || !Number.isFinite(minY)) return null
const padding = Math.max(this.lineWidth * 4, 12)
const x = Math.max(Math.floor(minX - padding), 0)
const y = Math.max(Math.floor(minY - padding), 0)
const width = Math.min(Math.ceil(maxX - minX + padding * 2), this.canvasWidth - x)
const height = Math.min(Math.ceil(maxY - minY + padding * 2), this.canvasHeight - y)
return {
x,
y,
width: Math.max(width, 1),
height: Math.max(height, 1)
}
},
getSignaturePointCount() {
return this.strokes.reduce((total, stroke) => total + ((stroke && stroke.length) || 0), 0)
},
isSignatureQualified(bounds) {
if (!bounds) return false
const pointCount = this.getSignaturePointCount()
const minWidth = Math.max(this.canvasWidth * 0.06, 24)
const minHeight = Math.max(this.canvasHeight * 0.05, 18)
const minArea = Math.max(this.canvasWidth * this.canvasHeight * 0.0035, 900)
const actualArea = bounds.width * bounds.height
return pointCount >= 4 && (bounds.width >= minWidth || bounds.height >= minHeight) && actualArea >= minArea
},
exportCurrentCanvas(cropRect) {
return new Promise((resolve, reject) => {
uni.canvasToTempFilePath({
canvasId: this.canvasId,
fileType: 'png',
quality: 1,
x: cropRect.x,
y: cropRect.y,
width: cropRect.width,
height: cropRect.height,
success: res => resolve(res.tempFilePath),
fail: reject
}, this)
})
},
normalizeLandscapeImage(tempFilePath, sourceWidth, sourceHeight) {
return new Promise((resolve, reject) => {
const tempCtx = uni.createCanvasContext('contract-sign-export-canvas', this)
tempCtx.restore()
tempCtx.save()
tempCtx.clearRect(0, 0, this.exportCanvasWidth, this.exportCanvasHeight)
tempCtx.translate(0, sourceWidth)
tempCtx.rotate(-90 * Math.PI / 180)
tempCtx.drawImage(tempFilePath, 0, 0, sourceWidth, sourceHeight)
tempCtx.draw(false, () => {
uni.canvasToTempFilePath({
canvasId: 'contract-sign-export-canvas',
fileType: 'png',
x: 0,
y: 0,
width: sourceHeight,
height: sourceWidth,
success: res => resolve(res.tempFilePath),
fail: reject
}, this)
})
})
},
handleSave() {
if (!this.hasSignerName) {
uni.showToast({
title: this.t('signerNameMissingToast'),
icon: 'none'
})
return
}
if (!this.hasSignature) {
uni.showToast({
title: this.t('signatureRequiredToast'),
icon: 'none'
})
return
}
const cropRect = this.getSignatureBounds()
if (!cropRect) {
uni.showToast({
title: this.t('boundsFailedToast'),
icon: 'none'
})
return
}
if (!this.isSignatureQualified(cropRect)) {
uni.showToast({
title: this.t('signatureTooSmallToast'),
icon: 'none'
})
return
}
this.exportCurrentCanvas(cropRect)
.then(tempFilePath => {
if (this.isLandscape) {
return this.normalizeLandscapeImage(tempFilePath, cropRect.width, cropRect.height)
}
return tempFilePath
})
.then(finalPath => {
this.$emit('save', finalPath)
})
.catch(error => {
console.error('导出签名图片失败', error)
uni.showToast({
title: this.t('exportFailedToast'),
icon: 'none'
})
})
}
}
}
</script>
<style lang="scss" scoped>
.contract-sign-dialog {
position: fixed;
inset: 0;
z-index: 9999;
}
.contract-sign-dialog__mask {
position: absolute;
inset: 0;
background:
radial-gradient(circle at top, rgba(73, 119, 196, 0.18), transparent 45%),
rgba(15, 23, 42, 0.58);
}
.contract-sign-dialog__panel {
position: absolute;
left: 20rpx;
right: 20rpx;
top: 50%;
transform: translateY(-50%);
border-radius: 36rpx;
background: linear-gradient(180deg, #FFFFFF 0%, #F8FBFF 100%);
box-shadow: 0 28rpx 90rpx rgba(15, 23, 42, 0.24);
padding: 36rpx 32rpx 30rpx;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.78);
}
.contract-sign-dialog__panel.is-landscape {
left: 12rpx;
right: 12rpx;
top: 50%;
transform: translateY(-50%);
padding: 22rpx 22rpx 18rpx;
max-height: calc(100vh - 24rpx);
height: calc(100vh - 24rpx);
display: flex;
flex-direction: column;
}
.contract-sign-dialog__canvas-card {
margin-top: 28rpx;
border-radius: 30rpx;
border: 1px solid rgba(42, 92, 170, 0.10);
background: linear-gradient(180deg, #FBFDFF 0%, #F2F7FD 100%);
padding: 26rpx;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.9);
}
.contract-sign-dialog__canvas-tip {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
}
.contract-sign-dialog__canvas-tip-text {
display: flex;
align-items: center;
gap: 8rpx;
font-size: 24rpx;
color: #607086;
font-weight: 600;
}
.contract-sign-dialog__inline-name {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8rpx;
min-width: 168rpx;
height: 64rpx;
padding: 0 20rpx;
border-radius: 999rpx;
background: linear-gradient(180deg, #EEF3FA 0%, #E5ECF6 100%);
color: #314966;
font-size: 22rpx;
font-weight: 600;
box-shadow: inset 0 0 0 1px rgba(85, 108, 141, 0.08);
}
.contract-sign-dialog__canvas-box {
margin-top: 18rpx;
height: 460rpx;
border-radius: 24rpx;
background: linear-gradient(180deg, rgba(255,255,255,0.95) 0%, rgba(247,250,255,0.98) 100%);
border: 1px solid rgba(42, 92, 170, 0.14);
position: relative;
overflow: hidden;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.9), 0 12rpx 30rpx rgba(34, 64, 110, 0.06);
}
.contract-sign-dialog__panel.is-landscape .contract-sign-dialog__canvas-card {
margin-top: 0;
padding: 18rpx;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.contract-sign-dialog__panel.is-landscape .contract-sign-dialog__canvas-box {
flex: 1;
min-height: 0;
height: auto;
}
.contract-sign-dialog__panel.is-landscape .contract-sign-dialog__canvas-guide {
padding: 14rpx 18rpx;
}
.contract-sign-dialog__panel.is-landscape .contract-sign-dialog__canvas-guide-grid {
flex-wrap: nowrap;
gap: 10rpx;
}
.contract-sign-dialog__panel.is-landscape .contract-sign-dialog__toolbar {
gap: 10rpx;
margin-top: 12rpx;
flex-shrink: 0;
}
.contract-sign-dialog__panel.is-landscape .tool-btn {
height: 64rpx;
border-radius: 18rpx;
gap: 6rpx;
font-size: 22rpx;
}
.contract-sign-dialog__panel.is-landscape .tool-btn text:first-child {
font-size: 22rpx;
}
.contract-sign-dialog__panel.is-landscape .contract-sign-dialog__canvas-tip {
flex-shrink: 0;
gap: 10rpx;
}
.contract-sign-dialog__panel.is-landscape .contract-sign-dialog__canvas-tip-text {
font-size: 22rpx;
}
.contract-sign-dialog__panel.is-landscape .contract-sign-dialog__inline-name {
min-width: 132rpx;
height: 52rpx;
padding: 0 16rpx;
font-size: 20rpx;
}
.contract-sign-dialog__panel.is-landscape .contract-sign-dialog__footer-tip {
margin-top: 10rpx;
}
.contract-sign-dialog__canvas-box::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(transparent 95%, rgba(42, 92, 170, 0.035) 100%);
background-size: 100% 72rpx;
pointer-events: none;
}
.contract-sign-dialog__canvas-guide {
position: absolute;
inset: 0;
z-index: 1;
pointer-events: none;
display: flex;
align-items: center;
justify-content: center;
padding: 12rpx;
box-sizing: border-box;
}
.contract-sign-dialog__canvas-guide-grid {
display: flex;
justify-content: center;
align-items: center;
align-content: center;
gap: 8rpx;
flex-wrap: wrap;
margin: 0 auto;
}
.contract-sign-dialog__canvas-guide-cell {
border-radius: 20rpx;
background: rgba(42, 92, 170, 0.035);
border: 1px dashed rgba(42, 92, 170, 0.12);
position: relative;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
.contract-sign-dialog__canvas-guide-line {
width: 90%;
height: 100%;
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.contract-sign-dialog__canvas-guide-line::after {
content: '';
position: absolute;
left: 12rpx;
right: 12rpx;
top: 50%;
border-top: 1px dashed rgba(42, 92, 170, 0.14);
}
.contract-sign-dialog__guide-cross {
position: absolute;
background: rgba(15, 23, 42, 0.08);
}
.contract-sign-dialog__guide-cross--horizontal {
left: 12rpx;
right: 12rpx;
top: 50%;
height: 1px;
transform: translateY(-50%);
}
.contract-sign-dialog__guide-cross--vertical {
top: 12rpx;
bottom: 12rpx;
left: 50%;
width: 1px;
transform: translateX(-50%);
}
.contract-sign-dialog__guide-baseline {
position: absolute;
left: 18rpx;
right: 18rpx;
top: 50%;
border-top: 1px dashed rgba(15, 23, 42, 0.08);
}
.contract-sign-dialog__canvas {
width: 100%;
height: 100%;
position: relative;
z-index: 2;
}
.contract-sign-dialog__canvas-guide-text {
position: relative;
z-index: 2;
max-width: calc(100% - 12rpx);
color: rgba(15, 23, 42, 0.14);
font-weight: 500;
letter-spacing: 2rpx;
text-align: center;
word-break: break-all;
transform: scaleY(1.08);
text-shadow: 0 0 1px rgba(15, 23, 42, 0.04);
}
.contract-sign-dialog__canvas-guide-text.is-en {
letter-spacing: 0;
font-style: italic;
transform: scaleY(1.02) rotate(-4deg);
}
.contract-sign-dialog__canvas-guide-char {
position: relative;
z-index: 1;
color: rgba(15, 23, 42, 0.14);
font-weight: 500;
line-height: 1;
transform: scale(1.08) rotate(-3deg);
text-shadow: 0 0 1px rgba(15, 23, 42, 0.04);
}
.contract-sign-dialog__toolbar {
margin-top: 30rpx;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16rpx;
}
.tool-btn {
height: 84rpx;
border-radius: 24rpx;
display: flex;
align-items: center;
justify-content: center;
gap: 8rpx;
font-size: 26rpx;
font-weight: 600;
color: #35506F;
background: #F2F5F9;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.tool-btn:active {
transform: scale(0.97);
}
.tool-btn--soft {
background: linear-gradient(180deg, #F7FAFE 0%, #EEF3F9 100%);
box-shadow: inset 0 0 0 1px rgba(85, 108, 141, 0.06);
}
.tool-btn--ghost {
color: #516173;
background: linear-gradient(180deg, #F4F7FB 0%, #E9EEF5 100%);
}
.tool-btn--primary {
color: #FFFFFF;
background: linear-gradient(135deg, #2A5CAA 0%, #3C74CB 100%);
box-shadow: 0 10rpx 24rpx rgba(42, 92, 170, 0.26);
}
.tool-btn.disabled {
opacity: 0.45;
pointer-events: none;
}
.contract-sign-dialog__footer-tip {
margin-top: 18rpx;
display: flex;
align-items: center;
justify-content: center;
gap: 8rpx;
font-size: 22rpx;
color: #74859A;
}
.contract-sign-dialog__export-canvas-wrap {
position: fixed;
left: -9999px;
top: -9999px;
width: 0;
height: 0;
overflow: hidden;
}
.contract-sign-dialog__confirm-layer {
position: absolute;
inset: 0;
z-index: 10020;
display: flex;
align-items: center;
justify-content: center;
padding: 32rpx;
box-sizing: border-box;
}
.contract-sign-dialog__confirm-mask {
position: absolute;
inset: 0;
background: rgba(15, 23, 42, 0.5);
}
.contract-sign-dialog__confirm-card {
position: relative;
z-index: 1;
width: 100%;
max-width: 560rpx;
border-radius: 28rpx;
background: linear-gradient(180deg, #FFFFFF 0%, #F8FBFF 100%);
box-shadow: 0 24rpx 72rpx rgba(15, 23, 42, 0.22);
padding: 34rpx 28rpx 28rpx;
border: 1px solid rgba(255, 255, 255, 0.78);
}
.contract-sign-dialog__confirm-title {
font-size: 32rpx;
font-weight: 700;
color: #10233F;
text-align: center;
}
.contract-sign-dialog__confirm-desc {
margin-top: 18rpx;
font-size: 24rpx;
line-height: 1.7;
color: #6B7A90;
text-align: center;
}
.contract-sign-dialog__confirm-actions {
margin-top: 28rpx;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16rpx;
}
.contract-sign-dialog__confirm-btn {
height: 78rpx;
border-radius: 20rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 26rpx;
font-weight: 600;
}
.contract-sign-dialog__confirm-btn.is-cancel {
color: #516173;
background: linear-gradient(180deg, #F4F7FB 0%, #E9EEF5 100%);
}
.contract-sign-dialog__confirm-btn.is-confirm {
color: #FFFFFF;
background: linear-gradient(135deg, #2A5CAA 0%, #3C74CB 100%);
}
</style>