update 富文本编辑器支持图片视频oss上传 支持私有库图片访问 增加ossContent oss富文本解析工具
This commit is contained in:
+167
-23
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user