Commit 8b8ed832 by lijiabin

【需求 25171】feat: 封面图上传支持裁剪后再提交

新增 ImageCropperDialog 裁剪弹窗,UploadFile 组件加 crop/aspect-ratio 支持,
publishLongArticle 封面图上传接入裁剪,避免用户上传的封面图比例不统一。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
parent 8fcc684b
......@@ -36,6 +36,7 @@
"@wecom/jssdk": "^2.3.3",
"archiver": "^7.0.1",
"axios": "^1.13.0",
"cropperjs": "^2.2.0",
"crypto-js": "^4.2.0",
"dayjs": "^1.11.19",
"element-plus": "^2.11.5",
......
......@@ -38,6 +38,9 @@ importers:
axios:
specifier: ^1.13.0
version: 1.13.0
cropperjs:
specifier: ^2.2.0
version: 2.2.0
crypto-js:
specifier: ^4.2.0
version: 4.2.0
......@@ -428,6 +431,39 @@ packages:
'@bufbuild/protobuf@2.10.0':
resolution: {integrity: sha512-fdRs9PSrBF7QUntpZpq6BTw58fhgGJojgg39m9oFOJGZT+nip9b0so5cYY1oWl5pvemDLr0cPPsH46vwThEbpQ==}
'@cropper/element-canvas@2.2.0':
resolution: {integrity: sha512-H0Ay2ZSZrxWLP/8fSudH9hghsgIJh9fCgEmpfzD7MnQbE8HdQKljnoRxbrjuFT7Y0rmf405Sp9PmNA6T4twegw==}
'@cropper/element-crosshair@2.2.0':
resolution: {integrity: sha512-RXNhlsCd7lPTTjR2rfUEYghaL5bkqiU70qojJCUz1EIKfLriToDztjlsw3TZKJUBHYA+JOnmTBDFTnizoMUN1w==}
'@cropper/element-grid@2.2.0':
resolution: {integrity: sha512-1hxHLi7Jrek/xxeG12a9+TO18Ot4pCG4V9Zh+A0GMLsfFjrd2k/kl0wOq0x1isNNrP3kkIw47FdID2iWR8A48A==}
'@cropper/element-handle@2.2.0':
resolution: {integrity: sha512-+WPzHWF96RtLTAtotIcQQ6Sx3zVLQdK+qbaM2yKa841Iz60gQ3qKk7Jl5ndy2OgkEfxZaJI/3lWyf1RMGs/TEw==}
'@cropper/element-image@2.2.0':
resolution: {integrity: sha512-J0zBFMEfW6yuAmXoP6S+ZdNJy5J2duVsUS0N2o3D/HxHiVo4Vm3jrSexasu/Vfvwe4Lv0nmZ/10KW3RxhrvDCQ==}
'@cropper/element-selection@2.2.0':
resolution: {integrity: sha512-ATtWeEb1J/scQ98ins2jy4YbVOqz13F54jDk4Mm7OCpG0fzDseOujSLoIP8y26gtY7XGQXvn8WiFBbIZbeSgxQ==}
'@cropper/element-shade@2.2.0':
resolution: {integrity: sha512-fiurnjecablA8+17GoOjiNrzKWe85Q6h9JLbPJkxKCQxcxqpjEsOdGu3YlRxaBYcYvr6wH9tghufOlUfYJS9nw==}
'@cropper/element-viewer@2.2.0':
resolution: {integrity: sha512-w6uO7m7PwBWKynMwj8DYkJHFXrHC6Xxvcp5Y+tSxhfcNtOvw92FDncqRLYv+xHQoCDXEvfCqJyrTx9nIiZgiLQ==}
'@cropper/element@2.2.0':
resolution: {integrity: sha512-Kckk7TiWt3Cf9MvSfmMx7ZOIuN1rzIUIwIPQSqSj0Sl6gj6opo+CPax+fREvyvVKEzAug7L0sQFZ0TL4H5LsNw==}
'@cropper/elements@2.2.0':
resolution: {integrity: sha512-OQiVvEjIQUbMAUGpFrI/L95elHNIXjdlSxxzeywbU6C93PdPypXeGQfOSLQ43zUYmFutWNb7jTrHvgb3JaE+VQ==}
'@cropper/utils@2.2.0':
resolution: {integrity: sha512-wTrpZl+F4Kr2hzovqjztSexWUU+EVK1CxPymbYgFYoAS/NZXwCDsrvikJQn+HYZVxjqyo/ClnFxA0fG+tFy6Hw==}
'@csstools/color-helpers@6.0.2':
resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==}
engines: {node: '>=20.19.0'}
......@@ -2219,6 +2255,9 @@ packages:
resolution: {integrity: sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==}
engines: {node: '>= 14'}
cropperjs@2.2.0:
resolution: {integrity: sha512-7egO+tXxt3jGHQcO6x4k1CVoMIlEu+4Klixc6ejspKOAns7onlC/lEi5tRi3qbuHVcATK7rqaZyp8LzynPz6Cg==}
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
......@@ -5272,6 +5311,72 @@ snapshots:
'@bufbuild/protobuf@2.10.0': {}
'@cropper/element-canvas@2.2.0':
dependencies:
'@cropper/element': 2.2.0
'@cropper/utils': 2.2.0
'@cropper/element-crosshair@2.2.0':
dependencies:
'@cropper/element': 2.2.0
'@cropper/utils': 2.2.0
'@cropper/element-grid@2.2.0':
dependencies:
'@cropper/element': 2.2.0
'@cropper/utils': 2.2.0
'@cropper/element-handle@2.2.0':
dependencies:
'@cropper/element': 2.2.0
'@cropper/utils': 2.2.0
'@cropper/element-image@2.2.0':
dependencies:
'@cropper/element': 2.2.0
'@cropper/element-canvas': 2.2.0
'@cropper/utils': 2.2.0
'@cropper/element-selection@2.2.0':
dependencies:
'@cropper/element': 2.2.0
'@cropper/element-canvas': 2.2.0
'@cropper/element-image': 2.2.0
'@cropper/utils': 2.2.0
'@cropper/element-shade@2.2.0':
dependencies:
'@cropper/element': 2.2.0
'@cropper/element-canvas': 2.2.0
'@cropper/element-selection': 2.2.0
'@cropper/utils': 2.2.0
'@cropper/element-viewer@2.2.0':
dependencies:
'@cropper/element': 2.2.0
'@cropper/element-canvas': 2.2.0
'@cropper/element-image': 2.2.0
'@cropper/element-selection': 2.2.0
'@cropper/utils': 2.2.0
'@cropper/element@2.2.0':
dependencies:
'@cropper/utils': 2.2.0
'@cropper/elements@2.2.0':
dependencies:
'@cropper/element': 2.2.0
'@cropper/element-canvas': 2.2.0
'@cropper/element-crosshair': 2.2.0
'@cropper/element-grid': 2.2.0
'@cropper/element-handle': 2.2.0
'@cropper/element-image': 2.2.0
'@cropper/element-selection': 2.2.0
'@cropper/element-shade': 2.2.0
'@cropper/element-viewer': 2.2.0
'@cropper/utils@2.2.0': {}
'@csstools/color-helpers@6.0.2': {}
'@csstools/css-calc@3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)':
......@@ -7023,6 +7128,11 @@ snapshots:
crc-32: 1.2.2
readable-stream: 4.7.0
cropperjs@2.2.0:
dependencies:
'@cropper/elements': 2.2.0
'@cropper/utils': 2.2.0
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
......
<template>
<el-dialog
v-model="visible"
title="裁剪封面图"
width="720px"
append-to-body
destroy-on-close
@open="initCropper"
@close="handleClose"
>
<div
ref="cropContainer"
class="h-[420px] overflow-hidden bg-[#f5f7fa] [&>cropper-canvas]:block [&>cropper-canvas]:h-full [&>cropper-canvas]:w-full"
>
<img ref="imageRef" :src="src" alt="待裁剪图片" class="block max-w-full" />
</div>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="loading" @click="handleConfirm">确定</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import Cropper, { type CropperSelection } from 'cropperjs'
import { push } from 'notivue'
const emit = defineEmits<{
confirm: [file: File]
cancel: []
}>()
const visible = ref(false)
const src = ref('')
// 原文件只用于导出时保留名称和类型,不参与界面渲染。
let sourceFile: File | null = null
let cropAspectRatio: number | undefined
const imageRef = useTemplateRef<HTMLImageElement>('imageRef')
const cropContainer = useTemplateRef<HTMLDivElement>('cropContainer')
const cropper = shallowRef<Cropper | null>(null)
const cropperSelection = shallowRef<CropperSelection | null>(null)
const loading = ref(false)
let suppressClose = false
// 每次打开都重新设置裁剪比例(宽 / 高),不传时恢复自由裁剪。
const open = (file: File, aspectRatio?: number) => {
sourceFile = file
cropAspectRatio = aspectRatio
if (src.value) URL.revokeObjectURL(src.value)
src.value = URL.createObjectURL(file)
suppressClose = false
visible.value = true
}
// 关闭或卸载时释放本次裁剪的临时资源。
const clearCropState = () => {
destroyCropper()
if (src.value) URL.revokeObjectURL(src.value)
src.value = ''
sourceFile = null
}
const destroyCropper = () => {
console.log('before')
cropperSelection.value?.removeEventListener('change', handleSelectionChange)
cropperSelection.value = null
cropper.value?.destroy()
cropper.value = null
}
const getImageBounds = () => {
const canvasRect = cropper.value?.getCropperCanvas()?.getBoundingClientRect()
const imageRect = cropper.value?.getCropperImage()?.getBoundingClientRect()
if (!canvasRect || !imageRect) return
return {
left: imageRect.left - canvasRect.left,
top: imageRect.top - canvasRect.top,
right: imageRect.right - canvasRect.left,
bottom: imageRect.bottom - canvasRect.top,
}
}
// 裁剪框超出图片可见区域时取消本次变化,避免导出透明或空白内容。
const handleSelectionChange = (event: Event) => {
const bounds = getImageBounds()
const { x, y, width, height } = (event as CustomEvent).detail ?? {}
if (!bounds || [x, y, width, height].some((value) => typeof value !== 'number')) return
if (x < bounds.left || y < bounds.top || x + width > bounds.right || y + height > bounds.bottom) {
event.preventDefault()
}
}
const initCropper = async () => {
console.log('开始了')
await nextTick()
if (!visible.value || !imageRef.value || !cropContainer.value) return
destroyCropper()
cropper.value = new Cropper(imageRef.value, {
container: cropContainer.value,
})
const image = cropper.value.getCropperImage()
const selection = cropper.value.getCropperSelection()
if (!image || !selection) return
await image.$ready()
// 图片加载期间可能关闭弹窗,不再初始化已销毁的裁剪器。
if (!visible.value || image !== cropper.value?.getCropperImage()) return
// 当前场景只调整裁剪框,固定图片可保证裁剪边界始终稳定。
image.translatable = false
image.scalable = false
image.rotatable = false
image.skewable = false
cropperSelection.value = selection
selection.addEventListener('change', handleSelectionChange)
const bounds = getImageBounds()
if (!bounds) return
const availableWidth = (bounds.right - bounds.left) * 0.8
const availableHeight = (bounds.bottom - bounds.top) * 0.8
let width = availableWidth
let height = availableHeight
if (cropAspectRatio) {
selection.aspectRatio = cropAspectRatio
if (width / height > cropAspectRatio) {
width = height * cropAspectRatio
} else {
height = width / cropAspectRatio
}
}
// 初始裁剪框按配置比例居中;不传比例时保持自由裁剪。
const x = Math.ceil(bounds.left + (bounds.right - bounds.left - width) / 2)
const y = Math.ceil(bounds.top + (bounds.bottom - bounds.top - height) / 2)
width = Math.floor(width)
height = Math.floor(height)
selection.$change(x, y, width, height)
}
const canvasToBlob = (canvas: HTMLCanvasElement, type: string) =>
new Promise<Blob>((resolve, reject) => {
canvas.toBlob((blob) => {
if (blob) {
resolve(blob)
} else {
reject(new Error('裁剪图片生成失败'))
}
}, type)
})
// 将裁剪结果转换为原文件类型,避免上传接口收到不可识别的临时文件名。
const handleConfirm = async () => {
const file = sourceFile
if (!file || loading.value) return
loading.value = true
try {
const canvas = await cropper.value?.getCropperSelection()?.$toCanvas()
if (!canvas) throw new Error('裁剪图片生成失败')
const blob = await canvasToBlob(canvas, file.type || 'image/jpeg')
// 导出期间若取消了本次裁剪,不再把结果交给上传组件。
if (!visible.value || sourceFile !== file) return
suppressClose = true
visible.value = false
emit('confirm', new File([blob], file.name, { type: blob.type }))
} catch (error) {
console.error('裁剪失败:', error)
push.error('裁剪失败,请重试')
} finally {
loading.value = false
}
}
const handleClose = () => {
clearCropState()
if (!suppressClose) {
emit('cancel')
}
suppressClose = false
}
onBeforeUnmount(() => {
clearCropState()
console.log(123)
})
defineExpose({ open })
</script>
<template>
<el-upload
v-bind="attrs"
v-model:file-list="fileList"
action="#"
ref="uploadRef"
......@@ -21,21 +22,26 @@
<img class="w-full" :src="dialogImageUrl" alt="Preview Image" />
</el-dialog>
</el-upload>
<ImageCropperDialog ref="cropDialogRef" @confirm="handleCropConfirm" @cancel="handleCropCancel" />
</template>
<script lang="ts" setup generic="T extends string | string[]">
import { useAttrs } from 'vue'
import { Plus } from '@element-plus/icons-vue'
import { uploadFile as uploadFileApi } from '@/api'
import type { UploadProps, UploadUserFile } from 'element-plus'
import type { UploadProps, UploadRawFile, UploadUserFile } from 'element-plus'
import type { UploadFileProps } from './types'
import { push } from 'notivue'
import { useMessageBox } from '@/hooks'
import ImageCropperDialog from '@/components/common/ImageCropperDialog/index.vue'
const props = withDefaults(defineProps<UploadFileProps>(), {
limit: 2,
multiple: false,
defineOptions({
inheritAttrs: false,
})
const attrs = useAttrs()
const { limit = 2, multiple = false, crop = false, aspectRatio } = defineProps<UploadFileProps>()
const modelValue = defineModel<T>({
required: true,
})
......@@ -46,9 +52,12 @@ const uploadRef = useTemplateRef('uploadRef')
const dialogImageUrl = ref('')
const dialogVisible = ref(false)
const uploadPercent = ref(0)
const cropDialogRef = useTemplateRef<InstanceType<typeof ImageCropperDialog>>('cropDialogRef')
// 仅保存取消裁剪时需要恢复的表单值,不参与界面渲染。
let cropPreviousModelValue: T | undefined
const isArrayType = computed(() => Array.isArray(modelValue.value))
const hasReachedLimit = computed(() => fileList.value.length >= props.limit)
const hasReachedLimit = computed(() => fileList.value.length >= limit)
const showUploadBtn = computed(() => (hasReachedLimit.value ? 'none' : 'flex'))
const isInternalUpdate = ref(false)
......@@ -105,15 +114,56 @@ watch(
)
const handleExceed: UploadProps['onExceed'] = (uploadFiles) => {
if (uploadFiles.length > props.limit) {
push.error(`最多上传 ${props.limit} 个文件`)
if (uploadFiles.length > limit) {
push.error(`最多上传 ${limit} 个文件`)
return
}
}
// 统一处理文件上传,裁剪后的文件和普通文件共用这条链路。
const uploadRawFile = async (rawFile: File, uid: number) => {
try {
const fileIndex = fileList.value.findIndex((file) => file.uid === uid)
if (fileIndex !== -1) {
fileList.value[fileIndex]!.status = 'uploading'
}
const { promise } = uploadFileApi(rawFile, {
onProgress: (progress) => {
uploadPercent.value = progress
},
})
const data = await promise
const url = data.filePath || ''
const name = data.finalName || ''
const currentFileIndex = fileList.value.findIndex((file) => file.uid === uid)
if (currentFileIndex !== -1) {
fileList.value[currentFileIndex] = {
...fileList.value[currentFileIndex],
url,
name,
status: 'success',
}
push.success('上传成功')
}
} catch (error) {
console.error('上传失败:', error)
push.error('上传失败,请重试')
const fileIndex = fileList.value.findIndex((file) => file.uid === uid)
if (fileIndex !== -1) {
fileList.value.splice(fileIndex, 1)
}
} finally {
uploadPercent.value = 0
}
}
const handleChange: UploadProps['onChange'] = async (uploadFile, uploadFiles) => {
if (uploadFiles.length > props.limit) {
push.error(`最多上传 ${props.limit} 个文件`)
if (uploadFiles.length > limit) {
push.error(`最多上传 ${limit} 个文件`)
const index = fileList.value.findIndex((file) => file.uid === uploadFile.uid)
if (index !== -1) {
fileList.value.splice(index, 1)
......@@ -122,50 +172,48 @@ const handleChange: UploadProps['onChange'] = async (uploadFile, uploadFiles) =>
}
if (uploadFile.raw && uploadFile.status === 'ready') {
const uid = uploadFile.uid
if (crop) {
cropPreviousModelValue = modelValue.value
try {
let fileIndex = fileList.value.findIndex((file) => file.uid === uid)
const fileIndex = fileList.value.findIndex((file) => file.uid === uploadFile.uid)
if (fileIndex !== -1) {
fileList.value[fileIndex]!.status = 'uploading'
fileList.value.splice(fileIndex, 1)
}
const { promise } = uploadFileApi(uploadFile.raw, {
onProgress: (progress) => {
uploadPercent.value = progress
},
})
const data = await promise
const url = data.filePath || ''
const name = data.finalName || ''
cropDialogRef.value?.open(uploadFile.raw, aspectRatio)
return
}
fileIndex = fileList.value.findIndex((file) => file.uid === uid)
await uploadRawFile(uploadFile.raw, uploadFile.uid)
}
}
if (fileIndex !== -1) {
fileList.value[fileIndex] = {
...fileList.value[fileIndex],
url,
name,
status: 'success',
}
push.success('上传成功')
} else {
console.warn('找不到对应的文件,uid:', uid)
}
} catch (error) {
console.error('上传失败:', error)
push.error('上传失败,请重试')
const handleCropConfirm = async (croppedFile: File) => {
const uid = Date.now()
// 封面图只允许保留裁剪后的结果,确认时替换临时文件和旧封面。
fileList.value = []
fileList.value.push({
uid,
name: croppedFile.name,
status: 'ready',
raw: croppedFile as UploadRawFile,
})
cropPreviousModelValue = undefined
await uploadRawFile(croppedFile, uid)
}
const fileIndex = fileList.value.findIndex((file) => file.uid === uid)
if (fileIndex !== -1) {
fileList.value.splice(fileIndex, 1)
}
} finally {
uploadPercent.value = 0
}
const handleCropCancel = () => {
if (cropPreviousModelValue !== undefined) {
isInternalUpdate.value = true
modelValue.value = cropPreviousModelValue
nextTick(() => {
isInternalUpdate.value = false
syncModelToFileList()
})
} else {
fileList.value = []
}
cropPreviousModelValue = undefined
}
const handleBeforeRemove: UploadProps['beforeRemove'] = () => {
......
export interface UploadFileProps {
multiple?: boolean
limit?: number
/** 仅用于需要裁剪后再上传的图片场景。 */
crop?: boolean
/** 固定裁剪比例,例如 3 / 2;不传时允许自由裁剪。 */
aspectRatio?: number
}
......@@ -99,7 +99,13 @@
: [{ required: true, message: '请上传封面图', trigger: 'blur' }]
"
>
<UploadFile class="w-fit" v-model="form.faceUrl" :limit="1" />
<UploadFile
class="w-fit"
v-model="form.faceUrl"
:limit="1"
:aspect-ratio="3 / 2"
crop
/>
</el-form-item>
</template>
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment