Files
im/components/cut-avatar/index.vue
T
cansnow 6720c15e30 27
2026-02-09 07:29:02 +08:00

679 lines
23 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>