update 富文本编辑器支持图片视频oss上传 支持私有库图片访问 增加ossContent oss富文本解析工具
This commit is contained in:
+181
-37
@@ -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 { Editor as WangEditor, Toolbar as EditorToolbar } from '@wangeditor-next/editor-for-vue';
|
||||||
import type { IDomEditor, IEditorConfig, IToolbarConfig } from '@wangeditor-next/editor';
|
import type { IDomEditor, IEditorConfig, IToolbarConfig } from '@wangeditor-next/editor';
|
||||||
import { propTypes } from '@/utils/propTypes';
|
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({
|
const props = defineProps({
|
||||||
/* 编辑器的内容 */
|
/* 编辑器的内容 */
|
||||||
@@ -29,10 +33,12 @@ const props = defineProps({
|
|||||||
minHeight: propTypes.number.def(400),
|
minHeight: propTypes.number.def(400),
|
||||||
/* 只读 */
|
/* 只读 */
|
||||||
readOnly: propTypes.bool.def(false),
|
readOnly: propTypes.bool.def(false),
|
||||||
/* 上传文件大小限制(MB) */
|
/* 图片上传文件大小限制(MB) */
|
||||||
fileSize: propTypes.number.def(5),
|
fileSize: propTypes.number.def(5),
|
||||||
/* 类型(base64格式、url格式) */
|
/* 视频上传文件大小限制(MB) */
|
||||||
type: propTypes.string.def('base64')
|
videoSize: propTypes.number.def(100),
|
||||||
|
/* 类型(base64格式、url格式) url模式下存储 oss://ossId 标记,展示时通过后端动态解析为可用URL */
|
||||||
|
type: propTypes.string.def('url')
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue']);
|
const emit = defineEmits(['update:modelValue']);
|
||||||
@@ -41,6 +47,16 @@ const { proxy } = getCurrentInstance() as ComponentInternalInstance;
|
|||||||
const editorRef = shallowRef<IDomEditor>();
|
const editorRef = shallowRef<IDomEditor>();
|
||||||
const content = ref('');
|
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 styles = computed(() => {
|
||||||
const style: Record<string, string> = {};
|
const style: Record<string, string> = {};
|
||||||
if (props.minHeight) {
|
if (props.minHeight) {
|
||||||
@@ -57,6 +73,7 @@ const toolbarConfig = computed<Partial<IToolbarConfig>>(() => {
|
|||||||
|
|
||||||
if (!props.type) {
|
if (!props.type) {
|
||||||
excludeKeys.push('uploadImage');
|
excludeKeys.push('uploadImage');
|
||||||
|
excludeKeys.push('uploadVideo');
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
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 validateImageFile = (file: File) => {
|
||||||
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/svg', 'image/svg+xml'];
|
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/svg', 'image/svg+xml'];
|
||||||
if (!allowedTypes.includes(file.type)) {
|
if (!allowedTypes.includes(file.type)) {
|
||||||
@@ -75,7 +151,7 @@ const validateImageFile = (file: File) => {
|
|||||||
if (props.fileSize) {
|
if (props.fileSize) {
|
||||||
const isLt = file.size / 1024 / 1024 < props.fileSize;
|
const isLt = file.size / 1024 / 1024 < props.fileSize;
|
||||||
if (!isLt) {
|
if (!isLt) {
|
||||||
proxy?.$modal.msgError(`上传文件大小不能超过 ${props.fileSize} MB!`);
|
proxy?.$modal.msgError(`上传图片大小不能超过 ${props.fileSize} MB!`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -83,42 +159,97 @@ const validateImageFile = (file: File) => {
|
|||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const uploadImageMenuConfig = {
|
const validateVideoFile = (file: File) => {
|
||||||
metaWithUrl: false,
|
const allowedTypes = ['video/mp4', 'video/webm', 'video/ogg'];
|
||||||
onSuccess() {},
|
if (!allowedTypes.includes(file.type)) {
|
||||||
onFailed() {},
|
proxy?.$modal.msgError('视频格式错误, 请上传 mp4/webm/ogg 格式!');
|
||||||
onError() {},
|
return false;
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
proxy?.$modal.loading('正在上传文件,请稍候...');
|
|
||||||
const reader = new FileReader();
|
|
||||||
|
|
||||||
reader.onload = () => {
|
|
||||||
insertFn(reader.result as string, undefined, file.name);
|
|
||||||
proxy?.$modal.closeLoading();
|
|
||||||
};
|
|
||||||
|
|
||||||
reader.onerror = () => {
|
|
||||||
proxy?.$modal.msgError('图片插入失败');
|
|
||||||
proxy?.$modal.closeLoading();
|
|
||||||
};
|
|
||||||
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
}
|
}
|
||||||
} as any;
|
|
||||||
|
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, file.name);
|
||||||
|
proxy?.$modal.closeLoading();
|
||||||
|
};
|
||||||
|
|
||||||
|
reader.onerror = () => {
|
||||||
|
proxy?.$modal.msgError('图片插入失败');
|
||||||
|
proxy?.$modal.closeLoading();
|
||||||
|
};
|
||||||
|
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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>>(() => ({
|
const editorConfig = computed<Partial<IEditorConfig>>(() => ({
|
||||||
placeholder: '请输入内容',
|
placeholder: '请输入内容',
|
||||||
autoFocus: false,
|
autoFocus: false,
|
||||||
MENU_CONF: {
|
MENU_CONF: {
|
||||||
uploadImage: uploadImageMenuConfig
|
uploadImage: getUploadImageMenuConfig() as any,
|
||||||
|
uploadVideo: getUploadVideoMenuConfig() as any
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
/* ==================== 双向数据绑定 ==================== */
|
||||||
|
|
||||||
const syncReadOnly = () => {
|
const syncReadOnly = () => {
|
||||||
const editor = editorRef.value;
|
const editor = editorRef.value;
|
||||||
if (!editor) {
|
if (!editor) {
|
||||||
@@ -134,18 +265,31 @@ const syncReadOnly = () => {
|
|||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.modelValue,
|
() => props.modelValue,
|
||||||
value => {
|
async (value) => {
|
||||||
const nextValue = 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 }
|
{ immediate: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
watch(content, value => {
|
watch(content, (value) => {
|
||||||
if (value !== props.modelValue) {
|
if (isResolvingContent.value) return;
|
||||||
emit('update:modelValue', value);
|
|
||||||
|
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 { listNotice, getNotice, delNotice, addNotice, updateNotice } from '@/api/system/notice';
|
||||||
import { NoticeForm, NoticeQuery, NoticeVO } from '@/api/system/notice/types';
|
import { NoticeForm, NoticeQuery, NoticeVO } from '@/api/system/notice/types';
|
||||||
import { sanitizeHtml } from '@/utils/sanitize';
|
import { sanitizeHtml } from '@/utils/sanitize';
|
||||||
|
import { resolveOssContent } from '@/utils/ossContent';
|
||||||
|
|
||||||
const { proxy } = getCurrentInstance() as ComponentInternalInstance;
|
const { proxy } = getCurrentInstance() as ComponentInternalInstance;
|
||||||
const { sys_notice_status, sys_notice_type } = toRefs<any>(proxy?.useDict('sys_notice_status', 'sys_notice_type'));
|
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 openDetail = async (noticeId: string | number) => {
|
||||||
const { data } = await getNotice(noticeId);
|
const { data } = await getNotice(noticeId);
|
||||||
|
data.noticeContent = await resolveOssContent(data.noticeContent);
|
||||||
detailForm.value = data;
|
detailForm.value = data;
|
||||||
detailDialog.visible = true;
|
detailDialog.visible = true;
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user