update 富文本编辑器支持图片视频oss上传 支持私有库图片访问 增加ossContent oss富文本解析工具

This commit is contained in:
疯狂的狮子Li
2026-04-09 11:44:47 +08:00
parent fb5b1ed1bd
commit 4f8bcbd525
3 changed files with 214 additions and 37 deletions
+167 -23
View File
@@ -19,6 +19,10 @@ import '@wangeditor-next/editor/dist/css/style.css';
import { Editor as WangEditor, Toolbar as EditorToolbar } from '@wangeditor-next/editor-for-vue';
import type { IDomEditor, IEditorConfig, IToolbarConfig } from '@wangeditor-next/editor';
import { propTypes } from '@/utils/propTypes';
import { globalHeaders } from '@/utils/request';
import { listByIds } from '@/api/system/oss';
const OSS_MARKER_RE = /oss:\/\/([\w-]+)/g;
const props = defineProps({
/* 编辑器的内容 */
@@ -29,10 +33,12 @@ const props = defineProps({
minHeight: propTypes.number.def(400),
/* 只读 */
readOnly: propTypes.bool.def(false),
/* 上传文件大小限制(MB) */
/* 图片上传文件大小限制(MB) */
fileSize: propTypes.number.def(5),
/* 类型(base64格式、url格式) */
type: propTypes.string.def('base64')
/* 视频上传文件大小限制(MB) */
videoSize: propTypes.number.def(100),
/* 类型(base64格式、url格式) url模式下存储 oss://ossId 标记,展示时通过后端动态解析为可用URL */
type: propTypes.string.def('url')
});
const emit = defineEmits(['update:modelValue']);
@@ -41,6 +47,16 @@ const { proxy } = getCurrentInstance() as ComponentInternalInstance;
const editorRef = shallowRef<IDomEditor>();
const content = ref('');
const baseUrl = import.meta.env.VITE_APP_BASE_API;
const uploadOssUrl = baseUrl + '/resource/oss/upload';
// URL → ossId 映射,在上传和解析阶段填充
const ossUrlToId = new Map<string, string>();
// 防止 modelValue ↔ content 双向 watch 循环
const isResolvingContent = ref(false);
// 记录最后一次 encode 后 emit 的值,用于跳过自身触发的 modelValue 变更
const lastEncodedValue = ref('');
const styles = computed(() => {
const style: Record<string, string> = {};
if (props.minHeight) {
@@ -57,6 +73,7 @@ const toolbarConfig = computed<Partial<IToolbarConfig>>(() => {
if (!props.type) {
excludeKeys.push('uploadImage');
excludeKeys.push('uploadVideo');
}
return {
@@ -65,6 +82,65 @@ const toolbarConfig = computed<Partial<IToolbarConfig>>(() => {
};
});
/* ==================== OSS 上传 & 内容转换 ==================== */
const uploadToOss = async (file: File): Promise<{ url: string; fileName: string; ossId: string }> => {
const formData = new FormData();
formData.append('file', file);
const res = await fetch(uploadOssUrl, {
method: 'POST',
headers: globalHeaders(),
body: formData
});
const data = await res.json();
if (data.code === 200) {
ossUrlToId.set(data.data.url, String(data.data.ossId));
return data.data;
}
throw new Error(data.msg || '上传失败');
};
/**
* 编码:将编辑器内容中的 OSS 真实 URL 替换为 oss://ossId 标记(用于存储)
*/
const encodeOssContent = (html: string): string => {
if (!html) return html;
let result = html;
for (const [url, ossId] of ossUrlToId) {
result = result.replaceAll(url, `oss://${ossId}`);
}
return result;
};
/**
* 解码:将存储的 oss://ossId 标记批量替换为真实可用 URL(用于编辑/展示)
*/
const decodeOssContent = async (html: string): Promise<string> => {
if (!html) return html;
const matches = [...html.matchAll(OSS_MARKER_RE)];
if (matches.length === 0) return html;
const ossIds = [...new Set(matches.map((m) => m[1]))];
try {
const res = await listByIds(ossIds.join(','));
let result = html;
for (const oss of res.data) {
const id = String(oss.ossId);
ossUrlToId.set(oss.url, id);
result = result.replaceAll(`oss://${id}`, oss.url);
}
return result;
} catch {
return html;
}
};
/* ==================== 文件校验 ==================== */
const validateImageFile = (file: File) => {
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/svg', 'image/svg+xml'];
if (!allowedTypes.includes(file.type)) {
@@ -75,7 +151,7 @@ const validateImageFile = (file: File) => {
if (props.fileSize) {
const isLt = file.size / 1024 / 1024 < props.fileSize;
if (!isLt) {
proxy?.$modal.msgError(`上传文件大小不能超过 ${props.fileSize} MB!`);
proxy?.$modal.msgError(`上传图片大小不能超过 ${props.fileSize} MB!`);
return false;
}
}
@@ -83,22 +159,38 @@ const validateImageFile = (file: File) => {
return true;
};
const uploadImageMenuConfig = {
metaWithUrl: false,
onSuccess() {},
onFailed() {},
onError() {},
allowedFileTypes: ['image/jpeg', 'image/jpg', 'image/png', 'image/svg+xml'],
customUpload(file: File, insertFn: (url: string, poster?: string, alt?: string, href?: string) => void) {
if (!validateImageFile(file)) {
return;
const validateVideoFile = (file: File) => {
const allowedTypes = ['video/mp4', 'video/webm', 'video/ogg'];
if (!allowedTypes.includes(file.type)) {
proxy?.$modal.msgError('视频格式错误, 请上传 mp4/webm/ogg 格式!');
return false;
}
proxy?.$modal.loading('正在上传文件,请稍候...');
if (props.videoSize) {
const isLt = file.size / 1024 / 1024 < props.videoSize;
if (!isLt) {
proxy?.$modal.msgError(`上传视频大小不能超过 ${props.videoSize} MB!`);
return false;
}
}
return true;
};
/* ==================== wangeditor 菜单配置 ==================== */
const getUploadImageMenuConfig = () => {
if (props.type === 'base64') {
return {
allowedFileTypes: ['image/jpeg', 'image/jpg', 'image/png', 'image/svg+xml'],
customUpload(file: File, insertFn: (url: string, alt?: string, href?: string) => void) {
if (!validateImageFile(file)) return;
proxy?.$modal.loading('正在上传图片,请稍候...');
const reader = new FileReader();
reader.onload = () => {
insertFn(reader.result as string, undefined, file.name);
insertFn(reader.result as string, file.name);
proxy?.$modal.closeLoading();
};
@@ -109,16 +201,55 @@ const uploadImageMenuConfig = {
reader.readAsDataURL(file);
}
} as any;
};
}
return {
allowedFileTypes: ['image/jpeg', 'image/jpg', 'image/png', 'image/svg+xml'],
async customUpload(file: File, insertFn: (url: string, alt?: string, href?: string) => void) {
if (!validateImageFile(file)) return;
proxy?.$modal.loading('正在上传图片,请稍候...');
try {
const result = await uploadToOss(file);
insertFn(result.url, file.name, result.url);
} catch {
proxy?.$modal.msgError('图片上传失败');
} finally {
proxy?.$modal.closeLoading();
}
}
};
};
const getUploadVideoMenuConfig = () => ({
allowedFileTypes: ['video/mp4', 'video/webm', 'video/ogg'],
async customUpload(file: File, insertFn: (url: string, poster?: string) => void) {
if (!validateVideoFile(file)) return;
proxy?.$modal.loading('正在上传视频,请稍候...');
try {
const result = await uploadToOss(file);
insertFn(result.url);
} catch {
proxy?.$modal.msgError('视频上传失败');
} finally {
proxy?.$modal.closeLoading();
}
}
});
const editorConfig = computed<Partial<IEditorConfig>>(() => ({
placeholder: '请输入内容',
autoFocus: false,
MENU_CONF: {
uploadImage: uploadImageMenuConfig
uploadImage: getUploadImageMenuConfig() as any,
uploadVideo: getUploadVideoMenuConfig() as any
}
}));
/* ==================== 双向数据绑定 ==================== */
const syncReadOnly = () => {
const editor = editorRef.value;
if (!editor) {
@@ -134,18 +265,31 @@ const syncReadOnly = () => {
watch(
() => props.modelValue,
value => {
async (value) => {
const nextValue = value || '';
if (nextValue !== content.value) {
content.value = nextValue;
// 跳过由自身 emit 引起的回传
if (nextValue === lastEncodedValue.value) return;
isResolvingContent.value = true;
const resolved = await decodeOssContent(nextValue);
if (resolved !== content.value) {
content.value = resolved;
}
nextTick(() => {
isResolvingContent.value = false;
});
},
{ immediate: true }
);
watch(content, value => {
if (value !== props.modelValue) {
emit('update:modelValue', value);
watch(content, (value) => {
if (isResolvingContent.value) return;
const encoded = encodeOssContent(value);
lastEncodedValue.value = encoded;
if (encoded !== props.modelValue) {
emit('update:modelValue', encoded);
}
});
+31
View File
@@ -0,0 +1,31 @@
import { listByIds } from '@/api/system/oss';
const OSS_MARKER_RE = /oss:\/\/([\w-]+)/g;
/**
* 将 HTML 中的 oss://{ossId} 标记批量解析为真实的 OSS 授权 URL
*
* 适用于富文本内容展示场景(Editor 组件 / 详情页只读渲染等)
*
* @example
* const html = await resolveOssContent('<p><img src="oss://12345"/></p>');
*/
export async function resolveOssContent(html: string): Promise<string> {
if (!html) return html;
const matches = [...html.matchAll(OSS_MARKER_RE)];
if (matches.length === 0) return html;
const ossIds = [...new Set(matches.map((m) => m[1]))];
try {
const res = await listByIds(ossIds.join(','));
let result = html;
for (const oss of res.data) {
result = result.replaceAll(`oss://${oss.ossId}`, oss.url);
}
return result;
} catch {
return html;
}
}
+2
View File
@@ -230,6 +230,7 @@
import { listNotice, getNotice, delNotice, addNotice, updateNotice } from '@/api/system/notice';
import { NoticeForm, NoticeQuery, NoticeVO } from '@/api/system/notice/types';
import { sanitizeHtml } from '@/utils/sanitize';
import { resolveOssContent } from '@/utils/ossContent';
const { proxy } = getCurrentInstance() as ComponentInternalInstance;
const { sys_notice_status, sys_notice_type } = toRefs<any>(proxy?.useDict('sys_notice_status', 'sys_notice_type'));
@@ -345,6 +346,7 @@ const handleDetail = async (row: NoticeVO) => {
/** 打开详情 */
const openDetail = async (noticeId: string | number) => {
const { data } = await getNotice(noticeId);
data.noticeContent = await resolveOssContent(data.noticeContent);
detailForm.value = data;
detailDialog.visible = true;
};