Skip to content

图片上传OSS弹窗组件

使用vue3和element UI plus,主要上传图片和视频,目前只支持单文件,多文件不太适用目前的UI,功能包括图片、视频上传,上传的百分比进度条,可取消上传,图片、视频的分页展示,点击图片会把链接赋值到页面的img标签上

image-20250515232247408

MediaUpload.vue

vue
<template>
  <el-dialog v-model="dialogVisible" title="媒体管理" width="650px">
    <!-- 上传区域 -->
    <div class="upload-container">
      <el-upload
        ref="uploadRef"
        :show-file-list="false"
        :http-request="handleUpload"
        name="file"
        :before-upload="beforeUpload"
        class="upload-area"
      >
        <div v-if="!uploading" class="upload-button">
          <el-icon :size="22"><Plus /></el-icon>
          <div class="el-upload__text">点击上传</div>
        </div>
        <!-- 上传进度条 -->
        <div v-else class="upload-progress">
          <el-progress
            :percentage="uploadProgress"
            :show-text="false"
            style="width: 90px"
          />
          <div class="progress-text">{{ uploadProgress }}%</div>
          <el-button
            size="small"
            type="danger"
            @click.stop="cancelUpload"
            class="cancel-btn"
          >
            取消
          </el-button>
        </div>
      </el-upload>

      <!-- 图片展示区域 -->
      <div
        v-for="(item, index) in paginatedImages"
        :key="index"
        class="media-item"
        @click="handleImageSelect(item.url, item.type)"
      >
        <!-- <img :src="item.url" alt="图片" /> -->
        <template v-if="item.type === 'image'">
          <img :src="item.url" alt="图片" />
        </template>
        <template v-else>
          <div class="video-preview">
            <video :src="item.url"></video>
            <div class="video-icon">
              <el-icon :size="24"><VideoPlay /></el-icon>
            </div>
          </div>
        </template>
        <div class="media-hover">点击选择</div>
      </div>
    </div>

    <!-- 分页 -->
    <div class="pagination">
      <el-pagination
        v-model:current-page="currentPage"
        :page-size="pageSize"
        layout="total, prev, pager, next"
        :total="total"
        background
        @size-change="handleSizeChange"
        @current-change="handleCurrentChange"
      >
      </el-pagination>
    </div>
  </el-dialog>
</template>

<script setup>
import axios from 'axios'
import { ref, computed, watchEffect, defineProps } from 'vue'

import {
  ElDialog,
  ElUpload,
  ElProgress,
  ElPagination,
  ElIcon,
  ElMessage
} from 'element-plus'
import { Plus, VideoPlay } from '@element-plus/icons-vue'

const props = defineProps({
  modelValue: Boolean
})

const emit = defineEmits(['update:modelValue', 'select'])

// API配置
const uploadUrl = 'http://localhost:3000/api/upload'
const listUrl = 'http://localhost:3000/api/images'

// 状态管理
const currentPage = ref(1)
const pageSize = ref(9)
const total = ref(0)
const imageList = ref([])
const uploadRef = ref(null)
const uploading = ref(false)
const uploadProgress = ref(0)
const controller = ref(null)
// 计算分页后的图片
const paginatedImages = computed(() => {
  return imageList.value
})

// 自定义上传逻辑
const handleUpload = async (options) => {
  try {
    uploading.value = true
    uploadProgress.value = 0

    controller.value = new AbortController()
    const formData = new FormData()
    formData.append('file', options.file) // 确保字段名与后端一致
    const response = await axios.post(uploadUrl, formData, {
      // headers: {
      //   'Content-Type': 'multipart/form-data'
      // },
      onUploadProgress: (progressEvent) => {
        if (progressEvent.lengthComputable) {
          const progress = Math.round(
            (progressEvent.loaded * 100) / progressEvent.total
          )
          uploadProgress.value = progress
        }
      },
      signal: controller.value.signal
    })

    if (response.data?.code === 200) {
      setTimeout(() => {
        ElMessage.success(response.data.message || '上传成功')
        uploading.value = false
        uploadProgress.value = 0
        fetchImages() // 等待列表刷新完成
      }, 1000)
    } else {
      throw new Error(response.data.message || '上传失败')
    }
  } catch (error) {
    if (axios.isCancel(error)) {
      console.log('用户的上传已取消')
    } else {
      console.error('Upload error:', error)
    }
    // ElMessage.error(`上传失败: ${error.message}`)
  } finally {
    // uploading.value = false
    // uploadProgress.value = 0
  }
}

