11 changed files with 990 additions and 6 deletions
@ -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" |
||||||
|
} |
||||||
|
} |
||||||
@ -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; |
||||||
|
} |
||||||
@ -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('图片加载失败') |
||||||
|
} |
||||||
|
}) |
||||||
|
}, |
||||||
|
}) |
||||||
@ -0,0 +1,41 @@ |
|||||||
|
<van-toast id="van-toast" /> |
||||||
|
|
||||||
|
<view class="container"> |
||||||
|
<view class="crop-wrapper"> |
||||||
|
<view class="crop-inner"> |
||||||
|
<view class="crop-area" style="width: {{cropSize}}px; height: {{cropSize}}px;"> |
||||||
|
<image |
||||||
|
class="source-image" |
||||||
|
src="{{imageSrc}}" |
||||||
|
style="width: {{imgWidth}}px; height: {{imgHeight}}px; transform: translate({{imgX}}px, {{imgY}}px) scale({{scale}});" |
||||||
|
bindtouchstart="onTouchStart" |
||||||
|
bindtouchmove="onTouchMove" |
||||||
|
bindtouchend="onTouchEnd" |
||||||
|
/> |
||||||
|
</view> |
||||||
|
|
||||||
|
<view class="crop-frame" style="width: {{cropSize}}px; height: {{cropSize}}px;"> |
||||||
|
<view class="frame-corner left-top"></view> |
||||||
|
<view class="frame-corner right-top"></view> |
||||||
|
<view class="frame-corner left-bottom"></view> |
||||||
|
<view class="frame-corner right-bottom"></view> |
||||||
|
</view> |
||||||
|
</view> |
||||||
|
</view> |
||||||
|
|
||||||
|
<view class="tip-text"> |
||||||
|
<van-icon name="info-o" size="14px" color="#999" /> |
||||||
|
<text>移动和缩放图片,调整裁剪区域</text> |
||||||
|
</view> |
||||||
|
|
||||||
|
<view class="action-buttons"> |
||||||
|
<van-button type="default" round block bindtap="chooseNewImage"> |
||||||
|
重新选择图片 |
||||||
|
</van-button> |
||||||
|
<van-button type="primary" round block custom-style="margin-top: 20rpx;" bindtap="saveCroppedImage"> |
||||||
|
保存裁剪图片 |
||||||
|
</van-button> |
||||||
|
</view> |
||||||
|
</view> |
||||||
|
|
||||||
|
<canvas type="2d" id="cropCanvas" class="crop-canvas" style="width: {{cropSize}}px; height: {{cropSize}}px;"></canvas> |
||||||
@ -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" |
||||||
|
} |
||||||
|
} |
||||||
@ -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; |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,266 @@ |
|||||||
|
import Toast from '@vant/weapp/toast/toast' |
||||||
|
|
||||||
|
const app = getApp<IAppOption>() |
||||||
|
|
||||||
|
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<string> { |
||||||
|
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<WechatMiniprogram.GetImageInfoSuccessCallbackResult> { |
||||||
|
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, |
||||||
|
}) |
||||||
|
}, |
||||||
|
}) |
||||||
@ -0,0 +1,108 @@ |
|||||||
|
<van-toast id="van-toast" /> |
||||||
|
|
||||||
|
<view class="container"> |
||||||
|
<!-- 标题 --> |
||||||
|
<view class="header"> |
||||||
|
<text class="title">图片拼接工具</text> |
||||||
|
<text class="subtitle">选择多张图片,自动拼接成长图</text> |
||||||
|
</view> |
||||||
|
|
||||||
|
<!-- 图片上传区域 --> |
||||||
|
<view class="upload-section"> |
||||||
|
<view class="section-title"> |
||||||
|
<text>选择图片({{imageList.length}}/9)</text> |
||||||
|
</view> |
||||||
|
|
||||||
|
<view class="image-grid"> |
||||||
|
<!-- 已选择的图片 --> |
||||||
|
<view |
||||||
|
class="image-item" |
||||||
|
wx:for="{{imageList}}" |
||||||
|
wx:key="index" |
||||||
|
data-index="{{index}}" |
||||||
|
bindtap="previewImage" |
||||||
|
> |
||||||
|
<image class="preview-img" src="{{item}}" mode="aspectFill" /> |
||||||
|
<view class="delete-btn" catchtap="deleteImage" data-index="{{index}}"> |
||||||
|
<van-icon name="cross" size="12px" color="#fff" /> |
||||||
|
</view> |
||||||
|
</view> |
||||||
|
|
||||||
|
<!-- 添加按钮 --> |
||||||
|
<view class="add-btn" bindtap="chooseImage" wx:if="{{imageList.length < 9}}"> |
||||||
|
<van-icon name="plus" size="32px" color="#999" /> |
||||||
|
<text class="add-text">添加图片</text> |
||||||
|
</view> |
||||||
|
</view> |
||||||
|
</view> |
||||||
|
|
||||||
|
<!-- 操作按钮 --> |
||||||
|
<view class="action-section" wx:if="{{imageList.length > 0}}"> |
||||||
|
<van-button |
||||||
|
type="primary" |
||||||
|
block |
||||||
|
round |
||||||
|
loading="{{isLoading}}" |
||||||
|
loading-text="拼接中..." |
||||||
|
disabled="{{imageList.length < 2}}" |
||||||
|
bindtap="mergeImages" |
||||||
|
> |
||||||
|
{{imageList.length < 2 ? '至少选择2张图片' : '开始拼接'}} |
||||||
|
</van-button> |
||||||
|
|
||||||
|
<van-button |
||||||
|
type="default" |
||||||
|
block |
||||||
|
round |
||||||
|
custom-style="margin-top: 20rpx;" |
||||||
|
bindtap="reset" |
||||||
|
> |
||||||
|
重新选择 |
||||||
|
</van-button> |
||||||
|
</view> |
||||||
|
|
||||||
|
<!-- 拼接结果预览 --> |
||||||
|
<view class="result-section" wx:if="{{mergedImage}}"> |
||||||
|
<view class="section-title"> |
||||||
|
<text>拼接结果</text> |
||||||
|
</view> |
||||||
|
|
||||||
|
<view class="result-preview" bindtap="previewMergedImage"> |
||||||
|
<image class="merged-img" src="{{mergedImage}}" mode="widthFix" /> |
||||||
|
<view class="preview-hint">点击预览大图</view> |
||||||
|
</view> |
||||||
|
|
||||||
|
<van-button |
||||||
|
type="primary" |
||||||
|
block |
||||||
|
round |
||||||
|
custom-style="margin-top: 30rpx;" |
||||||
|
bindtap="saveImage" |
||||||
|
> |
||||||
|
保存到相册 |
||||||
|
</van-button> |
||||||
|
</view> |
||||||
|
|
||||||
|
<!-- 提示信息 --> |
||||||
|
<view class="tips" wx:if="{{imageList.length === 0}}"> |
||||||
|
<van-icon name="info-o" size="16px" color="#999" /> |
||||||
|
<text>支持上传2-9张图片,建议图片宽度相近以获得更好的拼接效果</text> |
||||||
|
</view> |
||||||
|
</view> |
||||||
|
|
||||||
|
<!-- Canvas(隐藏)用于图片拼接 --> |
||||||
|
<canvas |
||||||
|
type="2d" |
||||||
|
id="mergeCanvas" |
||||||
|
class="merge-canvas" |
||||||
|
></canvas> |
||||||
|
|
||||||
|
<!-- 全屏预览弹窗 --> |
||||||
|
<view class="preview-modal" wx:if="{{previewShow}}" bindtap="closePreview"> |
||||||
|
<view class="preview-content"> |
||||||
|
<image class="full-img" src="{{mergedImage}}" mode="widthFix" /> |
||||||
|
</view> |
||||||
|
<view class="close-btn"> |
||||||
|
<van-icon name="cross" size="24px" color="#fff" /> |
||||||
|
</view> |
||||||
|
</view> |
||||||
Loading…
Reference in new issue