Skip to content

富文本编辑器自定义图片上传

wangeditor官网

这里我用wangeditor这个富文本编辑器,自定义图片和视频上传,搭配OSS的弹窗组件,用的是vue3、wangeditor v5

image-20250515232202484

主要是用customBrowseAndUpload这个方法(都是坑,踩出来的,靠)

js
// 编辑器配置
const editorConfig = {
  placeholder: props.placeholder,
  MENU_CONF: {
    uploadImage: {
      customBrowseAndUpload(insertFn) {
        insertMediaFn.value = (url) => insertFn(url) // 明确图片插入逻辑
        imageVisible.value = true
      }
    },
    uploadVideo: {
      customBrowseAndUpload(insertFn) {
        insertMediaFn.value = (url) => insertFn(url) // 明确视频插入逻辑
        imageVisible.value = true
      }
    }
  }
}
// 编辑器配置
const editorConfig = {
  placeholder: props.placeholder,
  MENU_CONF: {
    uploadImage: {
      customBrowseAndUpload(insertFn) {
        insertMediaFn.value = (url) => insertFn(url) // 明确图片插入逻辑
        imageVisible.value = true
      }
    },
    uploadVideo: {
      customBrowseAndUpload(insertFn) {
        insertMediaFn.value = (url) => insertFn(url) // 明确视频插入逻辑
        imageVisible.value = true
      }
    }
  }
}

Wangeditor组件代码如下:

vue
<template>
  <div class="wang-editor-container">
    <Toolbar
      class="editor-toolbar"
      :editor="editorRef"
      :defaultConfig="toolbarConfig"
      :mode="mode"
    />
    <Editor
      class="editor-content"
      v-model="valueHtml"
      :defaultConfig="editorConfig"
      :mode="mode"
      @onCreated="handleCreated"
      @onChange="handleChange"
      :style="{ height: props.height + 'px' }"
    />
    <MediaUpload v-model="imageVisible" @select="handleMediaSelected" />
  </div>
</template>

<script setup>
import '@wangeditor/editor/dist/css/style.css'
import { onBeforeUnmount, shallowRef, watch, ref, defineEmits } from 'vue'
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'

const props = defineProps({
  modelValue: {
    type: String,
    default: ''
  },
  height: {
    type: Number,
    default: 500
  },
  placeholder: {
    type: String,
    default: '请输入内容...'
  },
  mode: {
    type: String,
    default: 'default' // 或 'simple'
  }
})

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

// 编辑器实例,必须用 shallowRef
const editorRef = shallowRef()
const imageVisible = ref(false)
const insertMediaFn = ref(null)

// 内容 HTML
const valueHtml = ref(props.modelValue)

// 工具栏配置
const toolbarConfig = {
  excludeKeys: [
    'group-video',
    'insertVideo',
    'codeBlock',
    'divider',
    '|',
    'group-more-style', // 排除菜单组,写菜单组 key 的值即可
    'group-image', // 排除图片菜单组
    'insertImage'
  ],
  insertKeys: {
    index: 22, // 将图片按钮移动到链接按钮后面
    keys: ['uploadImage', 'uploadVideo'] // 添加单个图片按钮
  }
}

// 编辑器配置
const editorConfig = {
  placeholder: props.placeholder,
  MENU_CONF: {
    uploadImage: {
      customBrowseAndUpload(insertFn) {
        insertMediaFn.value = (url) => insertFn(url) // 明确图片插入逻辑
        imageVisible.value = true
      }
    },
    uploadVideo: {
      customBrowseAndUpload(insertFn) {
        insertMediaFn.value = (url) => insertFn(url) // 明确视频插入逻辑
        imageVisible.value = true
      }
    }
  }
}

// 移除图片上传配置

// 组件销毁时,也及时销毁编辑器
onBeforeUnmount(() => {
  const editor = editorRef.value
  if (editor == null) return
  editor.destroy()
})

// 处理媒体选择
const handleMediaSelected = ({ url, type }) => {
  if (insertMediaFn.value) {
    insertMediaFn.value(url)
    insertMediaFn.value = null
  }
  imageVisible.value = false
}

const handleCreated = (editor) => {
  editorRef.value = editor // 记录 editor 实例,重要!
}

const handleChange = (editor) => {
  emit('update:modelValue', editor.getHtml())
  emit('change', editor.getHtml())
}

// 监听外部传入的 modelValue 变化
watch(
  () => props.modelValue,
  (newVal) => {
    if (newVal !== valueHtml.value) {
      valueHtml.value = newVal
    }
  }
)

// 提供方法供父组件调用
defineExpose({
  getEditor: () => editorRef.value
})
</script>

<style lang="scss" scoped>
.wang-editor-container {
  border: 1px solid #dcdfe6;
  border-radius: 4px;
  /* height: v-bind('props.height + "px"'); */

  .editor-toolbar {
    border-bottom: 1px solid #dcdfe6;
  }

  .editor-content {
    overflow-y: auto;
    line-height: 1.5;
  }
}

