瀏覽代碼

feat(editor):优化富文本编辑器粘贴行为与样式清理逻辑

-为 quill 编辑器添加 clipboard 配置,关闭视觉换行并清空默认匹配器
- 自定义粘贴处理逻辑,支持保留颜色、字体等关键格式的同时清除背景样式
- 支持列表结构在粘贴时的正确解析与 Delta 构建
- 添加对 content 回显时 style 属性中 color 的清理与注入逻辑
-修复编辑器组件 ref 绑定问题,并完善组件挂载与卸载时的事件监听管理
- 调整部分 UI 样式细节,如盲注等级链接颜色统一为品牌蓝
fugui001 3 月之前
父節點
當前提交
ab6539fdca

+ 227 - 1
src/components/Editor/index.vue

@@ -88,6 +88,10 @@ const options = ref<any>({
           }
         }
       }
+    },
+    clipboard: {
+      matchVisual: false, // 关闭视觉换行
+      matchers: [] // 清空所有匹配器
     }
   },
   placeholder: '请输入内容',
@@ -155,11 +159,233 @@ const handleBeforeUpload = (file: any) => {
   proxy?.$modal.loading('正在上传文件,请稍候...');
   return true;
 };
-
 // 图片失败拦截
 const handleUploadError = (err: any) => {
   proxy?.$modal.msgError('上传文件失败');
 };
