You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

304 lines
7.0 KiB

  1. <template>
  2. <view v-if="show" class="sign-page" @touchmove.stop.prevent>
  3. <view class="canvas-wrap">
  4. <view class="hint-text">
  5. <text>请在此处手写签名</text>
  6. <text class="hint-sub">请横屏书写</text>
  7. </view>
  8. <view id="signContainer" class="sign-container" />
  9. </view>
  10. <view class="bottom-btns">
  11. <view class="btn-close" @click="handleClose">
  12. <text>取消</text>
  13. </view>
  14. <view class="btn-clear" @click="handleClear">
  15. <text>清除</text>
  16. </view>
  17. <view class="btn-save" @click="handleSave">
  18. <text>{{ saving ? '上传中' : '保存' }}</text>
  19. </view>
  20. </view>
  21. </view>
  22. </template>
  23. <script setup>
  24. import { ref, watch, nextTick } from 'vue'
  25. import { upload } from '@/utils/request.js'
  26. const props = defineProps({
  27. show: { type: Boolean, default: false }
  28. })
  29. const emit = defineEmits(['close', 'confirm'])
  30. let signature = null
  31. const hasDrawn = ref(false)
  32. const saving = ref(false)
  33. let scriptLoaded = false
  34. const loadScript = () => {
  35. return new Promise((resolve, reject) => {
  36. if (window.SmoothSignature) { resolve(); return }
  37. if (scriptLoaded) {
  38. const check = setInterval(() => {
  39. if (window.SmoothSignature) { clearInterval(check); resolve() }
  40. }, 50)
  41. return
  42. }
  43. scriptLoaded = true
  44. const s = document.createElement('script')
  45. s.src = 'https://cdn.csybhelp.com/lib/smooth-signature.umd.min.js'
  46. s.onload = resolve
  47. s.onerror = reject
  48. document.head.appendChild(s)
  49. })
  50. }
  51. const initSignature = async () => {
  52. await loadScript()
  53. await nextTick()
  54. setTimeout(() => {
  55. const container = document.getElementById('signContainer')
  56. if (!container) return
  57. container.innerHTML = ''
  58. const canvas = document.createElement('canvas')
  59. canvas.style.width = '100%'
  60. canvas.style.height = '100%'
  61. container.appendChild(canvas)
  62. signature = new window.SmoothSignature(canvas, {
  63. width: container.offsetWidth,
  64. height: container.offsetHeight,
  65. minWidth: 2,
  66. maxWidth: 6,
  67. color: '#333',
  68. bgColor: 'transparent'
  69. })
  70. canvas.addEventListener('touchstart', () => { hasDrawn.value = true })
  71. canvas.addEventListener('mousedown', () => { hasDrawn.value = true })
  72. }, 200)
  73. }
  74. watch(() => props.show, async (val) => {
  75. if (val) {
  76. hasDrawn.value = false
  77. saving.value = false
  78. signature = null
  79. await initSignature()
  80. }
  81. })
  82. const handleClear = () => {
  83. if (signature) signature.clear()
  84. hasDrawn.value = false
  85. }
  86. const handleClose = () => { emit('close') }
  87. const handleSave = async () => {
  88. if (!hasDrawn.value || !signature) {
  89. return uni.showToast({ title: '请先签名', icon: 'none' })
  90. }
  91. if (saving.value) return
  92. saving.value = true
  93. uni.showLoading({ title: '保存中...' })
  94. try {
  95. // 从 smooth-signature 获取 PNG DataURL
  96. const dataUrl = signature.getPNG()
  97. if (!dataUrl) throw { msg: '获取签名失败' }
  98. // 旋转签名(逆时针90度,横屏→竖屏,与小程序端保持一致)
  99. const rotatedDataUrl = await rotateImage(dataUrl, -90)
  100. const blob = dataURLtoBlob(rotatedDataUrl)
  101. const formData = new FormData()
  102. formData.append('file', blob, 'signature.png')
  103. // H5 下直接用 XMLHttpRequest 上传(uni.uploadFile 需要 filePath)
  104. const token = uni.getStorageSync('pap-cytx-token') || ''
  105. const baseUrl = uni.$u && uni.$u.http && uni.$u.http.config
  106. ? uni.$u.http.config.baseURL
  107. : '/pro-api'
  108. const url = baseUrl + '/api/mp/upload'
  109. const result = await new Promise((resolve, reject) => {
  110. const xhr = new XMLHttpRequest()
  111. xhr.open('POST', url)
  112. if (token) xhr.setRequestHeader('Authorization', 'Bearer ' + token)
  113. xhr.onload = () => {
  114. try {
  115. const res = JSON.parse(xhr.responseText)
  116. if (res.code === 0 && res.data && res.data.url) resolve(res.data.url)
  117. else reject({ msg: res.msg || '上传失败' })
  118. } catch (e) { reject({ msg: '上传失败' }) }
  119. }
  120. xhr.onerror = () => reject({ msg: '网络错误' })
  121. xhr.send(formData)
  122. })
  123. uni.hideLoading()
  124. emit('confirm', { url: result })
  125. } catch (err) {
  126. uni.hideLoading()
  127. uni.showToast({ title: err.msg || '上传失败', icon: 'none' })
  128. } finally {
  129. saving.value = false
  130. }
  131. }
  132. const dataURLtoBlob = (dataUrl) => {
  133. const arr = dataUrl.split(',')
  134. const mime = arr[0].match(/:(.*?);/)[1]
  135. const bstr = atob(arr[1])
  136. let n = bstr.length
  137. const u8arr = new Uint8Array(n)
  138. while (n--) u8arr[n] = bstr.charCodeAt(n)
  139. return new Blob([u8arr], { type: mime })
  140. }
  141. const rotateImage = (dataUrl, degrees) => {
  142. return new Promise((resolve) => {
  143. const img = new Image()
  144. img.onload = () => {
  145. const canvas = document.createElement('canvas')
  146. const rad = (degrees * Math.PI) / 180
  147. // 旋转 -90 度:宽高互换
  148. if (Math.abs(degrees) === 90) {
  149. canvas.width = img.height
  150. canvas.height = img.width
  151. } else {
  152. canvas.width = img.width
  153. canvas.height = img.height
  154. }
  155. const ctx = canvas.getContext('2d')
  156. ctx.translate(canvas.width / 2, canvas.height / 2)
  157. ctx.rotate(rad)
  158. ctx.drawImage(img, -img.width / 2, -img.height / 2)
  159. resolve(canvas.toDataURL('image/png'))
  160. }
  161. img.src = dataUrl
  162. })
  163. }
  164. </script>
  165. <style lang="scss" scoped>
  166. .sign-page {
  167. position: fixed;
  168. top: 30rpx;
  169. left: 30rpx;
  170. right: 30rpx;
  171. bottom: 30rpx;
  172. z-index: 9999;
  173. display: flex;
  174. flex-direction: column;
  175. background: #edf2fc;
  176. border-radius: 16rpx;
  177. overflow: hidden;
  178. box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.5);
  179. }
  180. .canvas-wrap {
  181. flex: 1;
  182. position: relative;
  183. overflow: hidden;
  184. .hint-text {
  185. position: absolute;
  186. top: 50%;
  187. left: 50%;
  188. transform: translate(-50%, -50%) rotate(90deg);
  189. pointer-events: none;
  190. z-index: 0;
  191. display: flex;
  192. flex-direction: column;
  193. align-items: center;
  194. gap: 16rpx;
  195. width: 80vh;
  196. text {
  197. font-size: 48rpx;
  198. color: #c0c8d8;
  199. font-weight: 300;
  200. letter-spacing: 16rpx;
  201. white-space: nowrap;
  202. }
  203. .hint-sub {
  204. font-size: 28rpx;
  205. letter-spacing: 8rpx;
  206. }
  207. }
  208. .sign-container {
  209. width: 100%;
  210. height: 100%;
  211. position: relative;
  212. z-index: 1;
  213. }
  214. }
  215. .bottom-btns {
  216. display: flex;
  217. height: calc(100rpx + env(safe-area-inset-bottom));
  218. flex-shrink: 0;
  219. gap: 12rpx;
  220. padding: 12rpx;
  221. padding-bottom: calc(12rpx + env(safe-area-inset-bottom));
  222. background: #edf2fc;
  223. .btn-close {
  224. flex: 1;
  225. display: flex;
  226. align-items: center;
  227. justify-content: center;
  228. background: #8c939d;
  229. border-radius: 12rpx;
  230. text {
  231. writing-mode: vertical-rl;
  232. transform: rotate(90deg);
  233. font-size: 32rpx;
  234. color: #fff;
  235. letter-spacing: 4rpx;
  236. height: 120rpx;
  237. text-align: center;
  238. }
  239. }
  240. .btn-clear {
  241. flex: 1;
  242. display: flex;
  243. align-items: center;
  244. justify-content: center;
  245. background: #ff4040;
  246. border-radius: 12rpx;
  247. text {
  248. writing-mode: vertical-rl;
  249. transform: rotate(90deg);
  250. font-size: 32rpx;
  251. color: #fff;
  252. letter-spacing: 4rpx;
  253. height: 120rpx;
  254. text-align: center;
  255. }
  256. }
  257. .btn-save {
  258. flex: 2;
  259. display: flex;
  260. align-items: center;
  261. justify-content: center;
  262. background: #0e63e3;
  263. border-radius: 12rpx;
  264. text {
  265. writing-mode: vertical-rl;
  266. transform: rotate(90deg);
  267. font-size: 32rpx;
  268. color: #fff;
  269. letter-spacing: 4rpx;
  270. height: 120rpx;
  271. text-align: center;
  272. }
  273. }
  274. }
  275. </style>