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'
|
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() {
|
export function getMarketingStats() {
|
||||||
return request({
|
return request({
|
||||||
url: '/api/admin/marketing/stats',
|
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-form-item label="名称" required>
|
||||||
<el-input v-model="categoryForm.name" maxlength="50" show-word-limit />
|
<el-input v-model="categoryForm.name" maxlength="50" show-word-limit />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="图标URL">
|
<el-form-item label="图标">
|
||||||
<el-input v-model="categoryForm.icon" placeholder="可选:填写图标链接" />
|
<ImageUpload
|
||||||
|
v-model="categoryForm.icon"
|
||||||
|
placeholder="上传图标"
|
||||||
|
preview-width="64px"
|
||||||
|
preview-height="64px"
|
||||||
|
:max-size="2"
|
||||||
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="排序">
|
<el-form-item label="排序">
|
||||||
<el-input-number v-model="categoryForm.sort_order" :min="0" :max="9999" />
|
<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-option v-for="item in categories" :key="item.id" :label="item.name" :value="item.id" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="图片URL" required>
|
<el-form-item label="模板图片" required>
|
||||||
<el-input v-model="templateForm.image_url" placeholder="模板原图 URL" />
|
<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>
|
||||||
<el-form-item label="缩略图URL">
|
<el-form-item label="缩略图">
|
||||||
<el-input v-model="templateForm.thumbnail_url" placeholder="可选:缩略图 URL" />
|
<ImageUpload
|
||||||
|
v-model="templateForm.thumbnail_url"
|
||||||
|
placeholder="上传缩略图(可选)"
|
||||||
|
preview-width="100px"
|
||||||
|
preview-height="100px"
|
||||||
|
:max-size="5"
|
||||||
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="宽度(px)">
|
<el-form-item label="宽度(px)">
|
||||||
<el-input-number v-model="templateForm.width" :min="0" :max="10000" />
|
<el-input-number v-model="templateForm.width" :min="0" :max="10000" />
|
||||||
@@ -221,6 +240,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted, reactive, ref } from 'vue'
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
|
import ImageUpload from '../../components/ImageUpload.vue'
|
||||||
import {
|
import {
|
||||||
createMarketingCategory,
|
createMarketingCategory,
|
||||||
createMarketingTemplate,
|
createMarketingTemplate,
|
||||||
@@ -439,6 +459,18 @@ const openTemplateDialog = (row) => {
|
|||||||
templateDialogVisible.value = true
|
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 () => {
|
const saveTemplate = async () => {
|
||||||
if (!(templateForm.title || '').trim()) {
|
if (!(templateForm.title || '').trim()) {
|
||||||
ElMessage.warning('请先填写模板名称')
|
ElMessage.warning('请先填写模板名称')
|
||||||
@@ -449,7 +481,7 @@ const saveTemplate = async () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!(templateForm.image_url || '').trim()) {
|
if (!(templateForm.image_url || '').trim()) {
|
||||||
ElMessage.warning('请先填写图片URL')
|
ElMessage.warning('请先上传模板图片')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user