L27 · 文件上传:拖拽 + 预览 + 进度
🎯 本节目标:实现图片上传(拖拽/点击)+ 预览 + 进度条 + 服务端 multer 处理
📦 本节产出:通用的 ImageUploader 组件 + 服务端上传 API
🔗 前置钩子:L26 的支付模拟与异步请求
🔗 后续钩子:L28 将实现 WebSocket 实时通知1. 文件上传全流程
2. 后端:multer 文件处理
在 server/ 安装 Multer 2 与 sharp 0.35.4。Multer 负责解析 multipart 和限制体积;MIME 来自客户端,不能只看 file.mimetype 就认定文件是图片。这里用 sharp 解码 JPG/PNG/WebP,并统一输出静态 WebP,原始文件名和扩展名不会决定磁盘路径。
npm install multer@2 sharp@0.35.4
npm install -D @types/multer@2// server/src/middlewares/upload.ts
import multer from 'multer'
import { mkdirSync } from 'node:fs'
import path from 'node:path'
import { AppError } from '../utils/AppError'
// npm run dev/start 在 server/ 下运行,开发和构建后的服务共用同一个目录。
export const UPLOAD_DIR = path.resolve(process.cwd(), 'uploads')
mkdirSync(UPLOAD_DIR, { recursive: true })
export const upload = multer({
storage: multer.memoryStorage(),
fileFilter(_req, file, cb) {
if (['image/jpeg', 'image/png', 'image/webp'].includes(file.mimetype)) cb(null, true)
else cb(new AppError('仅支持 JPG / PNG / WebP'))
},
limits: { fileSize: 5 * 1024 * 1024, files: 5, fields: 0, parts: 5 },
})// server/src/routes/uploadRoutes.ts
import { Router, type Request, type Response, type NextFunction } from 'express'
import multer from 'multer'
import sharp from 'sharp'
import { randomUUID } from 'node:crypto'
import { writeFile, rm } from 'node:fs/promises'
import path from 'node:path'
import { upload, UPLOAD_DIR } from '../middlewares/upload'
import { authMiddleware } from '../middlewares/auth'
import { adminOnly } from '../middlewares/role'
import { AppError } from '../utils/AppError'
import { success } from '../utils/response'
const assetOrigin = new URL(process.env.PUBLIC_ASSET_ORIGIN || 'http://127.0.0.1:3000')
if (!['http:', 'https:'].includes(assetOrigin.protocol)) throw new Error('PUBLIC_ASSET_ORIGIN 必须是 HTTP(S) 地址')
interface SavedImage { filename: string; url: string; originalName: string; size: number; mimetype: string }
async function saveImage(file: Express.Multer.File): Promise<SavedImage> {
let bytes: Buffer
try {
const image = sharp(file.buffer, { limitInputPixels: 20000000, failOn: 'warning', animated: false })
const metadata = await image.metadata()
if (!metadata.format || !['jpeg', 'png', 'webp'].includes(metadata.format)) throw new Error('不支持的图片内容')
bytes = await image.rotate().webp({ quality: 85 }).toBuffer()
} catch { throw new AppError('图片无法解码、格式不支持或超过 2000 万像素') }
const filename = `${randomUUID()}.webp`
const destination = path.join(UPLOAD_DIR, filename)
try { await writeFile(destination, bytes, { flag: 'wx' }) }
catch (error) { await rm(destination, { force: true }); throw error }
return { filename, url: new URL(`/uploads/${filename}`, assetOrigin).href,
originalName: file.originalname, size: bytes.length, mimetype: 'image/webp' }
}
const router = Router()
router.use(authMiddleware, adminOnly) // 商品图片由管理员上传
router.post('/single', upload.single('file'), async (req, res) => {
if (!req.file) throw new AppError('未选择文件')
success(res, await saveImage(req.file), 201)
})
router.post('/multiple', upload.array('files', 5), async (req, res) => {
if (!Array.isArray(req.files) || req.files.length === 0) throw new AppError('未选择文件')
const saved: SavedImage[] = []
try {
for (const file of req.files) saved.push(await saveImage(file))
success(res, saved, 201)
} catch (error) {
await Promise.all(saved.map(file => rm(path.join(UPLOAD_DIR, file.filename), { force: true })))
throw error
}
})
router.use((error: unknown, _req: Request, res: Response, next: NextFunction) => {
if (error instanceof multer.MulterError) {
const messages: Record<string, string> = {
LIMIT_FILE_SIZE: '单个文件不能超过 5 MiB',
LIMIT_FILE_COUNT: '每次最多上传 5 个文件',
LIMIT_UNEXPECTED_FILE: '文件字段不匹配,或该字段文件数量过多',
LIMIT_PART_COUNT: 'multipart 部分数量过多',
LIMIT_FIELD_COUNT: '本接口不接受额外表单字段',
}
res.status(400).json({ success: false, message: messages[error.code] || '上传请求不符合限制' })
return
}
next(error)
})
export default router一次最多接收 5 个 5 MiB 文件;还限制解码像素数,避免把小体积文件等同于低解码成本。WebP 动画只保留首帧,重编码会改变体积和可能的画质,不是保留原文件。Multer 的 array maxCount 与 limits.files 可能返回不同错误码,分别处理。Multer 官方配置、sharp 输入限制、sharp 输出
在 app.ts 顶部新增下面两个导入,再把两次挂载放在 404 之前;已有 express 导入不用重复:
// server/src/app.ts:新增导入与挂载
import uploadRoutes from './routes/uploadRoutes'
import { UPLOAD_DIR } from './middlewares/upload'
app.use('/api/upload', uploadRoutes)
app.use('/uploads', express.static(UPLOAD_DIR, {
dotfiles: 'deny',
setHeaders(res) { res.setHeader('X-Content-Type-Options', 'nosniff') },
}))PUBLIC_ASSET_ORIGIN 是资源对浏览器开放的 origin;本地默认 http://127.0.0.1:3000,部署时改为实际 HTTPS 地址。完整图片 URL 不会错误指向前端 5173 端口。上传目录中的图片公开读取,只用于公开商品图;头像或私密附件需另设权限。把 server/uploads/ 加入 Git 忽略列表。本课未实现孤立文件回收,页面删除预览不等于删除磁盘文件。
3. 前端:ImageUploader 组件
<!-- client/src/components/ui/ImageUploader.vue -->
<script setup lang="ts">
import { ref, reactive, computed, watch, onScopeDispose } from 'vue'
import axios from 'axios'
import request from '@/utils/request'
import type { ApiResponse } from '@/types/api'
interface UploadedImage {
id: string; url: string; originalName: string; progress: number
status: 'pending' | 'uploading' | 'done' | 'error'
error?: string; file: File; previewUrl?: string
}
interface UploadResult { url: string; originalName: string; size: number; mimetype: string }
const props = withDefaults(defineProps<{
maxFiles?: number; maxSize?: number; accept?: string; disabled?: boolean
}>(), { maxFiles: 5, maxSize: 5, accept: 'image/jpeg,image/png,image/webp', disabled: false })
const emit = defineEmits<{ uploaded: [urls: string[]]; busy: [value: boolean] }>()
const images = ref<UploadedImage[]>([])
const isDragOver = ref(false)
const validationError = ref('')
const fileInputRef = ref<HTMLInputElement>()
const controllers = new Map<string, AbortController>()
let disposed = false
const fileLimit = computed(() => Number.isSafeInteger(props.maxFiles) ? Math.max(1, Math.min(5, props.maxFiles)) : 5)
const sizeLimit = computed(() => Number.isFinite(props.maxSize) && props.maxSize > 0 ? Math.min(5, props.maxSize) : 5)
const allowedTypes = computed(() => props.accept.split(',').map(type => type.trim())
.filter(type => ['image/jpeg', 'image/png', 'image/webp'].includes(type)))
const uploadedUrls = computed(() => images.value.filter(image => image.status === 'done').map(image => image.url))
const isBusy = computed(() => images.value.some(image => image.status === 'pending' || image.status === 'uploading'))
watch(isBusy, value => emit('busy', value), { immediate: true })
function triggerFileInput() { if (!props.disabled && images.value.length < fileLimit.value) fileInputRef.value?.click() }
function onDragEnter(event: DragEvent) { event.preventDefault(); if (!props.disabled) isDragOver.value = true }
function onDragLeave(event: DragEvent) { event.preventDefault(); isDragOver.value = false }
function onDrop(event: DragEvent) {
event.preventDefault()
isDragOver.value = false
handleFiles(Array.from(event.dataTransfer?.files || []))
}
function onFileChange(event: Event) {
const input = event.target as HTMLInputElement
handleFiles(Array.from(input.files || []))
input.value = ''
}
function handleFiles(files: File[]) {
if (props.disabled) return
validationError.value = ''
for (const file of files) {
if (images.value.length >= fileLimit.value) { validationError.value = `最多选择 ${fileLimit.value} 张`; break }
if (!allowedTypes.value.includes(file.type)) { validationError.value = `${file.name} 格式不支持`; continue }
if (!file.size || file.size > sizeLimit.value * 1024 * 1024) { validationError.value = `${file.name} 为空或超过大小限制`; continue }
// 直接创建响应式条目,异步函数修改的是代理,而不是放入 ref 数组前的原始对象。
const image = reactive<UploadedImage>({ id: crypto.randomUUID(), url: '', originalName: file.name,
progress: 0, status: 'pending', file, previewUrl: URL.createObjectURL(file) })
images.value.push(image)
void uploadFile(image)
}
}
function releasePreview(image: UploadedImage) {
if (image.previewUrl) URL.revokeObjectURL(image.previewUrl)
image.previewUrl = undefined
}
async function uploadFile(image: UploadedImage) {
if (disposed || props.disabled || controllers.has(image.id)) return
const controller = new AbortController()
controllers.set(image.id, controller)
const alive = () => !disposed && controllers.get(image.id) === controller && images.value.some(value => value.id === image.id)
image.status = 'uploading'
image.progress = 0
image.error = undefined
const form = new FormData()
form.append('file', image.file)
try {
const result = await request.post<ApiResponse<UploadResult>>('/upload/single', form, {
signal: controller.signal,
// 不设置 Content-Type,让浏览器为 FormData 添加正确的 boundary。
onUploadProgress(event) {
if (alive() && event.total) image.progress = Math.min(99, Math.round(event.loaded / event.total * 100))
},
})
if (!alive()) return
image.url = result.data.url
image.status = 'done'
image.progress = 100
releasePreview(image)
emit('uploaded', uploadedUrls.value)
} catch (error) {
if (!alive() || controller.signal.aborted || axios.isCancel(error)) return
image.status = 'error'
image.error = error instanceof Error ? error.message : '上传失败'
} finally { if (controllers.get(image.id) === controller) controllers.delete(image.id) }
}
function removeImage(image: UploadedImage) {
if (props.disabled) return
controllers.get(image.id)?.abort()
controllers.delete(image.id)
releasePreview(image)
images.value = images.value.filter(value => value.id !== image.id)
emit('uploaded', uploadedUrls.value)
}
function retryUpload(image: UploadedImage) { if (image.status === 'error') void uploadFile(image) }
onScopeDispose(() => {
disposed = true
controllers.forEach(controller => controller.abort())
controllers.clear()
images.value.forEach(releasePreview)
})
</script>
<template>
<div class="image-uploader">
<!-- 拖拽区域 -->
<div
class="drop-zone"
:class="{ 'is-drag-over': isDragOver, 'is-full': disabled || images.length >= fileLimit }"
role="button"
:tabindex="disabled || images.length >= fileLimit ? -1 : 0"
:aria-disabled="disabled || images.length >= fileLimit"
aria-label="选择商品图片"
@keydown.enter.prevent="triggerFileInput"
@keydown.space.prevent="triggerFileInput"
@dragenter="onDragEnter"
@dragover.prevent
@dragleave="onDragLeave"
@drop="onDrop"
@click="triggerFileInput"
>
<input
ref="fileInputRef"
type="file"
:accept="allowedTypes.join(',')"
:disabled="disabled || images.length >= fileLimit"
multiple
hidden
@change="onFileChange"
@click.stop
/>
<div class="drop-content">
<span class="drop-icon">📁</span>
<p class="drop-text">
{{ isDragOver ? '释放鼠标上传' : '拖拽图片到这里,或点击选择' }}
</p>
<p class="drop-hint">
支持 JPG / PNG / WebP,单个文件最大 {{ sizeLimit }} MiB,最多 {{ fileLimit }} 张
</p>
</div>
</div>
<p v-if="validationError" role="alert">{{ validationError }}</p>
<!-- 图片预览列表 -->
<div v-if="images.length > 0" class="preview-list">
<div v-for="img in images" :key="img.id" class="preview-item">
<!-- 缩略图 -->
<div class="preview-image">
<img :src="img.previewUrl || img.url" :alt="img.originalName" />
<!-- 上传中遮罩 -->
<div v-if="img.status === 'uploading'" class="upload-overlay">
<div class="progress-ring">{{ img.progress }}%</div>
</div>
<!-- 错误遮罩 -->
<button v-if="img.status === 'error'" type="button" :disabled="disabled" class="error-overlay" @click="retryUpload(img)">
<span>❌</span>
<span class="retry-text">重试上传</span>
</button>
</div>
<!-- 进度条 -->
<div v-if="img.status === 'uploading'" class="progress-bar">
<div class="progress-fill" :style="{ width: img.progress + '%' }"></div>
</div>
<!-- 删除按钮 -->
<button type="button" :disabled="disabled" @click="removeImage(img)" class="remove-btn" :aria-label="`移除 ${img.originalName}`" title="移除">×</button>
<!-- 文件名 -->
<p class="file-name">{{ img.originalName }}</p>
<p v-if="img.error" role="alert">{{ img.error }}</p>
</div>
</div>
</div>
</template>
<style scoped>
.drop-zone {
border: 2px dashed #d0d0d0;
border-radius: 12px;
padding: 40px 20px;
text-align: center;
cursor: pointer;
transition: all 0.2s;
background: #fafafa;
}
.drop-zone:hover, .drop-zone.is-drag-over {
border-color: #42b883;
background: #42b88308;
}
.drop-zone.is-full {
opacity: 0.5;
pointer-events: none;
}
.drop-icon { font-size: 2.5rem; }
.drop-text { margin: 8px 0 4px; font-size: 0.95rem; color: #555; }
.drop-hint { font-size: 0.75rem; color: #aaa; margin: 0; }
/* 预览列表 */
.preview-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 12px;
margin-top: 16px;
}
.preview-item {
position: relative;
}
.preview-image {
position: relative;
aspect-ratio: 1;
border-radius: 8px;
overflow: hidden;
border: 1px solid #e0e0e0;
}
.preview-image img {
width: 100%;
height: 100%;
object-fit: cover;
}
/* 上传遮罩 */
.upload-overlay, .error-overlay {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.5);
}
.progress-ring {
color: white;
font-weight: 700;
font-size: 1.1rem;
}
.error-overlay {
cursor: pointer;
color: white;
}
.retry-text {
font-size: 0.7rem;
margin-top: 4px;
}
/* 进度条 */
.progress-bar {
height: 3px;
background: #e0e0e0;
border-radius: 2px;
margin-top: 4px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: #42b883;
transition: width 0.3s;
}
/* 删除按钮 */
.remove-btn {
position: absolute;
top: 4px;
right: 4px;
width: 22px;
height: 22px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.6);
color: white;
border: none;
cursor: pointer;
font-size: 0.8rem;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
opacity: 1;
transition: opacity 0.15s;
}
.preview-item:hover .remove-btn {
opacity: 1;
}
.file-name {
font-size: 0.65rem;
color: #999;
margin: 4px 0 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>4. 在商品表单中使用
上传与保存商品是两次请求。上传完成只得到资源 URL,下面把它写入 L21 的 ProductInput.images,再调用已受管理员保护的商品创建 API。进度 100% 以服务端成功响应为准;onUploadProgress 本身只表示请求体发送进度。
<!-- client/src/views/ProductCreateView.vue -->
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useRouter, onBeforeRouteLeave } from 'vue-router'
import ImageUploader from '@/components/ui/ImageUploader.vue'
import { productApi } from '@/api/products'
import type { ProductInput } from '@/types/product'
const router = useRouter()
const form = reactive<ProductInput>({ name: '', description: '', category: '', price: 0, stock: 0, images: [] })
const uploading = ref(false)
const saving = ref(false)
const error = ref('')
onBeforeRouteLeave(() => !uploading.value && !saving.value)
async function handleSubmit() {
if (uploading.value || saving.value) return
saving.value = true
error.value = ''
try {
const result = await productApi.create({ ...form, images: [...form.images] })
saving.value = false
await router.push({ name: 'product-detail', params: { id: result.data._id } })
} catch (cause) { error.value = cause instanceof Error ? cause.message : '保存失败' }
finally { saving.value = false }
}
</script>
<template>
<main><h1>新增商品</h1>
<form @submit.prevent="handleSubmit">
<fieldset :disabled="saving"><legend>商品信息</legend>
<label>名称 <input v-model="form.name" required maxlength="100" /></label>
<label>描述 <textarea v-model="form.description" required maxlength="5000"></textarea></label>
<label>分类 <input v-model="form.category" required maxlength="80" /></label>
<label>价格(元) <input v-model.number="form.price" type="number" min="0" step="0.01" required /></label>
<label>库存 <input v-model.number="form.stock" type="number" min="0" step="1" required /></label>
<ImageUploader :max-files="5" :max-size="5" :disabled="saving"
@uploaded="form.images = $event" @busy="uploading = $event" />
<button :disabled="uploading || saving">{{ saving ? '保存中…' : '保存商品' }}</button>
</fieldset>
<p v-if="error" role="alert">{{ error }}</p>
</form>
</main>
</template>向路由数组加入管理员页面,并在 App header 中增加 <RouterLink v-if="auth.isAdmin" to="/admin/products/new">新增商品</RouterLink>:
// client/src/router/index.ts:routes 数组新增项
{ path: '/admin/products/new', name: 'product-create', component: () => import('@/views/ProductCreateView.vue'), meta: { requiresAuth: true, requiresAdmin: true } },组件的 accept 参数只支持逗号分隔的上述 MIME 精确值,用于缩小可选范围,不处理 image/* 或文件扩展名语法。前端校验改善反馈,服务端仍独立验证。移除或卸载会 abort 在途请求并释放 Blob URL,但已到达服务端的写入可能完成;本课只移除表单引用,未实现服务器文件删除与孤立资源定期回收。
5. 本节总结
检查清单
- [ ] 能用 multer 配置文件上传(存储、过滤、限制)
- [ ] 能实现拖拽上传(dragenter / dragover / dragleave / drop)
- [ ] 能用
URL.createObjectURL实现本地即时预览 - [ ] 能用
onUploadProgress显示上传进度 - [ ] 能处理上传失败和重试
- [ ] 能用 FormData 发送 multipart/form-data 请求
- [ ] 知道
URL.revokeObjectURL的必要性(防止内存泄漏)
Git 提交
git add .
git commit -m "L27: 图片上传 - 拖拽/预览/进度/multer"🔗 → 下一节
L28 将实现 WebSocket 实时通知——当订单状态变化时,用 Socket.IO 推送通知给用户。