|
|
@@ -0,0 +1,378 @@
|
|
|
+<template>
|
|
|
+ <view class="page">
|
|
|
+ <!-- 顶部导航 -->
|
|
|
+ <view class="nav-bar">
|
|
|
+ <view class="status-bar"></view>
|
|
|
+ <view class="nav-content">
|
|
|
+ <text class="nav-back" @click="goBack"></text>
|
|
|
+ <text class="nav-title">裁剪头像</text>
|
|
|
+ <text class="nav-save" @click="saveImage">完成</text>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <!-- 裁剪区域 -->
|
|
|
+ <view class="crop-area"
|
|
|
+ @touchstart="onTouchStart"
|
|
|
+ @touchmove.prevent="onTouchMove"
|
|
|
+ @touchend="onTouchEnd">
|
|
|
+ <!-- 图片 - 填满显示 -->
|
|
|
+ <image class="crop-image" :src="imageSrc" mode="scaleToFill"
|
|
|
+ :style="{
|
|
|
+ width: imgWidth + 'px',
|
|
|
+ height: imgHeight + 'px',
|
|
|
+ left: imgLeft + 'px',
|
|
|
+ top: imgTop + 'px'
|
|
|
+ }"></image>
|
|
|
+
|
|
|
+ <!-- 圆形遮罩 - 用box-shadow实现圆形镂空 -->
|
|
|
+ <view class="crop-circle-mask" :style="{
|
|
|
+ width: cropSize + 'px',
|
|
|
+ height: cropSize + 'px',
|
|
|
+ top: cropTop + 'px',
|
|
|
+ left: cropLeft + 'px'
|
|
|
+ }"></view>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <!-- 底部:实时预览 + 提示 -->
|
|
|
+ <view class="bottom-bar">
|
|
|
+ <view class="preview-circle">
|
|
|
+ <image :src="imageSrc" mode="scaleToFill" :style="previewStyle"></image>
|
|
|
+ </view>
|
|
|
+ <text class="tip-text">拖动裁剪框 · 双指缩放图片</text>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <!-- 输出canvas(隐藏) -->
|
|
|
+ <canvas canvas-id="cropCanvas" class="crop-canvas"
|
|
|
+ :style="{width: canvasSize + 'px', height: canvasSize + 'px'}"></canvas>
|
|
|
+ <ay-toast ref="ayToast" />
|
|
|
+ </view>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script>
|
|
|
+import ayToast from '@/components/ay-toast/ay-toast.nvue'
|
|
|
+
|
|
|
+export default {
|
|
|
+ components: {
|
|
|
+ ayToast
|
|
|
+ },
|
|
|
+ data() {
|
|
|
+ return {
|
|
|
+ statusBarHeight: 44,
|
|
|
+ imageSrc: '',
|
|
|
+ screenWidth: 375,
|
|
|
+ areaHeight: 500,
|
|
|
+ // 裁剪框
|
|
|
+ cropSize: 280,
|
|
|
+ cropTop: 0,
|
|
|
+ cropLeft: 0,
|
|
|
+ // 图片显示
|
|
|
+ imgWidth: 375,
|
|
|
+ imgHeight: 500,
|
|
|
+ imgLeft: 0,
|
|
|
+ imgTop: 0,
|
|
|
+ naturalWidth: 0,
|
|
|
+ naturalHeight: 0,
|
|
|
+ // 触摸状态
|
|
|
+ touchMode: 'none',
|
|
|
+ startX: 0,
|
|
|
+ startY: 0,
|
|
|
+ startCropLeft: 0,
|
|
|
+ startCropTop: 0,
|
|
|
+ startDistance: 0,
|
|
|
+ startImgWidth: 0,
|
|
|
+ startImgHeight: 0,
|
|
|
+ startImgLeft: 0,
|
|
|
+ startImgTop: 0,
|
|
|
+ // 输出
|
|
|
+ canvasSize: 400,
|
|
|
+ previewSize: 70
|
|
|
+ }
|
|
|
+ },
|
|
|
+ computed: {
|
|
|
+ // 实时预览样式 - 将裁剪框内容映射到预览圆中
|
|
|
+ previewStyle() {
|
|
|
+ if (!this.imgWidth || !this.cropSize) return {}
|
|
|
+ const ratio = this.previewSize / this.cropSize
|
|
|
+ const w = this.imgWidth * ratio
|
|
|
+ const h = this.imgHeight * ratio
|
|
|
+ const x = -(this.cropLeft - this.imgLeft) * ratio
|
|
|
+ const y = -(this.cropTop - this.imgTop) * ratio
|
|
|
+ return {
|
|
|
+ width: w + 'px',
|
|
|
+ height: h + 'px',
|
|
|
+ marginLeft: x + 'px',
|
|
|
+ marginTop: y + 'px'
|
|
|
+ }
|
|
|
+ }
|
|
|
+ },
|
|
|
+ onLoad(options) {
|
|
|
+ const sysInfo = uni.getSystemInfoSync()
|
|
|
+ this.statusBarHeight = sysInfo.statusBarHeight || 44
|
|
|
+ this.screenWidth = sysInfo.windowWidth
|
|
|
+ const screenHeight = sysInfo.windowHeight
|
|
|
+ const navHeight = this.statusBarHeight + 44
|
|
|
+ const bottomHeight = 80
|
|
|
+ this.areaHeight = screenHeight - navHeight - bottomHeight
|
|
|
+
|
|
|
+ // 裁剪框大小为屏幕宽度的 75%
|
|
|
+ this.cropSize = Math.floor(this.screenWidth * 0.75)
|
|
|
+ // 裁剪框居中
|
|
|
+ this.cropLeft = Math.floor((this.screenWidth - this.cropSize) / 2)
|
|
|
+ this.cropTop = Math.floor((this.areaHeight - this.cropSize) / 2)
|
|
|
+
|
|
|
+ if (options.src) {
|
|
|
+ this.imageSrc = decodeURIComponent(options.src)
|
|
|
+ this.loadImageInfo()
|
|
|
+ }
|
|
|
+ },
|
|
|
+ methods: {
|
|
|
+ goBack() {
|
|
|
+ uni.navigateBack()
|
|
|
+ },
|
|
|
+ loadImageInfo() {
|
|
|
+ uni.getImageInfo({
|
|
|
+ src: this.imageSrc,
|
|
|
+ success: (info) => {
|
|
|
+ this.naturalWidth = info.width
|
|
|
+ this.naturalHeight = info.height
|
|
|
+ const ratio = info.width / info.height
|
|
|
+
|
|
|
+ // 图片填满整个裁剪区域
|
|
|
+ const areaRatio = this.screenWidth / this.areaHeight
|
|
|
+ if (ratio > areaRatio) {
|
|
|
+ // 图片更宽 - 按高度适配
|
|
|
+ this.imgHeight = this.areaHeight
|
|
|
+ this.imgWidth = Math.floor(this.areaHeight * ratio)
|
|
|
+ } else {
|
|
|
+ // 图片更高 - 按宽度适配
|
|
|
+ this.imgWidth = this.screenWidth
|
|
|
+ this.imgHeight = Math.floor(this.screenWidth / ratio)
|
|
|
+ }
|
|
|
+ // 居中
|
|
|
+ this.imgLeft = Math.floor((this.screenWidth - this.imgWidth) / 2)
|
|
|
+ this.imgTop = Math.floor((this.areaHeight - this.imgHeight) / 2)
|
|
|
+ },
|
|
|
+ fail: () => {
|
|
|
+ this.imgWidth = this.screenWidth
|
|
|
+ this.imgHeight = this.areaHeight
|
|
|
+ this.imgLeft = 0
|
|
|
+ this.imgTop = 0
|
|
|
+ }
|
|
|
+ })
|
|
|
+ },
|
|
|
+
|
|
|
+ // === 触摸事件 ===
|
|
|
+ onTouchStart(e) {
|
|
|
+ const touches = e.touches || e.changedTouches
|
|
|
+ if (touches.length === 1) {
|
|
|
+ // 单指 → 拖动裁剪框
|
|
|
+ this.touchMode = 'drag'
|
|
|
+ this.startX = touches[0].pageX
|
|
|
+ this.startY = touches[0].pageY
|
|
|
+ this.startCropLeft = this.cropLeft
|
|
|
+ this.startCropTop = this.cropTop
|
|
|
+ } else if (touches.length === 2) {
|
|
|
+ // 双指 → 缩放图片
|
|
|
+ this.touchMode = 'zoom'
|
|
|
+ this.startDistance = this.getDistance(touches[0], touches[1])
|
|
|
+ this.startImgWidth = this.imgWidth
|
|
|
+ this.startImgHeight = this.imgHeight
|
|
|
+ this.startImgLeft = this.imgLeft
|
|
|
+ this.startImgTop = this.imgTop
|
|
|
+ }
|
|
|
+ },
|
|
|
+ onTouchMove(e) {
|
|
|
+ const touches = e.touches || e.changedTouches
|
|
|
+ if (this.touchMode === 'drag' && touches.length === 1) {
|
|
|
+ const dx = touches[0].pageX - this.startX
|
|
|
+ const dy = touches[0].pageY - this.startY
|
|
|
+ // 限制裁剪框在区域内
|
|
|
+ let newLeft = this.startCropLeft + dx
|
|
|
+ let newTop = this.startCropTop + dy
|
|
|
+ newLeft = Math.max(0, Math.min(this.screenWidth - this.cropSize, newLeft))
|
|
|
+ newTop = Math.max(0, Math.min(this.areaHeight - this.cropSize, newTop))
|
|
|
+ this.cropLeft = newLeft
|
|
|
+ this.cropTop = newTop
|
|
|
+ } else if (this.touchMode === 'zoom' && touches.length === 2) {
|
|
|
+ const dist = this.getDistance(touches[0], touches[1])
|
|
|
+ let ratio = dist / this.startDistance
|
|
|
+ ratio = Math.max(0.5, Math.min(3, ratio))
|
|
|
+
|
|
|
+ const newWidth = Math.floor(this.startImgWidth * ratio)
|
|
|
+ const newHeight = Math.floor(this.startImgHeight * ratio)
|
|
|
+ // 保持图片中心不变
|
|
|
+ const centerX = this.startImgLeft + this.startImgWidth / 2
|
|
|
+ const centerY = this.startImgTop + this.startImgHeight / 2
|
|
|
+ this.imgWidth = newWidth
|
|
|
+ this.imgHeight = newHeight
|
|
|
+ this.imgLeft = Math.floor(centerX - newWidth / 2)
|
|
|
+ this.imgTop = Math.floor(centerY - newHeight / 2)
|
|
|
+ }
|
|
|
+ },
|
|
|
+ onTouchEnd(e) {
|
|
|
+ const remaining = e.touches ? e.touches.length : 0
|
|
|
+ if (this.touchMode === 'zoom' && remaining === 1) {
|
|
|
+ // 双指结束剩一指 → 切换为拖动
|
|
|
+ this.touchMode = 'drag'
|
|
|
+ this.startX = e.touches[0].pageX
|
|
|
+ this.startY = e.touches[0].pageY
|
|
|
+ this.startCropLeft = this.cropLeft
|
|
|
+ this.startCropTop = this.cropTop
|
|
|
+ } else if (remaining === 0) {
|
|
|
+ this.touchMode = 'none'
|
|
|
+ }
|
|
|
+ },
|
|
|
+ getDistance(t1, t2) {
|
|
|
+ const dx = (t1.pageX || t1.screenX) - (t2.pageX || t2.screenX)
|
|
|
+ const dy = (t1.pageY || t1.screenY) - (t2.pageY || t2.screenY)
|
|
|
+ return Math.sqrt(dx * dx + dy * dy)
|
|
|
+ },
|
|
|
+
|
|
|
+ // === 保存裁剪 ===
|
|
|
+ saveImage() {
|
|
|
+ this.$toastRef.loading('处理中...')
|
|
|
+
|
|
|
+ const ctx = uni.createCanvasContext('cropCanvas', this)
|
|
|
+ const canvasSize = this.canvasSize
|
|
|
+
|
|
|
+ // 裁剪框对应canvas的缩放比
|
|
|
+ const outputScale = canvasSize / this.cropSize
|
|
|
+ // 图片在canvas上的绘制位置
|
|
|
+ const drawX = (this.imgLeft - this.cropLeft) * outputScale
|
|
|
+ const drawY = (this.imgTop - this.cropTop) * outputScale
|
|
|
+ const drawW = this.imgWidth * outputScale
|
|
|
+ const drawH = this.imgHeight * outputScale
|
|
|
+
|
|
|
+ // 圆形裁剪
|
|
|
+ ctx.save()
|
|
|
+ ctx.beginPath()
|
|
|
+ ctx.arc(canvasSize / 2, canvasSize / 2, canvasSize / 2, 0, 2 * Math.PI)
|
|
|
+ ctx.clip()
|
|
|
+ ctx.drawImage(this.imageSrc, drawX, drawY, drawW, drawH)
|
|
|
+ ctx.restore()
|
|
|
+
|
|
|
+ ctx.draw(false, () => {
|
|
|
+ setTimeout(() => {
|
|
|
+ uni.canvasToTempFilePath({
|
|
|
+ canvasId: 'cropCanvas',
|
|
|
+ x: 0, y: 0,
|
|
|
+ width: canvasSize,
|
|
|
+ height: canvasSize,
|
|
|
+ destWidth: canvasSize,
|
|
|
+ destHeight: canvasSize,
|
|
|
+ fileType: 'png',
|
|
|
+ success: (res) => {
|
|
|
+ this.$toastRef.hide()
|
|
|
+ uni.setStorageSync('avatar', res.tempFilePath)
|
|
|
+ this.$toastRef.success('头像已更新')
|
|
|
+ setTimeout(() => uni.navigateBack(), 500)
|
|
|
+ },
|
|
|
+ fail: (err) => {
|
|
|
+ console.error('canvasToTempFilePath fail:', err)
|
|
|
+ this.$toastRef.hide()
|
|
|
+ uni.setStorageSync('avatar', this.imageSrc)
|
|
|
+ this.$toastRef.success('头像已更新')
|
|
|
+ setTimeout(() => uni.navigateBack(), 500)
|
|
|
+ }
|
|
|
+ }, this)
|
|
|
+ }, 500)
|
|
|
+ })
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+.page {
|
|
|
+ width: 100%;
|
|
|
+ height: 100vh;
|
|
|
+ background-color: #1A1A1A;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ overflow: hidden;
|
|
|
+}
|
|
|
+/* 导航栏 */
|
|
|
+.nav-bar {
|
|
|
+ background-color: #1A1A1A;
|
|
|
+}
|
|
|
+.status-bar {
|
|
|
+ height: var(--status-bar-height);
|
|
|
+}
|
|
|
+.nav-content {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: row;
|
|
|
+ align-items: center;
|
|
|
+ height: 44px;
|
|
|
+ padding: 0 15px;
|
|
|
+}
|
|
|
+.nav-back {
|
|
|
+ font-family: iconfont;
|
|
|
+ font-size: 20px;
|
|
|
+ color: #FFFFFF;
|
|
|
+ width: 40px;
|
|
|
+}
|
|
|
+.nav-title {
|
|
|
+ flex: 1;
|
|
|
+ text-align: center;
|
|
|
+ font-size: 17px;
|
|
|
+ color: #FFFFFF;
|
|
|
+ font-weight: bold;
|
|
|
+}
|
|
|
+.nav-save {
|
|
|
+ font-size: 15px;
|
|
|
+ color: #389588;
|
|
|
+ width: 40px;
|
|
|
+ text-align: right;
|
|
|
+}
|
|
|
+/* 裁剪区域 */
|
|
|
+.crop-area {
|
|
|
+ flex: 1;
|
|
|
+ position: relative;
|
|
|
+ overflow: hidden;
|
|
|
+}
|
|
|
+.crop-image {
|
|
|
+ position: absolute;
|
|
|
+}
|
|
|
+/* 圆形遮罩 - 用box-shadow实现圆形镂空 */
|
|
|
+.crop-circle-mask {
|
|
|
+ position: absolute;
|
|
|
+ border-radius: 50%;
|
|
|
+ box-sizing: border-box;
|
|
|
+ border: 2px solid rgba(255, 255, 255, 0.8);
|
|
|
+ box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.6);
|
|
|
+ pointer-events: none;
|
|
|
+}
|
|
|
+/* 底部栏 */
|
|
|
+.bottom-bar {
|
|
|
+ height: 80px;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: row;
|
|
|
+ align-items: center;
|
|
|
+ padding: 0 20px;
|
|
|
+ background-color: #1A1A1A;
|
|
|
+}
|
|
|
+/* 实时预览圆 */
|
|
|
+.preview-circle {
|
|
|
+ width: 70px;
|
|
|
+ height: 70px;
|
|
|
+ border-radius: 50%;
|
|
|
+ overflow: hidden;
|
|
|
+ border: 2px solid rgba(255, 255, 255, 0.5);
|
|
|
+ flex-shrink: 0;
|
|
|
+}
|
|
|
+.preview-circle image {
|
|
|
+ display: block;
|
|
|
+}
|
|
|
+.tip-text {
|
|
|
+ flex: 1;
|
|
|
+ text-align: center;
|
|
|
+ font-size: 13px;
|
|
|
+ color: #999999;
|
|
|
+}
|
|
|
+/* canvas隐藏 */
|
|
|
+.crop-canvas {
|
|
|
+ position: fixed;
|
|
|
+ left: -9999px;
|
|
|
+ top: -9999px;
|
|
|
+}
|
|
|
+</style>
|