Appearance
富文本编辑器自定义图片上传
这里我用wangeditor这个富文本编辑器,自定义图片和视频上传,搭配OSS的弹窗组件,用的是vue3、wangeditor v5
主要是用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>