// 获取图片列表
const fetchImages = async () => {
  try {
    const params = {
      page: currentPage.value,
      pageSize: pageSize.value
    }

    const { data } = await axios.get(listUrl, { params })

    imageList.value = data.data.images
    currentPage.value = data.data.pagination.page
    pageSize.value = data.data.pagination.pageSize
    total.value = data.data.pagination.total
  } catch (error) {
    ElMessage.error('获取图片列表失败')
    console.error('Error fetching images:', error)
  }
}
// 取消上传
const cancelUpload = () => {
  controller.value.abort()

  uploading.value = false
  uploadProgress.value = 0
  // 清除上传组件内部文件列表
  ElMessage.warning('上传已取消')
  setTimeout(() => {
    fetchImages()
  }, 500)
}
// 文件上传前校验
const beforeUpload = (file) => {
  const allowedTypes = [
    'image/jpeg',
    'image/png',
    'image/gif',
    'video/mp4',
    'video/webm',
    'video/ogg'
  ]
  const isAllowed = allowedTypes.includes(file.type)
  const isLt500M = file.size / 1024 / 1024 < 500

  if (!isAllowed) {
    ElMessage.error('只能上传图片或视频文件!')
    return false
  }

  if (!isLt500M) {
    ElMessage.error('文件大小不能超过500MB!')
    return false
  }

  return true
}

// 分页操作
const handleSizeChange = (val) => {
  pageSize.value = val
  fetchImages()
}

const handleCurrentChange = (val) => {
  currentPage.value = val
  fetchImages()
}

// 图片选择处理
const handleImageSelect = (url, type) => {
  emit('select', { url, type })
  dialogVisible.value = false
}

// 控制弹窗显示
const dialogVisible = computed({
  get: () => props.modelValue,
  set: (val) => {
    emit('update:modelValue', val)
    if (val) fetchImages() // 打开弹窗时自动加载
  }
})

// 初始化加载
watchEffect(() => {
  if (dialogVisible.value) {
    fetchImages()
  }
})
</script>

<style scoped>
.upload-container {
  display: flex;
  flex-wrap: wrap;
  gap: 12px;
  height: 236px;
}

.upload-button {
  width: 100px;
  height: 100px;
  padding: 5px;
  color: #999;
  border: 1px dashed #d9d9d9;
  border-radius: 6px;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  transition: border-color 0.3s;
}
.el-upload__text {
  font-size: 12px;
}

.media-item {
  width: 100px;
  height: 100px;
  position: relative;
  cursor: pointer;
  padding: 5px;
  border: 1px solid #ddd;
  border-radius: 4px;
  overflow: hidden;
}

.media-item img,
.media-item video {
  width: 100%;
  height: 100%;
  object-fit: contain;
}

.video-preview {
  position: relative;
  width: 100%;
  height: 100%;
  background-color: #000;
}

.video-icon {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  color: rgba(255, 255, 255, 0.8);
}

.media-hover {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background: rgba(0, 0, 0, 0.5);
  color: white;
  display: none;
  align-items: center;
  justify-content: center;
}

.media-item:hover .media-hover {
  display: flex;
}

.media-hover {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background: rgba(0, 0, 0, 0.5);
  color: white;
  display: none;
  align-items: center;
  justify-content: center;
}

.media-item:hover .media-hover {
  display: flex;
}

.pagination {
  display: flex;
  align-items: center;
  justify-content: center;
  margin-top: 20px;
}
.total {
  margin-left: 10px;
}

.upload-progress {
  display: flex;
  flex-direction: column;
  align-items: center;
  padding: 26px 12px;
  background: #f5f7fa;
  border-radius: 4px;
}

.progress-text {
  font-size: 12px;
  color: #409eff;
  margin-top: 8px;
}

