Files
aida_front/src/views/SellerDashboard/BrandProfile/image-clip.vue
2026-05-06 13:25:42 +08:00

392 lines
9.3 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="image-clip" :class="{ 'is-product': isProduct }" :data-crop-type="type || ''">
<div class="image-clip-body" :class="{ 'is-cover': type === 'cover' }" ref="imageClipBody">
<VueCropper
ref="cropper"
:img="url"
crossOrigin="Anonymous"
:autoCrop="true"
:fixedNumber="ratio"
:fixed="isProduct ? type !== 'apparel' : true"
movable
@realTime="onChange"
outputType="png"
:full="true"
></VueCropper>
</div>
<div class="clip_opterate">
<div class="item" @click="rotateLeft()">
<span class="icon iconfont icon-chexiao operate_icon"></span>
</div>
<div class="item" @click="rotateRight()">
<span class="icon iconfont icon-chexiao operate_icon icon_chexiao_sec"></span>
</div>
<div class="item" @click="changeScale(-0.1)">
<span class="operate_icon icon_font">-</span>
</div>
<div class="item" @click="changeScale(0.1)">
<span class="operate_icon icon_font">+</span>
</div>
<div class="item" @click="refreshCrop()">
<span class="icon iconfont icon-shuaxin operate_icon"></span>
</div>
</div>
</div>
</template>
<script setup>
import { ref, useAttrs, onMounted, onBeforeUnmount, computed, nextTick, watch } from "vue"
import "vue-cropper/dist/index.css"
import { VueCropper } from "vue-cropper"
const props = defineProps({
url: {
type: String,
default: ""
},
ratio: {
type: Array,
default: () => [1, 1]
},
isProduct: {
type: Boolean,
default: false
},
fixedBox: {
type: Boolean,
default: true
},
type: {
type: String,
default: () => ""
}
})
const attrs = useAttrs()
const autoCropHeight = computed(() => {
let height = 426
if (props.type === "cover") height = 375
else if (props.type === "apparel") height = 320
return height
})
const onChange = (data) => {
if (attrs.onChange) {
getCropUrl().then((url) => attrs.onChange(url))
}
}
const cropper = ref(null)
const imageClipBody = ref(null)
let injectLabelFrame = 0
const observer = new ResizeObserver((entries) => {
refreshCrop()
})
const clearCropLabels = (cropperBox) => {
if (!cropperBox) return
cropperBox.querySelectorAll(".cropper-line-label").forEach((node) => node.remove())
}
const createCropLabel = ({ text, top, className }) => {
const label = document.createElement("div")
label.className = `cropper-line-label ${className}`
label.textContent = text
label.style.top = top
label.style.left = className === "label-v" ? "50%" : "0"
label.style.transform =
className === "label-v" ? "translate(-50%, -50%)" : "translateY(-50%)"
return label
}
const centerLabelTop = "8px"
const cropLabelMap = {
cover: [
{ text: "crown", top: "2.67%", className: "label-h" },
{ text: "hip line", top: "63.47%", className: "label-h" },
{ text: "mid-thigh", top: "92.8%", className: "label-h" },
{ text: "center", top: centerLabelTop, className: "label-v" }
],
mainProductImage: [
{ text: "crown", top: "2.67%", className: "label-h" },
{ text: "footbase", top: "97.6%", className: "label-h" },
{ text: "center", top: centerLabelTop, className: "label-v" }
],
sketch: [
{ text: "crown", top: "2.67%", className: "label-h" },
{ text: "footbase", top: "97.6%", className: "label-h" },
{ text: "center", top: centerLabelTop, className: "label-v" }
],
apparel: [{ text: "center", top: centerLabelTop, className: "label-v" }]
}
const injectCropLabel = () => {
const cropperBox = imageClipBody.value?.querySelector(".cropper-view-box")
if (!cropperBox) return false
clearCropLabels(cropperBox)
;(cropLabelMap[props.type] || []).forEach((config) => {
cropperBox.appendChild(createCropLabel(config))
})
return true
}
const scheduleInjectCropLabel = (retry = 0) => {
cancelAnimationFrame(injectLabelFrame)
injectLabelFrame = requestAnimationFrame(() => {
if (!injectCropLabel() && retry < 10) {
scheduleInjectCropLabel(retry + 1)
}
})
}
onMounted(() => {
observer.observe(imageClipBody.value)
scheduleInjectCropLabel()
})
onBeforeUnmount(() => {
observer.disconnect()
cancelAnimationFrame(injectLabelFrame)
})
const rotateLeft = () => {
cropper.value.rotateLeft()
}
const rotateRight = () => {
cropper.value.rotateRight()
}
const refreshCrop = () => {
cropper.value.refresh()
}
const changeScale = (num = 1) => {
cropper.value.changeScale(num)
}
const getCropUrl = () => {
return new Promise((resolve, reject) => {
cropper.value.getCropData(resolve)
})
}
const getCropBlob = () => {
return new Promise((resolve, reject) => {
cropper.value.getCropBlob(resolve)
})
}
watch(
[() => props.type, () => props.url],
async () => {
await nextTick()
scheduleInjectCropLabel()
},
{ flush: "post" }
)
defineExpose({
getCropUrl,
getCropBlob
})
</script>
<style lang="less" scoped>
.image-clip {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
// height: 100%;
background: #fff;
border-radius: calc(2rem * 1.2);
padding: calc(1.3rem * 1.2) calc(1.3rem * 1.2) calc(2rem * 1.2);
box-sizing: border-box;
.image-clip-body {
width: 100%;
height: calc(40rem * 1.2);
// height: 53rem;
background: yellow;
:deep(.cropper-box) {
.cropper-box-canvas {
background-color: #ffffff;
// img {
// height: 100%;
// }
}
}
&.is-cover {
:deep(.vue-cropper) {
overflow: hidden;
}
// :deep(.cropper-box-canvas) {
// width: 31.1rem !important;
// left: 50% !important;
// transform: translateX(-50%) !important;
// img {
// display: none;
// }
// }
}
}
.clip_opterate {
margin: calc(2.7rem * 1.2) auto 0;
border-radius: calc(1.6rem * 1.2);
display: flex;
overflow: hidden;
border: 1px solid #e2e2e4;
width: calc(24rem * 1.2);
.item {
width: calc(4.7rem * 1.2);
height: calc(4rem * 1.2);
display: flex;
align-items: center;
justify-content: center;
border-right: 0.1rem solid #e6e8ea;
cursor: pointer;
.icon_chexiao_sec {
transform: rotateY(180deg); /* 垂直镜像翻转 */
}
.operate_icon {
font-size: calc(1.8rem * 1.2);
color: rgba(102, 102, 102, 1);
font-weight: bold;
}
.icon_font {
font-size: calc(2.5rem * 1.2);
position: relative;
top: calc(-0.3rem * 1.2);
user-select: none;
}
.icon-shuaxin {
font-size: calc(1.4rem * 1.2);
}
&:last-child {
border: none;
}
}
}
&.is-product {
.image-clip-body {
width: 45.7rem;
height: 45.7rem;
:deep(.cropper-modal) {
background: transparent;
}
:deep(.vue-cropper .cropper-view-box) {
position: relative;
overflow: visible !important;
/* 原有的蓝色边框outline由组件控制这里不干涉 */
}
:deep(.vue-cropper .cropper-view-box::after) {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
box-sizing: border-box;
border: 1px solid rgba(75, 165, 255, 0.85);
pointer-events: none;
z-index: 9; /* 位于图片之上,但在控制点之下 */
background-image: none;
background-repeat: no-repeat;
}
:deep(.vue-cropper .crop-point) {
z-index: 10;
}
}
&[data-crop-type="cover"] {
.image-clip-body {
:deep(.vue-cropper .cropper-view-box::after) {
background-image:
linear-gradient(to right, #4ba5ff 50%, transparent 50%),
linear-gradient(to right, #4ba5ff 50%, transparent 50%),
linear-gradient(to right, #4ba5ff 50%, transparent 50%),
linear-gradient(to bottom, #4ba5ff 50%, transparent 50%);
background-repeat: repeat-x, repeat-x, repeat-x, repeat-y;
background-size:
8px 1px,
8px 1px,
8px 1px,
1px 8px;
background-position:
0 2.67%,
0 63.47%,
0 92.8%,
50% 0;
}
}
}
&[data-crop-type="mainProductImage"],
&[data-crop-type="sketch"] {
.image-clip-body {
:deep(.vue-cropper .cropper-view-box::after) {
background-image:
linear-gradient(to right, #4ba5ff 50%, transparent 50%),
linear-gradient(to right, #4ba5ff 50%, transparent 50%),
linear-gradient(to bottom, #4ba5ff 50%, transparent 50%);
background-repeat: repeat-x, repeat-x, repeat-y;
background-size:
8px 1px,
8px 1px,
1px 8px;
background-position:
0 2.67%,
0 97.6%,
50% 0;
}
}
}
&[data-crop-type="apparel"] {
.image-clip-body {
:deep(.vue-cropper .cropper-view-box::after) {
background-image: linear-gradient(to bottom, #4ba5ff 50%, transparent 50%);
background-repeat: repeat-y;
background-size: 1px 8px;
background-position: 50% 0;
}
}
}
}
}
</style>
<style lang="less">
.cropper-line-label {
position: absolute;
color: #4ba5ff; /* 统一颜色 */
font-size: 11px;
font-weight: bold;
background: rgba(255, 255, 255); /* 浅色背景确保在深色图片上可见 */
// padding: 0 4px;
border-radius: 2px;
white-space: nowrap;
pointer-events: none;
z-index: 10;
line-height: 1.2;
}
/* 水平线名称:放在线段上方 2px */
.label-h {
transform: translateY(-100%);
margin-top: -2px;
left: 4px;
}
/* 垂直线名称:放在裁剪框顶部边缘上方 */
.label-v {
transform: translateX(-50%);
top: -20px;
left: 50%;
}
</style>