679 lines
23 KiB
Vue
679 lines
23 KiB
Vue
<template>
|
||
<div class="cut-avatar-page" v-if="tempFilePath">
|
||
<div class="main-content" v-if="1==2">
|
||
<div class="preview-section">
|
||
<div v-if="resultPath" class="result-box">
|
||
<image :src="resultPath" mode="widthFix" class="result-image" @click="previewResult"></image>
|
||
<div class="action-row">
|
||
<button class="btn btn-outline" @click="reSelect">重新选择</button>
|
||
<button class="btn btn-primary" @click="uploadImage">确认上传</button>
|
||
</div>
|
||
<text class="tip-text">点击图片可预览大图</text>
|
||
</div>
|
||
|
||
<div v-else class="upload-box" @click="onSelect">
|
||
<div class="placeholder-icon">+</div>
|
||
<text class="upload-text">点击上传图片</text>
|
||
<text class="upload-sub-text">支持 JPG/PNG,自动裁剪 1:1</text>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="isShowEditor" class="editor-modal" :class="{ 'dark-mode': isDarkMode }">
|
||
<div class="cropper-wrapper">
|
||
<movable-area class="crop-area" :style="{ height: screenHeight + 'px' }">
|
||
<movable-view
|
||
class="crop-view"
|
||
direction="all"
|
||
:out-of-bounds="true"
|
||
:x="imgX"
|
||
:y="imgY"
|
||
:scale="scale+''"
|
||
:scale-min="0.5"
|
||
:scale-max="4"
|
||
:animation="false"
|
||
@change="onMoveChange"
|
||
@scale="onScaleChange"
|
||
:style="{ width: imgDisplayWidth + 'px', height: imgDisplayHeight + 'px' }"
|
||
>
|
||
<image :src="tempFilePath" class="target-image" :style="{ transform: 'rotate(' + rotation + 'deg)' }"></image>
|
||
</movable-view>
|
||
</movable-area>
|
||
|
||
<div class="mask-layer" :style="{ height: screenHeight + 'px' }">
|
||
<div class="mask-top" :style="{ height: (screenHeight - cropSize) / 2 + 'px' }"></div>
|
||
<div class="mask-middle" :style="{ height: cropSize + 'px' }">
|
||
<div class="mask-left" :style="{ width: (screenWidth - cropSize) / 2 + 'px' }"></div>
|
||
<div class="crop-box" :style="{ width: cropSize + 'px', height: cropSize + 'px' }">
|
||
<div class="corner c-tl"></div>
|
||
<div class="corner c-tr"></div>
|
||
<div class="corner c-bl"></div>
|
||
<div class="corner c-br"></div>
|
||
</div>
|
||
<div class="mask-right" :style="{ width: (screenWidth - cropSize) / 2 + 'px' }"></div>
|
||
</div>
|
||
<div class="mask-bottom" :style="{ height: (screenHeight - cropSize) / 2 + 'px' }"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="editor-toolbar">
|
||
<div class="tool-btn" @click="onCancel">取消</div>
|
||
<div class="tool-btn" @click="onReset"> <text class="icon-reset">↻</text> 重置 </div>
|
||
<div class="tool-btn" @click="onRotate"> <text class="icon-rotate">↻</text> 旋转 </div>
|
||
<div class="tool-btn btn-confirm" @click="onConfirm">确定</div>
|
||
</div>
|
||
|
||
<canvas canvas-id="cropCanvas" class="crop-canvas" :style="{ width: cropSize + 'px', height: cropSize + 'px' }"></canvas>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script>
|
||
/**
|
||
* 核心逻辑说明:
|
||
* 1. 使用 movable-view 实现用户手势交互(拖拽、缩放)。
|
||
* 2. 使用 image 的 css transform 实现视觉上的旋转。
|
||
* 3. 最终生成时,通过计算偏移量,使用 CanvasContext 绘制图像。
|
||
* 4. 旋转后的 Canvas 绘图坐标系需要特殊处理 (translate + rotate)。
|
||
*/
|
||
export default {
|
||
data() {
|
||
return {
|
||
// --- 系统/环境参数 ---
|
||
screenWidth: 375,
|
||
screenHeight: 600,
|
||
isDarkMode: false,
|
||
|
||
// --- 业务数据 ---
|
||
resultPath: '', // 最终生成的图片路径
|
||
|
||
// --- 编辑器状态 ---
|
||
isShowEditor: false,
|
||
tempFilePath: '', // 选中的原始图片路径
|
||
|
||
// --- 图片原始信息 ---
|
||
realImgWidth: 0,
|
||
realImgHeight: 0,
|
||
|
||
// --- 交互状态数据 ---
|
||
cropSize: 280, // 裁剪框大小 (正方形)
|
||
imgDisplayWidth: 0, // 图片在屏幕上的初始显示宽度
|
||
imgDisplayHeight: 0, // 图片在屏幕上的初始显示高度
|
||
|
||
// movable-view 的属性
|
||
imgX: 0,
|
||
imgY: 0,
|
||
scale: 1,
|
||
rotation: 0, // 0, 90, 180, 270
|
||
|
||
// 记录移动过程中的临时数据 (用于计算)
|
||
currentX: 0,
|
||
currentY: 0,
|
||
currentScale: 1,
|
||
};
|
||
},
|
||
created() {
|
||
this.initSystemInfo();
|
||
},
|
||
methods: {
|
||
// -----------------------------------------------------------
|
||
// 初始化与系统适配
|
||
// -----------------------------------------------------------
|
||
initSystemInfo() {
|
||
const sys = uni.getSystemInfoSync();
|
||
this.screenWidth = sys.windowWidth;
|
||
this.screenHeight = sys.windowHeight;
|
||
|
||
// 适配暗黑模式
|
||
if (sys.theme === 'dark') {
|
||
this.isDarkMode = true;
|
||
}
|
||
|
||
// 动态计算裁剪框大小 (屏幕宽度的 80%,最大 400px 用于适配 Pad)
|
||
let size = this.screenWidth * 0.8;
|
||
if (size > 400) size = 400;
|
||
this.cropSize = Math.floor(size);
|
||
},
|
||
|
||
// -----------------------------------------------------------
|
||
// 交互逻辑
|
||
// -----------------------------------------------------------
|
||
onSelect() {
|
||
console.log('[Debug] 点击选择图片');
|
||
uni.chooseImage({
|
||
count: 1,
|
||
sizeType: ['original', 'compressed'],
|
||
sourceType: ['album', 'camera'],
|
||
success: (res) => {
|
||
const src = res.tempFilePaths[0];
|
||
this.enterEditor(src);
|
||
},
|
||
fail: (err) => {
|
||
console.error('[Error] 选择图片失败', err);
|
||
},
|
||
});
|
||
},
|
||
|
||
reSelect() {
|
||
this.onSelect();
|
||
},
|
||
|
||
previewResult() {
|
||
if (this.resultPath) {
|
||
uni.previewImage({
|
||
urls: [this.resultPath],
|
||
});
|
||
}
|
||
},
|
||
|
||
uploadImage() {
|
||
console.log('[Debug] 触发上传逻辑, 路径:', this.resultPath);
|
||
// 这里预留上传 API 调用
|
||
uni.showLoading({ title: '正在上传...' });
|
||
setTimeout(() => {
|
||
uni.hideLoading();
|
||
uni.showToast({ title: '模拟上传成功', icon: 'success' });
|
||
}, 1000);
|
||
},
|
||
|
||
// -----------------------------------------------------------
|
||
// 编辑器逻辑
|
||
// -----------------------------------------------------------
|
||
|
||
// 进入编辑模式,初始化图片位置
|
||
enterEditor(src) {
|
||
uni.showLoading({ title: '加载中...' });
|
||
|
||
uni.getImageInfo({
|
||
src: src,
|
||
success: (res) => {
|
||
this.tempFilePath = src;
|
||
this.realImgWidth = res.width;
|
||
this.realImgHeight = res.height;
|
||
|
||
// 重置状态
|
||
this.rotation = 0;
|
||
this.scale = 1;
|
||
this.currentScale = 1;
|
||
|
||
// 计算图片初始显示尺寸(使其适应屏幕宽度,类似 width: 100%)
|
||
// 这里我们让图片的短边至少填满裁剪框,方便用户操作
|
||
const ratio = this.realImgWidth / this.realImgHeight;
|
||
|
||
if (ratio > 1) {
|
||
// 横图:高度定为裁剪框大小 + 一点冗余,宽度自适应
|
||
this.imgDisplayHeight = this.cropSize;
|
||
this.imgDisplayWidth = this.imgDisplayHeight * ratio;
|
||
} else {
|
||
// 竖图
|
||
this.imgDisplayWidth = this.cropSize;
|
||
this.imgDisplayHeight = this.imgDisplayWidth / ratio;
|
||
}
|
||
|
||
// 居中计算
|
||
// 屏幕中心
|
||
const centerX = this.screenWidth / 2;
|
||
const centerY = this.screenHeight / 2;
|
||
|
||
// 计算 movable-view 的初始 x, y 使其居中
|
||
// movable-view 的 x,y 是相对于 movable-area (全屏) 的左上角
|
||
this.imgX = centerX - this.imgDisplayWidth / 2;
|
||
this.imgY = centerY - this.imgDisplayHeight / 2;
|
||
|
||
// 同步 current 值
|
||
this.currentX = this.imgX;
|
||
this.currentY = this.imgY;
|
||
|
||
this.isShowEditor = true;
|
||
uni.hideLoading();
|
||
},
|
||
fail: () => {
|
||
uni.hideLoading();
|
||
uni.showToast({ title: '图片加载失败', icon: 'none' });
|
||
},
|
||
});
|
||
},
|
||
|
||
onMoveChange(e) {
|
||
// 记录移动位置,用于最终裁剪计算
|
||
// detail: {x, y, source}
|
||
this.currentX = e.detail.x;
|
||
this.currentY = e.detail.y;
|
||
},
|
||
onReset(){
|
||
this.rotation = 0;
|
||
this.currentScale = 1;
|
||
},
|
||
onScaleChange(e) {
|
||
// detail: {scale}
|
||
this.currentScale = e.detail.scale;
|
||
},
|
||
|
||
onRotate() {
|
||
console.log('[Debug] 点击旋转');
|
||
// 顺时针旋转 90 度
|
||
this.rotation = (this.rotation + 90) % 360;
|
||
// 旋转后,可能需要重置一下缩放或位置逻辑?
|
||
// 本方案中,为了简化体验,仅做视觉旋转,
|
||
// 最终 Canvas 裁剪时根据 rotation 参数处理坐标系。
|
||
},
|
||
|
||
onCancel() {
|
||
this.isShowEditor = false;
|
||
this.tempFilePath = '';
|
||
},
|
||
|
||
// -----------------------------------------------------------
|
||
// 核心裁剪生成逻辑 (Canvas)
|
||
// -----------------------------------------------------------
|
||
// -----------------------------------------------------------
|
||
onConfirm() {
|
||
console.log('[Debug] 开始生成图片');
|
||
uni.showLoading({ title: '处理中...' });
|
||
|
||
const ctx = uni.createCanvasContext('cropCanvas', this);
|
||
|
||
// 1. 基础参数
|
||
const destSize = this.cropSize; // 画布大小 (正方形)
|
||
const halfDest = destSize / 2;
|
||
|
||
// 清空画布
|
||
ctx.setFillStyle('#FFFFFF');
|
||
ctx.fillRect(0, 0, destSize, destSize);
|
||
|
||
// 2. 计算【屏幕坐标系】下的中心点偏移
|
||
// 这里的逻辑是:找出"图片中心"相对于"裁剪框中心"在屏幕上的距离
|
||
|
||
// A. 裁剪框中心在屏幕上的坐标
|
||
const cropCenterX = (this.screenWidth - this.cropSize) / 2 + this.cropSize / 2;
|
||
const cropCenterY = (this.screenHeight - this.cropSize) / 2 + this.cropSize / 2;
|
||
|
||
// B. 图片中心在屏幕上的坐标
|
||
// 注意:movable-view 的 x,y 是左上角坐标,且 movable-view 的缩放是以中心为原点的
|
||
// 实际上 uni-app 的 movable-view 在缩放/移动后,x/y 会自动更新为当前的左上角位置
|
||
// 所以图片中心 = 当前x + (显示宽度 * 缩放 / 2)
|
||
const currentRealWidth = this.imgDisplayWidth * this.currentScale;
|
||
const currentRealHeight = this.imgDisplayHeight * this.currentScale;
|
||
|
||
const imgCenterX = this.currentX + currentRealWidth / 2;
|
||
const imgCenterY = this.currentY + currentRealHeight / 2;
|
||
|
||
// C. 算出屏幕偏移向量 (Screen Diff)
|
||
// 含义:图片中心在裁剪框中心的 右侧 diffX 像素,下方 diffY 像素
|
||
const diffX = imgCenterX - cropCenterX;
|
||
const diffY = imgCenterY - cropCenterY;
|
||
|
||
// 3. 将屏幕偏移向量【映射】到旋转后的 Canvas 坐标系
|
||
// 我们需要算出:在旋转后的坐标系里,图片中心应该在哪里 (drawX, drawY)
|
||
let drawX = 0;
|
||
let drawY = 0;
|
||
|
||
// 规范化旋转角度 (0, 90, 180, 270)
|
||
const angle = this.rotation % 360;
|
||
|
||
// 坐标系映射逻辑:
|
||
// 屏幕 X+ (向右),屏幕 Y+ (向下)
|
||
// 假设 Canvas 原点已移至中心并旋转:
|
||
switch (angle) {
|
||
case 0:
|
||
// 0度:坐标系一致
|
||
drawX = diffX;
|
||
drawY = diffY;
|
||
break;
|
||
case 90:
|
||
// 90度 (顺时针):Canvas X轴指向屏幕下,Canvas Y轴指向屏幕左
|
||
// 屏幕的 X+ (向右) 对应 Canvas 的 Y- (向左的相反) -> drawY = -diffX
|
||
// 屏幕的 Y+ (向下) 对应 Canvas 的 X+ (向下) -> drawX = diffY
|
||
drawX = diffY;
|
||
drawY = -diffX;
|
||
break;
|
||
case 180:
|
||
// 180度:Canvas X轴向左,Canvas Y轴向上 (完全相反)
|
||
drawX = -diffX;
|
||
drawY = -diffY;
|
||
break;
|
||
case 270: // 相当于 -90度
|
||
// 270度:Canvas X轴向上,Canvas Y轴向右
|
||
// 屏幕 X+ (向右) 对应 Canvas Y+ -> drawY = diffX
|
||
// 屏幕 Y+ (向下) 对应 Canvas X- -> drawX = -diffY
|
||
drawX = -diffY;
|
||
drawY = diffX;
|
||
break;
|
||
}
|
||
|
||
// 4. 执行绘制
|
||
ctx.save();
|
||
|
||
// a. 将原点移到画布中心
|
||
ctx.translate(halfDest, halfDest);
|
||
// b. 旋转坐标系
|
||
ctx.rotate((angle * Math.PI) / 180);
|
||
|
||
// c. 绘制图片
|
||
// drawImage 的 x,y 是图片的左上角坐标
|
||
// 我们算出的 drawX, drawY 是图片中心相对于画布中心的坐标
|
||
// 所以要减去宽高的一半
|
||
ctx.drawImage(this.tempFilePath, drawX - currentRealWidth / 2, drawY - currentRealHeight / 2, currentRealWidth, currentRealHeight);
|
||
|
||
ctx.restore();
|
||
|
||
// 5. 导出图片
|
||
ctx.draw(false, () => {
|
||
setTimeout(() => {
|
||
uni.canvasToTempFilePath(
|
||
{
|
||
canvasId: 'cropCanvas',
|
||
width: destSize,
|
||
height: destSize,
|
||
fileType: 'jpg',
|
||
quality: 1,
|
||
success: (res) => {
|
||
this.resultPath = res.tempFilePath;
|
||
this.tempFilePath = "";
|
||
this.isShowEditor = false;
|
||
uni.hideLoading();
|
||
this.$emit('save', {path:this.resultPath});
|
||
},
|
||
fail: () => {
|
||
uni.hideLoading();
|
||
uni.showToast({ title: '生成失败', icon: 'none' });
|
||
},
|
||
},
|
||
this,
|
||
);
|
||
}, 200);
|
||
});
|
||
},
|
||
},
|
||
};
|
||
</script>
|
||
|
||
<style scoped>
|
||
/* ========================
|
||
CSS 变量定义
|
||
======================== */
|
||
.cut-avatar-page {
|
||
--primary-color: #ef3912;
|
||
--bg-color: #f8f9fa;
|
||
--text-color: #333333;
|
||
--modal-bg: #000000;
|
||
|
||
min-height: 100vh;
|
||
background-color: var(--bg-color);
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
/* 适配暗黑模式 */
|
||
@media (prefers-color-scheme: dark) {
|
||
.page-container {
|
||
--bg-color: #1a1a1a;
|
||
--text-color: #ffffff;
|
||
}
|
||
}
|
||
|
||
/* ========================
|
||
主界面样式
|
||
======================== */
|
||
.nav-bar {
|
||
height: 44px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background-color: var(--bg-color);
|
||
/* 简单的顶部阴影 */
|
||
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.05);
|
||
}
|
||
|
||
.nav-title {
|
||
font-size: 16px;
|
||
font-weight: bold;
|
||
color: var(--text-color);
|
||
}
|
||
|
||
.main-content {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
padding: 20px;
|
||
}
|
||
|
||
.preview-section {
|
||
width: 100%;
|
||
max-width: 600px; /* Pad 适配限制 */
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
margin-top: 40px;
|
||
}
|
||
|
||
/* 上传占位框 */
|
||
.upload-box {
|
||
width: 100%;
|
||
height: 200px;
|
||
border: 2px dashed #cccccc;
|
||
border-radius: 12px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background-color: rgba(0, 0, 0, 0.02);
|
||
transition: all 0.3s;
|
||
}
|
||
|
||
.upload-box:active {
|
||
background-color: rgba(239, 57, 18, 0.05);
|
||
border-color: var(--primary-color);
|
||
}
|
||
|
||
.placeholder-icon {
|
||
font-size: 40px;
|
||
color: #999;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.upload-text {
|
||
font-size: 16px;
|
||
color: var(--text-color);
|
||
font-weight: bold;
|
||
}
|
||
|
||
.upload-sub-text {
|
||
font-size: 12px;
|
||
color: #999;
|
||
margin-top: 5px;
|
||
}
|
||
|
||
/* 结果展示区 */
|
||
.result-box {
|
||
width: 100%;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
}
|
||
|
||
.result-image {
|
||
width: 200px;
|
||
height: 200px;
|
||
border-radius: 8px;
|
||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||
background-color: #eee;
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.action-row {
|
||
display: flex;
|
||
flex-direction: row;
|
||
justify-content: center;
|
||
gap: 15px;
|
||
width: 100%;
|
||
}
|
||
|
||
.btn {
|
||
font-size: 14px;
|
||
padding: 8px 24px;
|
||
border-radius: 20px;
|
||
border: none;
|
||
}
|
||
|
||
.btn-primary {
|
||
background-color: var(--primary-color);
|
||
color: #fff;
|
||
}
|
||
|
||
.btn-outline {
|
||
background-color: transparent;
|
||
border: 1px solid #ccc;
|
||
color: var(--text-color);
|
||
}
|
||
|
||
.tip-text {
|
||
font-size: 12px;
|
||
color: #999;
|
||
margin-top: 15px;
|
||
}
|
||
|
||
/* ========================
|
||
编辑器弹窗样式 (全屏)
|
||
======================== */
|
||
.editor-modal {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
z-index: 999;
|
||
background-color: #000;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.cropper-wrapper {
|
||
flex: 1;
|
||
position: relative;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.crop-area {
|
||
width: 100%;
|
||
/* height 由 JS 动态控制 */
|
||
}
|
||
|
||
.crop-view {
|
||
/* 初始不可见,加载图片后显示 */
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.target-image {
|
||
width: 100%;
|
||
height: 100%;
|
||
/* 优化图片渲染 */
|
||
will-change: transform;
|
||
}
|
||
|
||
/* 遮罩层布局 */
|
||
.mask-layer {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
width: 100%;
|
||
pointer-events: none; /* 让事件穿透到底下的 movable-area */
|
||
display: flex;
|
||
flex-direction: column;
|
||
z-index: 10;
|
||
}
|
||
|
||
.mask-top,
|
||
.mask-bottom,
|
||
.mask-left,
|
||
.mask-right {
|
||
background-color: rgba(0, 0, 0, 0.6);
|
||
}
|
||
|
||
.mask-middle {
|
||
display: flex;
|
||
flex-direction: row;
|
||
}
|
||
|
||
/* 裁剪框样式 */
|
||
.crop-box {
|
||
position: relative;
|
||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
/* 四个角的装饰 */
|
||
.corner {
|
||
position: absolute;
|
||
width: 15px;
|
||
height: 15px;
|
||
border: 3px solid #fff;
|
||
}
|
||
|
||
.c-tl {
|
||
top: -2px;
|
||
left: -2px;
|
||
border-right: none;
|
||
border-bottom: none;
|
||
}
|
||
.c-tr {
|
||
top: -2px;
|
||
right: -2px;
|
||
border-left: none;
|
||
border-bottom: none;
|
||
}
|
||
.c-bl {
|
||
bottom: -2px;
|
||
left: -2px;
|
||
border-right: none;
|
||
border-top: none;
|
||
}
|
||
.c-br {
|
||
bottom: -2px;
|
||
right: -2px;
|
||
border-left: none;
|
||
border-top: none;
|
||
}
|
||
|
||
/* 底部工具栏 */
|
||
.editor-toolbar {
|
||
height: 80px; /* 留足安全距离 */
|
||
background-color: #1a1a1a;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 0 30px;
|
||
padding-bottom: constant(safe-area-inset-bottom);
|
||
padding-bottom: env(safe-area-inset-bottom);
|
||
}
|
||
|
||
.tool-btn {
|
||
color: #fff;
|
||
font-size: 16px;
|
||
padding: 10px;
|
||
}
|
||
|
||
.btn-confirm {
|
||
color: var(--primary-color);
|
||
font-weight: bold;
|
||
}
|
||
|
||
.icon-rotate {
|
||
font-size: 18px;
|
||
margin-right: 4px;
|
||
}
|
||
|
||
/* Canvas 隐藏处理 */
|
||
.crop-canvas {
|
||
position: absolute;
|
||
left: -9999px;
|
||
top: -9999px;
|
||
pointer-events: none;
|
||
visibility: hidden; /* 这里用 hidden, 有些环境 display:none 会导致不渲染 */
|
||
opacity: 0;
|
||
}
|
||
</style>
|