.cancel-btn {
  margin-top: 8px;
  width: 100%;
}
</style>
<template>
  <el-dialog v-model="dialogVisible" title="媒体管理" width="650px">
    <!-- 上传区域 -->
    <div class="upload-container">
      <el-upload
        ref="uploadRef"
        :show-file-list="false"
        :http-request="handleUpload"
        name="file"
        :before-upload="beforeUpload"
        class="upload-area"
      >
        <div v-if="!uploading" class="upload-button">
          <el-icon :size="22"><Plus /></el-icon>
          <div class="el-upload__text">点击上传</div>
        </div>
        <!-- 上传进度条 -->
        <div v-else class="upload-progress">
          <el-progress
            :percentage="uploadProgress"
            :show-text="false"
            style="width: 90px"
          />
          <div class="progress-text">{{ uploadProgress }}%</div>
          <el-button
            size="small"
            type="danger"
            @click.stop="cancelUpload"
            class="cancel-btn"
          >
            取消
          </el-button>
        </div>
      </el-upload>

      <!-- 图片展示区域 -->
      <div
        v-for="(item, index) in paginatedImages"
        :key="index"
        class="media-item"
        @click="handleImageSelect(item.url, item.type)"
      >
        <!-- <img :src="item.url" alt="图片" /> -->
        <template v-if="item.type === 'image'">
          <img :src="item.url" alt="图片" />
        </template>
        <template v-else>
          <div class="video-preview">
            <video :src="item.url"></video>
            <div class="video-icon">
              <el-icon :size="24"><VideoPlay /></el-icon>
            </div>
          </div>
        </template>
        <div class="media-hover">点击选择</div>
      </div>
    </div>

    <!-- 分页 -->
    <div class="pagination">
      <el-pagination
        v-model:current-page="currentPage"
        :page-size="pageSize"
        layout="total, prev, pager, next"
        :total="total"
        background
        @size-change="handleSizeChange"
        @current-change="handleCurrentChange"
      >
      </el-pagination>
    </div>
  </el-dialog>
</template>

<script setup>
import axios from 'axios'
import { ref, computed, watchEffect, defineProps } from 'vue'

import {
  ElDialog,
  ElUpload,
  ElProgress,
  ElPagination,
  ElIcon,
  ElMessage
} from 'element-plus'
import { Plus, VideoPlay } from '@element-plus/icons-vue'

const props = defineProps({
  modelValue: Boolean
})

const emit = defineEmits(['update:modelValue', 'select'])

// API配置
const uploadUrl = 'http://localhost:3000/api/upload'
const listUrl = 'http://localhost:3000/api/images'

// 状态管理
const currentPage = ref(1)
const pageSize = ref(9)
const total = ref(0)
const imageList = ref([])
const uploadRef = ref(null)
const uploading = ref(false)
const uploadProgress = ref(0)
const controller = ref(null)
// 计算分页后的图片
const paginatedImages = computed(() => {
  return imageList.value
})

// 自定义上传逻辑
const handleUpload = async (options) => {
  try {
    uploading.value = true
    uploadProgress.value = 0

    controller.value = new AbortController()
    const formData = new FormData()
    formData.append('file', options.file) // 确保字段名与后端一致
    const response = await axios.post(uploadUrl, formData, {
      // headers: {
      //   'Content-Type': 'multipart/form-data'
      // },
      onUploadProgress: (progressEvent) => {
        if (progressEvent.lengthComputable) {
          const progress = Math.round(
            (progressEvent.loaded * 100) / progressEvent.total
          )
          uploadProgress.value = progress
        }
      },
      signal: controller.value.signal
    })

    if (response.data?.code === 200) {
      setTimeout(() => {
        ElMessage.success(response.data.message || '上传成功')
        uploading.value = false
        uploadProgress.value = 0
        fetchImages() // 等待列表刷新完成
      }, 1000)
    } else {
      throw new Error(response.data.message || '上传失败')
    }
  } catch (error) {
    if (axios.isCancel(error)) {
      console.log('用户的上传已取消')
    } else {
      console.error('Upload error:', error)
    }
    // ElMessage.error(`上传失败: ${error.message}`)
  } finally {
    // uploading.value = false
    // uploadProgress.value = 0
  }
}

