Skip to content

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,原始文件名和扩展名不会决定磁盘路径。

bash
npm install multer@2 sharp@0.35.4
npm install -D @types/multer@2
typescript
// 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 },
})
typescript
// 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 导入不用重复:

typescript
// 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 组件 ​

vue
<!-- 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 本身只表示请求体发送进度。

vue
<!-- 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>:

typescript
// 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 提交 ​

bash
git add .
git commit -m "L27: 图片上传 - 拖拽/预览/进度/multer"

🔗 → 下一节 ​

L28 将实现 WebSocket 实时通知——当订单状态变化时,用 Socket.IO 推送通知给用户。