diff --git a/project.private.config.json b/project.private.config.json index e713fa2..75212d5 100644 --- a/project.private.config.json +++ b/project.private.config.json @@ -23,11 +23,25 @@ "miniprogram": { "list": [ { + "name": "图片裁剪", + "pathName": "patient/pages/imageCrop/index", + "query": "", + "scene": null, + "launchMode": "default" + }, + { + "name": "图片拼接", + "pathName": "patient/pages/imageMerge/index", + "query": "", + "launchMode": "default", + "scene": null + }, + { "name": "患者-qol", "pathName": "patient/pages/qol/index", "query": "pushId=81", - "scene": null, - "launchMode": "default" + "launchMode": "default", + "scene": null }, { "name": "医生-患者量表", diff --git a/src/app.json b/src/app.json index 37b37b3..ece80ee 100644 --- a/src/app.json +++ b/src/app.json @@ -71,7 +71,9 @@ "pages/hormones/index", "pages/hormonesResult/index", "pages/medical/index", - "pages/medicalDetail/index" + "pages/medicalDetail/index", + "pages/imageMerge/index", + "pages/imageCrop/index" ] }, { diff --git a/src/patient/pages/imageCrop/index.json b/src/patient/pages/imageCrop/index.json new file mode 100644 index 0000000..2c210d0 --- /dev/null +++ b/src/patient/pages/imageCrop/index.json @@ -0,0 +1,9 @@ +{ + "navigationBarTitleText": "图片裁剪", + "disableScroll": true, + "usingComponents": { + "van-button": "@vant/weapp/button/index", + "van-icon": "@vant/weapp/icon/index", + "van-toast": "@vant/weapp/toast/index" + } +} \ No newline at end of file diff --git a/src/patient/pages/imageCrop/index.scss b/src/patient/pages/imageCrop/index.scss new file mode 100644 index 0000000..8a5dd70 --- /dev/null +++ b/src/patient/pages/imageCrop/index.scss @@ -0,0 +1,97 @@ +page { + background-color: #1a1a1a; + height: 100vh; + overflow: hidden; +} + +.container { + height: 100vh; + display: flex; + flex-direction: column; + padding: 30rpx; +} + +.crop-wrapper { + flex: 1; + display: flex; + align-items: center; + justify-content: center; +} + +.crop-inner { + position: relative; +} + +.crop-area { + position: relative; + overflow: hidden; + background-color: #000; +} + +.source-image { + transform-origin: 0 0; +} + +.crop-frame { + position: absolute; + top: 0; + left: 0; + pointer-events: none; + border: 2rpx solid rgba(255, 255, 255, 0.5); + box-shadow: 0 0 0 9999rpx rgba(0, 0, 0, 0.5); +} + +.frame-corner { + position: absolute; + width: 40rpx; + height: 40rpx; + border: 4rpx solid #fff; +} + +.left-top { + top: 0; + left: 0; + border-right: none; + border-bottom: none; +} + +.right-top { + top: 0; + right: 0; + border-left: none; + border-bottom: none; +} + +.left-bottom { + bottom: 0; + left: 0; + border-right: none; + border-top: none; +} + +.right-bottom { + bottom: 0; + right: 0; + border-left: none; + border-top: none; +} + +.tip-text { + display: flex; + align-items: center; + justify-content: center; + gap: 8rpx; + padding: 30rpx 0; + font-size: 24rpx; + color: #999; +} + +.action-buttons { + padding: 20rpx 0; +} + +.crop-canvas { + position: fixed; + left: -9999px; + top: -9999px; +} diff --git a/src/patient/pages/imageCrop/index.ts b/src/patient/pages/imageCrop/index.ts new file mode 100644 index 0000000..10cbcc5 --- /dev/null +++ b/src/patient/pages/imageCrop/index.ts @@ -0,0 +1,252 @@ +import Toast from '@vant/weapp/toast/toast' + +interface TouchPoint { + x: number + y: number +} + +Page({ + data: { + imageSrc: '', + imgWidth: 0, + imgHeight: 0, + imgX: 0, + imgY: 0, + scale: 1, + minScale: 0.5, + maxScale: 4, + cropSize: 280, + originScale: 1, + lastTouches: [] as TouchPoint[], + }, + + onLoad(options: { imageSrc?: string }) { + const systemInfo = wx.getSystemInfoSync() + const cropSize = Math.min(systemInfo.windowWidth - 60, 350) + this.setData({ cropSize }) + + if (options.imageSrc) { + this.setData({ imageSrc: decodeURIComponent(options.imageSrc) }) + } + else { + this.chooseImage() + } + }, + + chooseImage() { + wx.chooseMedia({ + count: 1, + mediaType: ['image'], + sourceType: ['album', 'camera'], + success: (res) => { + if (res.tempFiles.length > 0) { + this.initImage(res.tempFiles[0].tempFilePath) + } + }, + fail: () => { + Toast('请先选择图片') + wx.navigateBack() + }, + }) + }, + + initImage(src: string) { + wx.getImageInfo({ + src, + success: (res) => { + const { width, height } = res + const { cropSize } = this.data + + const scaleX = cropSize / width + const scaleY = cropSize / height + const scale = Math.max(scaleX, scaleY) + + const imgWidth = width * scale + const imgHeight = height * scale + const imgX = (cropSize - imgWidth) / 2 + const imgY = (cropSize - imgHeight) / 2 + + this.setData({ + imageSrc: src, + imgWidth, + imgHeight, + imgX, + imgY, + scale: 1, + originScale: 1, + }) + }, + fail: () => { + Toast('图片加载失败') + }, + }) + }, + + onTouchStart(e: WechatMiniprogram.TouchEvent) { + const touches = e.touches + if (touches.length === 1) { + this.setData({ + lastTouches: [{ x: touches[0].clientX, y: touches[0].clientY }], + }) + } + else if (touches.length === 2) { + const lastTouches = this.data.lastTouches + if (lastTouches.length < 2) { + this.setData({ + lastTouches: [ + { x: touches[0].clientX, y: touches[0].clientY }, + { x: touches[1].clientX, y: touches[1].clientY }, + ], + }) + } + } + }, + + onTouchMove(e: WechatMiniprogram.TouchEvent) { + const touches = e.touches + + if (touches.length === 1) { + const lastTouches = this.data.lastTouches + if (lastTouches.length === 1) { + const dx = touches[0].clientX - lastTouches[0].x + const dy = touches[0].clientY - lastTouches[0].y + + this.setData({ + imgX: this.data.imgX + dx, + imgY: this.data.imgY + dy, + lastTouches: [{ x: touches[0].clientX, y: touches[0].clientY }], + }) + } + } + else if (touches.length === 2) { + const lastTouches = this.data.lastTouches + if (lastTouches.length === 2) { + const currentDist = Math.hypot( + touches[0].clientX - touches[1].clientX, + touches[0].clientY - touches[1].clientY, + ) + const lastDist = Math.hypot( + lastTouches[0].x - lastTouches[1].x, + lastTouches[0].y - lastTouches[1].y, + ) + + let newScale = this.data.scale * (currentDist / lastDist) + newScale = Math.max(this.data.minScale, Math.min(this.data.maxScale, newScale)) + + const centerX = (touches[0].clientX + touches[1].clientX) / 2 + const centerY = (touches[0].clientY + touches[1].clientY) / 2 + const lastCenterX = (lastTouches[0].x + lastTouches[1].x) / 2 + const lastCenterY = (lastTouches[0].y + lastTouches[1].y) / 2 + + const dx = centerX - lastCenterX + const dy = centerY - lastCenterY + + const imgCenterX = this.data.imgX + (this.data.imgWidth * this.data.scale) / 2 + const imgCenterY = this.data.imgY + (this.data.imgHeight * this.data.scale) / 2 + + const newImgX = imgCenterX - (this.data.imgWidth * newScale) / 2 + const newImgY = imgCenterY - (this.data.imgHeight * newScale) / 2 + + this.setData({ + scale: newScale, + imgX: newImgX + dx, + imgY: newImgY + dy, + lastTouches: [ + { x: touches[0].clientX, y: touches[0].clientY }, + { x: touches[1].clientX, y: touches[1].clientY }, + ], + }) + } + } + }, + + onTouchEnd() { + this.setData({ lastTouches: [] }) + }, + + chooseNewImage() { + wx.chooseMedia({ + count: 1, + mediaType: ['image'], + sourceType: ['album', 'camera'], + success: (res) => { + if (res.tempFiles.length > 0) { + this.initImage(res.tempFiles[0].tempFilePath) + } + }, + }) + }, + + saveCroppedImage() { + Toast.loading({ message: '裁剪中...', forbidClick: true }) + + const { imageSrc, imgWidth, imgHeight, imgX, imgY, scale, cropSize } = this.data + + const query = wx.createSelectorQuery() + query + .select('#cropCanvas') + .fields({ node: true, size: true }) + .exec((res) => { + if (!res[0]) { + Toast.clear() + Toast('裁剪失败') + return + } + + const canvas = res[0].node + const ctx = canvas.getContext('2d') + const pixelRatio = wx.getWindowInfo().pixelRatio + + canvas.width = cropSize * pixelRatio + canvas.height = cropSize * pixelRatio + + ctx.fillStyle = '#ffffff' + ctx.fillRect(0, 0, canvas.width, canvas.height) + + const img = canvas.createImage() + img.src = imageSrc + + img.onload = () => { + const sx = (-imgX / scale) * pixelRatio + const sy = (-imgY / scale) * pixelRatio + const sWidth = (imgWidth / scale) * pixelRatio + const sHeight = (imgHeight / scale) * pixelRatio + + ctx.drawImage(img, sx, sy, sWidth, sHeight, 0, 0, canvas.width, canvas.height) + + wx.canvasToTempFilePath({ + canvas, + x: 0, + y: 0, + width: canvas.width, + height: canvas.height, + destWidth: cropSize, + destHeight: cropSize, + fileType: 'png', + quality: 1, + success: (result) => { + Toast.clear() + wx.saveImageToPhotosAlbum({ + filePath: result.tempFilePath, + success: () => { + Toast.success('保存成功') + }, + fail: () => { + Toast.fail('保存失败,请检查权限') + }, + }) + }, + fail: () => { + Toast.clear() + Toast('裁剪失败') + }, + }) + } + + img.onerror = () => { + Toast.clear() + Toast('图片加载失败') + } + }) + }, +}) diff --git a/src/patient/pages/imageCrop/index.wxml b/src/patient/pages/imageCrop/index.wxml new file mode 100644 index 0000000..4c9a2dc --- /dev/null +++ b/src/patient/pages/imageCrop/index.wxml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + 移动和缩放图片,调整裁剪区域 + + + + + 重新选择图片 + + + 保存裁剪图片 + + + + + diff --git a/src/patient/pages/imageMerge/index.json b/src/patient/pages/imageMerge/index.json new file mode 100644 index 0000000..9fad963 --- /dev/null +++ b/src/patient/pages/imageMerge/index.json @@ -0,0 +1,10 @@ +{ + "navigationBarTitleText": "图片拼接", + "usingComponents": { + "van-button": "@vant/weapp/button/index", + "van-icon": "@vant/weapp/icon/index", + "van-toast": "@vant/weapp/toast/index", + "van-dialog": "@vant/weapp/dialog/index", + "van-loading": "@vant/weapp/loading/index" + } +} diff --git a/src/patient/pages/imageMerge/index.scss b/src/patient/pages/imageMerge/index.scss new file mode 100644 index 0000000..56302cb --- /dev/null +++ b/src/patient/pages/imageMerge/index.scss @@ -0,0 +1,187 @@ +page { + background-color: #f5f5f5; + min-height: 100vh; +} + +.container { + padding: 30rpx; +} + +.header { + text-align: center; + margin-bottom: 40rpx; + + .title { + display: block; + font-size: 40rpx; + font-weight: bold; + color: #333; + margin-bottom: 10rpx; + } + + .subtitle { + display: block; + font-size: 26rpx; + color: #999; + } +} + +.upload-section { + background-color: #fff; + border-radius: 16rpx; + padding: 30rpx; + margin-bottom: 30rpx; +} + +.section-title { + font-size: 30rpx; + font-weight: 500; + color: #333; + margin-bottom: 20rpx; +} + +.image-grid { + display: flex; + flex-wrap: wrap; + gap: 20rpx; +} + +.image-item { + position: relative; + width: calc((100% - 40rpx) / 3); + aspect-ratio: 1; + border-radius: 12rpx; + overflow: hidden; + + .preview-img { + width: 100%; + height: 100%; + } + + .delete-btn { + position: absolute; + top: 8rpx; + right: 8rpx; + width: 40rpx; + height: 40rpx; + background-color: rgba(0, 0, 0, 0.5); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + } +} + +.add-btn { + width: calc((100% - 40rpx) / 3); + aspect-ratio: 1; + border: 2rpx dashed #ddd; + border-radius: 12rpx; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + background-color: #fafafa; + + .add-text { + font-size: 24rpx; + color: #999; + margin-top: 10rpx; + } +} + +.action-section { + margin-bottom: 30rpx; +} + +.result-section { + background-color: #fff; + border-radius: 16rpx; + padding: 30rpx; + margin-bottom: 30rpx; +} + +.result-preview { + position: relative; + border-radius: 12rpx; + overflow: hidden; + background-color: #f5f5f5; + + .merged-img { + width: 100%; + display: block; + } + + .preview-hint { + position: absolute; + bottom: 0; + left: 0; + right: 0; + padding: 20rpx; + background: linear-gradient(transparent, rgba(0, 0, 0, 0.5)); + color: #fff; + font-size: 24rpx; + text-align: center; + } +} + +.tips { + display: flex; + align-items: flex-start; + justify-content: center; + padding: 40rpx; + text-align: center; + + text { + font-size: 24rpx; + color: #999; + margin-left: 10rpx; + line-height: 1.5; + } +} + +// 隐藏的Canvas +.merge-canvas { + position: fixed; + left: -9999px; + top: -9999px; + width: 1px; + height: 1px; +} + +// 预览弹窗 +.preview-modal { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.9); + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + + .preview-content { + width: 90%; + max-height: 80%; + overflow: auto; + + .full-img { + width: 100%; + } + } + + .close-btn { + position: absolute; + top: 40rpx; + right: 40rpx; + width: 60rpx; + height: 60rpx; + background-color: rgba(255, 255, 255, 0.2); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + } +} diff --git a/src/patient/pages/imageMerge/index.ts b/src/patient/pages/imageMerge/index.ts new file mode 100644 index 0000000..26f3ddc --- /dev/null +++ b/src/patient/pages/imageMerge/index.ts @@ -0,0 +1,266 @@ +import Toast from '@vant/weapp/toast/toast' + +const app = getApp() + +Page({ + data: { + imageList: [] as string[], + imageTimes: [] as string[], // 存储每张图片的拍摄时间 + mergedImage: '', + canvasWidth: 0, + canvasHeight: 0, + isLoading: false, + previewShow: false, + }, + + onLoad() { + // 页面加载 + }, + + // 选择图片 + chooseImage() { + const remainingCount = 9 - this.data.imageList.length + if (remainingCount <= 0) { + Toast('最多只能选择9张图片') + return + } + + wx.chooseMedia({ + count: remainingCount, + mediaType: ['image'], + sourceType: ['album', 'camera'], + success: (res) => { + const newImages = res.tempFiles.map((file) => file.tempFilePath) + // 获取当前时间作为图片时间 + const now = new Date() + const timeStr = this.formatTime(now) + const newTimes = res.tempFiles.map(() => timeStr) + + this.setData({ + imageList: [...this.data.imageList, ...newImages], + imageTimes: [...this.data.imageTimes, ...newTimes], + }) + }, + }) + }, + + // 格式化时间 + formatTime(date: Date): string { + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + const hours = String(date.getHours()).padStart(2, '0') + const minutes = String(date.getMinutes()).padStart(2, '0') + return `${year}-${month}-${day} ${hours}:${minutes}` + }, + + // 删除图片 + deleteImage(e: WechatMiniprogram.TouchEvent) { + const index = e.currentTarget.dataset.index + const newList = [...this.data.imageList] + const newTimes = [...this.data.imageTimes] + newList.splice(index, 1) + newTimes.splice(index, 1) + this.setData({ + imageList: newList, + imageTimes: newTimes, + }) + }, + + // 预览单张图片 + previewImage(e: WechatMiniprogram.TouchEvent) { + const index = e.currentTarget.dataset.index + wx.previewImage({ + current: this.data.imageList[index], + urls: this.data.imageList, + }) + }, + + // 拼接图片 + async mergeImages() { + if (this.data.imageList.length < 2) { + Toast('至少需要2张图片才能拼接') + return + } + + this.setData({ isLoading: true }) + + try { + const mergedImage = await this.drawImagesOnCanvas() + this.setData({ + mergedImage, + isLoading: false, + }) + Toast('拼接成功') + } catch (error) { + console.error('拼接失败:', error) + this.setData({ isLoading: false }) + Toast('拼接失败,请重试') + } + }, + + // 在Canvas上绘制图片(纵向拼接) + drawImagesOnCanvas(): Promise { + return new Promise((resolve, reject) => { + const { imageList, imageTimes } = this.data + const query = wx.createSelectorQuery() + query + .select('#mergeCanvas') + .fields({ node: true, size: true }) + .exec((res) => { + if (!res[0]) { + reject(new Error('Canvas not found')) + return + } + + const canvas = res[0].node + const ctx = canvas.getContext('2d') + + // 设置画布背景为白色 + ctx.fillStyle = '#ffffff' + + // 加载所有图片并获取尺寸 + Promise.all( + imageList.map((src) => this.getImageInfo(src)) + ).then((imageInfos) => { + // 计算目标宽度(取最大宽度或固定宽度) + const targetWidth = 750 // 固定宽度为750rpx对应的像素 + const pixelRatio = wx.getWindowInfo().pixelRatio + const canvasWidth = Math.floor(targetWidth * pixelRatio / 2) // 转换为实际像素 + + // 底部时间文字区域高度 + const timeAreaHeight = 60 + // 右下角水印区域高度 + const watermarkHeight = 80 + + // 计算每张图片缩放后的高度 + let totalHeight = 0 + const scaledHeights: number[] = [] + + imageInfos.forEach((info) => { + const scale = canvasWidth / info.width + const scaledHeight = Math.floor(info.height * scale) + scaledHeights.push(scaledHeight) + totalHeight += scaledHeight + timeAreaHeight + }) + + // 添加水印区域高度 + totalHeight += watermarkHeight + + // 设置画布尺寸 + canvas.width = canvasWidth + canvas.height = totalHeight + + // 填充白色背景 + ctx.fillRect(0, 0, canvasWidth, totalHeight) + + // 绘制所有图片 + let currentY = 0 + let loadedCount = 0 + + imageInfos.forEach((info, index) => { + const img = canvas.createImage() + img.src = info.path + img.onload = () => { + // 绘制图片 + ctx.drawImage(img, 0, currentY, canvasWidth, scaledHeights[index]) + + // 绘制时间文字(图片底部中间) + const timeY = currentY + scaledHeights[index] + 40 + ctx.fillStyle = '#666666' + ctx.font = '24px sans-serif' + ctx.textAlign = 'center' + ctx.fillText(imageTimes[index] || this.formatTime(new Date()), canvasWidth / 2, timeY) + + currentY += scaledHeights[index] + timeAreaHeight + loadedCount++ + + if (loadedCount === imageInfos.length) { + // 所有图片绘制完成,绘制右下角水印 + ctx.fillStyle = '#999999' + ctx.font = '28px sans-serif' + ctx.textAlign = 'right' + ctx.fillText('TED关爱小助手', canvasWidth - 30, totalHeight - 30) + + // 导出图片 + wx.canvasToTempFilePath({ + canvas, + success: (result) => { + resolve(result.tempFilePath) + }, + fail: reject, + }) + } + } + img.onerror = () => { + reject(new Error(`Failed to load image: ${info.path}`)) + } + }) + }).catch(reject) + }) + }) + }, + + // 获取图片信息 + getImageInfo(src: string): Promise { + return new Promise((resolve, reject) => { + wx.getImageInfo({ + src, + success: resolve, + fail: reject, + }) + }) + }, + + // 预览拼接结果 + previewMergedImage() { + if (!this.data.mergedImage) return + this.setData({ previewShow: true }) + }, + + // 关闭预览 + closePreview() { + this.setData({ previewShow: false }) + }, + + // 保存图片到相册 + saveImage() { + if (!this.data.mergedImage) { + Toast('请先拼接图片') + return + } + + wx.saveImageToPhotosAlbum({ + filePath: this.data.mergedImage, + success: () => { + Toast('保存成功') + }, + fail: (err) => { + console.error('保存失败:', err) + if (err.errMsg.includes('auth')) { + wx.showModal({ + title: '提示', + content: '需要授权保存到相册权限', + success: (res) => { + if (res.confirm) { + wx.openSetting() + } + }, + }) + } else { + Toast('保存失败') + } + }, + }) + }, + + // 重新选择 + reset() { + this.setData({ + imageList: [], + imageTimes: [], + mergedImage: '', + previewShow: false, + }) + }, +}) diff --git a/src/patient/pages/imageMerge/index.wxml b/src/patient/pages/imageMerge/index.wxml new file mode 100644 index 0000000..4408e23 --- /dev/null +++ b/src/patient/pages/imageMerge/index.wxml @@ -0,0 +1,108 @@ + + + + + + 图片拼接工具 + 选择多张图片,自动拼接成长图 + + + + + + 选择图片({{imageList.length}}/9) + + + + + + + + + + + + + + + 添加图片 + + + + + + + + {{imageList.length < 2 ? '至少选择2张图片' : '开始拼接'}} + + + + 重新选择 + + + + + + + 拼接结果 + + + + + 点击预览大图 + + + + 保存到相册 + + + + + + + 支持上传2-9张图片,建议图片宽度相近以获得更好的拼接效果 + + + + + + + + + + + + + + + diff --git a/src/patient/pages/qolReport/index.ts b/src/patient/pages/qolReport/index.ts index 169c295..fde8e18 100644 --- a/src/patient/pages/qolReport/index.ts +++ b/src/patient/pages/qolReport/index.ts @@ -103,9 +103,7 @@ Page({ .exec() }, handleChangeType(e?: WechatMiniprogram.CustomEvent) { - if (e) { - app.mpBehavior({ PageName: 'BTN_QolReportTab' }) - } + app.mpBehavior({ PageName: 'BTN_QolReportTab' }) let type = '' if (e) { // Tab 已移除"日",现仅有"月度/年度"