// 获取图片列表
const fetchImages = async () => {
  try {
    const params = {
      page: currentPage.value,
      pageSize: pageSize.value
    }

    const { data } = await axios.get(listUrl, { params })

    imageList.value = data.data.images
    currentPage.value = data.data.pagination.page
    pageSize.value = data.data.pagination.pageSize
    total.value = data.data.pagination.total
  } catch (error) {
    ElMessage.error('获取图片列表失败')
    console.error('Error fetching images:', error)
  }
}
// 取消上传
const cancelUpload = () => {
  controller.value.abort()

  uploading.value = false
  uploadProgress.value = 0
  // 清除上传组件内部文件列表
  ElMessage.warning('上传已取消')
  setTimeout(() => {
    fetchImages()
  }, 500)
}
// 文件上传前校验
const beforeUpload = (file) => {
  const allowedTypes = [
    'image/jpeg',
    'image/png',
    'image/gif',
    'video/mp4',
    'video/webm',
    'video/ogg'
  ]
  const isAllowed = allowedTypes.includes(file.type)
  const isLt500M = file.size / 1024 / 1024 < 500

  if (!isAllowed) {
    ElMessage.error('只能上传图片或视频文件!')
    return false
  }

  if (!isLt500M) {
    ElMessage.error('文件大小不能超过500MB!')
    return false
  }

  return true
}

// 分页操作
const handleSizeChange = (val) => {
  pageSize.value = val
  fetchImages()
}

const handleCurrentChange = (val) => {
  currentPage.value = val
  fetchImages()
}

// 图片选择处理
const handleImageSelect = (url, type) => {
  emit('select', { url, type })
  dialogVisible.value = false
}

// 控制弹窗显示
const dialogVisible = computed({
  get: () => props.modelValue,
  set: (val) => {
    emit('update:modelValue', val)
    if (val) fetchImages() // 打开弹窗时自动加载
  }
})

// 初始化加载
watchEffect(() => {
  if (dialogVisible.value) {
    fetchImages()
  }
})
</script>

<style scoped>
.upload-container {
  display: flex;
  flex-wrap: wrap;
  gap: 12px;
  height: 236px;
}

.upload-button {
  width: 100px;
  height: 100px;
  padding: 5px;
  color: #999;
  border: 1px dashed #d9d9d9;
  border-radius: 6px;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  transition: border-color 0.3s;
}
.el-upload__text {
  font-size: 12px;
}

.media-item {
  width: 100px;
  height: 100px;
  position: relative;
  cursor: pointer;
  padding: 5px;
  border: 1px solid #ddd;
  border-radius: 4px;
  overflow: hidden;
}

.media-item img,
.media-item video {
  width: 100%;
  height: 100%;
  object-fit: contain;
}

.video-preview {
  position: relative;
  width: 100%;
  height: 100%;
  background-color: #000;
}

.video-icon {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  color: rgba(255, 255, 255, 0.8);
}

.media-hover {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background: rgba(0, 0, 0, 0.5);
  color: white;
  display: none;
  align-items: center;
  justify-content: center;
}

.media-item:hover .media-hover {
  display: flex;
}

.media-hover {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background: rgba(0, 0, 0, 0.5);
  color: white;
  display: none;
  align-items: center;
  justify-content: center;
}

.media-item:hover .media-hover {
  display: flex;
}

.pagination {
  display: flex;
  align-items: center;
  justify-content: center;
  margin-top: 20px;
}
.total {
  margin-left: 10px;
}

.upload-progress {
  display: flex;
  flex-direction: column;
  align-items: center;
  padding: 26px 12px;
  background: #f5f7fa;
  border-radius: 4px;
}

.progress-text {
  font-size: 12px;
  color: #409eff;
  margin-top: 8px;
}

.cancel-btn {
  margin-top: 8px;
  width: 100%;
}
</style>

另外我也用nodejs写了一个上传到阿里云OSS的接口(上传、获取图片(分页))

后端阿里云OSS上传代码

需要安装库

