| @@ -55,6 +55,13 @@ | |||
| "navigationBarTitleText": "授权签名" | |||
| } | |||
| }, | |||
| { | |||
| "path": "pages/sign/signature", | |||
| "style": { | |||
| "navigationStyle": "default", | |||
| "navigationBarTitleText": "手写签名" | |||
| } | |||
| }, | |||
| { | |||
| "path": "pages/message/message", | |||
| "style": { | |||
| @@ -38,7 +38,7 @@ | |||
| <text v-else-if="patientStatus === 2" class="extra rejected">已驳回</text> | |||
| <u-icon name="arrow-right" size="16" color="#c0c4cc" /> | |||
| </view> | |||
| <view class="menu-item" @tap="goTo('/pages/message/message')"> | |||
| <view class="menu-item" @tap="goMessage"> | |||
| <view class="menu-icon"> | |||
| <u-icon name="chat-fill" size="20" color="#fa8c16" /> | |||
| </view> | |||
| @@ -46,7 +46,7 @@ | |||
| <u-badge v-if="unreadCount > 0" :value="unreadCount" :max="99" type="error" /> | |||
| <u-icon name="arrow-right" size="16" color="#c0c4cc" /> | |||
| </view> | |||
| <view class="menu-item" @tap="goTo('/pages/verify/verify')"> | |||
| <view class="menu-item" @tap="goVerify"> | |||
| <view class="menu-icon"> | |||
| <u-icon name="account-fill" size="20" color="#52c41a" /> | |||
| </view> | |||
| @@ -141,26 +141,48 @@ const fetchUnreadCount = async () => { | |||
| } 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) { | |||
| 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' }) | |||
| } | |||
| const goChangePhone = () => { | |||
| if (!isAuthed.value) { | |||
| uni.showToast({ title: '请先完成实名认证', icon: 'none' }) | |||
| return | |||
| } | |||
| if (!checkAuth()) return | |||
| 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 = () => { | |||
| uni.navigateTo({ url: '/pages/login/index' }) | |||
| } | |||
| @@ -1,6 +1,5 @@ | |||
| <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="doc-title">{{ docTitle }}</view> | |||
| @@ -9,7 +8,7 @@ | |||
| <!-- 收入金额(仅income类型) --> | |||
| <view class="section" v-if="signType === 'income'"> | |||
| <view class="form-label">请填写您的个人月可支配收入(元)</view> | |||
| <view class="form-label">请填写您的个人年可支配收入(元)</view> | |||
| <view class="amount-wrap"> | |||
| <text class="amount-prefix">¥</text> | |||
| <u-input v-model="incomeAmount" type="number" placeholder="请输入金额" border="none" /> | |||
| @@ -19,13 +18,15 @@ | |||
| <!-- 签名区域 --> | |||
| <view class="section"> | |||
| <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 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> | |||
| @@ -33,43 +34,33 @@ | |||
| <view class="btn-wrap"> | |||
| <u-button text="确认签署" :loading="submitting" @click="confirmSign" color="#0E63E3" size="large" /> | |||
| </view> | |||
| </scroll-view> | |||
| </view> | |||
| </template> | |||
| <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 docTitle = ref('') | |||
| const docContent = ref('') | |||
| const incomeAmount = ref('') | |||
| const hasSigned = ref(false) | |||
| const signImageUrl = ref('') | |||
| 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) => { | |||
| signType.value = options.type || 'privacy' | |||
| // uni.setNavigationBarTitle({ title: titleMap[signType.value] || '签署协议' }) | |||
| uni.$on('signatureResult', onSignatureResult) | |||
| 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 () => { | |||
| @@ -81,38 +72,12 @@ const loadContent = async () => { | |||
| } 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 () => { | |||
| if (!hasSigned.value) { | |||
| if (!signImageUrl.value) { | |||
| return uni.showToast({ title: '请先签名', icon: 'none' }) | |||
| } | |||
| if (signType.value === 'income' && (!incomeAmount.value || Number(incomeAmount.value) <= 0)) { | |||
| @@ -121,32 +86,13 @@ const confirmSign = async () => { | |||
| submitting.value = true | |||
| 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 = { | |||
| type: signType.value, | |||
| signImage, | |||
| signImage: signImageUrl.value, | |||
| amount: signType.value === 'income' ? incomeAmount.value : undefined | |||
| } | |||
| const res = await post('/api/mp/sign', params) | |||
| // 4. 通过事件把结果传回 myinfo 页面 | |||
| uni.$emit('signResult', { | |||
| type: signType.value, | |||
| url: res.data.url, | |||
| @@ -165,10 +111,10 @@ const confirmSign = async () => { | |||
| <style lang="scss" scoped> | |||
| .page { | |||
| height: 100vh; | |||
| min-height: 100vh; | |||
| background: #f4f4f5; | |||
| padding: 24rpx; | |||
| padding-bottom: 140rpx; | |||
| padding-bottom: calc(140rpx + env(safe-area-inset-bottom)); | |||
| } | |||
| .section { | |||
| @@ -225,31 +171,38 @@ const confirmSign = async () => { | |||
| .canvas-wrap { | |||
| position: relative; | |||
| border: 1rpx solid #ddd; | |||
| border: 2rpx dashed #ddd; | |||
| border-radius: 12rpx; | |||
| overflow: hidden; | |||
| margin-bottom: 16rpx; | |||
| min-height: 300rpx; | |||
| display: flex; | |||
| align-items: center; | |||
| justify-content: center; | |||
| background: #fafafa; | |||
| } | |||
| .sign-canvas { | |||
| .sign-preview { | |||
| width: 100%; | |||
| height: 300rpx; | |||
| background: #fff; | |||
| } | |||
| .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 { | |||
| display: flex; | |||
| justify-content: flex-end; | |||
| margin-top: 16rpx; | |||
| } | |||
| .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> | |||