初次提交

This commit is contained in:
2025-08-26 10:06:23 +08:00
commit a1944a573e
58 changed files with 19131 additions and 0 deletions

View File

@@ -0,0 +1,345 @@
<template>
<div class="image-upload">
<el-upload
ref="uploadRef"
:action="uploadUrl"
:headers="uploadHeaders"
:data="uploadData"
:before-upload="beforeUpload"
:on-success="handleSuccess"
:on-error="handleError"
:on-progress="handleProgress"
:show-file-list="false"
:auto-upload="true"
accept="image/*"
class="upload-container"
>
<div class="upload-area" :class="{ 'uploading': uploading, 'has-image': imageUrl }">
<div v-if="!imageUrl && !uploading" class="upload-placeholder">
<el-icon class="upload-icon"><Plus /></el-icon>
<div class="upload-text">{{ placeholder }}</div>
<div class="upload-hint">支持 JPGPNG 格式大小不超过 5MB</div>
</div>
<div v-if="uploading" class="upload-progress">
<el-icon class="loading-icon"><Loading /></el-icon>
<div class="progress-text">上传中... {{ uploadProgress }}%</div>
<el-progress :percentage="uploadProgress" :show-text="false" />
</div>
<div v-if="imageUrl && !uploading" class="image-preview">
<img :src="imageUrl" :alt="placeholder" class="preview-image" />
<div class="image-overlay">
<el-button type="primary" size="small" @click.stop="previewImage">
<el-icon><ZoomIn /></el-icon>
预览
</el-button>
<el-button type="danger" size="small" @click.stop="removeImage">
<el-icon><Delete /></el-icon>
删除
</el-button>
</div>
</div>
</div>
</el-upload>
<!-- 图片预览对话框 -->
<el-dialog v-model="previewVisible" title="图片预览" width="60%" center>
<div class="preview-dialog">
<img :src="imageUrl" :alt="placeholder" class="preview-dialog-image" />
</div>
</el-dialog>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { Plus, Loading, ZoomIn, Delete } from '@element-plus/icons-vue'
// Props
const props = defineProps({
modelValue: {
type: String,
default: ''
},
placeholder: {
type: String,
default: '点击上传图片'
},
disabled: {
type: Boolean,
default: false
}
})
// Emits
const emit = defineEmits(['update:modelValue', 'upload-success', 'upload-error'])
// 响应式数据
const uploadRef = ref()
const uploading = ref(false)
const uploadProgress = ref(0)
const imageUrl = ref(props.modelValue)
const previewVisible = ref(false)
// 上传配置
const uploadUrl = '/api/upload/image'
const uploadHeaders = computed(() => {
const token = localStorage.getItem('token')
return token ? { Authorization: `Bearer ${token}` } : {}
})
const uploadData = { type: 'document' }
// 监听 modelValue 变化
watch(() => props.modelValue, (newValue) => {
imageUrl.value = newValue
})
/**
* 上传前验证
*/
const beforeUpload = (file) => {
if (props.disabled) {
ElMessage.warning('当前状态下不允许上传')
return false
}
// 检查文件类型
const isImage = file.type.startsWith('image/')
if (!isImage) {
ElMessage.error('只能上传图片文件!')
return false
}
// 检查文件大小5MB
const isLt5M = file.size / 1024 / 1024 < 5
if (!isLt5M) {
ElMessage.error('图片大小不能超过 5MB')
return false
}
uploading.value = true
uploadProgress.value = 0
return true
}
/**
* 上传进度
*/
const handleProgress = (event) => {
uploadProgress.value = Math.round(event.percent)
}
/**
* 上传成功
*/
const handleSuccess = (response) => {
uploading.value = false
uploadProgress.value = 0
if (response.success) {
imageUrl.value = response.data.url
emit('update:modelValue', response.data.url)
emit('upload-success', response.data)
ElMessage.success('图片上传成功')
} else {
ElMessage.error(response.message || '上传失败')
emit('upload-error', response)
}
}
/**
* 上传失败
*/
const handleError = (error) => {
uploading.value = false
uploadProgress.value = 0
console.error('上传失败:', error)
ElMessage.error('图片上传失败,请重试')
emit('upload-error', error)
}
/**
* 预览图片
*/
const previewImage = () => {
previewVisible.value = true
}
/**
* 删除图片
*/
const removeImage = () => {
imageUrl.value = ''
emit('update:modelValue', '')
ElMessage.success('图片已删除')
}
/**
* 手动触发上传
*/
const triggerUpload = () => {
if (!props.disabled) {
uploadRef.value?.$el.querySelector('input').click()
}
}
// 暴露方法
defineExpose({
triggerUpload
})
</script>
<style scoped>
.image-upload {
width: 100%;
}
.upload-container {
width: 100%;
}
.upload-area {
width: 200px;
height: 120px;
border: 2px dashed #d9d9d9;
border-radius: 6px;
cursor: pointer;
position: relative;
overflow: hidden;
transition: all 0.3s ease;
display: flex;
align-items: center;
justify-content: center;
background-color: #fafafa;
}
.upload-area:hover {
border-color: #409eff;
background-color: #f0f9ff;
}
.upload-area.uploading {
border-color: #409eff;
background-color: #f0f9ff;
cursor: not-allowed;
}
.upload-area.has-image {
border-color: #409eff;
padding: 0;
}
.upload-placeholder {
text-align: center;
color: #8c939d;
}
.upload-icon {
font-size: 28px;
color: #8c939d;
margin-bottom: 8px;
}
.upload-text {
font-size: 14px;
margin-bottom: 4px;
}
.upload-hint {
font-size: 12px;
color: #c0c4cc;
}
.upload-progress {
text-align: center;
width: 100%;
padding: 0 20px;
}
.loading-icon {
font-size: 24px;
color: #409eff;
margin-bottom: 8px;
animation: rotate 2s linear infinite;
}
.progress-text {
font-size: 14px;
color: #606266;
margin-bottom: 8px;
}
.image-preview {
width: 100%;
height: 100%;
position: relative;
}
.preview-image {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.image-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
opacity: 0;
transition: opacity 0.3s ease;
}
.image-preview:hover .image-overlay {
opacity: 1;
}
.preview-dialog {
text-align: center;
}
.preview-dialog-image {
max-width: 100%;
max-height: 70vh;
object-fit: contain;
}
@keyframes rotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* 响应式设计 */
@media (max-width: 768px) {
.upload-area {
width: 150px;
height: 100px;
}
.upload-icon {
font-size: 24px;
}
.upload-text {
font-size: 12px;
}
.upload-hint {
font-size: 10px;
}
}
</style>