leiyun vor 2 Tagen
Ursprung
Commit
5e2ef0f25c
4 geänderte Dateien mit 322 neuen und 101 gelöschten Zeilen
  1. +7
    -0
      pages.json
  2. +32
    -10
      pages/profile/profile.vue
  3. +44
    -91
      pages/sign/sign.vue
  4. +239
    -0
      pages/sign/signature.vue

+ 7
- 0
pages.json Datei anzeigen

@@ -55,6 +55,13 @@
"navigationBarTitleText": "授权签名"
}
},
{
"path": "pages/sign/signature",
"style": {
"navigationStyle": "default",
"navigationBarTitleText": "手写签名"
}
},
{
"path": "pages/message/message",
"style": {


+ 32
- 10
pages/profile/profile.vue Datei anzeigen

@@ -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' })
}


+ 44
- 91
pages/sign/sign.vue Datei anzeigen

@@ -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 {


+ 239
- 0
pages/sign/signature.vue Datei anzeigen

@@ -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>

Laden…
Abbrechen
Speichern