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:
nepiedg
2026-04-04 02:52:50 +08:00
parent 54b461dfb4
commit d62c51f140
3 changed files with 239 additions and 7 deletions
+17
View File
@@ -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',
+183
View File
@@ -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>
+39 -7
View File
@@ -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
}