npm i express multer ali-oss cors
npm i express multer ali-oss cors

完整代码

js
const express = require('express');
const multer = require('multer');
const OSS = require('ali-oss');
const cors = require('cors');
const path = require('path');

const app = express();
app.use(cors());

// 初始化OSS客户端
const client = new OSS({
  region: '你的region',
  accessKeyId: '你的accessKeyId',
  accessKeySecret: '你的accessKeySecret',
  bucket: '你的bucket'
});

// 配置临时文件存储
const upload = multer({
  storage: multer.diskStorage({
    destination: (req, file, cb) => {
      cb(null, 'temp/');
    },
    filename: (req, file, cb) => {
      const ext = path.extname(file.originalname);
      cb(null, `${Date.now()}${ext}`);
    }
  }),
  fileFilter: (req, file, cb) => {
    if(file.mimetype.startsWith('image/')) {
      cb(null, true);
    } else {
      cb(new Error('只允许上传图片文件'), false);
    }
  },
  limits: {
    fileSize: 1024 * 1024 * 5 // 限制5MB
  }
});

// 上传接口
app.post('/api/upload', upload.single('file'), async (req, res) => {
  try {
    if(!req.file) {
      return res.status(400).json({ code: 400, message: '未选择文件',data: null });
    }

    // 上传到OSS
    const result = await client.put(
      `images/${req.file.filename}`, // OSS存储路径
      req.file.path
    );

    // 返回结果
    res.json({
      code: 200,
      data: {
        url: result.url,
        name: result.name
      }
    });

  } catch (error) {
    console.error('上传失败:', error);
    res.status(500).json({
      code: 500,
      data: null,
      message: '文件上传失败',
      error: error.message
    });
  }
});

// 新增获取图片列表接口
app.get('/api/images', async (req, res) => {
  try {
    const { page = 1, pageSize = 9 } = req.query;
    const pageNum = parseInt(page);
    const pageSizeNum = parseInt(pageSize);

// 获取全量文件列表(OSS限制需循环获取)
let allObjects = [];
let marker;
do {
  const result = await client.list({
    prefix: 'images/',
    marker,
    'max-keys': 1000
  });
  allObjects = allObjects.concat(result.objects);
  marker = result.nextMarker;
} while (marker);

// 按修改时间倒序排序
allObjects.sort((a, b) => {
  return new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime();
});

// 手动分页逻辑
const startIdx = (pageNum - 1) * pageSizeNum;
const endIdx = startIdx + pageSizeNum;
const pageObjects = allObjects.slice(startIdx, endIdx);

const images = pageObjects.map(item => ({
  url: client.generateObjectUrl(item.name),
  name: item.name,
  lastModified: new Date(item.lastModified).toISOString()
}))

    res.json({
      code: 200,
      data: {
        images,
        pagination: {
          total: allObjects.length,
          page: pageNum,
          pageSize: pageSizeNum,
        }
      }
    });

  } catch (error) {
    console.error('获取图片列表失败:', error);
    res.status(500).json({
      code: 500,
      message: '获取图片列表失败',
      error: error.message
    });
  }
});

// 启动服务器
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});
const express = require('express');
const multer = require('multer');
const OSS = require('ali-oss');
const cors = require('cors');
const path = require('path');

const app = express();
app.use(cors());

// 初始化OSS客户端
const client = new OSS({
  region: '你的region',
  accessKeyId: '你的accessKeyId',
  accessKeySecret: '你的accessKeySecret',
  bucket: '你的bucket'
});

// 配置临时文件存储
const upload = multer({
  storage: multer.diskStorage({
    destination: (req, file, cb) => {
      cb(null, 'temp/');
    },
    filename: (req, file, cb) => {
      const ext = path.extname(file.originalname);
      cb(null, `${Date.now()}${ext}`);
    }
  }),
  fileFilter: (req, file, cb) => {
    if(file.mimetype.startsWith('image/')) {
      cb(null, true);
    } else {
      cb(new Error('只允许上传图片文件'), false);
    }
  },
  limits: {
    fileSize: 1024 * 1024 * 5 // 限制5MB
  }
});