+import { onMounted, nextTick } from 'vue';
+import Delta from 'quill-delta';
+
+onMounted(async () => {
+  await nextTick();
+  const quill = quillEditorRef.value?.getQuill();
+  if (!quill) return;
+
+  // 清空默认行为
+  quill.clipboard.matchers = [];
+
+  quill.clipboard.addMatcher(Node.ELEMENT_NODE, (node: HTMLElement, delta: Delta) => {
+    const tempDiv = document.createElement('div');
+    tempDiv.innerHTML = node.outerHTML;
+
+    // ✅ 只清除 background 相关样式,保留 color
+    cleanBackgroundOnly(tempDiv);
+
+    // 构建 Delta(支持格式 + 列表)
+    const newDelta = new Delta();
+    buildDeltaWithListSupport(tempDiv, newDelta, {}, null);
+    return newDelta;
+  });
+});
+
+// ✅ 只清除 background,保留 color/font-size 等
+function cleanBackgroundOnly(parent: HTMLElement) {
+  if (parent.style) {
+    parent.style.backgroundColor = '';
+    parent.style.background = '';
+    parent.style.color = '';
+    parent.style.backgroundImage = '';
+    parent.style.backgroundPosition = '';
+    parent.style.backgroundRepeat = '';
+    parent.style.backgroundSize = '';
+    parent.style.backgroundAttachment = '';
+    // ✅ 不动 color、font、size 等
+  }
+
+  Array.from(parent.children).forEach((child) => {
+    if (child instanceof HTMLElement) {
+      cleanBackgroundOnly(child);
+    }
+  });
+}
+
+// ✅ 支持列表的 Delta 构建
+function buildDeltaWithListSupport(
+  node: Node,
+  delta: Delta,
+  formatStack: { [key: string]: any },
+  listType: 'ordered' | 'bullet' | null // 当前是否在列表中
+) {
+  if (node.nodeType === Node.TEXT_NODE) {
+    const text = node.textContent || '';
+    if (text.trim() || text === ' ') {
+      delta.insert(text, formatStack);
+    }
+    return;
+  }
+
+  if (node.nodeType !== Node.ELEMENT_NODE) return;
+
+  const el = node as HTMLElement;
+  const tagName = el.tagName.toLowerCase();
+
+  const currentFormat = { ...formatStack };
+  let newListType: 'ordered' | 'bullet' | null = listType;
+
+  // 处理列表开始
+  if (tagName === 'ol') {
+    newListType = 'ordered';
+  }
+  if (tagName === 'ul') {
+    newListType = 'bullet';
+  }
+
+  // 处理列表项
+  if (tagName === 'li') {
+    if (listType) {
+      // 标记为列表项
+      currentFormat.list = listType;
+    }
+    // 如果 li 有嵌套 ol/ul,子项可能改变类型,但这里简化处理
+  }
+
+  // 添加内联格式
+  if (tagName === 'strong' || tagName === 'b') {
+    currentFormat.bold = true;
+  }
+  if (tagName === 'em' || tagName === 'i') {
+    currentFormat.italic = true;
+  }
+  if (tagName === 'u' || el.style.textDecoration === 'underline') {
+    currentFormat.underline = true;
+  }
+  if (tagName === 's' || tagName === 'strike') {
+    currentFormat.strike = true;
+  }
+
+  // ✅ 保留颜色(关键!)
+  if (el.style.color) {
+    currentFormat.color = el.style.color;
+  }
+  if (el.style.fontSize) {
+    currentFormat.size = el.style.fontSize; // 注意:Quill 的 size 是 small/large/huge 或值
+  }
+  if (el.style.fontFamily) {
+    currentFormat.font = el.style.fontFamily.split(',')[0].trim().replace(/['"]/g, '');
+  }
+
+  // 是否是块级元素(需要换行)
+  const isBlock = ['p', 'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'br'].includes(tagName);
+
+  // 遍历子节点
+  for (let i = 0; i < el.childNodes.length; i++) {
+    const child = el.childNodes[i];
+
+    if (child.nodeType === Node.TEXT_NODE && isBlock && i === 0) {
+      // 块级元素开头插入换行(除了第一个)
+      if (delta.length() > 0) {
+        delta.insert('\n');
+      }
+    }
+
+    buildDeltaWithListSupport(child, delta, currentFormat, newListType);
+
+    // 如果是块级元素且不是最后一个,加换行
+    if (isBlock && i === el.childNodes.length - 1 && (el.nextSibling || el.parentNode !== el.ownerDocument?.body)) {
+      if (!delta.ops.length || delta.ops[delta.ops.length - 1].insert !== '\n') {
+        delta.insert('\n');
+      }
+    }
+  }
+}
+/*
+
+onMounted(async () => {
+  await nextTick();
+  const quill = quillEditorRef.value?.getQuill();
+  if (!quill) return;
+
+  // ✅ 1. 移除所有默认的 matcher,避免干扰
+  quill.clipboard.matchers = [];
+
+  // ✅ 2. 添加自定义 matcher:对所有元素节点,只提取 innerText
+  quill.clipboard.addMatcher(Node.ELEMENT_NODE, (node: HTMLElement, delta: Delta) => {
+    // 获取纯文本
+    const text = node.innerText || node.textContent || '';
+
+    // 返回一个新的 Delta,只包含纯文本,无任何格式
+    return new Delta().insert(text);
+  });
+
+  // ✅ 3. 特别处理根节点是纯文本的情况(比如从记事本复制)
+  quill.clipboard.addMatcher(Node.TEXT_NODE, (node: Text, delta: Delta) => {
+    return new Delta().insert(node.data || '');
+  });
+
+  // ✅ 4. 可选:如果你希望保留换行,可以不做处理,Quill 会自动处理 p/br
+  // 如果你想更激进地清理,也可以在这里统一处理
+
+  console.log('[Clipboard] Custom matcher set, only plain text will be pasted.');
+});
+
+
+import { onMounted } from 'vue';
+
+onMounted(() => {
+  const quill = quillEditorRef.value.getQuill();
+  debugger;
+  // ✅ 2. 手动监听 paste 事件,完全由你控制
+  quill.root.addEventListener('paste', async (e) => {
+    e.preventDefault(); // ✅ 阻止浏览器默认行为
+
+    const clipboardData = e.clipboardData || (e as any).originalEvent.clipboardData;
+    const html = clipboardData.getData('text/html');
+    const text = clipboardData.getData('text/plain');
+
+    // 使用 HTML 优先,否则用纯文本
+    const tempDiv = document.createElement('div');
+    tempDiv.innerHTML = html || text;
+
+    // 清理所有样式和 class
+    const walk = (node: Node) => {
+      if (node.nodeType === Node.ELEMENT_NODE) {
+
+        const el = node as HTMLElement;
+        el.style.cssText = '';
+        el.removeAttribute('class');
+        el.removeAttribute('style');
+
+        // 特别清理 span 的内联样式
+        if (el.tagName === 'SPAN') {
+          el.style.color = '';
+          el.style.backgroundColor = '';
+          el.style.fontWeight = '';
+          el.style.fontStyle = '';
+          el.style.textDecoration = '';
+        }
+
+        Array.from(el.children).forEach((child) => walk(child));
+      }
+    };
+
+    Array.from(tempDiv.childNodes).forEach(walk);
+
+    const cleanHtml = tempDiv.innerHTML;
+    const range = quill.getSelection();
+    const index = range ? range.index : 0;
+    // ✅ 调试:打印关键信息
+    console.log('[Paste Debug]', { html, text, cleanHtml, index });
+
+    // ✅ 插入清理后的内容
+    quill.clipboard.dangerouslyPasteHTML(index, cleanHtml);
+
+    // 可选:将光标移到末尾
+    // setTimeout(() => {
+    //   const length = quill.getLength();
+    //   quill.setSelection(length, 0);
+    // }, 10);
+  });
+});*/
 </script>
 
 <style>

+ 2 - 2
src/layout/components/Settings/index.vue

@@ -129,9 +129,9 @@ watch(isDark, () => {
   }
 });
 // 强制设置为深色模式
-onMounted(() => {
+/*onMounted(() => {
   isDark.value = true; // 强制开启
-});
+});*/
 const toggleDark = () => useToggle(isDark);
 
 const topNavChange = (val: any) => {

+ 75 - 7
src/views/system/business/info/index.vue

@@ -161,7 +161,7 @@
         </el-form-item>
 
         <el-form-item label="正文">
-          <editor v-model="form.content" :min-height="192" />
+          <editor v-model="form.content" :min-height="192" ref="quillEditorRef" />
         </el-form-item>
         <el-form-item label="新闻类型" prop="gameType">
           <el-select aria-required="true" v-model="form.newsType" placeholder="请选择">
@@ -178,7 +178,7 @@
           </el-radio-group>
         </el-form-item>
 
-<!--        <el-form-item label="发布状态" prop="">
+        <!--        <el-form-item label="发布状态" prop="">
           <el-select aria-required="true" v-model="form.status" placeholder="请选择">
             <el-option v-for="dict in news_status" :key="dict.value" :label="dict.label" :value="dict.value"> </el-option>
           </el-select>
@@ -325,7 +325,14 @@ const handleUpdate = async (row?: InfoVO) => {
   const _id = row?.id || ids.value[0];
   const res = await getInfo(_id);
   iconPreviewUrl.value = res.data.imageUrl;
-  Object.assign(form.value, res.data);
+  // 去除content中的style属性里的color设置
+  let content = res.data.content;
+  if (content) {
+    content = content.replace(/<p style="color: rgb\(255, 255, 255\);"/g, '<p');
+  }
+
+  Object.assign(form.value, res.data, { content });
+
   dialog.visible = true;
   dialog.title = '修改新闻资讯';
 };
@@ -334,6 +341,11 @@ const handleUpdate = async (row?: InfoVO) => {
 const submitForm = () => {
   infoFormRef.value?.validate(async (valid: boolean) => {
     if (valid) {
+      const vals = form.value.content;
+      console.log(vals);
+      // 使用正则表达式替换所有的 <p> 标签,添加内联样式
+      const modifiedContent = vals.replace(/<p/g, '<p style="color: rgb(255, 255, 255);"');
+      form.value.content = modifiedContent;
       buttonLoading.value = true;
       if (form.value.id) {
         await updateInfo(form.value).finally(() => (buttonLoading.value = false));
@@ -382,10 +394,6 @@ const handleExport = () => {
   );
 };
 
-onMounted(() => {
-  getList();
-  loadCategoryOptions();
-});
 // 下拉选项数据 selectBlingStructuresInfo
 const categoryOptions = ref<{ id: number; label: string }[]>([]);
 
@@ -469,6 +477,66 @@ const handleIconRemove = (file, updatedFileList) => {
   iconPreviewUrl.value = '';
   competitionIcon.value = ''; // 如果需要清除后台加载的图标,可以在这里设置为空字符串
 };
+const quillEditorRef = ref();
+/*onMounted(() => {
+  getList();
+  loadCategoryOptions();
+});*/
+import { onMounted, onUnmounted } from 'vue';
+// 监听粘贴事件
+const handlePaste = (e: ClipboardEvent) => {
+  const activeElement = document.activeElement;
+
+  // 判断是否在富文本编辑器中(通过 class 或 tagName 判断)
+  // 常见富文本编辑器的编辑区通常是 contenteditable 的 div
+  const isEditor =
+    activeElement?.classList.contains('ql-editor') || // Quill
+    activeElement?.getAttribute('contenteditable') === 'true' ||
+    (activeElement?.tagName === 'DIV' && activeElement?.parentElement?.classList.contains('my-editor')); // 自定义类名
+
+  if (!isEditor) return;
+
+  e.preventDefault();
+  debugger;
+  const clipboardData = e.clipboardData || (e as any).originalEvent.clipboardData;
+  const html = clipboardData.getData('text/html');
+  const text = clipboardData.getData('text/plain');
+
+  // 创建临时元素清理 HTML
+  const tempDiv = document.createElement('div');
+  tempDiv.innerHTML = html || `<p>${text}</p>`;
+
+  // 清理所有 style 和 class
+  const walk = (node: Node) => {
+    if (node.nodeType === Node.ELEMENT_NODE) {
+      const el = node as HTMLElement;
+      el.removeAttribute('style');
+      el.removeAttribute('class');
+      el.removeAttribute('id');
+      Array.from(el.childNodes).forEach(walk);
+    }
+  };
+  Array.from(tempDiv.childNodes).forEach(walk);
+
+  const cleanHtml = tempDiv.innerHTML;
+
+  // ✅ 获取当前光标位置并插入内容(关键:使用 document.execCommand)
+  document.execCommand('insertHTML', false, cleanHtml);
+};
+
+onMounted(() => {
+  // 绑定全局粘贴事件
+  //document.addEventListener('paste', handlePaste);
+
+  // 其他初始化
+  getList();
+  loadCategoryOptions();
+});
+
+onUnmounted(() => {
+  // 移除事件,防止内存泄漏
+  document.removeEventListener('paste', handlePaste);
+});
 </script>
 <style>
 .custom-editor-content .ql-editor {

+ 1 - 1
src/views/system/business/structures/index.vue

@@ -49,7 +49,7 @@
         <!-- 新增列:盲注等级 -->
         <el-table-column label="预览" align="center" width="150">
           <template #default="scope">
-            <span class="level-link" @click="handleViewLevels(scope.row)" style="color: blue; cursor: pointer; text-decoration: underline">
+            <span class="level-link" @click="handleViewLevels(scope.row)" style="color: #007bff; cursor: pointer; text-decoration: underline">
               {{ formatBlindLevel(scope.row.blindLevels) }}
             </span>
           </template>