Compare commits

2 Commits

Author SHA1 Message Date
ec632554e2 feat: 裁剪组件 2026-04-21 16:13:55 +08:00
429c7db195 feat: 图片裁剪组件 2026-04-21 13:47:23 +08:00
3 changed files with 339 additions and 195 deletions

View File

@@ -1,6 +1,7 @@
<template> <template>
<a-modal <a-modal
class="image-clip-dialog generalModel" class="image-clip-dialog generalModel"
:class="{ 'is-product': data.isProduct }"
v-model:visible="show" v-model:visible="show"
:footer="null" :footer="null"
width="70%" width="70%"
@@ -25,10 +26,20 @@
ref="imageClipRef" ref="imageClipRef"
v-bind="$attrs" v-bind="$attrs"
:ratio="data.ratio" :ratio="data.ratio"
:fixedBox="type !== 'apparel'"
:url="data.url" :url="data.url"
:type="type"
@change="(v) => (data.preview_url = v)" @change="(v) => (data.preview_url = v)"
/> />
<div class="preview" v-if="data.isPreview"> <div
class="preview"
:class="{
'is-product': data.isProduct,
'is-apparel': type === 'apparel',
'is-cover': type === 'cover'
}"
v-if="data.isPreview"
>
<div class="title"> <div class="title">
<span class="icon"><svg-icon name="seller-preview" size="24" /></span> <span class="icon"><svg-icon name="seller-preview" size="24" /></span>
<span class="label">Crop Preview</span> <span class="label">Crop Preview</span>
@@ -44,124 +55,161 @@
</template> </template>
<script setup> <script setup>
import { ref } from "vue" import { ref } from "vue"
import ImageClip from "./image-clip.vue" import ImageClip from "./image-clip.vue"
const data = reactive({
url: "", defineProps({
title: "Crop Image", type: {
preview_url: "", type: String,
ratio: [1, 1], default: () => false
isPreview: true, }
callback: null })
})
const show = ref(false) const data = reactive({
const open = (url, callback, options) => { url: "",
if (!url || !callback) return title: "Crop Image",
data.url = url preview_url: "",
data.callback = callback ratio: [1, 1],
data.ratio = [1, 1] isPreview: true,
data.isPreview = true callback: null,
data.preview_url = "" isProduct: false // 是否商品编辑
data.title = "Crop Image" })
if (options) { const show = ref(false)
if (options.hasOwnProperty("isPreview")) data.isPreview = options.isPreview const open = (url, callback, options) => {
if (options.hasOwnProperty("ratio")) data.ratio = options.ratio if (!url || !callback) return
if (options.hasOwnProperty("title")) data.title = options.title data.url = url
} data.callback = callback
show.value = true data.ratio = options.ratio || [1, 1]
} data.isPreview = true
const onCancel = () => { data.preview_url = ""
show.value = false data.title = options.title || "Crop Image"
} if (options) {
const imageClipRef = ref(null) if (options.hasOwnProperty("isPreview")) data.isPreview = options.isPreview
const onSubmit = () => { data.isProduct = options.isProduct
imageClipRef.value.getCropBlob().then((blob) => { }
if (data.callback) data.callback(blobToFile(blob, "image.png")) show.value = true
onCancel() }
}) const onCancel = () => {
} show.value = false
// 将blob转换为file对象 }
const blobToFile = (blob, fileName) => { const imageClipRef = ref(null)
return new File([blob], fileName, { type: blob.type }) const onSubmit = () => {
} imageClipRef.value.getCropBlob().then((blob) => {
defineExpose({ if (data.callback) data.callback(blobToFile(blob, "image.png"))
open onCancel()
}) })
}
// 将blob转换为file对象
const blobToFile = (blob, fileName) => {
return new File([blob], fileName, { type: blob.type })
}
defineExpose({
open
})
</script> </script>
<style scoped lang="less"> <style scoped lang="less">
.image-clip-dialog-box { .image-clip-dialog-box {
width: 100%; width: 100%;
height: 100%; height: 100%;
overflow: hidden; overflow: hidden;
display: flex;
flex-direction: column;
.submit {
width: 4rem;
height: 4rem;
border-radius: 50%;
background: #262626;
color: #fff;
cursor: pointer;
}
> .header {
display: flex; display: flex;
flex-direction: column; justify-content: space-between;
.submit { margin-bottom: 5rem;
width: 4rem; > .title {
height: 4rem; font-family: pingfang_heavy;
border-radius: 50%; font-size: 2.4rem;
background: #262626; color: #595959;
color: #fff;
cursor: pointer;
} }
> .header { > .right {
display: flex;
justify-content: space-between;
margin-bottom: 5rem;
> .title {
font-family: pingfang_heavy;
font-size: 2.4rem;
color: #595959;
}
> .right {
display: flex;
align-items: center;
justify-content: center;
gap: 2rem;
> button {
width: 10rem;
height: 4.8rem;
border-radius: 4rem;
border: none;
background: #e4e5eb;
font-family: pingfang_heavy;
font-size: 1.6rem;
color: #000;
}
}
}
> .content {
flex: 1;
overflow: hidden;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
> .image-clip { gap: 2rem;
flex: 1; > button {
width: 10rem;
height: 4.8rem;
border-radius: 4rem;
border: none;
background: #e4e5eb;
font-family: pingfang_heavy;
font-size: 1.6rem;
color: #000;
} }
> .preview { }
margin-left: 6rem; }
width: 28rem; > .content {
flex: 1;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
> .image-clip {
flex: 1;
&.is-product {
width: initial;
flex: none;
}
}
> .preview {
margin-left: 6rem;
width: 28rem;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 2.4rem;
> .title {
display: flex; display: flex;
flex-direction: column;
justify-content: center;
align-items: center; align-items: center;
gap: 2.4rem; justify-content: center;
> .title { gap: 1.2rem;
display: flex; > .label {
align-items: center; font-family: pingfang_heavy;
justify-content: center; font-size: 1.6rem;
gap: 1.2rem; }
> .label { }
font-family: pingfang_heavy; > img {
font-size: 1.6rem; width: 100%;
height: auto;
margin-bottom: 3rem;
}
&.is-product {
margin-left: 0;
height: 100%;
justify-content: flex-start;
img {
width: 20.8rem;
height: 36.7rem;
margin-bottom: 0;
box-shadow: 4px 4px 16px 0px #0000000f;
border: 1px solid #ededed;
}
&.is-cover {
img {
// width: 29.7rem;
height: 37.5rem;
} }
} }
> img { &.is-apparel {
width: 100%; img {
height: auto; height: auto;
margin-bottom: 3rem; }
} }
} }
} }
} }
}
</style> </style>

View File

@@ -1,18 +1,21 @@
<template> <template>
<div class="image-clip" :class="{ 'is-product': isProduct }"> <div class="image-clip" :class="{ 'is-product': isProduct }" :data-crop-type="type || ''">
<div class="image-clip-body" ref="imageClipBody"> <div class="image-clip-body" :class="{ 'is-cover': type === 'cover' }" ref="imageClipBody">
<VueCropper <VueCropper
ref="cropper" ref="cropper"
:img="url" :img="url"
crossOrigin="Anonymous" crossOrigin="Anonymous"
:autoCrop="true" :autoCrop="true"
:fixedNumber="ratio" :fixedNumber="ratio"
fixed :fixed="type !== 'apparel'"
movable movable
centerBox centerBox
:fixedBox="fixedBox"
@realTime="onChange" @realTime="onChange"
v-bind="$attrs" v-bind="$attrs"
outputType="png" outputType="png"
:autoCropWidth="type === 'cover' ? 297 : 242"
:autoCropHeight="autoCropHeight"
></VueCropper> ></VueCropper>
</div> </div>
<div class="clip_opterate"> <div class="clip_opterate">
@@ -35,7 +38,7 @@
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, useAttrs, onMounted, onBeforeUnmount } from "vue" import { ref, useAttrs, onMounted, onBeforeUnmount, computed, nextTick, watch } from "vue"
import "vue-cropper/dist/index.css" import "vue-cropper/dist/index.css"
import { VueCropper } from "vue-cropper" import { VueCropper } from "vue-cropper"
const props = defineProps({ const props = defineProps({
@@ -50,10 +53,25 @@ const props = defineProps({
isProduct: { isProduct: {
type: Boolean, type: Boolean,
default: false default: false
},
fixedBox: {
type: Boolean,
default: true
},
type: {
type: String,
default: () => ""
} }
}) })
const attrs = useAttrs() 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) => { const onChange = (data) => {
if (attrs.onChange) { if (attrs.onChange) {
getCropUrl().then((url) => attrs.onChange(url)) getCropUrl().then((url) => attrs.onChange(url))
@@ -61,15 +79,77 @@ const onChange = (data) => {
} }
const cropper = ref(null) const cropper = ref(null)
const imageClipBody = ref(null) const imageClipBody = ref(null)
let injectLabelFrame = 0
const observer = new ResizeObserver((entries) => { const observer = new ResizeObserver((entries) => {
refreshCrop() 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 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: "0", className: "label-v" }
],
mainProductImage: [
{ text: "crown", top: "2.67%", className: "label-h" },
{ text: "footbase", top: "97.6%", className: "label-h" },
{ text: "center", top: "0", className: "label-v" }
],
sketch: [
{ text: "crown", top: "2.67%", className: "label-h" },
{ text: "footbase", top: "97.6%", className: "label-h" },
{ text: "center", top: "0", className: "label-v" }
],
apparel: [{ text: "center", top: "0", 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(() => { onMounted(() => {
observer.observe(imageClipBody.value) observer.observe(imageClipBody.value)
injectCropLabel() scheduleInjectCropLabel()
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
observer.disconnect() observer.disconnect()
cancelAnimationFrame(injectLabelFrame)
}) })
const rotateLeft = () => { const rotateLeft = () => {
cropper.value.rotateLeft() cropper.value.rotateLeft()
@@ -94,47 +174,14 @@ const getCropBlob = () => {
}) })
} }
const injectCropLabel = () => { watch(
const cropperBox = imageClipBody.value.querySelector(".cropper-view-box") [() => props.type, () => props.url],
async () => {
if (cropperBox) { await nextTick()
// Add crown label scheduleInjectCropLabel()
const crownLabel = document.createElement("div") },
crownLabel.className = "cropper-line-label label-h" { flush: "post" }
crownLabel.textContent = "crown" )
crownLabel.style.top = "2.67%"
crownLabel.style.left = "0"
crownLabel.style.transform = "translateY(-50%)"
cropperBox.appendChild(crownLabel)
// Add hip line label
const hipLabel = document.createElement("div")
hipLabel.className = "cropper-line-label label-h"
hipLabel.textContent = "hip line"
hipLabel.style.top = "63.47%"
hipLabel.style.left = "0"
hipLabel.style.transform = "translateY(-50%)"
cropperBox.appendChild(hipLabel)
// Add mid-thigh label
const thighLabel = document.createElement("div")
thighLabel.className = "cropper-line-label label-h"
thighLabel.textContent = "mid-thigh"
thighLabel.style.top = "92.8%"
thighLabel.style.left = "0"
thighLabel.style.transform = "translateY(-50%)"
cropperBox.appendChild(thighLabel)
// Add center label
const centerLabel = document.createElement("div")
centerLabel.className = "cropper-line-label label-v"
centerLabel.textContent = "center"
centerLabel.style.top = "0"
centerLabel.style.left = "50%"
centerLabel.style.transform = "translate(-50%, -50%)"
cropperBox.appendChild(centerLabel)
}
}
defineExpose({ defineExpose({
getCropUrl, getCropUrl,
@@ -167,6 +214,19 @@ defineExpose({
} }
} }
} }
&.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 { .clip_opterate {
@@ -213,52 +273,75 @@ defineExpose({
} }
} }
&.is-product { &.is-product {
.image-clip-body { .image-clip-body {
width: 45.7rem; width: 45.7rem;
height: 45.7rem; height: 45.7rem;
:deep(.cropper-modal) { :deep(.cropper-modal) {
background: transparent; background: transparent;
} }
:deep(.vue-cropper .cropper-view-box) { :deep(.vue-cropper .cropper-view-box) {
position: relative; position: relative;
overflow: visible !important; overflow: visible !important;
/* 原有的蓝色边框outline由组件控制这里不干涉 */ /* 原有的蓝色边框outline由组件控制这里不干涉 */
}
:deep(.vue-cropper .cropper-view-box::after) {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 9; /* 位于图片之上,但在控制点之下 */
background-image: none;
background-repeat: no-repeat;
}
} }
:deep(.vue-cropper .cropper-view-box::after) { &[data-crop-type="cover"] {
content: ""; .image-clip-body {
position: absolute; :deep(.vue-cropper .cropper-view-box::after) {
top: 0; background-image:
left: 0; linear-gradient(to right, #4ba5ff 50%, transparent 50%),
width: 100%; linear-gradient(to right, #4ba5ff 50%, transparent 50%),
height: 100%; linear-gradient(to right, #4ba5ff 50%, transparent 50%),
pointer-events: none; linear-gradient(to bottom, #4ba5ff 50%, transparent 50%);
z-index: 9; /* 位于图片之上,但在控制点之下 */ 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;
}
}
}
/* 绘制 4 条 #4BA5FF 的虚线 */ &[data-crop-type="mainProductImage"],
background-image: &[data-crop-type="sketch"] {
linear-gradient(to right, #4ba5ff 50%, transparent 50%), .image-clip-body {
/* Crown */ linear-gradient(to right, #4ba5ff 50%, transparent 50%), :deep(.vue-cropper .cropper-view-box::after) {
/* Hip line */ linear-gradient(to right, #4ba5ff 50%, transparent 50%), background-image:
/* Mid-thigh */ linear-gradient(to bottom, #4ba5ff 50%, transparent 50%); /* Center */ 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;
}
}
}
background-repeat: repeat-x, repeat-x, repeat-x, repeat-y; &[data-crop-type="apparel"] {
background-size: .image-clip-body {
8px 1px, :deep(.vue-cropper .cropper-view-box::after) {
8px 1px, background-image: linear-gradient(to bottom, #4ba5ff 50%, transparent 50%);
8px 1px, background-repeat: repeat-y;
1px 8px; /* 虚线间距 */ background-size: 1px 8px;
background-position: 50% 0;
background-position: }
0 2.67%, }
0 63.47%,
0 92.8%,
50% 0;
} }
} }
} }
}
</style> </style>
<style lang="less"> <style lang="less">

View File

@@ -41,7 +41,7 @@
<div <div
v-if="previewImageMap[type]" v-if="previewImageMap[type]"
class="crop-tool flex flex-center" class="crop-tool flex flex-center"
@click="handleClickCrop(previewImageMap[type])" @click="handleClickCrop(previewImageMap[type], type)"
> >
<SvgIcon name="CCrop" color="#fff" size="12" /> <SvgIcon name="CCrop" color="#fff" size="12" />
</div> </div>
@@ -128,7 +128,10 @@
class="sketch-element flex flex-center" class="sketch-element flex flex-center"
> >
<img class="img-src" :src="item.url" alt="" /> <img class="img-src" :src="item.url" alt="" />
<div class="crop-tool flex flex-center"> <div
class="crop-tool flex flex-center"
@click="handleClickCrop(item.url, 'apparel')"
>
<SvgIcon name="CCrop" color="#fff" size="12" /> <SvgIcon name="CCrop" color="#fff" size="12" />
</div> </div>
</div> </div>
@@ -221,7 +224,8 @@
fixedBox fixedBox
isProduct isProduct
:info="false" :info="false"
:autoCropWidth="90" v-bind="$attrs"
:type="cropType"
/> />
</template> </template>
@@ -394,14 +398,23 @@ const handleSelectProdImg = (index: number) => {
} }
} }
const handleClickCrop = (data) => { const cropType = ref("")
console.log(data) const handleClickCrop = (data, type) => {
console.log(data, type)
const titleList = {
sketch: "Crop Sketch",
mainProductImage: "Crop Main Product Image",
cover: "Crop Cover",
apparel: "Crop Apparel Sketch"
}
const ratio = type === "apparel" ? [4, 5] : [9, 16]
cropType.value = type
imageClipDialogRef.value.open( imageClipDialogRef.value.open(
data, data,
(file) => { (file) => {
selectList.value[currentIndex.value].sketch = URL.createObjectURL(file) selectList.value[currentIndex.value].sketch = URL.createObjectURL(file)
}, },
{ ratio: [9, 16], isPreview: true, title: "Crop Brand Banner" } { ratio, isPreview: true, title: titleList[type], isProduct: true }
) )
} }