// 上传接口
app.post('/api/upload', upload.single('file'), async (req, res) => {
  try {
    if(!req.file) {
      return res.status(400).json({ code: 400, message: '未选择文件',data: null });
    }

    // 上传到OSS
    const result = await client.put(
      `images/${req.file.filename}`, // OSS存储路径
      req.file.path
    );

    // 返回结果
    res.json({
      code: 200,
      data: {
        url: result.url,
        name: result.name
      }
    });

  } catch (error) {
    console.error('上传失败:', error);
    res.status(500).json({
      code: 500,
      data: null,
      message: '文件上传失败',
      error: error.message
    });
  }
});

// 新增获取图片列表接口
app.get('/api/images', async (req, res) => {
  try {
    const { page = 1, pageSize = 9 } = req.query;
    const pageNum = parseInt(page);
    const pageSizeNum = parseInt(pageSize);

// 获取全量文件列表(OSS限制需循环获取)
let allObjects = [];
let marker;
do {
  const result = await client.list({
    prefix: 'images/',
    marker,
    'max-keys': 1000
  });
  allObjects = allObjects.concat(result.objects);
  marker = result.nextMarker;
} while (marker);

// 按修改时间倒序排序
allObjects.sort((a, b) => {
  return new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime();
});

// 手动分页逻辑
const startIdx = (pageNum - 1) * pageSizeNum;
const endIdx = startIdx + pageSizeNum;
const pageObjects = allObjects.slice(startIdx, endIdx);

const images = pageObjects.map(item => ({
  url: client.generateObjectUrl(item.name),
  name: item.name,
  lastModified: new Date(item.lastModified).toISOString()
}))

    res.json({
      code: 200,
      data: {
        images,
        pagination: {
          total: allObjects.length,
          page: pageNum,
          pageSize: pageSizeNum,
        }
      }
    });

  } catch (error) {
    console.error('获取图片列表失败:', error);
    res.status(500).json({
      code: 500,
      message: '获取图片列表失败',
      error: error.message
    });
  }
});

// 启动服务器
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});

后端图片上传代码

一直测试导致OSS的流量起飞,所以我又写了一个本地的文件上传,这里写了一个中间件,就是为了监听前端取消图片上传,监听请求中断事件,OSS中如果前端取消上传是不会存储图片的,因为图片不完整,但是一般的后端会,就是你上传图片,期间中断了上传,但是他会上传中断前的那一部分,所以你会看到图片只显示上面一小部分

js
const express = require('express');
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const cors = require('cors');
 
const app = express();


// 启用 CORS
// app.use(cors());
app.use(cors())

 
// 确保upload目录存在 
const uploadDir = path.join(__dirname,  'upload');
if (!fs.existsSync(uploadDir))  {
  fs.mkdirSync(uploadDir,  { recursive: true });
}
 
// 配置multer存储 
const storage = multer.diskStorage({ 
  destination: uploadDir,
  filename: (req, file, cb) => {
    const ext = path.extname(file.originalname);
    const uniqueSuffix = Date.now() + '' + Math.round(Math.random() * 1E9);
    const filename = file.fieldname + '-' + uniqueSuffix + ext;
    req.uploadingFileName = filename; // 关键:存储文件名到请求对象
    cb(null, file.fieldname  + '-' + uniqueSuffix + ext);
  }
});
 
// 文件过滤器 
const fileFilter = (req, file, cb) => {
  const allowedTypes = ['image/jpeg', 'image/png', 'image/gif','video/mp4', 'video/webm', 'video/ogg'];
  if (allowedTypes.includes(file.mimetype))  {
    cb(null, true);
  } else {
    cb(new Error('只允许上传图片和视频文件 (JPEG, PNG, GIF, MP4, WEBM, OGG)'), false);
  }
};
 
const upload = multer({
  storage: storage,
  fileFilter: fileFilter,
  limits: {
    fileSize: 500 * 1024 * 1024 // 限制5MB 
  }
});

