feat: add image upload component with backend proxy and auto thumbnail
- Create reusable ImageUpload.vue component (drag-drop, preview, progress) - Replace URL input fields with ImageUpload in category and template dialogs - Upload via backend proxy to avoid OSS CORS issues - Auto-fill thumbnail_url and image dimensions on template image upload Made-with: Cursor
This commit is contained in:
@@ -1,5 +1,22 @@
|
||||
import request from '../utils/request'
|
||||
|
||||
export async function uploadToOSS(file) {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
const res = await request({
|
||||
url: '/api/admin/marketing/upload',
|
||||
method: 'post',
|
||||
data: formData,
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
timeout: 60000
|
||||
})
|
||||
return {
|
||||
url: res.data.url,
|
||||
thumbnailUrl: res.data.thumbnail_url || res.data.url
|
||||
}
|
||||
}
|
||||
|
||||
export function getMarketingStats() {
|
||||
return request({
|
||||
url: '/api/admin/marketing/stats',
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
<template>
|
||||
<div class="image-upload">
|
||||
<div v-if="modelValue" class="preview-wrapper">
|
||||
<el-image
|
||||
:src="modelValue"
|
||||
fit="contain"
|
||||
class="preview-img"
|
||||
:style="previewStyle"
|
||||
:preview-src-list="[modelValue]"
|
||||
/>
|
||||
<div class="preview-actions">
|
||||
<el-button link type="primary" @click="triggerUpload">
|
||||
<el-icon><Refresh /></el-icon> 替换
|
||||
</el-button>
|
||||
<el-button link type="danger" @click="handleRemove">
|
||||
<el-icon><Delete /></el-icon> 删除
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-upload
|
||||
v-else
|
||||
ref="uploadRef"
|
||||
:auto-upload="false"
|
||||
:show-file-list="false"
|
||||
:on-change="handleFileChange"
|
||||
accept="image/*"
|
||||
drag
|
||||
class="upload-dragger"
|
||||
>
|
||||
<div class="upload-placeholder" :style="placeholderStyle">
|
||||
<el-icon v-if="!uploading" class="upload-icon"><Plus /></el-icon>
|
||||
<el-progress
|
||||
v-if="uploading"
|
||||
type="circle"
|
||||
:percentage="progress"
|
||||
:width="48"
|
||||
:stroke-width="3"
|
||||
/>
|
||||
<div class="upload-text">{{ uploading ? '上传中...' : placeholder }}</div>
|
||||
</div>
|
||||
</el-upload>
|
||||
<input
|
||||
ref="hiddenInput"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style="display: none"
|
||||
@change="handleHiddenInput"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Plus, Refresh, Delete } from '@element-plus/icons-vue'
|
||||
import { uploadToOSS } from '../api/marketing'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, default: '' },
|
||||
placeholder: { type: String, default: '点击或拖拽上传图片' },
|
||||
previewWidth: { type: String, default: '120px' },
|
||||
previewHeight: { type: String, default: '120px' },
|
||||
maxSize: { type: Number, default: 10 },
|
||||
onUploaded: { type: Function, default: null }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const uploadRef = ref(null)
|
||||
const hiddenInput = ref(null)
|
||||
const uploading = ref(false)
|
||||
const progress = ref(0)
|
||||
|
||||
const previewStyle = { width: props.previewWidth, height: props.previewHeight }
|
||||
const placeholderStyle = { width: props.previewWidth, height: props.previewHeight }
|
||||
|
||||
function triggerUpload() {
|
||||
hiddenInput.value?.click()
|
||||
}
|
||||
|
||||
function handleRemove() {
|
||||
emit('update:modelValue', '')
|
||||
}
|
||||
|
||||
async function handleHiddenInput(e) {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) {
|
||||
await doUpload(file)
|
||||
}
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
async function handleFileChange(uploadFile) {
|
||||
if (uploadFile?.raw) {
|
||||
await doUpload(uploadFile.raw)
|
||||
}
|
||||
}
|
||||
|
||||
async function doUpload(file) {
|
||||
if (file.size > props.maxSize * 1024 * 1024) {
|
||||
ElMessage.warning(`图片大小不能超过 ${props.maxSize}MB`)
|
||||
return
|
||||
}
|
||||
|
||||
uploading.value = true
|
||||
progress.value = 0
|
||||
|
||||
const timer = setInterval(() => {
|
||||
if (progress.value < 90) progress.value += 10
|
||||
}, 200)
|
||||
|
||||
try {
|
||||
const result = await uploadToOSS(file)
|
||||
progress.value = 100
|
||||
emit('update:modelValue', result.url)
|
||||
if (props.onUploaded) props.onUploaded(result, file)
|
||||
} catch (e) {
|
||||
console.error('上传失败:', e)
|
||||
ElMessage.error('图片上传失败,请重试')
|
||||
} finally {
|
||||
clearInterval(timer)
|
||||
uploading.value = false
|
||||
progress.value = 0
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.image-upload {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.preview-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.preview-img {
|
||||
border-radius: 6px;
|
||||
border: 1px solid #e4e7ed;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.preview-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.upload-dragger :deep(.el-upload-dragger) {
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
}
|
||||
|
||||
.upload-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
border: 1px dashed #d9d9d9;
|
||||
border-radius: 6px;
|
||||
background: #fafafa;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.upload-placeholder:hover {
|
||||
border-color: #409eff;
|
||||
}
|
||||
|
||||
.upload-icon {
|
||||
font-size: 24px;
|
||||
color: #8c939d;
|
||||
}
|
||||
|
||||
.upload-text {
|
||||
font-size: 12px;
|
||||
color: #8c939d;
|
||||
}
|
||||
</style>
|
||||
@@ -161,8 +161,14 @@
|
||||
<el-form-item label="名称" required>
|
||||
<el-input v-model="categoryForm.name" maxlength="50" show-word-limit />
|
||||
</el-form-item>
|
||||
<el-form-item label="图标URL">
|
||||
<el-input v-model="categoryForm.icon" placeholder="可选:填写图标链接" />
|
||||
<el-form-item label="图标">
|
||||
<ImageUpload
|
||||
v-model="categoryForm.icon"
|
||||
placeholder="上传图标"
|
||||
preview-width="64px"
|
||||
preview-height="64px"
|
||||
:max-size="2"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="排序">
|
||||
<el-input-number v-model="categoryForm.sort_order" :min="0" :max="9999" />
|
||||
@@ -191,11 +197,24 @@
|
||||
<el-option v-for="item in categories" :key="item.id" :label="item.name" :value="item.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="图片URL" required>
|
||||
<el-input v-model="templateForm.image_url" placeholder="模板原图 URL" />
|
||||
<el-form-item label="模板图片" required>
|
||||
<ImageUpload
|
||||
v-model="templateForm.image_url"
|
||||
placeholder="上传模板原图"
|
||||
preview-width="160px"
|
||||
preview-height="200px"
|
||||
:max-size="10"
|
||||
:on-uploaded="onTemplateImageUploaded"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="缩略图URL">
|
||||
<el-input v-model="templateForm.thumbnail_url" placeholder="可选:缩略图 URL" />
|
||||
<el-form-item label="缩略图">
|
||||
<ImageUpload
|
||||
v-model="templateForm.thumbnail_url"
|
||||
placeholder="上传缩略图(可选)"
|
||||
preview-width="100px"
|
||||
preview-height="100px"
|
||||
:max-size="5"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="宽度(px)">
|
||||
<el-input-number v-model="templateForm.width" :min="0" :max="10000" />
|
||||
@@ -221,6 +240,7 @@
|
||||
<script setup>
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import ImageUpload from '../../components/ImageUpload.vue'
|
||||
import {
|
||||
createMarketingCategory,
|
||||
createMarketingTemplate,
|
||||
@@ -439,6 +459,18 @@ const openTemplateDialog = (row) => {
|
||||
templateDialogVisible.value = true
|
||||
}
|
||||
|
||||
const onTemplateImageUploaded = (result) => {
|
||||
if (result.thumbnailUrl && !templateForm.thumbnail_url) {
|
||||
templateForm.thumbnail_url = result.thumbnailUrl
|
||||
}
|
||||
const img = new Image()
|
||||
img.onload = () => {
|
||||
templateForm.width = img.naturalWidth
|
||||
templateForm.height = img.naturalHeight
|
||||
}
|
||||
img.src = result.url
|
||||
}
|
||||
|
||||
const saveTemplate = async () => {
|
||||
if (!(templateForm.title || '').trim()) {
|
||||
ElMessage.warning('请先填写模板名称')
|
||||
@@ -449,7 +481,7 @@ const saveTemplate = async () => {
|
||||
return
|
||||
}
|
||||
if (!(templateForm.image_url || '').trim()) {
|
||||
ElMessage.warning('请先填写图片URL')
|
||||
ElMessage.warning('请先上传模板图片')
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user