:deep(.w-e-text-container) {
  --w-e-textarea-bg-color: #fff;
  --w-e-textarea-color: #606266;
  --w-e-textarea-border-color: #dcdfe6;
  --w-e-textarea-slight-border-color: #e4e7ed;
  --w-e-textarea-slight-color: #909399;
  --w-e-textarea-slight-bg-color: #f5f7fa;
  --w-e-textarea-selected-border-color: #409eff;
  --w-e-textarea-handler-bg-color: #409eff;
}
</style>
<template>
  <div class="wang-editor-container">
    <Toolbar
      class="editor-toolbar"
      :editor="editorRef"
      :defaultConfig="toolbarConfig"
      :mode="mode"
    />
    <Editor
      class="editor-content"
      v-model="valueHtml"
      :defaultConfig="editorConfig"
      :mode="mode"
      @onCreated="handleCreated"
      @onChange="handleChange"
      :style="{ height: props.height + 'px' }"
    />
    <MediaUpload v-model="imageVisible" @select="handleMediaSelected" />
  </div>
</template>

<script setup>
import '@wangeditor/editor/dist/css/style.css'
import { onBeforeUnmount, shallowRef, watch, ref, defineEmits } from 'vue'
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'

const props = defineProps({
  modelValue: {
    type: String,
    default: ''
  },
  height: {
    type: Number,
    default: 500
  },
  placeholder: {
    type: String,
    default: '请输入内容...'
  },
  mode: {
    type: String,
    default: 'default' // 或 'simple'
  }
})

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

// 编辑器实例,必须用 shallowRef
const editorRef = shallowRef()
const imageVisible = ref(false)
const insertMediaFn = ref(null)

// 内容 HTML
const valueHtml = ref(props.modelValue)

// 工具栏配置
const toolbarConfig = {
  excludeKeys: [
    'group-video',
    'insertVideo',
    'codeBlock',
    'divider',
    '|',
    'group-more-style', // 排除菜单组,写菜单组 key 的值即可
    'group-image', // 排除图片菜单组
    'insertImage'
  ],
  insertKeys: {
    index: 22, // 将图片按钮移动到链接按钮后面
    keys: ['uploadImage', 'uploadVideo'] // 添加单个图片按钮
  }
}

// 编辑器配置
const editorConfig = {
  placeholder: props.placeholder,
  MENU_CONF: {
    uploadImage: {
      customBrowseAndUpload(insertFn) {
        insertMediaFn.value = (url) => insertFn(url) // 明确图片插入逻辑
        imageVisible.value = true
      }
    },
    uploadVideo: {
      customBrowseAndUpload(insertFn) {
        insertMediaFn.value = (url) => insertFn(url) // 明确视频插入逻辑
        imageVisible.value = true
      }
    }
  }
}

// 移除图片上传配置

// 组件销毁时,也及时销毁编辑器
onBeforeUnmount(() => {
  const editor = editorRef.value
  if (editor == null) return
  editor.destroy()
})

// 处理媒体选择
const handleMediaSelected = ({ url, type }) => {
  if (insertMediaFn.value) {
    insertMediaFn.value(url)
    insertMediaFn.value = null
  }
  imageVisible.value = false
}

const handleCreated = (editor) => {
  editorRef.value = editor // 记录 editor 实例,重要!
}

const handleChange = (editor) => {
  emit('update:modelValue', editor.getHtml())
  emit('change', editor.getHtml())
}

// 监听外部传入的 modelValue 变化
watch(
  () => props.modelValue,
  (newVal) => {
    if (newVal !== valueHtml.value) {
      valueHtml.value = newVal
    }
  }
)

// 提供方法供父组件调用
defineExpose({
  getEditor: () => editorRef.value
})
</script>

<style lang="scss" scoped>
.wang-editor-container {
  border: 1px solid #dcdfe6;
  border-radius: 4px;
  /* height: v-bind('props.height + "px"'); */

  .editor-toolbar {
    border-bottom: 1px solid #dcdfe6;
  }

  .editor-content {
    overflow-y: auto;
    line-height: 1.5;
  }
}

:deep(.w-e-text-container) {
  --w-e-textarea-bg-color: #fff;
  --w-e-textarea-color: #606266;
  --w-e-textarea-border-color: #dcdfe6;
  --w-e-textarea-slight-border-color: #e4e7ed;
  --w-e-textarea-slight-color: #909399;
  --w-e-textarea-slight-bg-color: #f5f7fa;
  --w-e-textarea-selected-border-color: #409eff;
  --w-e-textarea-handler-bg-color: #409eff;
}
</style>

MediaUpload 弹窗组件代码

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>

程序员小洛文档