// 中间件:监听请求中断事件
const handleUploadAbort = (req, res, next) => {

  req.on('aborted', () => {
    console.log('请求被中止');
    if (req.uploadingFileName) {
      const filePath = path.join(uploadDir, req.uploadingFileName);
      fs.unlink(filePath, (err) => {
        if (err) console.error('删除未完成文件失败:', err);
        else console.log('已清理未完成文件:', req.uploadingFileName);
      });
      // 防止重复删除
      req.uploadingFileName = null;
    }
  });

  next();
};
 

// 1. 图片上传接口 
app.post('/api/upload', handleUploadAbort, upload.single('file'),  (req, res) => {
  console.log('收到上传请求,文件名:', req.file.filename) // 添加日志
  
  if (!req.file)  {
    return res.status(400).json({  
      code: 400, 
      message: '未选择文件或文件类型不正确' 
    });
  }
 
  res.json({ 
    code: 200,
    message: '上传成功',
    data: {
      filename: req.file.filename,
      url: `http://localhost:3000/upload/${req.file.filename}`,
      size: req.file.size, 
      mimetype: req.file.mimetype  
    }
  });
});
 
// 2. 图片列表接口(带分页)
app.get('/api/images',  (req, res) => {
  const { page = 1, pageSize = 9 } = req.query; 
  const pageNum = parseInt(page);
  const size = parseInt(pageSize);
 
  // 读取upload目录下的所有文件 
  fs.readdir(uploadDir,  (err, files) => {
    if (err) {
      console.error(' 读取目录失败:', err);
      return res.status(500).json({ 
        code: 500,
        message: '获取图片列表失败',
        error: err.message  
      });
    }
 
    // 过滤出图片文件 
    const imageFiles = files.filter(file  => {
      const ext = path.extname(file).toLowerCase(); 
      // return ['.jpg', '.jpeg', '.png', '.gif'].includes(ext);
      return ['.jpg', '.jpeg', '.png', '.gif', '.mp4', '.webm', '.ogg'].includes(ext);
    });
 
    // 按修改时间排序(最新的在前面)
    const sortedImages = imageFiles.map(file  => {
      const ext = path.extname(file).toLowerCase();
      const isVideo = ['.mp4', '.webm', '.ogg'].includes(ext);
      const stat = fs.statSync(path.join(uploadDir,  file));
      return {
        filename: file,
        url: `http://localhost:3000/upload/${file}`,
        size: stat.size, 
        lastModified: stat.mtime, 
        created: stat.birthtime,
        type: isVideo ? 'video' : 'image'  
      };
    }).sort((a, b) => b.lastModified  - a.lastModified); 
 
    // 分页处理 
    const total = sortedImages.length; 
    const startIndex = (pageNum - 1) * size;
    const endIndex = startIndex + size;
    const paginatedImages = sortedImages.slice(startIndex,  endIndex);
 
    res.json({ 
      code: 200,
      data: {
        images: paginatedImages,
        pagination: {
          total: total,
          page: pageNum,
          pageSize: size,
          totalPages: Math.ceil(total  / size)
        }
      }
    });
  });
});
 
// 静态文件服务(用于访问上传的图片)
app.use('/upload',  express.static(uploadDir)); 
 
// 启动服务器 
const PORT = process.env.PORT  || 3000;
app.listen(PORT,  () => {
  console.log(` 服务器运行在 http://localhost:${PORT}`);
  console.log(` 上传目录: ${uploadDir}`);
});
const express = require('express');
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const cors = require('cors');
 
const app = express();


// 启用 CORS
// app.use(cors());
app.use(cors())

 
// 确保upload目录存在 
const uploadDir = path.join(__dirname,  'upload');
if (!fs.existsSync(uploadDir))  {
  fs.mkdirSync(uploadDir,  { recursive: true });
}
 
// 配置multer存储 
const storage = multer.diskStorage({ 
  destination: uploadDir,
  filename: (req, file, cb) => {
    const ext = path.extname(file.originalname);
    const uniqueSuffix = Date.now() + '' + Math.round(Math.random() * 1E9);
    const filename = file.fieldname + '-' + uniqueSuffix + ext;
    req.uploadingFileName = filename; // 关键:存储文件名到请求对象
    cb(null, file.fieldname  + '-' + uniqueSuffix + ext);
  }
});
 
