| @@ -55,6 +55,13 @@ | |||||
| "navigationBarTitleText": "授权签名" | "navigationBarTitleText": "授权签名" | ||||
| } | } | ||||
| }, | }, | ||||
| { | |||||
| "path": "pages/sign/signature", | |||||
| "style": { | |||||
| "navigationStyle": "default", | |||||
| "navigationBarTitleText": "手写签名" | |||||
| } | |||||
| }, | |||||
| { | { | ||||
| "path": "pages/message/message", | "path": "pages/message/message", | ||||
| "style": { | "style": { | ||||
| @@ -38,7 +38,7 @@ | |||||
| <text v-else-if="patientStatus === 2" class="extra rejected">已驳回</text> | <text v-else-if="patientStatus === 2" class="extra rejected">已驳回</text> | ||||
| <u-icon name="arrow-right" size="16" color="#c0c4cc" /> | <u-icon name="arrow-right" size="16" color="#c0c4cc" /> | ||||
| </view> | </view> | ||||
| <view class="menu-item" @tap="goTo('/pages/message/message')"> | |||||
| <view class="menu-item" @tap="goMessage"> | |||||
| <view class="menu-icon"> | <view class="menu-icon"> | ||||
| <u-icon name="chat-fill" size="20" color="#fa8c16" /> | <u-icon name="chat-fill" size="20" color="#fa8c16" /> | ||||
| </view> | </view> | ||||
| @@ -46,7 +46,7 @@ | |||||
| <u-badge v-if="unreadCount > 0" :value="unreadCount" :max="99" type="error" /> | <u-badge v-if="unreadCount > 0" :value="unreadCount" :max="99" type="error" /> | ||||
| <u-icon name="arrow-right" size="16" color="#c0c4cc" /> | <u-icon name="arrow-right" size="16" color="#c0c4cc" /> | ||||
| </view> | </view> | ||||
| <view class="menu-item" @tap="goTo('/pages/verify/verify')"> | |||||
| <view class="menu-item" @tap="goVerify"> | |||||
| <view class="menu-icon"> | <view class="menu-icon"> | ||||
| <u-icon name="account-fill" size="20" color="#52c41a" /> | <u-icon name="account-fill" size="20" color="#52c41a" /> | ||||
| </view> | </view> | ||||
| @@ -141,26 +141,48 @@ const fetchUnreadCount = async () => { | |||||
| } catch (e) {} | } catch (e) {} | ||||
| } | } | ||||
| const goTo = (url) => { | |||||
| uni.navigateTo({ url }) | |||||
| const checkLogin = () => { | |||||
| if (!isLoggedIn.value) { | |||||
| uni.showToast({ title: '请先登录', icon: 'none' }) | |||||
| setTimeout(() => goLogin(), 1500) | |||||
| return false | |||||
| } | |||||
| return true | |||||
| } | } | ||||
| const goMyInfo = () => { | |||||
| const checkAuth = () => { | |||||
| if (!checkLogin()) return false | |||||
| if (!isAuthed.value) { | if (!isAuthed.value) { | ||||
| uni.showToast({ title: '请先完成实名认证', icon: 'none' }) | uni.showToast({ title: '请先完成实名认证', icon: 'none' }) | ||||
| return | |||||
| return false | |||||
| } | } | ||||
| return true | |||||
| } | |||||
| const goTo = (url) => { | |||||
| uni.navigateTo({ url }) | |||||
| } | |||||
| const goMyInfo = () => { | |||||
| if (!checkAuth()) return | |||||
| uni.navigateTo({ url: '/pages/myinfo/myinfo' }) | uni.navigateTo({ url: '/pages/myinfo/myinfo' }) | ||||
| } | } | ||||
| const goChangePhone = () => { | const goChangePhone = () => { | ||||
| if (!isAuthed.value) { | |||||
| uni.showToast({ title: '请先完成实名认证', icon: 'none' }) | |||||
| return | |||||
| } | |||||
| if (!checkAuth()) return | |||||
| uni.navigateTo({ url: '/pages/change-phone/change-phone' }) | uni.navigateTo({ url: '/pages/change-phone/change-phone' }) | ||||
| } | } | ||||
| const goMessage = () => { | |||||
| if (!checkLogin()) return | |||||
| uni.navigateTo({ url: '/pages/message/message' }) | |||||
| } | |||||
| const goVerify = () => { | |||||
| if (!checkLogin()) return | |||||
| uni.navigateTo({ url: '/pages/verify/verify' }) | |||||
| } | |||||
| const goLogin = () => { | const goLogin = () => { | ||||
| uni.navigateTo({ url: '/pages/login/index' }) | uni.navigateTo({ url: '/pages/login/index' }) | ||||
| } | } | ||||
| @@ -1,6 +1,5 @@ | |||||
| <template> | <template> | ||||
| <scroll-view class="page" scroll-y :scroll-with-animation="false" :enable-flex="true" | |||||
| :scroll-enabled="!isSigning"> | |||||
| <view class="page"> | |||||
| <!-- 协议内容 --> | <!-- 协议内容 --> | ||||
| <view class="section"> | <view class="section"> | ||||
| <view class="doc-title">{{ docTitle }}</view> | <view class="doc-title">{{ docTitle }}</view> | ||||
| @@ -9,7 +8,7 @@ | |||||
| <!-- 收入金额(仅income类型) --> | <!-- 收入金额(仅income类型) --> | ||||
| <view class="section" v-if="signType === 'income'"> | <view class="section" v-if="signType === 'income'"> | ||||
| <view class="form-label">请填写您的个人月可支配收入(元)</view> | |||||
| <view class="form-label">请填写您的个人年可支配收入(元)</view> | |||||
| <view class="amount-wrap"> | <view class="amount-wrap"> | ||||
| <text class="amount-prefix">¥</text> | <text class="amount-prefix">¥</text> | ||||
| <u-input v-model="incomeAmount" type="number" placeholder="请输入金额" border="none" /> | <u-input v-model="incomeAmount" type="number" placeholder="请输入金额" border="none" /> | ||||
| @@ -19,13 +18,15 @@ | |||||
| <!-- 签名区域 --> | <!-- 签名区域 --> | ||||
| <view class="section"> | <view class="section"> | ||||
| <view class="sign-label">请在下方签名确认</view> | <view class="sign-label">请在下方签名确认</view> | ||||
| <view class="canvas-wrap"> | |||||
| <canvas canvas-id="signCanvas" class="sign-canvas" | |||||
| @touchstart="onTouchStart" @touchmove.stop.prevent="onTouchMove" @touchend="onTouchEnd" /> | |||||
| <text v-if="!hasSigned" class="canvas-placeholder">请在此处签名</text> | |||||
| <view class="canvas-wrap" @tap="goSignature"> | |||||
| <image v-if="signImageUrl" class="sign-preview" :src="signImageUrl" mode="aspectFit" /> | |||||
| <view v-else class="canvas-placeholder"> | |||||
| <u-icon name="edit-pen" size="28" color="#ccc" /> | |||||
| <text>点击此处去签名</text> | |||||
| </view> | |||||
| </view> | </view> | ||||
| <view class="sign-actions"> | |||||
| <view class="clear-btn" @tap="clearSign">清除重签</view> | |||||
| <view v-if="signImageUrl" class="sign-actions"> | |||||
| <view class="clear-btn" @tap="goSignature">重新签名</view> | |||||
| </view> | </view> | ||||
| </view> | </view> | ||||
| @@ -33,43 +34,33 @@ | |||||
| <view class="btn-wrap"> | <view class="btn-wrap"> | ||||
| <u-button text="确认签署" :loading="submitting" @click="confirmSign" color="#0E63E3" size="large" /> | <u-button text="确认签署" :loading="submitting" @click="confirmSign" color="#0E63E3" size="large" /> | ||||
| </view> | </view> | ||||
| </scroll-view> | |||||
| </view> | |||||
| </template> | </template> | ||||
| <script setup> | <script setup> | ||||
| import { ref } from 'vue' | |||||
| import { onLoad, onReady } from '@dcloudio/uni-app' | |||||
| import { get, post, upload } from '@/utils/request.js' | |||||
| import { ref, onBeforeUnmount } from 'vue' | |||||
| import { onLoad } from '@dcloudio/uni-app' | |||||
| import { get, post } from '@/utils/request.js' | |||||
| const signType = ref('') | const signType = ref('') | ||||
| const docTitle = ref('') | const docTitle = ref('') | ||||
| const docContent = ref('') | const docContent = ref('') | ||||
| const incomeAmount = ref('') | const incomeAmount = ref('') | ||||
| const hasSigned = ref(false) | |||||
| const signImageUrl = ref('') | |||||
| const submitting = ref(false) | const submitting = ref(false) | ||||
| const isSigning = ref(false) | |||||
| let ctx = null | |||||
| let points = [] | |||||
| const titleMap = { | |||||
| income: '个人可支配收入声明', | |||||
| privacy: '个人信息处理同意书', | |||||
| promise: '声明与承诺' | |||||
| const onSignatureResult = (data) => { | |||||
| if (data.url) signImageUrl.value = data.url | |||||
| } | } | ||||
| onLoad((options) => { | onLoad((options) => { | ||||
| signType.value = options.type || 'privacy' | signType.value = options.type || 'privacy' | ||||
| // uni.setNavigationBarTitle({ title: titleMap[signType.value] || '签署协议' }) | |||||
| uni.$on('signatureResult', onSignatureResult) | |||||
| loadContent() | loadContent() | ||||
| }) | }) | ||||
| onReady(() => { | |||||
| ctx = uni.createCanvasContext('signCanvas') | |||||
| ctx.setStrokeStyle('#333') | |||||
| ctx.setLineWidth(3) | |||||
| ctx.setLineCap('round') | |||||
| ctx.setLineJoin('round') | |||||
| onBeforeUnmount(() => { | |||||
| uni.$off('signatureResult', onSignatureResult) | |||||
| }) | }) | ||||
| const loadContent = async () => { | const loadContent = async () => { | ||||
| @@ -81,38 +72,12 @@ const loadContent = async () => { | |||||
| } catch (e) {} | } catch (e) {} | ||||
| } | } | ||||
| const onTouchStart = (e) => { | |||||
| isSigning.value = true | |||||
| const touch = e.touches[0] | |||||
| points = [{ x: touch.x, y: touch.y }] | |||||
| ctx.beginPath() | |||||
| ctx.moveTo(touch.x, touch.y) | |||||
| } | |||||
| const onTouchMove = (e) => { | |||||
| const touch = e.touches[0] | |||||
| points.push({ x: touch.x, y: touch.y }) | |||||
| ctx.lineTo(touch.x, touch.y) | |||||
| ctx.stroke() | |||||
| ctx.draw(true) | |||||
| ctx.beginPath() | |||||
| ctx.moveTo(touch.x, touch.y) | |||||
| if (!hasSigned.value) hasSigned.value = true | |||||
| } | |||||
| const onTouchEnd = () => { | |||||
| points = [] | |||||
| isSigning.value = false | |||||
| } | |||||
| const clearSign = () => { | |||||
| ctx.clearRect(0, 0, 9999, 9999) | |||||
| ctx.draw() | |||||
| hasSigned.value = false | |||||
| const goSignature = () => { | |||||
| uni.navigateTo({ url: '/pages/sign/signature' }) | |||||
| } | } | ||||
| const confirmSign = async () => { | const confirmSign = async () => { | ||||
| if (!hasSigned.value) { | |||||
| if (!signImageUrl.value) { | |||||
| return uni.showToast({ title: '请先签名', icon: 'none' }) | return uni.showToast({ title: '请先签名', icon: 'none' }) | ||||
| } | } | ||||
| if (signType.value === 'income' && (!incomeAmount.value || Number(incomeAmount.value) <= 0)) { | if (signType.value === 'income' && (!incomeAmount.value || Number(incomeAmount.value) <= 0)) { | ||||
| @@ -121,32 +86,13 @@ const confirmSign = async () => { | |||||
| submitting.value = true | submitting.value = true | ||||
| try { | try { | ||||
| // 1. 导出 canvas 为图片 | |||||
| const tempPath = await new Promise((resolve, reject) => { | |||||
| uni.canvasToTempFilePath({ | |||||
| canvasId: 'signCanvas', | |||||
| fileType: 'png', | |||||
| success: (res) => resolve(res.tempFilePath), | |||||
| fail: reject | |||||
| }) | |||||
| }) | |||||
| // 2. 上传签名图到 COS | |||||
| const uploadRes = await upload('/api/mp/upload', { filePath: tempPath, name: 'file' }) | |||||
| if (!uploadRes.data || !uploadRes.data.url) { | |||||
| throw { msg: '签名图上传失败' } | |||||
| } | |||||
| const signImage = uploadRes.data.url | |||||
| // 3. 调后端合成接口 | |||||
| const params = { | const params = { | ||||
| type: signType.value, | type: signType.value, | ||||
| signImage, | |||||
| signImage: signImageUrl.value, | |||||
| amount: signType.value === 'income' ? incomeAmount.value : undefined | amount: signType.value === 'income' ? incomeAmount.value : undefined | ||||
| } | } | ||||
| const res = await post('/api/mp/sign', params) | const res = await post('/api/mp/sign', params) | ||||
| // 4. 通过事件把结果传回 myinfo 页面 | |||||
| uni.$emit('signResult', { | uni.$emit('signResult', { | ||||
| type: signType.value, | type: signType.value, | ||||
| url: res.data.url, | url: res.data.url, | ||||
| @@ -165,10 +111,10 @@ const confirmSign = async () => { | |||||
| <style lang="scss" scoped> | <style lang="scss" scoped> | ||||
| .page { | .page { | ||||
| height: 100vh; | |||||
| min-height: 100vh; | |||||
| background: #f4f4f5; | background: #f4f4f5; | ||||
| padding: 24rpx; | padding: 24rpx; | ||||
| padding-bottom: 140rpx; | |||||
| padding-bottom: calc(140rpx + env(safe-area-inset-bottom)); | |||||
| } | } | ||||
| .section { | .section { | ||||
| @@ -225,31 +171,38 @@ const confirmSign = async () => { | |||||
| .canvas-wrap { | .canvas-wrap { | ||||
| position: relative; | position: relative; | ||||
| border: 1rpx solid #ddd; | |||||
| border: 2rpx dashed #ddd; | |||||
| border-radius: 12rpx; | border-radius: 12rpx; | ||||
| overflow: hidden; | overflow: hidden; | ||||
| margin-bottom: 16rpx; | |||||
| min-height: 300rpx; | |||||
| display: flex; | |||||
| align-items: center; | |||||
| justify-content: center; | |||||
| background: #fafafa; | |||||
| } | } | ||||
| .sign-canvas { | |||||
| .sign-preview { | |||||
| width: 100%; | width: 100%; | ||||
| height: 300rpx; | height: 300rpx; | ||||
| background: #fff; | |||||
| } | } | ||||
| .canvas-placeholder { | .canvas-placeholder { | ||||
| position: absolute; | |||||
| top: 50%; | |||||
| left: 50%; | |||||
| transform: translate(-50%, -50%); | |||||
| color: #ccc; | |||||
| font-size: 32rpx; | |||||
| pointer-events: none; | |||||
| display: flex; | |||||
| flex-direction: column; | |||||
| align-items: center; | |||||
| gap: 12rpx; | |||||
| padding: 40rpx 0; | |||||
| text { | |||||
| color: #ccc; | |||||
| font-size: 28rpx; | |||||
| } | |||||
| } | } | ||||
| .sign-actions { | .sign-actions { | ||||
| display: flex; | display: flex; | ||||
| justify-content: flex-end; | justify-content: flex-end; | ||||
| margin-top: 16rpx; | |||||
| } | } | ||||
| .clear-btn { | .clear-btn { | ||||
| @@ -0,0 +1,239 @@ | |||||
| <template> | |||||
| <view class="sign-page"> | |||||
| <view class="canvas-wrap"> | |||||
| <view class="hint-text"> | |||||
| <text>请在此处手写签名</text> | |||||
| </view> | |||||
| <canvas id="signCanvas" canvas-id="signCanvas" type="2d" class="sign-canvas" disable-scroll | |||||
| @touchstart="onTouchStart" @touchmove="onTouchMove" @touchend="onTouchEnd" /> | |||||
| </view> | |||||
| <view class="bottom-btns"> | |||||
| <view class="btn-clear" @click="handleClear"> | |||||
| <text>清除</text> | |||||
| </view> | |||||
| <view class="btn-save" @click="handleSave"> | |||||
| <text>保存</text> | |||||
| </view> | |||||
| </view> | |||||
| </view> | |||||
| </template> | |||||
| <script setup> | |||||
| import { ref, nextTick } from 'vue' | |||||
| import { onLoad } from '@dcloudio/uni-app' | |||||
| import { upload } from '@/utils/request.js' | |||||
| let ctx = null | |||||
| let canvasEl = null | |||||
| let canvasWidth = 0 | |||||
| let canvasHeight = 0 | |||||
| const hasDrawn = ref(false) | |||||
| const saving = ref(false) | |||||
| let lastX = 0 | |||||
| let lastY = 0 | |||||
| onLoad(async () => { | |||||
| await nextTick() | |||||
| setTimeout(() => { | |||||
| const query = uni.createSelectorQuery() | |||||
| query.select('#signCanvas').fields({ node: true, size: true }).exec((res) => { | |||||
| if (!res[0]) return | |||||
| canvasEl = res[0].node | |||||
| const dpr = uni.getSystemInfoSync().pixelRatio | |||||
| canvasWidth = res[0].width | |||||
| canvasHeight = res[0].height | |||||
| canvasEl.width = canvasWidth * dpr | |||||
| canvasEl.height = canvasHeight * dpr | |||||
| ctx = canvasEl.getContext('2d') | |||||
| ctx.scale(dpr, dpr) | |||||
| ctx.lineCap = 'round' | |||||
| ctx.lineJoin = 'round' | |||||
| ctx.lineWidth = 4 | |||||
| ctx.strokeStyle = '#333' | |||||
| }) | |||||
| }, 300) | |||||
| }) | |||||
| const getPos = (e) => { | |||||
| const touch = e.touches[0] | |||||
| return { x: touch.x, y: touch.y } | |||||
| } | |||||
| const onTouchStart = (e) => { | |||||
| if (!ctx) return | |||||
| hasDrawn.value = true | |||||
| const { x, y } = getPos(e) | |||||
| lastX = x | |||||
| lastY = y | |||||
| ctx.beginPath() | |||||
| ctx.moveTo(x, y) | |||||
| } | |||||
| const onTouchMove = (e) => { | |||||
| if (!ctx) return | |||||
| const { x, y } = getPos(e) | |||||
| ctx.beginPath() | |||||
| ctx.moveTo(lastX, lastY) | |||||
| ctx.lineTo(x, y) | |||||
| ctx.stroke() | |||||
| lastX = x | |||||
| lastY = y | |||||
| } | |||||
| const onTouchEnd = () => {} | |||||
| const handleClear = () => { | |||||
| if (!ctx || !canvasEl) return | |||||
| ctx.clearRect(0, 0, canvasWidth, canvasHeight) | |||||
| hasDrawn.value = false | |||||
| } | |||||
| const handleSave = () => { | |||||
| if (!hasDrawn.value) { | |||||
| uni.showToast({ title: '请先签名', icon: 'none' }) | |||||
| return | |||||
| } | |||||
| if (saving.value) return | |||||
| saving.value = true | |||||
| uni.showLoading({ title: '保存中...' }) | |||||
| uni.canvasToTempFilePath({ | |||||
| canvas: canvasEl, | |||||
| fileType: 'png', | |||||
| quality: 1, | |||||
| success: async (res) => { | |||||
| try { | |||||
| const dpr = uni.getSystemInfoSync().pixelRatio | |||||
| // 创建离屏 canvas 旋转签名(横屏→竖屏,逆时针90度) | |||||
| const offscreen = uni.createOffscreenCanvas({ | |||||
| type: '2d', | |||||
| width: canvasHeight * dpr, | |||||
| height: canvasWidth * dpr | |||||
| }) | |||||
| const offCtx = offscreen.getContext('2d') | |||||
| const img = offscreen.createImage() | |||||
| await new Promise((resolve, reject) => { | |||||
| img.onload = resolve | |||||
| img.onerror = reject | |||||
| img.src = res.tempFilePath | |||||
| }) | |||||
| offCtx.translate(0, canvasWidth * dpr) | |||||
| offCtx.rotate(-Math.PI / 2) | |||||
| offCtx.drawImage(img, 0, 0) | |||||
| const rotatedRes = await new Promise((resolve, reject) => { | |||||
| uni.canvasToTempFilePath({ | |||||
| canvas: offscreen, | |||||
| fileType: 'png', | |||||
| quality: 1, | |||||
| success: resolve, | |||||
| fail: reject | |||||
| }) | |||||
| }) | |||||
| // 上传签名图 | |||||
| const uploadRes = await upload('/api/mp/upload', { | |||||
| filePath: rotatedRes.tempFilePath, | |||||
| name: 'file' | |||||
| }) | |||||
| if (!uploadRes.data || !uploadRes.data.url) { | |||||
| throw { msg: '上传失败' } | |||||
| } | |||||
| uni.hideLoading() | |||||
| // 把签名图 URL 传回 sign 页面 | |||||
| uni.$emit('signatureResult', { url: uploadRes.data.url }) | |||||
| uni.navigateBack() | |||||
| } catch (err) { | |||||
| uni.hideLoading() | |||||
| uni.showToast({ title: err.msg || '上传失败,请重试', icon: 'none' }) | |||||
| } finally { | |||||
| saving.value = false | |||||
| } | |||||
| }, | |||||
| fail: () => { | |||||
| uni.hideLoading() | |||||
| uni.showToast({ title: '保存失败', icon: 'none' }) | |||||
| saving.value = false | |||||
| } | |||||
| }) | |||||
| } | |||||
| </script> | |||||
| <style lang="scss" scoped> | |||||
| .sign-page { | |||||
| display: flex; | |||||
| flex-direction: column; | |||||
| height: 100vh; | |||||
| background: #edf2fc; | |||||
| } | |||||
| .canvas-wrap { | |||||
| flex: 1; | |||||
| position: relative; | |||||
| overflow: hidden; | |||||
| .hint-text { | |||||
| position: absolute; | |||||
| top: 50%; | |||||
| left: 50%; | |||||
| transform: translate(-50%, -50%) rotate(90deg); | |||||
| pointer-events: none; | |||||
| white-space: nowrap; | |||||
| z-index: 0; | |||||
| text { | |||||
| font-size: 48rpx; | |||||
| color: #c0c8d8; | |||||
| font-weight: 300; | |||||
| letter-spacing: 16rpx; | |||||
| } | |||||
| } | |||||
| .sign-canvas { | |||||
| width: 100%; | |||||
| height: 100%; | |||||
| position: relative; | |||||
| z-index: 1; | |||||
| } | |||||
| } | |||||
| .bottom-btns { | |||||
| display: flex; | |||||
| height: calc(100rpx + env(safe-area-inset-bottom)); | |||||
| flex-shrink: 0; | |||||
| padding-bottom: env(safe-area-inset-bottom); | |||||
| .btn-clear { | |||||
| flex: 1; | |||||
| display: flex; | |||||
| align-items: center; | |||||
| justify-content: center; | |||||
| background: #fff; | |||||
| text { | |||||
| writing-mode: vertical-rl; | |||||
| transform: rotate(90deg); | |||||
| font-size: 32rpx; | |||||
| color: #333; | |||||
| letter-spacing: 4rpx; | |||||
| } | |||||
| } | |||||
| .btn-save { | |||||
| flex: 2; | |||||
| display: flex; | |||||
| align-items: center; | |||||
| justify-content: center; | |||||
| background: #0e63e3; | |||||
| text { | |||||
| writing-mode: vertical-rl; | |||||
| transform: rotate(90deg); | |||||
| font-size: 32rpx; | |||||
| color: #fff; | |||||
| letter-spacing: 4rpx; | |||||
| } | |||||
| } | |||||
| } | |||||
| </style> | |||||