feat(cropper): 添加图片裁剪组件及依赖

- 新增 Cropper 组件,支持图片裁剪、缩放、旋转功能
- 集成 cropperjs 库及其相关元素组件
- 在专业模式页面中引入并使用裁剪组件
- 更新 package.json 和 lock 文件以包含新依赖
- 优化登录过期弹窗按钮文本显示
This commit is contained in:
陈昱达
2025-12-05 10:10:15 +08:00
parent 8a20689aeb
commit 688f0dff32
5 changed files with 324 additions and 1 deletions

118
package-lock.json generated
View File

@@ -1246,6 +1246,115 @@
"to-fast-properties": "^2.0.0"
}
},
"@cropper/element": {
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/@cropper/element/-/element-2.1.0.tgz",
"integrity": "sha512-2zELddqHQNmlvkPoiYzE5nxEjPE+C8nXoTPuvV3FvLp3YjBinc7qb73Icg9UXP0o9qC4+h9q96JgGo0AyMO/Ng==",
"requires": {
"@cropper/utils": "^2.1.0"
}
},
"@cropper/element-canvas": {
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/@cropper/element-canvas/-/element-canvas-2.1.0.tgz",
"integrity": "sha512-el+rfJpZxsD2q5XxDBA4fRczcrOqB65Lb7roqXOq8LKufwf4bPWA9C6DjNJJahh/TP94dsLIEy3tSkgRMDv3Aw==",
"requires": {
"@cropper/element": "^2.1.0",
"@cropper/utils": "^2.1.0"
}
},
"@cropper/element-crosshair": {
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/@cropper/element-crosshair/-/element-crosshair-2.1.0.tgz",
"integrity": "sha512-0V589dAx8uZAfvJwdINLn76gfPQEafPH94ukjJ76uX0FCUovLaAVX+VRD/MDSYn0Mza/xejzmL9Dhd1DfemvmA==",
"requires": {
"@cropper/element": "^2.1.0",
"@cropper/utils": "^2.1.0"
}
},
"@cropper/element-grid": {
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/@cropper/element-grid/-/element-grid-2.1.0.tgz",
"integrity": "sha512-dEnk0rO+vp553LMvsPYgfrqVFcYXeVFrgFeavBYYEhAXtO40p7kN4rmLYLMMjaN+T/Mx2BATv6kUQpALKy2HLw==",
"requires": {
"@cropper/element": "^2.1.0",
"@cropper/utils": "^2.1.0"
}
},
"@cropper/element-handle": {
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/@cropper/element-handle/-/element-handle-2.1.0.tgz",
"integrity": "sha512-8BklWA4C/2GGAULupIWleSnGutECvYt3vx9flodqDfZpDEozws4LgLqmmzVuQmVkRVUdLnXdtx28kjgWLtzkHg==",
"requires": {
"@cropper/element": "^2.1.0",
"@cropper/utils": "^2.1.0"
}
},
"@cropper/element-image": {
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/@cropper/element-image/-/element-image-2.1.0.tgz",
"integrity": "sha512-mXOV8ixJvG0XtTxLebYAKDjEkFbFOQnsF02hXPZk1yQSV0J+LLhN7a2NePrtKnoTsEV19fhhX3UorMoyGGxvzg==",
"requires": {
"@cropper/element": "^2.1.0",
"@cropper/element-canvas": "^2.1.0",
"@cropper/utils": "^2.1.0"
}
},
"@cropper/element-selection": {
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/@cropper/element-selection/-/element-selection-2.1.0.tgz",
"integrity": "sha512-mtFtBl6HIa/s9TWohXw+Z5eJoeYTqylrIcHvS7oVv0uM7IyeRwBW65Q7z+KtLfq/LW+2Sw/XDyvR+VN/DawBPw==",
"requires": {
"@cropper/element": "^2.1.0",
"@cropper/element-canvas": "^2.1.0",
"@cropper/element-image": "^2.1.0",
"@cropper/utils": "^2.1.0"
}
},
"@cropper/element-shade": {
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/@cropper/element-shade/-/element-shade-2.1.0.tgz",
"integrity": "sha512-zMdyqbb0lc0Vd1oj2Z1miIZvhyZG41OXMHvrNt0hNwblh0dVdrvtw48lnFDgRv+672vt2CNx7Q04GuvCQfPlgg==",
"requires": {
"@cropper/element": "^2.1.0",
"@cropper/element-canvas": "^2.1.0",
"@cropper/element-selection": "^2.1.0",
"@cropper/utils": "^2.1.0"
}
},
"@cropper/element-viewer": {
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/@cropper/element-viewer/-/element-viewer-2.1.0.tgz",
"integrity": "sha512-XnxlQuqHitd1FOFZ6E0yXAF5NYd/LyIvONLLHI9p1rJw747WYKUPxQaSYtFKF7IOizJu/8mMj++Zc1dZ5ZP3YQ==",
"requires": {
"@cropper/element": "^2.1.0",
"@cropper/element-canvas": "^2.1.0",
"@cropper/element-image": "^2.1.0",
"@cropper/element-selection": "^2.1.0",
"@cropper/utils": "^2.1.0"
}
},
"@cropper/elements": {
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/@cropper/elements/-/elements-2.1.0.tgz",
"integrity": "sha512-qvzlYDn3VQgPPpsCu6Gi1XUO0v3vpXQFSjjxcVijbXeNsl/eiKrN7H9/CEiRgi5vr8kXfd7ZvgYxBjUBbH+y+w==",
"requires": {
"@cropper/element": "^2.1.0",
"@cropper/element-canvas": "^2.1.0",
"@cropper/element-crosshair": "^2.1.0",
"@cropper/element-grid": "^2.1.0",
"@cropper/element-handle": "^2.1.0",
"@cropper/element-image": "^2.1.0",
"@cropper/element-selection": "^2.1.0",
"@cropper/element-shade": "^2.1.0",
"@cropper/element-viewer": "^2.1.0"
}
},
"@cropper/utils": {
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/@cropper/utils/-/utils-2.1.0.tgz",
"integrity": "sha512-wLtpZ4/UWgo+fGmG8NBWge8x5ehjfDe9ovleDfLy8kpwFaw43XXOEXQtRL1UNr0u4JZxaeO8FcXcolRWUUrlRQ=="
},
"@ctrl/tinycolor": {
"version": "3.6.0",
"resolved": "https://registry.npmmirror.com/@ctrl/tinycolor/-/tinycolor-3.6.0.tgz",
@@ -3906,6 +4015,15 @@
"readable-stream": "^3.4.0"
}
},
"cropperjs": {
"version": "2.0.0-rc.2",
"resolved": "https://registry.npmmirror.com/cropperjs/-/cropperjs-2.0.0-rc.2.tgz",
"integrity": "sha512-BTuz+UeZphGOEnBCuQiNT4rk1uFfKJaKmTgoH9XU7Q8IMkLdodW7YPWINmXJXwWMt1nXiKze5qKADVbz9xtVFg==",
"requires": {
"@cropper/elements": "^2.0.0-rc.2",
"@cropper/utils": "^2.0.0-rc.2"
}
},
"cross-spawn": {
"version": "7.0.3",
"resolved": "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.3.tgz",

View File

@@ -18,6 +18,7 @@
"axios": "^1.1.3",
"babel-eslint": "^10.1.0",
"core-js": "^3.8.3",
"cropperjs": "^2.0.0-rc.2",
"dayjs": "^1.11.6",
"echarts": "^5.4.1",
"element-plus": "^2.2.17",

View File

@@ -43,12 +43,13 @@ jsonRequest.interceptors.response.use(
{
type: "warning",
confirmButtonText: "重新登录",
cancelButtonText: "取消",
}
).then(() => {
window.location.href = ReLoginUrl;
});
} else if (code === 403) {
ElMessage.error("当前操作没有权限");
$message.error("当前操作没有权限");
} else if (code === 302) {
window.location.href = ReLoginUrl;
} else {
@@ -113,6 +114,7 @@ formRequest.interceptors.response.use(
ElMessageBox.confirm("登录状态无效,即将跳转至登录页", "登录已过期", {
type: "warning",
confirmButtonText: "确认",
cancelButtonText: "取消",
}).then(() => {
window.location.href = ReLoginUrl;
});

View File

@@ -0,0 +1,187 @@
<template>
<el-dialog class="image-cropper" v-model="show" width="800px">
<!-- 图像容器 -->
<div class="img-container">
<cropper-canvas
background
style="width: 100%; height: 100%"
ref="cropCanvas"
>
<cropper-image
:src="props.img"
alt="Picture"
rotatable
scalable
skewable
translatable
crossorigin="anonymous"
></cropper-image>
<cropper-shade hidden></cropper-shade>
<cropper-handle action="move" plain></cropper-handle>
<cropper-selection initial-coverage="0.5" outlined>
<cropper-grid role="grid" covered></cropper-grid>
<cropper-crosshair centered></cropper-crosshair>
<cropper-handle
action="move"
theme-color="rgba(255, 255, 255, 0.35)"
></cropper-handle>
<cropper-handle action="n-resize"></cropper-handle>
<cropper-handle action="e-resize"></cropper-handle>
<cropper-handle action="s-resize"></cropper-handle>
<cropper-handle action="w-resize"></cropper-handle>
<cropper-handle action="ne-resize"></cropper-handle>
<cropper-handle action="nw-resize"></cropper-handle>
<cropper-handle action="se-resize"></cropper-handle>
<cropper-handle action="sw-resize"></cropper-handle>
</cropper-selection>
</cropper-canvas>
</div>
<template #footer>
<div slot="footer">
<el-button @click="show = false">取消</el-button>
<el-button type="primary" @click="crop">确定</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup>
import { ref, watch, onBeforeUnmount } from "vue";
import "cropperjs";
// import "cropperjs/dist/cropper.css";
import { ElDialog, ElButton } from "element-plus";
const imageRef = ref(null);
const imgSrc = ref("");
const cropper = ref(null);
const resultUrl = ref("");
const scale = ref(1);
const cropCanvas = ref(null);
const props = defineProps({
img: {
type: String,
default: "",
},
show: {
type: Boolean,
default: false,
},
});
const show = ref(props.show);
const emit = defineEmits(["update:show", "crop-success"]);
watch(
() => show.value,
(n) => {
emit("update:show", n);
}
);
watch(
() => props.show,
(n) => {
show.value = n;
}
);
// 文件选择
const onFileChange = (e) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
imgSrc.value = event.target.result;
// 延迟初始化 cropper
setTimeout(initCropper, 50);
};
reader.readAsDataURL(file);
// 清空 input
e.target.value = "";
};
// 初始化 cropper
const initCropper = () => {
const image = imageRef.value;
};
// 缩放
const onScale = (e) => {
const value = parseFloat(e.target.value);
scale.value = value;
cropper.value?.zoomTo(value);
};
// 旋转
const rotate = (deg) => {
cropper.value?.rotate(deg);
};
// 裁剪并输出
const crop = async () => {
try {
const canvas = await cropCanvas.value.$toCanvas();
console.log(canvas);
// 使用toBlob方法替代toDataURL解决跨域问题
canvas.toBlob(
(blob) => {
const dataUrl = URL.createObjectURL(blob);
console.log(dataUrl);
emit("crop-success", dataUrl);
show.value = false;
},
"image/jpeg",
0.9
);
} catch (error) {
console.error("裁剪失败:", error);
}
};
// 销毁实例
onBeforeUnmount(() => {
if (cropper.value) {
cropper.value.destroy();
cropper.value = null;
}
});
</script>
<style scoped>
.image-cropper {
padding: 20px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
.img-container {
margin-top: 10px;
width: 100%;
height: 500px;
}
.controls {
margin-top: 15px;
display: flex;
align-items: center;
gap: 10px;
}
.controls input[type="range"] {
flex: 1;
}
.preview {
margin-top: 20px;
}
.rounded {
width: 100px;
height: 100px;
border-radius: 50%;
object-fit: cover;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
</style>

View File

@@ -16,8 +16,10 @@ import {
ElSelectV2,
ElTreeSelect,
ElOption,
ElDialog,
} from "element-plus";
import FieldCloud from "@/components/FileCloud/index.vue";
import Cropper from "@/components/Cropper/Cropper.vue";
import { useUpload } from "@/hooks/useUpload";
import { useCourseForm } from "@/hooks/useCourseForm";
import { useRouter } from "vue-router";
@@ -66,6 +68,7 @@ const dlgFileShow = ref(false);
const chooseFile = () => {
dlgFileShow.value = true;
};
const dlgCutShow = ref(false);
const changeCourseImage = (img) => {
if (!img.path) {
@@ -73,12 +76,17 @@ const changeCourseImage = (img) => {
}
dlgFileShow.value = false;
formState.coverImg = fileBaseUrl + img.path;
dlgCutShow.value = true;
};
const choseChoose = () => {
dlgFileShow.value = false;
};
const success = (e) => {
console.log(e);
formState.coverImg = e;
};
// 表单提交
const handleSubmit = () => {
// formRef.value
@@ -331,6 +339,13 @@ onMounted(() => {
@choose="changeCourseImage"
@close="choseChoose"
></field-cloud>
<!-- 裁切-->
<Cropper
:img="formState.coverImg"
v-model:show="dlgCutShow"
@crop-success="success"
></Cropper>
</div>
</template>