// 文件过滤器 
const fileFilter = (req, file, cb) => {
  const allowedTypes = ['image/jpeg', 'image/png', 'image/gif','video/mp4', 'video/webm', 'video/ogg'];
  if (allowedTypes.includes(file.mimetype))  {
    cb(null, true);
  } else {
    cb(new Error('只允许上传图片和视频文件 (JPEG, PNG, GIF, MP4, WEBM, OGG)'), false);
  }
};
 
const upload = multer({
  storage: storage,
  fileFilter: fileFilter,
  limits: {
    fileSize: 500 * 1024 * 1024 // 限制5MB 
  }
});

// 中间件:监听请求中断事件
const handleUploadAbort = (req, res, next) => {

  req.on('aborted', () => {
    console.log('请求被中止');
    if (req.uploadingFileName) {
      const filePath = path.join(uploadDir, req.uploadingFileName);
      fs.unlink(filePath, (err) => {
        if (err) console.error('删除未完成文件失败:', err);
        else console.log('已清理未完成文件:', req.uploadingFileName);
      });
      // 防止重复删除
      req.uploadingFileName = null;
    }
  });

  next();
};
 

// 1. 图片上传接口 
app.post('/api/upload', handleUploadAbort, upload.single('file'),  (req, res) => {
  console.log('收到上传请求,文件名:', req.file.filename) // 添加日志
  
  if (!req.file)  {
    return res.status(400).json({  
      code: 400, 
      message: '未选择文件或文件类型不正确' 
    });
  }
 
  res.json({ 
    code: 200,
    message: '上传成功',
    data: {
      filename: req.file.filename,
      url: `http://localhost:3000/upload/${req.file.filename}`,
      size: req.file.size, 
      mimetype: req.file.mimetype  
    }
  });
});
 
// 2. 图片列表接口(带分页)
app.get('/api/images',  (req, res) => {
  const { page = 1, pageSize = 9 } = req.query; 
  const pageNum = parseInt(page);
  const size = parseInt(pageSize);
 
  // 读取upload目录下的所有文件 
  fs.readdir(uploadDir,  (err, files) => {
    if (err) {
      console.error(' 读取目录失败:', err);
      return res.status(500).json({ 
        code: 500,
        message: '获取图片列表失败',
        error: err.message  
      });
    }
 
    // 过滤出图片文件 
    const imageFiles = files.filter(file  => {
      const ext = path.extname(file).toLowerCase(); 
      // return ['.jpg', '.jpeg', '.png', '.gif'].includes(ext);
      return ['.jpg', '.jpeg', '.png', '.gif', '.mp4', '.webm', '.ogg'].includes(ext);
    });
 
    // 按修改时间排序(最新的在前面)
    const sortedImages = imageFiles.map(file  => {
      const ext = path.extname(file).toLowerCase();
      const isVideo = ['.mp4', '.webm', '.ogg'].includes(ext);
      const stat = fs.statSync(path.join(uploadDir,  file));
      return {
        filename: file,
        url: `http://localhost:3000/upload/${file}`,
        size: stat.size, 
        lastModified: stat.mtime, 
        created: stat.birthtime,
        type: isVideo ? 'video' : 'image'  
      };
    }).sort((a, b) => b.lastModified  - a.lastModified); 
 
    // 分页处理 
    const total = sortedImages.length; 
    const startIndex = (pageNum - 1) * size;
    const endIndex = startIndex + size;
    const paginatedImages = sortedImages.slice(startIndex,  endIndex);
 
    res.json({ 
      code: 200,
      data: {
        images: paginatedImages,
        pagination: {
          total: total,
          page: pageNum,
          pageSize: size,
          totalPages: Math.ceil(total  / size)
        }
      }
    });
  });
});
 
// 静态文件服务(用于访问上传的图片)
app.use('/upload',  express.static(uploadDir)); 
 
// 启动服务器 
const PORT = process.env.PORT  || 3000;
app.listen(PORT,  () => {
  console.log(` 服务器运行在 http://localhost:${PORT}`);
  console.log(` 上传目录: ${uploadDir}`);
});

程序员小洛文档