SignPad.vue 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642
  1. <script setup lang="ts">
  2. import { ref, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
  3. import { getFamilyMemberSign } from '@/api/elderly/common'
  4. import { ElMessage } from 'element-plus'
  5. interface StrokePoint {
  6. x: number
  7. y: number
  8. }
  9. interface Stroke {
  10. points: StrokePoint[]
  11. }
  12. interface SignPadProps {
  13. modelValue?: string
  14. title?: string
  15. width?: number
  16. height?: number
  17. elderId?: number | string
  18. }
  19. const props = withDefaults(defineProps<SignPadProps>(), {
  20. modelValue: '',
  21. title: '请签字',
  22. width: 600,
  23. height: 260,
  24. elderId: ''
  25. })
  26. const emit = defineEmits<{
  27. (e: 'update:modelValue', val: string): void
  28. (e: 'confirm', val: string): void
  29. (e: 'cancel'): void
  30. }>()
  31. const visible = ref(false)
  32. const canvasRef = ref<HTMLCanvasElement | null>(null)
  33. const previewRef = ref<HTMLCanvasElement | null>(null)
  34. const isDrawing = ref(false)
  35. const strokes = ref<Stroke[]>([])
  36. const currentStroke = ref<Stroke>({ points: [] })
  37. const hasContent = ref(false)
  38. const loadingApi = ref(false)
  39. const apiFamilyList = ref<any[]>([])
  40. const selectedFamilyId = ref<string>('')
  41. const activeTab = ref<'draw' | 'family'>('draw')
  42. // 选中家属签名时,可能是图片格式,直接保存原始值
  43. const selectedRawSign = ref<string>('')
  44. let ctx: CanvasRenderingContext2D | null = null
  45. // ============ 格式检测 ============
  46. const detectSignFormat = (data: string): 'vector' | 'image' | 'empty' => {
  47. if (!data) return 'empty'
  48. if (data.startsWith('data:image')) return 'image'
  49. try {
  50. const jsonStr = decodeURIComponent(escape(atob(data)))
  51. const parsed = JSON.parse(jsonStr)
  52. if (Array.isArray(parsed) && parsed.length > 0) return 'vector'
  53. } catch (e) {
  54. // fallback
  55. }
  56. if (data.length > 100) return 'image'
  57. return 'empty'
  58. }
  59. // ============ 矢量编解码 ============
  60. const vectorToBase64 = (strokeList: Stroke[]): string => {
  61. const simpleData = strokeList.map((s) => s.points.map((p) => [Math.round(p.x), Math.round(p.y)]))
  62. const jsonStr = JSON.stringify(simpleData)
  63. return btoa(unescape(encodeURIComponent(jsonStr)))
  64. }
  65. const base64ToVector = (b64: string): Stroke[] => {
  66. try {
  67. const jsonStr = decodeURIComponent(escape(atob(b64)))
  68. const parsed = JSON.parse(jsonStr)
  69. if (!Array.isArray(parsed)) return []
  70. return parsed.map((strokeArr: number[][]) => ({
  71. points: strokeArr.map(([x, y]) => ({ x, y }))
  72. }))
  73. } catch (e) {
  74. return []
  75. }
  76. }
  77. // ============ Canvas渲染 ============
  78. const renderVectorToCanvas = (canvas: HTMLCanvasElement, b64: string) => {
  79. const c = canvas.getContext('2d')
  80. if (!c) return
  81. c.clearRect(0, 0, canvas.width, canvas.height)
  82. c.fillStyle = '#ffffff'
  83. c.fillRect(0, 0, canvas.width, canvas.height)
  84. if (!b64) return
  85. const vStrokes = base64ToVector(b64)
  86. if (vStrokes.length === 0) return
  87. let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity
  88. vStrokes.forEach((s) => {
  89. s.points.forEach((p) => {
  90. if (p.x < minX) minX = p.x
  91. if (p.y < minY) minY = p.y
  92. if (p.x > maxX) maxX = p.x
  93. if (p.y > maxY) maxY = p.y
  94. })
  95. })
  96. const contentWidth = maxX - minX || 1
  97. const contentHeight = maxY - minY || 1
  98. const padding = 8
  99. const scaleX = (canvas.width - padding * 2) / contentWidth
  100. const scaleY = (canvas.height - padding * 2) / contentHeight
  101. const scale = Math.min(scaleX, scaleY, 2)
  102. const offsetX = padding + (canvas.width - padding * 2 - contentWidth * scale) / 2 - minX * scale
  103. const offsetY = padding + (canvas.height - padding * 2 - contentHeight * scale) / 2 - minY * scale
  104. c.strokeStyle = '#000000'
  105. c.lineWidth = 2
  106. c.lineCap = 'round'
  107. c.lineJoin = 'round'
  108. vStrokes.forEach((stroke) => {
  109. if (stroke.points.length < 2) return
  110. c.beginPath()
  111. c.moveTo(stroke.points[0].x * scale + offsetX, stroke.points[0].y * scale + offsetY)
  112. for (let i = 1; i < stroke.points.length; i++) {
  113. c.lineTo(stroke.points[i].x * scale + offsetX, stroke.points[i].y * scale + offsetY)
  114. }
  115. c.stroke()
  116. })
  117. }
  118. const renderImageToCanvas = (canvas: HTMLCanvasElement, src: string) => {
  119. const c = canvas.getContext('2d')
  120. if (!c) return
  121. const img = new Image()
  122. img.onload = () => {
  123. c.clearRect(0, 0, canvas.width, canvas.height)
  124. c.fillStyle = '#ffffff'
  125. c.fillRect(0, 0, canvas.width, canvas.height)
  126. const scale = Math.min(canvas.width / img.width, canvas.height / img.height)
  127. const w = img.width * scale
  128. const h = img.height * scale
  129. const x = (canvas.width - w) / 2
  130. const y = (canvas.height - h) / 2
  131. c.drawImage(img, x, y, w, h)
  132. }
  133. img.src = src.startsWith('data:') ? src : `data:image/png;base64,${src}`
  134. }
  135. // 渲染签名到canvas(自动判断格式)
  136. const renderSignToCanvas = (canvas: HTMLCanvasElement, signData: string) => {
  137. const fmt = detectSignFormat(signData)
  138. if (fmt === 'vector') {
  139. renderVectorToCanvas(canvas, signData)
  140. } else if (fmt === 'image') {
  141. renderImageToCanvas(canvas, signData)
  142. }
  143. }
  144. // 家属签名预览渲染
  145. const renderFamilyPreview = (canvas: HTMLCanvasElement, id: string | number) => {
  146. const item = apiFamilyList.value.find((i) => String(i.id) === String(id))
  147. if (!item || !item.sign) return
  148. renderSignToCanvas(canvas, item.sign)
  149. }
  150. const setFamilyPreviewRef = (el: any, id: string | number) => {
  151. if (el) {
  152. renderFamilyPreview(el, id)
  153. }
  154. }
  155. // ============ 手写画板 ============
  156. const redrawCanvas = () => {
  157. if (!ctx || !canvasRef.value) return
  158. const canvas = canvasRef.value
  159. ctx.clearRect(0, 0, canvas.width, canvas.height)
  160. ctx.fillStyle = '#ffffff'
  161. ctx.fillRect(0, 0, canvas.width, canvas.height)
  162. ctx.strokeStyle = '#000000'
  163. ctx.lineWidth = 2.2
  164. ctx.lineCap = 'round'
  165. ctx.lineJoin = 'round'
  166. const allStrokes = [...strokes.value, currentStroke.value]
  167. allStrokes.forEach((stroke) => {
  168. if (!stroke.points || stroke.points.length < 2) return
  169. ctx!.beginPath()
  170. ctx!.moveTo(stroke.points[0].x, stroke.points[0].y)
  171. for (let i = 1; i < stroke.points.length; i++) {
  172. ctx!.lineTo(stroke.points[i].x, stroke.points[i].y)
  173. }
  174. ctx!.stroke()
  175. })
  176. }
  177. const getCanvasPosition = (e: MouseEvent | TouchEvent): StrokePoint => {
  178. const canvas = canvasRef.value!
  179. const rect = canvas.getBoundingClientRect()
  180. const scaleX = canvas.width / rect.width
  181. const scaleY = canvas.height / rect.height
  182. let clientX = 0
  183. let clientY = 0
  184. if ('touches' in e) {
  185. clientX = e.touches[0].clientX
  186. clientY = e.touches[0].clientY
  187. } else {
  188. clientX = (e as MouseEvent).clientX
  189. clientY = (e as MouseEvent).clientY
  190. }
  191. return { x: (clientX - rect.left) * scaleX, y: (clientY - rect.top) * scaleY }
  192. }
  193. const handleMouseDown = (e: MouseEvent | TouchEvent) => {
  194. if (activeTab.value !== 'draw') return
  195. e.preventDefault()
  196. isDrawing.value = true
  197. currentStroke.value = { points: [getCanvasPosition(e)] }
  198. }
  199. const handleMouseMove = (e: MouseEvent | TouchEvent) => {
  200. if (!isDrawing.value) return
  201. e.preventDefault()
  202. currentStroke.value.points.push(getCanvasPosition(e))
  203. redrawCanvas()
  204. }
  205. const handleMouseUp = () => {
  206. if (!isDrawing.value) return
  207. isDrawing.value = false
  208. if (currentStroke.value.points.length >= 2) {
  209. strokes.value.push({ ...currentStroke.value, points: [...currentStroke.value.points] })
  210. hasContent.value = true
  211. }
  212. currentStroke.value = { points: [] }
  213. redrawCanvas()
  214. }
  215. const clearCanvas = () => {
  216. strokes.value = []
  217. currentStroke.value = { points: [] }
  218. hasContent.value = false
  219. selectedFamilyId.value = ''
  220. selectedRawSign.value = ''
  221. if (activeTab.value === 'draw') {
  222. redrawCanvas()
  223. }
  224. }
  225. // ============ 弹窗控制 ============
  226. const initCanvas = () => {
  227. if (!canvasRef.value) return
  228. ctx = canvasRef.value.getContext('2d')
  229. }
  230. const open = async (existingValue?: string) => {
  231. visible.value = true
  232. loadingApi.value = false
  233. apiFamilyList.value = []
  234. selectedFamilyId.value = ''
  235. selectedRawSign.value = ''
  236. activeTab.value = 'draw'
  237. await nextTick()
  238. initCanvas()
  239. const val = existingValue ?? props.modelValue
  240. if (val) {
  241. const fmt = detectSignFormat(val)
  242. if (fmt === 'vector') {
  243. strokes.value = base64ToVector(val)
  244. hasContent.value = strokes.value.length > 0
  245. } else {
  246. strokes.value = []
  247. hasContent.value = false
  248. }
  249. } else {
  250. strokes.value = []
  251. hasContent.value = false
  252. }
  253. redrawCanvas()
  254. }
  255. const confirmSign = () => {
  256. if (!hasContent.value) {
  257. ElMessage.warning('请先完成签字')
  258. return
  259. }
  260. let result = ''
  261. if (activeTab.value === 'family' && selectedRawSign.value) {
  262. // 家属签名:直接使用原始值(可能是图片base64或矢量base64)
  263. result = selectedRawSign.value
  264. } else if (strokes.value.length > 0) {
  265. // 手写签字:矢量编码
  266. result = vectorToBase64(strokes.value)
  267. }
  268. if (!result) {
  269. ElMessage.warning('请先完成签字')
  270. return
  271. }
  272. emit('update:modelValue', result)
  273. emit('confirm', result)
  274. visible.value = false
  275. }
  276. const cancelSign = () => {
  277. emit('cancel')
  278. visible.value = false
  279. }
  280. // ============ 家属签名 ============
  281. const loadFamilySignFromApi = async () => {
  282. if (!props.elderId) {
  283. ElMessage.warning('缺少长者信息,无法读取家属签名')
  284. return
  285. }
  286. loadingApi.value = true
  287. try {
  288. const res: any = await getFamilyMemberSign(props.elderId)
  289. if (res && res.data) {
  290. apiFamilyList.value = Array.isArray(res.data) ? res.data : []
  291. if (apiFamilyList.value.length === 0) {
  292. ElMessage.info('暂无家属签名数据')
  293. }
  294. }
  295. } catch (e) {
  296. console.error('读取家属签名失败', e)
  297. ElMessage.error('读取家属签名失败')
  298. } finally {
  299. loadingApi.value = false
  300. }
  301. }
  302. const selectFamilySign = (item: any) => {
  303. if (!item || !item.sign) return
  304. selectedFamilyId.value = String(item.id)
  305. selectedRawSign.value = item.sign
  306. hasContent.value = true
  307. // 如果是矢量格式,也同步到strokes(用于预览)
  308. const fmt = detectSignFormat(item.sign)
  309. if (fmt === 'vector') {
  310. strokes.value = base64ToVector(item.sign)
  311. } else {
  312. strokes.value = []
  313. }
  314. // 渲染预览
  315. nextTick(() => {
  316. if (previewRef.value) {
  317. renderSignToCanvas(previewRef.value, item.sign)
  318. }
  319. })
  320. }
  321. const handleTabChange = (tab: 'draw' | 'family') => {
  322. activeTab.value = tab
  323. // 切换tab时不清除已选内容,但需要重新初始化canvas
  324. nextTick(() => {
  325. if (tab === 'draw') {
  326. initCanvas()
  327. redrawCanvas()
  328. } else if (tab === 'family' && apiFamilyList.value.length === 0) {
  329. // 自动加载家属签名
  330. loadFamilySignFromApi()
  331. }
  332. })
  333. }
  334. watch(visible, (val) => {
  335. if (val) {
  336. document.body.style.overflow = 'hidden'
  337. } else {
  338. document.body.style.overflow = ''
  339. }
  340. })
  341. onMounted(() => {
  342. window.addEventListener('mouseup', handleMouseUp)
  343. window.addEventListener('touchend', handleMouseUp)
  344. })
  345. onBeforeUnmount(() => {
  346. window.removeEventListener('mouseup', handleMouseUp)
  347. window.removeEventListener('touchend', handleMouseUp)
  348. })
  349. defineExpose({ open })
  350. </script>
  351. <template>
  352. <Teleport to="body">
  353. <div v-if="visible" class="sign-pad-mask" @click.self="cancelSign">
  354. <div class="sign-pad-dialog">
  355. <div class="sign-pad-header">
  356. <span class="sign-pad-title">{{ title }}</span>
  357. <span class="sign-pad-close" @click="cancelSign">×</span>
  358. </div>
  359. <!-- Tab切换:始终显示 -->
  360. <div class="sign-pad-tabs">
  361. <div
  362. class="sign-pad-tab"
  363. :class="{ active: activeTab === 'draw' }"
  364. @click="handleTabChange('draw')"
  365. >手写签字</div>
  366. <div
  367. class="sign-pad-tab"
  368. :class="{ active: activeTab === 'family' }"
  369. @click="handleTabChange('family')"
  370. >读取签名</div>
  371. </div>
  372. <div class="sign-pad-body">
  373. <!-- 手写签字 -->
  374. <template v-if="activeTab === 'draw'">
  375. <div class="sign-pad-canvas-wrap">
  376. <canvas
  377. ref="canvasRef"
  378. :width="width"
  379. :height="height"
  380. class="sign-pad-canvas"
  381. @mousedown="handleMouseDown"
  382. @mousemove="handleMouseMove"
  383. @touchstart="handleMouseDown"
  384. @touchmove="handleMouseMove"
  385. ></canvas>
  386. </div>
  387. </template>
  388. <!-- 读取家属签名 -->
  389. <div v-if="activeTab === 'family'" class="sign-pad-family">
  390. <div v-if="apiFamilyList.length === 0" class="sign-pad-empty">
  391. <el-button type="primary" :loading="loadingApi" @click="loadFamilySignFromApi">
  392. {{ loadingApi ? '加载中...' : '点击加载家属签名' }}
  393. </el-button>
  394. </div>
  395. <div v-else class="sign-pad-family-list">
  396. <div
  397. class="sign-pad-family-item"
  398. v-for="item in apiFamilyList"
  399. :key="item.id"
  400. :class="{ selected: selectedFamilyId === String(item.id) }"
  401. @click="selectFamilySign(item)"
  402. >
  403. <div class="sign-pad-family-name">{{ item.name || '家属' }}</div>
  404. <div class="sign-pad-family-sign">
  405. <canvas
  406. :ref="(el: any) => setFamilyPreviewRef(el, item.id)"
  407. :width="200"
  408. :height="80"
  409. class="sign-pad-family-preview"
  410. ></canvas>
  411. </div>
  412. </div>
  413. </div>
  414. <!-- 已选签名预览 -->
  415. <div v-if="hasContent && selectedFamilyId" class="sign-pad-family-preview-label">已选签名预览</div>
  416. <div class="sign-pad-canvas-wrap" v-if="hasContent && selectedFamilyId">
  417. <canvas
  418. ref="previewRef"
  419. :width="width"
  420. :height="height"
  421. class="sign-pad-canvas"
  422. style="pointer-events: none; background: #fff;"
  423. ></canvas>
  424. </div>
  425. </div>
  426. </div>
  427. <div class="sign-pad-footer">
  428. <el-button @click="clearCanvas">清空</el-button>
  429. <el-button type="primary" :disabled="!hasContent" @click="confirmSign">确认签字</el-button>
  430. <el-button @click="cancelSign">取消</el-button>
  431. </div>
  432. </div>
  433. </div>
  434. </Teleport>
  435. </template>
  436. <style scoped lang="scss">
  437. .sign-pad-mask {
  438. position: fixed;
  439. inset: 0;
  440. background: rgba(0, 0, 0, 0.45);
  441. z-index: 3000;
  442. display: flex;
  443. align-items: center;
  444. justify-content: center;
  445. }
  446. .sign-pad-dialog {
  447. background: #fff;
  448. border-radius: 8px;
  449. box-shadow: 0 8px 30px rgba(0, 0, 0, 0.15);
  450. width: 640px;
  451. max-width: 95vw;
  452. display: flex;
  453. flex-direction: column;
  454. overflow: hidden;
  455. }
  456. .sign-pad-header {
  457. padding: 14px 20px;
  458. border-bottom: 1px solid #eee;
  459. display: flex;
  460. justify-content: space-between;
  461. align-items: center;
  462. .sign-pad-title {
  463. font-size: 16px;
  464. font-weight: 600;
  465. color: #303133;
  466. }
  467. .sign-pad-close {
  468. font-size: 24px;
  469. cursor: pointer;
  470. color: #909399;
  471. line-height: 1;
  472. user-select: none;
  473. &:hover {
  474. color: #303133;
  475. }
  476. }
  477. }
  478. .sign-pad-tabs {
  479. display: flex;
  480. border-bottom: 1px solid #e4e7ed;
  481. background: #fafafa;
  482. .sign-pad-tab {
  483. flex: 1;
  484. padding: 12px 20px;
  485. text-align: center;
  486. cursor: pointer;
  487. font-size: 14px;
  488. color: #606266;
  489. transition: all 0.2s;
  490. &.active {
  491. color: #409eff;
  492. font-weight: 500;
  493. border-bottom: 2px solid #409eff;
  494. background: #fff;
  495. }
  496. &:hover:not(.active) {
  497. color: #409eff;
  498. background: #f2f6fc;
  499. }
  500. }
  501. }
  502. .sign-pad-body {
  503. padding: 20px;
  504. }
  505. .sign-pad-canvas-wrap {
  506. border: 2px dashed #dcdfe6;
  507. border-radius: 6px;
  508. overflow: hidden;
  509. background: #fff;
  510. &:hover {
  511. border-color: #c0c4cc;
  512. }
  513. }
  514. .sign-pad-canvas {
  515. display: block;
  516. width: 100%;
  517. height: auto;
  518. cursor: crosshair;
  519. touch-action: none;
  520. }
  521. .sign-pad-family {
  522. height: 260px;
  523. .sign-pad-empty {
  524. text-align: center;
  525. padding: 40px 0;
  526. }
  527. .sign-pad-family-list {
  528. display: flex;
  529. flex-wrap: wrap;
  530. gap: 14px;
  531. }
  532. .sign-pad-family-item {
  533. border: 1px solid #dcdfe6;
  534. border-radius: 6px;
  535. padding: 12px;
  536. cursor: pointer;
  537. min-width: 200px;
  538. background: #fafafa;
  539. &:hover {
  540. border-color: #409eff;
  541. background: #f2f6fc;
  542. }
  543. &.selected {
  544. border-color: #409eff;
  545. background: #ecf5ff;
  546. }
  547. }
  548. .sign-pad-family-name {
  549. font-size: 14px;
  550. color: #303133;
  551. margin-bottom: 8px;
  552. }
  553. .sign-pad-family-preview {
  554. display: block;
  555. background: #fff;
  556. }
  557. .sign-pad-family-preview-label {
  558. text-align: center;
  559. margin: 16px 0 8px;
  560. font-size: 13px;
  561. color: #606266;
  562. }
  563. }
  564. .sign-pad-footer {
  565. padding: 14px 20px;
  566. border-top: 1px solid #eee;
  567. display: flex;
  568. justify-content: center;
  569. gap: 12px;
  570. }
  571. </style>