2026-05-09 11:30:34 +08:00
|
|
|
|
<script setup lang="ts">
|
2026-05-12 21:41:39 +08:00
|
|
|
|
import { computed, onBeforeUnmount, reactive, ref, shallowRef, watch } from 'vue';
|
2026-05-09 11:30:34 +08:00
|
|
|
|
import '@wangeditor/editor/dist/css/style.css';
|
2026-05-12 21:41:39 +08:00
|
|
|
|
import { ElImageViewer } from 'element-plus';
|
2026-05-09 11:30:34 +08:00
|
|
|
|
import { Editor, Toolbar } from '@wangeditor/editor-for-vue';
|
|
|
|
|
|
import type { IDomEditor, IEditorConfig, IToolbarConfig } from '@wangeditor/editor';
|
2026-05-15 10:06:51 +08:00
|
|
|
|
import { buildFileProxyUrl, deleteFile, uploadFile } from '@/service/api/file';
|
2026-05-09 11:30:34 +08:00
|
|
|
|
|
|
|
|
|
|
defineOptions({ name: 'BusinessRichTextEditor' });
|
|
|
|
|
|
|
|
|
|
|
|
interface Props {
|
|
|
|
|
|
placeholder?: string;
|
|
|
|
|
|
disabled?: boolean;
|
|
|
|
|
|
height?: number | string;
|
|
|
|
|
|
/** 上传目录,传给后端 directory 字段 */
|
|
|
|
|
|
uploadDirectory?: string;
|
|
|
|
|
|
/** 单张图片大小上限(MB),默认 5 */
|
|
|
|
|
|
maxImageSizeMB?: number;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const props = withDefaults(defineProps<Props>(), {
|
|
|
|
|
|
placeholder: '请输入内容',
|
|
|
|
|
|
disabled: false,
|
|
|
|
|
|
height: 320,
|
|
|
|
|
|
uploadDirectory: undefined,
|
|
|
|
|
|
maxImageSizeMB: 5
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const model = defineModel<string | null | undefined>({ default: '' });
|
|
|
|
|
|
|
|
|
|
|
|
const editorRef = shallowRef<IDomEditor>();
|
2026-05-12 21:41:39 +08:00
|
|
|
|
const containerRef = ref<HTMLElement>();
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 图片预览:
|
|
|
|
|
|
* - hover 富文本里的 <img> → 在图片右上角浮一个放大镜按钮
|
|
|
|
|
|
* - 点按钮 → ElImageViewer 多图模式,url-list = 当前 HTML 里所有 img src(按出现顺序去重)
|
|
|
|
|
|
* - 编辑态与 disabled 只读态共用
|
|
|
|
|
|
*/
|
|
|
|
|
|
const zoomBtnVisible = ref(false);
|
|
|
|
|
|
const zoomBtnStyle = ref<Record<string, string>>({});
|
|
|
|
|
|
const hoveredImageSrc = ref('');
|
|
|
|
|
|
|
|
|
|
|
|
const viewerVisible = ref(false);
|
|
|
|
|
|
const viewerUrlList = ref<string[]>([]);
|
|
|
|
|
|
const viewerIndex = ref(0);
|
|
|
|
|
|
|
|
|
|
|
|
let hideZoomBtnTimer: number | undefined;
|
|
|
|
|
|
|
|
|
|
|
|
function cancelHideZoomBtn() {
|
|
|
|
|
|
if (hideZoomBtnTimer !== undefined) {
|
|
|
|
|
|
window.clearTimeout(hideZoomBtnTimer);
|
|
|
|
|
|
hideZoomBtnTimer = undefined;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function scheduleHideZoomBtn() {
|
|
|
|
|
|
cancelHideZoomBtn();
|
|
|
|
|
|
hideZoomBtnTimer = window.setTimeout(() => {
|
|
|
|
|
|
zoomBtnVisible.value = false;
|
|
|
|
|
|
}, 150);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function positionZoomBtn(img: HTMLImageElement) {
|
|
|
|
|
|
const container = containerRef.value;
|
|
|
|
|
|
if (!container) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const containerRect = container.getBoundingClientRect();
|
|
|
|
|
|
const imgRect = img.getBoundingClientRect();
|
|
|
|
|
|
const btnSize = 28;
|
|
|
|
|
|
const gap = 8;
|
|
|
|
|
|
zoomBtnStyle.value = {
|
|
|
|
|
|
top: `${imgRect.top - containerRect.top + gap}px`,
|
|
|
|
|
|
left: `${imgRect.right - containerRect.left - btnSize - gap}px`
|
|
|
|
|
|
};
|
|
|
|
|
|
hoveredImageSrc.value = img.getAttribute('src') ?? '';
|
|
|
|
|
|
zoomBtnVisible.value = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function isZoomBtn(el: EventTarget | null): boolean {
|
|
|
|
|
|
return el instanceof HTMLElement && Boolean(el.closest('.business-rich-text-editor__zoom-btn'));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function findImageAtPoint(e: MouseEvent): HTMLImageElement | null {
|
|
|
|
|
|
const container = containerRef.value;
|
|
|
|
|
|
if (!container) {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
const target = e.target as HTMLElement | null;
|
|
|
|
|
|
// 1) target 本身或祖先链上是 img
|
|
|
|
|
|
const direct =
|
|
|
|
|
|
target?.tagName === 'IMG' ? (target as HTMLImageElement) : (target?.closest('img') as HTMLImageElement | null);
|
|
|
|
|
|
if (direct && container.contains(direct)) {
|
|
|
|
|
|
return direct;
|
|
|
|
|
|
}
|
|
|
|
|
|
// 2) 兜底:wangeditor 可能在图片上层叠了 resize/selection 遮罩,target 不是 img;用坐标穿透找
|
|
|
|
|
|
if (typeof document.elementsFromPoint === 'function') {
|
|
|
|
|
|
const stack = document.elementsFromPoint(e.clientX, e.clientY);
|
|
|
|
|
|
for (const el of stack) {
|
|
|
|
|
|
if (el.tagName === 'IMG' && container.contains(el)) {
|
|
|
|
|
|
return el as HTMLImageElement;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function onContainerMouseOver(e: MouseEvent) {
|
|
|
|
|
|
if (isZoomBtn(e.target)) {
|
|
|
|
|
|
cancelHideZoomBtn();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const img = findImageAtPoint(e);
|
|
|
|
|
|
if (img) {
|
|
|
|
|
|
cancelHideZoomBtn();
|
|
|
|
|
|
positionZoomBtn(img);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
scheduleHideZoomBtn();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function onContainerMouseLeave() {
|
|
|
|
|
|
scheduleHideZoomBtn();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function onTextScroll() {
|
|
|
|
|
|
// wangeditor 内部滚动后按钮坐标会和图片错位,直接隐藏由下次 hover 重算
|
|
|
|
|
|
zoomBtnVisible.value = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function openImageViewer() {
|
|
|
|
|
|
if (!hoveredImageSrc.value) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const urls = listImageSrcs(model.value);
|
|
|
|
|
|
const idx = urls.indexOf(hoveredImageSrc.value);
|
|
|
|
|
|
viewerUrlList.value = urls.length > 0 ? urls : [hoveredImageSrc.value];
|
|
|
|
|
|
viewerIndex.value = idx >= 0 ? idx : 0;
|
|
|
|
|
|
viewerVisible.value = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function closeImageViewer() {
|
|
|
|
|
|
viewerVisible.value = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 会话级清理账本(富文本图片治标):
|
|
|
|
|
|
* - uploadedMap: 本次会话内通过 customUpload 上传成功的图片 url -> fileId
|
|
|
|
|
|
* - committed: commit() 调用后置 true,阻止后续 rollback / 卸载兜底重复删
|
|
|
|
|
|
*
|
|
|
|
|
|
* 真删时机:
|
|
|
|
|
|
* - commit(): 扫当前 model HTML,删 uploadedMap 里"url 已不在 HTML"的项(被用户删掉的图)
|
|
|
|
|
|
* - rollback(): 删 uploadedMap 里所有项(整个会话不要了)
|
|
|
|
|
|
* - onBeforeUnmount: 兜底走 rollback 等价逻辑
|
|
|
|
|
|
*/
|
|
|
|
|
|
interface RichTextSession {
|
|
|
|
|
|
uploadedMap: Map<string, string>;
|
|
|
|
|
|
committed: boolean;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const session = reactive<RichTextSession>({
|
|
|
|
|
|
uploadedMap: new Map(),
|
|
|
|
|
|
committed: false
|
|
|
|
|
|
});
|
2026-05-09 11:30:34 +08:00
|
|
|
|
|
|
|
|
|
|
const toolbarConfig: Partial<IToolbarConfig> = {
|
|
|
|
|
|
excludeKeys: [
|
|
|
|
|
|
// 视频组
|
|
|
|
|
|
'group-video',
|
|
|
|
|
|
'insertVideo',
|
|
|
|
|
|
'uploadVideo',
|
|
|
|
|
|
// 更多样式分组
|
|
|
|
|
|
'group-more-style',
|
|
|
|
|
|
// 图片:只允许本地上传,不允许插入网络图片 URL
|
|
|
|
|
|
'insertImage',
|
|
|
|
|
|
// 超链接:业务暂不需要
|
|
|
|
|
|
'insertLink',
|
|
|
|
|
|
'editLink',
|
|
|
|
|
|
'unLink',
|
|
|
|
|
|
'viewLink'
|
|
|
|
|
|
]
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const editorConfig: Partial<IEditorConfig> = {
|
|
|
|
|
|
placeholder: props.placeholder,
|
|
|
|
|
|
readOnly: props.disabled,
|
|
|
|
|
|
MENU_CONF: {
|
|
|
|
|
|
uploadImage: {
|
|
|
|
|
|
maxFileSize: props.maxImageSizeMB * 1024 * 1024,
|
|
|
|
|
|
allowedFileTypes: ['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/bmp'],
|
|
|
|
|
|
async customUpload(file: File, insertFn: (url: string, alt?: string, href?: string) => void) {
|
|
|
|
|
|
const result = await uploadFile(file, props.uploadDirectory);
|
|
|
|
|
|
|
|
|
|
|
|
if (result.error || !result.data) {
|
|
|
|
|
|
const msg = result.error?.response?.data?.msg || '图片上传失败';
|
|
|
|
|
|
window.$message?.error(msg);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-15 10:06:51 +08:00
|
|
|
|
// 用永久代理路径塞 <img src>,不要用 result.data.url(24h 签名会过期)
|
|
|
|
|
|
const { id, configId, path } = result.data;
|
|
|
|
|
|
const proxyUrl = buildFileProxyUrl(configId, path);
|
2026-05-12 21:41:39 +08:00
|
|
|
|
// 记录 url -> fileId,后续 commit/rollback 才知道删哪个
|
2026-05-15 10:06:51 +08:00
|
|
|
|
session.uploadedMap.set(proxyUrl, id);
|
|
|
|
|
|
insertFn(proxyUrl, file.name, proxyUrl);
|
2026-05-09 11:30:34 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
watch(
|
|
|
|
|
|
() => props.disabled,
|
|
|
|
|
|
value => {
|
|
|
|
|
|
const editor = editorRef.value;
|
|
|
|
|
|
if (!editor) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (value) {
|
|
|
|
|
|
editor.disable();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
editor.enable();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
function handleCreated(editor: IDomEditor) {
|
|
|
|
|
|
editorRef.value = editor;
|
2026-05-12 21:41:39 +08:00
|
|
|
|
const textContainer = containerRef.value?.querySelector('.w-e-text-container');
|
|
|
|
|
|
textContainer?.addEventListener('scroll', onTextScroll, { passive: true });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 从 HTML 字符串里抓所有 <img src="...">,返回 url 集合。
|
|
|
|
|
|
* 用 regex 而不是 DOMParser 是为了避免对 SSR / 测试环境的依赖。
|
|
|
|
|
|
*/
|
|
|
|
|
|
function extractImageUrls(html: string | null | undefined): Set<string> {
|
|
|
|
|
|
const urls = new Set<string>();
|
|
|
|
|
|
if (!html) {
|
|
|
|
|
|
return urls;
|
|
|
|
|
|
}
|
|
|
|
|
|
const re = /<img\b[^>]*\bsrc=["']([^"']+)["'][^>]*>/gi;
|
|
|
|
|
|
let match: RegExpExecArray | null = re.exec(html);
|
|
|
|
|
|
while (match !== null) {
|
|
|
|
|
|
urls.add(match[1]);
|
|
|
|
|
|
match = re.exec(html);
|
|
|
|
|
|
}
|
|
|
|
|
|
return urls;
|
2026-05-09 11:30:34 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-12 21:41:39 +08:00
|
|
|
|
/** 按出现顺序去重列出当前 HTML 内所有 img src,给 ElImageViewer 用。 */
|
|
|
|
|
|
function listImageSrcs(html: string | null | undefined): string[] {
|
|
|
|
|
|
const list: string[] = [];
|
|
|
|
|
|
if (!html) {
|
|
|
|
|
|
return list;
|
|
|
|
|
|
}
|
|
|
|
|
|
const re = /<img\b[^>]*\bsrc=["']([^"']+)["'][^>]*>/gi;
|
|
|
|
|
|
let match: RegExpExecArray | null = re.exec(html);
|
|
|
|
|
|
while (match !== null) {
|
|
|
|
|
|
if (!list.includes(match[1])) {
|
|
|
|
|
|
list.push(match[1]);
|
|
|
|
|
|
}
|
|
|
|
|
|
match = re.exec(html);
|
|
|
|
|
|
}
|
|
|
|
|
|
return list;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 删除一批 fileId。fire-and-forget;单项失败仅 console.warn。 */
|
|
|
|
|
|
async function deleteMany(ids: string[]) {
|
|
|
|
|
|
if (ids.length === 0) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
await Promise.allSettled(
|
|
|
|
|
|
ids.map(async id => {
|
|
|
|
|
|
const { error } = await deleteFile(id);
|
|
|
|
|
|
if (error) {
|
|
|
|
|
|
// eslint-disable-next-line no-console
|
|
|
|
|
|
console.warn('[BusinessRichTextEditor] 删除失败(已忽略)', id, error);
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
defineExpose({
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 父组件在【打开弹层并填充 model 之后】调用。
|
|
|
|
|
|
* 清空 uploadedMap 并重置 committed;HTML 里已有的图(编辑模式回显的)不进 uploadedMap,
|
|
|
|
|
|
* 因此 commit/rollback 不会动它们——只动本次会话上传的图。
|
|
|
|
|
|
*/
|
|
|
|
|
|
initSession() {
|
|
|
|
|
|
session.uploadedMap.clear();
|
|
|
|
|
|
session.committed = false;
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 父组件在【业务保存成功后】调用。
|
|
|
|
|
|
* 扫当前 model HTML:uploadedMap 里 url 不在 HTML 的图 = 用户已删除 = 真删。
|
|
|
|
|
|
*/
|
|
|
|
|
|
async commit() {
|
|
|
|
|
|
const currentUrls = extractImageUrls(model.value);
|
|
|
|
|
|
const toDelete: string[] = [];
|
|
|
|
|
|
session.uploadedMap.forEach((fileId, url) => {
|
|
|
|
|
|
if (!currentUrls.has(url)) {
|
|
|
|
|
|
toDelete.push(fileId);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
session.uploadedMap.clear();
|
|
|
|
|
|
session.committed = true;
|
|
|
|
|
|
await deleteMany(toDelete);
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 父组件取消/关闭时调用;onBeforeUnmount 也会兜底调一次。
|
|
|
|
|
|
* 删 uploadedMap 里所有项(整个会话回滚)。
|
|
|
|
|
|
*/
|
|
|
|
|
|
async rollback() {
|
|
|
|
|
|
if (session.committed) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const toDelete = Array.from(session.uploadedMap.values());
|
|
|
|
|
|
session.uploadedMap.clear();
|
|
|
|
|
|
session.committed = true;
|
|
|
|
|
|
await deleteMany(toDelete);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-09 11:30:34 +08:00
|
|
|
|
onBeforeUnmount(() => {
|
2026-05-12 21:41:39 +08:00
|
|
|
|
cancelHideZoomBtn();
|
|
|
|
|
|
const textContainer = containerRef.value?.querySelector('.w-e-text-container');
|
|
|
|
|
|
textContainer?.removeEventListener('scroll', onTextScroll);
|
|
|
|
|
|
|
|
|
|
|
|
// 兜底:用户没显式 rollback 就直接关弹层 / 切路由 / unmount
|
|
|
|
|
|
if (!session.committed) {
|
|
|
|
|
|
const toDelete = Array.from(session.uploadedMap.values());
|
|
|
|
|
|
session.uploadedMap.clear();
|
|
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
|
|
|
|
|
deleteMany(toDelete);
|
|
|
|
|
|
}
|
2026-05-09 11:30:34 +08:00
|
|
|
|
editorRef.value?.destroy();
|
|
|
|
|
|
editorRef.value = undefined;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
/** 当 height 传 '100%' 或 'auto' 时启用「撑满父容器」模式 —— 父级必须有具体高度。 */
|
|
|
|
|
|
const isAutoFill = computed(() => props.height === '100%' || props.height === 'auto');
|
|
|
|
|
|
|
|
|
|
|
|
const containerClass = computed(() => ({
|
|
|
|
|
|
'business-rich-text-editor': true,
|
|
|
|
|
|
'business-rich-text-editor--auto-fill': isAutoFill.value
|
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
|
|
const editorStyle = computed(() => {
|
|
|
|
|
|
if (isAutoFill.value) {
|
|
|
|
|
|
return { flex: 1, minHeight: 0, overflowY: 'hidden' as const };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
height: typeof props.height === 'number' ? `${props.height}px` : props.height,
|
|
|
|
|
|
overflowY: 'hidden' as const
|
|
|
|
|
|
};
|
|
|
|
|
|
});
|
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<template>
|
2026-05-12 21:41:39 +08:00
|
|
|
|
<div ref="containerRef" :class="containerClass" @mouseover="onContainerMouseOver" @mouseleave="onContainerMouseLeave">
|
2026-05-09 11:30:34 +08:00
|
|
|
|
<Toolbar
|
|
|
|
|
|
class="business-rich-text-editor__toolbar"
|
|
|
|
|
|
:editor="editorRef"
|
|
|
|
|
|
:default-config="toolbarConfig"
|
|
|
|
|
|
mode="default"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<Editor
|
|
|
|
|
|
v-model="model"
|
|
|
|
|
|
class="business-rich-text-editor__editor"
|
|
|
|
|
|
:style="editorStyle"
|
|
|
|
|
|
:default-config="editorConfig"
|
|
|
|
|
|
mode="default"
|
|
|
|
|
|
@on-created="handleCreated"
|
|
|
|
|
|
/>
|
2026-05-12 21:41:39 +08:00
|
|
|
|
<button
|
|
|
|
|
|
v-show="zoomBtnVisible"
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="business-rich-text-editor__zoom-btn"
|
|
|
|
|
|
:style="zoomBtnStyle"
|
|
|
|
|
|
title="预览图片"
|
|
|
|
|
|
aria-label="预览图片"
|
|
|
|
|
|
@click.stop="openImageViewer"
|
|
|
|
|
|
>
|
|
|
|
|
|
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor" aria-hidden="true">
|
|
|
|
|
|
<path
|
|
|
|
|
|
d="M10 2a8 8 0 1 1-5.29 14.04L1.4 19.36a1 1 0 1 1-1.4-1.4l3.32-3.32A8 8 0 0 1 10 2zm0 2a6 6 0 1 0 0 12 6 6 0 0 0 0-12zm1 3v2h2v2h-2v2H9v-2H7V9h2V7h2z"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</svg>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<ElImageViewer
|
|
|
|
|
|
v-if="viewerVisible"
|
|
|
|
|
|
:url-list="viewerUrlList"
|
|
|
|
|
|
:initial-index="viewerIndex"
|
|
|
|
|
|
:z-index="3100"
|
|
|
|
|
|
teleported
|
|
|
|
|
|
hide-on-click-modal
|
|
|
|
|
|
@close="closeImageViewer"
|
|
|
|
|
|
/>
|
2026-05-09 11:30:34 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<style scoped lang="scss">
|
|
|
|
|
|
.business-rich-text-editor {
|
2026-05-12 21:41:39 +08:00
|
|
|
|
position: relative;
|
2026-05-09 11:30:34 +08:00
|
|
|
|
width: 100%;
|
|
|
|
|
|
border: 1px solid var(--el-border-color);
|
|
|
|
|
|
border-radius: var(--el-border-radius-base);
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
background: var(--el-bg-color);
|
|
|
|
|
|
|
|
|
|
|
|
&__toolbar {
|
|
|
|
|
|
border-bottom: 1px solid var(--el-border-color);
|
|
|
|
|
|
background: var(--el-fill-color-light);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
&__editor {
|
|
|
|
|
|
background: var(--el-bg-color);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
&--auto-fill {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
height: 100%;
|
|
|
|
|
|
min-height: 0;
|
|
|
|
|
|
}
|
2026-05-12 21:41:39 +08:00
|
|
|
|
|
|
|
|
|
|
&__zoom-btn {
|
|
|
|
|
|
position: absolute;
|
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
width: 28px;
|
|
|
|
|
|
height: 28px;
|
|
|
|
|
|
padding: 0;
|
|
|
|
|
|
border: none;
|
|
|
|
|
|
border-radius: 4px;
|
|
|
|
|
|
background: rgba(0, 0, 0, 0.55);
|
|
|
|
|
|
color: #fff;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
transition: background 0.15s;
|
|
|
|
|
|
z-index: 10;
|
|
|
|
|
|
|
|
|
|
|
|
&:hover {
|
|
|
|
|
|
background: rgba(0, 0, 0, 0.75);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-05-09 11:30:34 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* wangeditor 弹层(链接、图片菜单等)默认 z-index 偏低,提高一档避免被 ElDialog 遮挡 */
|
|
|
|
|
|
:deep(.w-e-modal),
|
|
|
|
|
|
:deep(.w-e-drop-panel),
|
|
|
|
|
|
:deep(.w-e-bar-divider),
|
|
|
|
|
|
:deep(.w-e-hover-bar) {
|
|
|
|
|
|
z-index: 3000 !important;
|
|
|
|
|
|
}
|
|
|
|
|
|
</style>
|