diff --git a/src/components/Editor/index.vue b/src/components/Editor/index.vue index 9cdd99d..29c6852 100644 --- a/src/components/Editor/index.vue +++ b/src/components/Editor/index.vue @@ -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(); const content = ref(''); +const baseUrl = import.meta.env.VITE_APP_BASE_API; +const uploadOssUrl = baseUrl + '/resource/oss/upload'; + +// URL → ossId 映射,在上传和解析阶段填充 +const ossUrlToId = new Map(); +// 防止 modelValue ↔ content 双向 watch 循环 +const isResolvingContent = ref(false); +// 记录最后一次 encode 后 emit 的值,用于跳过自身触发的 modelValue 变更 +const lastEncodedValue = ref(''); + const styles = computed(() => { const style: Record = {}; if (props.minHeight) { @@ -57,6 +73,7 @@ const toolbarConfig = computed>(() => { if (!props.type) { excludeKeys.push('uploadImage'); + excludeKeys.push('uploadVideo'); } return { @@ -65,6 +82,65 @@ const toolbarConfig = computed>(() => { }; }); +/* ==================== 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 => { + 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,42 +159,97 @@ 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; - } - - 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); +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; } -} 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>(() => ({ 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); } }); diff --git a/src/utils/ossContent.ts b/src/utils/ossContent.ts new file mode 100644 index 0000000..8d4f1ac --- /dev/null +++ b/src/utils/ossContent.ts @@ -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('

'); + */ +export async function resolveOssContent(html: string): Promise { + 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; + } +} diff --git a/src/views/system/notice/index.vue b/src/views/system/notice/index.vue index 5d5fe0d..76457e5 100644 --- a/src/views/system/notice/index.vue +++ b/src/views/system/notice/index.vue @@ -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(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; };