Kaynağa Gözat

赛事专区bug修复

wengan01 2 hafta önce
ebeveyn
işleme
a1cc211093
1 değiştirilmiş dosya ile 285 ekleme ve 7 silme
  1. 285 7
      src/components/Editor/index.vue

+ 285 - 7
src/components/Editor/index.vue

@@ -24,6 +24,35 @@
       @text-change="(e: any) => $emit('update:modelValue', content)"
     />
   </div>
+  <!-- 视频插入弹窗:网页链接 / 本地上传 二选一 -->
+  <el-dialog v-model="videoDialog.visible" title="插入视频" width="520px" append-to-body>
+    <el-radio-group v-model="videoDialog.mode">
+      <el-radio value="url">网页链接</el-radio>
+      <el-radio value="upload">本地上传</el-radio>
+    </el-radio-group>
+    <div v-if="videoDialog.mode === 'url'" style="margin-top: 12px">
+      <el-input v-model="videoDialog.url" placeholder="请输入视频地址(Embed URL)" clearable />
+    </div>
+    <div v-else style="margin-top: 12px">
+      <el-upload
+        :action="upload.url"
+        :headers="upload.headers"
+        name="file"
+        accept="video/*"
+        :show-file-list="false"
+        :before-upload="handleVideoBeforeUpload"
+        :on-success="handleVideoUploadSuccess"
+        :on-error="handleUploadError"
+      >
+        <el-button type="primary">选择视频文件</el-button>
+      </el-upload>
+      <div v-if="videoDialog.uploadedUrl" style="margin-top: 8px; color: #67c23a">已上传:{{ videoDialog.uploadedName }}</div>
+    </div>
+    <template #footer>
+      <el-button @click="videoDialog.visible = false">取 消</el-button>
+      <el-button type="primary" @click="confirmInsertVideo">确 定</el-button>
+    </template>
+  </el-dialog>
 </template>
 
 <script setup lang="ts">
@@ -33,6 +62,144 @@ import { QuillEditor, Quill } from '@vueup/vue-quill';
 import { propTypes } from '@/utils/propTypes';
 import { globalHeaders } from '@/utils/request';
 
+// 富文本统一输出内联样式:Quill默认输出class名(如ql-size-huge、ql-align-center),
+// 只有加载了Quill样式表的页面才能渲染,APP端渲染器不认识会导致字体大小/对齐丢失
+const SizeAttributor = Quill.import('attributors/style/size') as any;
+SizeAttributor.whitelist = ['10px', '12px', '14px', '16px', '18px', '20px', '24px', '32px'];
+Quill.register(SizeAttributor, true);
+Quill.register(Quill.import('attributors/style/align') as any, true);
+Quill.register(Quill.import('attributors/style/direction') as any, true);
+
+// 视频用 <video> 标签插入:Quill默认video格式是iframe嵌入,APP端渲染器对iframe兼容差;
+// video标签在WebView原生可播、flutter_html等组件渲染也可扩展支持,兼容性严格优于iframe
+const BlockEmbed = Quill.import('blots/block/embed') as any;
+class VideoTagBlot extends BlockEmbed {
+  static blotName = 'videoTag';
+  static tagName = 'video';
+  static create(value: string) {
+    const node = super.create(value);
+    node.setAttribute('src', value);
+    node.setAttribute('controls', 'controls');
+    node.setAttribute('preload', 'metadata');
+    node.setAttribute('playsinline', 'true');
+    node.setAttribute('style', 'max-width:100%;');
+    return node;
+  }
+  static value(node: HTMLElement) {
+    return node.getAttribute('src');
+  }
+}
+Quill.register(VideoTagBlot, true);
+
+// 修复列表符号:Quill实际DOM为 ol > li[data-list=bullet/checked/unchecked],
+// 而vue-quill自带样式表是旧版规则(圆点只给 ul > li::before,ol li:before 统一走数字计数器),
+// 导致无序列表显示成数字编号。这里按 data-list 重写 li::before 的 content。
+// 注意:符号只能加在 li::before(旧样式表已为它配好宽度/边距/对齐),
+// 不能同时加到 li > .ql-ui::before,否则会出现两个圆点且后者紧贴文字
+// 用JS注入<style>而非SFC样式块:模块加载即生效,不依赖样式热更新通道
+const LIST_FIX_CSS = `
+.editor .ql-editor li[data-list='bullet']::before { content: '\\2022' !important; }
+.editor .ql-editor li[data-list='checked']::before { content: '\\2611' !important; color: #777; }
+.editor .ql-editor li[data-list='unchecked']::before { content: '\\2610' !important; color: #777; }
+.editor .ql-editor li[data-list='bullet'],
+.editor .ql-editor li[data-list='checked'],
+.editor .ql-editor li[data-list='unchecked'] { counter-increment: none !important; }
+`;
+const listFixStyle = document.getElementById('ql-list-fix-css') as HTMLStyleElement | null;
+if (listFixStyle) {
+  listFixStyle.textContent = LIST_FIX_CSS;
+} else {
+  const s = document.createElement('style');
+  s.id = 'ql-list-fix-css';
+  s.textContent = LIST_FIX_CSS;
+  document.head.appendChild(s);
+}
+
+// 工具栏悬停提示:选择器 -> 提示文案(原生title,鼠标悬停停顿后显示)
+const TOOLBAR_TIPS: [string, string][] = [
+  ['button.ql-bold', '加粗:选中文字加粗'],
+  ['button.ql-italic', '斜体:选中文字变斜体'],
+  ['button.ql-underline', '下划线:选中文字加下划线'],
+  ['button.ql-strike', '删除线:选中文字加删除线'],
+  ['button.ql-blockquote', '引用:选中段落设为引用'],
+  ['button.ql-code-block', '代码块:选中内容设为代码块'],
+  ['button.ql-list[value="ordered"]', '有序列表:选中内容加数字编号'],
+  ['button.ql-list[value="bullet"]', '无序列表:选中内容加圆点符号'],
+  ['button.ql-indent[value="-1"]', '减少缩进:降低段落缩进层级'],
+  ['button.ql-indent[value="+1"]', '增加缩进:提高段落缩进层级'],
+  ['.ql-picker.ql-size .ql-picker-label', '字体大小:设置选中文字的字号'],
+  ['.ql-picker.ql-header .ql-picker-label', '标题:切换段落标题级别'],
+  ['.ql-picker.ql-color .ql-picker-label', '字体颜色:设置选中文字的颜色'],
+  ['.ql-picker.ql-background .ql-picker-label', '背景颜色:设置选中文字的背景色'],
+  ['.ql-picker.ql-align .ql-picker-label', '对齐方式:设置段落对齐'],
+  ['.ql-align .ql-picker-item:not([data-value])', '左对齐(默认)'],
+  ['.ql-align .ql-picker-item[data-value="center"]', '居中对齐:段落居中(居中请用此按钮)'],
+  ['.ql-align .ql-picker-item[data-value="right"]', '右对齐:段落靠右'],
+  ['.ql-align .ql-picker-item[data-value="justify"]', '两端对齐:段落两端对齐'],
+  ['button.ql-clean', '清除格式:移除选中内容的所有格式'],
+  ['button.ql-link', '链接:插入超链接'],
+  ['button.ql-image', '图片:上传并插入图片'],
+  ['button.ql-video', '视频:网页链接或本地上传插入视频']
+];
+
+/** 工具栏悬停功能提示:自定义气泡,不依赖原生title,也不依赖编辑器挂载时机 */
+const TIP_DELAY = 400; // 悬停停顿多久后显示(毫秒)
+let tipEl: HTMLDivElement | null = null;
+let tipTimer: any = null;
+
+function getTipEl() {
+  if (!tipEl) {
+    tipEl = document.createElement('div');
+    tipEl.style.cssText =
+      'position:fixed;z-index:99999;padding:6px 10px;background:rgba(48,49,51,.95);color:#fff;' +
+      'font-size:12px;line-height:1.5;border-radius:4px;pointer-events:none;white-space:nowrap;' +
+      'transform:translate(-50%,-100%);display:none;';
+    document.body.appendChild(tipEl);
+  }
+  return tipEl;
+}
+
+function hideTip() {
+  if (tipTimer) {
+    clearTimeout(tipTimer);
+    tipTimer = null;
+  }
+  if (tipEl) tipEl.style.display = 'none';
+}
+
+function bindToolbarTips() {
+  // 挂window上防HMR/多实例重复绑定
+  if ((window as any).__qlToolbarTipsBound) return;
+  (window as any).__qlToolbarTipsBound = true;
+  document.addEventListener('mouseover', (e: MouseEvent) => {
+    const target = e.target as HTMLElement | null;
+    if (!target || !target.closest) return;
+    if (!target.closest('.ql-toolbar')) {
+      hideTip();
+      return;
+    }
+    const hit = TOOLBAR_TIPS.find(([sel]) => target.closest(sel));
+    if (!hit) {
+      hideTip();
+      return;
+    }
+    const anchor = target.closest(hit[0]) as HTMLElement;
+    if (tipTimer) clearTimeout(tipTimer);
+    tipTimer = setTimeout(() => {
+      const el = getTipEl();
+      el.textContent = hit[1];
+      const rect = anchor.getBoundingClientRect();
+      el.style.left = `${rect.left + rect.width / 2}px`;
+      el.style.top = `${rect.top - 6}px`;
+      el.style.display = 'block';
+    }, TIP_DELAY);
+  });
+  document.addEventListener('mouseout', hideTip);
+  document.addEventListener('mousedown', hideTip, true);
+  window.addEventListener('scroll', hideTip, true);
+}
+bindToolbarTips();
+
 defineEmits(['update:modelValue']);
 
 const props = defineProps({
@@ -58,6 +225,17 @@ const upload = reactive<UploadOption>({
 });
 const quillEditorRef = ref();
 const uploadRef = ref<HTMLDivElement>();
+// 点击图片按钮时记录插入位置(上传弹窗关闭后光标可能丢失)
+let pendingImageIndex: number | null = null;
+// 点击视频按钮时记录插入位置,并维护视频弹窗状态
+let pendingVideoIndex: number | null = null;
+const videoDialog = reactive({
+  visible: false,
+  mode: 'url' as 'url' | 'upload',
+  url: '',
+  uploadedUrl: '',
+  uploadedName: ''
+});
 
 const options = ref<any>({
   theme: 'snow',
@@ -71,7 +249,7 @@ const options = ref<any>({
         ['blockquote', 'code-block'], // 引用  代码块
         [{ list: 'ordered' }, { list: 'bullet' }], // 有序、无序列表
         [{ indent: '-1' }, { indent: '+1' }], // 缩进
-        [{ size: ['small', false, 'large', 'huge'] }], // 字体大小
+        [{ size: ['10px', false, '18px', '32px'] }], // 字体大小(内联样式模式)
         [{ header: [1, 2, 3, 4, 5, 6, false] }], // 标题
         [{ color: [] }, { background: [] }], // 字体颜色、字体背景颜色
         [{ align: [] }], // 对齐方式
@@ -81,11 +259,29 @@ const options = ref<any>({
       handlers: {
         image: (value: boolean) => {
           if (value) {
+            // 先记录插入位置,避免上传弹窗关闭后光标丢失
+            const quill = toRaw(quillEditorRef.value)?.getQuill();
+            const range = quill?.getSelection();
+            pendingImageIndex = range ? range.index : Math.max((quill?.getLength() ?? 1) - 1, 0);
             // 调用element图片上传
             uploadRef.value.click();
           } else {
             Quill.format('image', true);
           }
+        },
+        // 视频弹窗:网页链接或本地上传。Quill自带tooltip是绝对定位,会飘出编辑器压住下面表单,
+        // 且编辑器未聚焦时第一次点击没反应
+        video: (value: boolean) => {
+          if (!value) return;
+          const quill = toRaw(quillEditorRef.value)?.getQuill();
+          if (!quill) return;
+          const range = quill.getSelection();
+          pendingVideoIndex = range ? range.index : Math.max(quill.getLength() - 1, 0);
+          videoDialog.url = '';
+          videoDialog.uploadedUrl = '';
+          videoDialog.uploadedName = '';
+          videoDialog.mode = 'url';
+          videoDialog.visible = true;
         }
       }
     },
@@ -126,12 +322,15 @@ const handleUploadSuccess = (res: any) => {
   if (res.code === 200) {
     // 获取富文本实例
     const quill = toRaw(quillEditorRef.value).getQuill();
-    // 获取光标位置
-    const length = quill.selection.savedRange.index;
+    // 用点击图片按钮时记录的位置,为空则退回当前光标或末尾;
+    // 直接读 selection.savedRange 在编辑器未聚焦时是 null 会报错,导致第一次上传没反应
+    const range = quill.getSelection();
+    const index = pendingImageIndex ?? range?.index ?? Math.max(quill.getLength() - 1, 0);
+    pendingImageIndex = null;
     // 插入图片,res为服务器返回的图片链接地址
-    quill.insertEmbed(length, 'image', res.data.url);
-    // 调整光标到最后
-    quill.setSelection(length + 1);
+    quill.insertEmbed(index, 'image', res.data.url);
+    // 光标放到图片后
+    quill.setSelection(index + 1);
     proxy?.$modal.closeLoading();
   } else {
     proxy?.$modal.msgError('图片插入失败');
@@ -159,9 +358,69 @@ const handleBeforeUpload = (file: any) => {
   proxy?.$modal.loading('正在上传文件,请稍候...');
   return true;
 };
-// 图片失败拦截
+// 图片/视频上传失败拦截
 const handleUploadError = (err: any) => {
   proxy?.$modal.msgError('上传文件失败');
+  proxy?.$modal.closeLoading();
+};
+
+// 视频上传前拦截:类型 + 2GB上限(与后端multipart及视频内容页对齐)
+const handleVideoBeforeUpload = (file: any) => {
+  const isVideo = file.type?.startsWith('video/') || /\.(mp4|mov|m4v|webm|mkv|avi)$/i.test(file.name);
+  if (!isVideo) {
+    proxy?.$modal.msgError('请选择视频文件!');
+    return false;
+  }
+  const sizeMB = file.size / 1024 / 1024;
+  if (sizeMB >= 2048) {
+    proxy?.$modal.msgError('视频文件大小不能超过2GB');
+    return false;
+  }
+  proxy?.$modal.loading('正在上传视频,请稍候...');
+  return true;
+};
+
+// 视频上传成功:先只记录地址,点确定时才插入正文
+const handleVideoUploadSuccess = (res: any, file: any) => {
+  proxy?.$modal.closeLoading();
+  if (res.code === 200) {
+    videoDialog.uploadedUrl = res.data.url;
+    videoDialog.uploadedName = file?.name || '视频';
+  } else {
+    proxy?.$modal.msgError('视频上传失败');
+  }
+};
+
+// 确定插入视频:按所选方式取地址,插到点按钮时记录的位置
+const confirmInsertVideo = async () => {
+  const src = (videoDialog.mode === 'url' ? videoDialog.url : videoDialog.uploadedUrl).trim();
+  if (!src) {
+    proxy?.$modal.msgWarning(videoDialog.mode === 'url' ? '请输入视频地址' : '请先上传视频文件');
+    return;
+  }
+  if (videoDialog.mode === 'url') {
+    // <video>只认视频文件直链;抖音/哔哩等网页地址塞进去是黑屏,提前提醒
+    let pathname = src;
+    try {
+      pathname = new URL(src).pathname;
+    } catch {
+      /* 不是标准URL就按原串校验 */
+    }
+    if (!/\.(mp4|m4v|mov|webm|mkv|avi|flv)$/i.test(pathname)) {
+      try {
+        await proxy?.$modal.confirm('该链接不带视频文件扩展名(.mp4等)。网页链接(抖音、哔哩等)在正文中无法播放,建议下载后本地上传。仍要插入吗?');
+      } catch {
+        return;
+      }
+    }
+  }
+  const quill = toRaw(quillEditorRef.value)?.getQuill();
+  if (!quill) return;
+  const index = pendingVideoIndex ?? Math.max(quill.getLength() - 1, 0);
+  pendingVideoIndex = null;
+  quill.insertEmbed(index, 'videoTag', src);
+  quill.setSelection(index + 1, 0);
+  videoDialog.visible = false;
 };
 import { onMounted, nextTick } from 'vue';
 import Delta from 'quill-delta';
@@ -435,6 +694,18 @@ onMounted(() => {
 .ql-snow .ql-picker.ql-size .ql-picker-item[data-value='huge']::before {
   content: '32px';
 }
+.ql-snow .ql-picker.ql-size .ql-picker-label[data-value='10px']::before,
+.ql-snow .ql-picker.ql-size .ql-picker-item[data-value='10px']::before {
+  content: '10px';
+}
+.ql-snow .ql-picker.ql-size .ql-picker-label[data-value='18px']::before,
+.ql-snow .ql-picker.ql-size .ql-picker-item[data-value='18px']::before {
+  content: '18px';
+}
+.ql-snow .ql-picker.ql-size .ql-picker-label[data-value='32px']::before,
+.ql-snow .ql-picker.ql-size .ql-picker-item[data-value='32px']::before {
+  content: '32px';
+}
 .ql-snow .ql-picker.ql-header .ql-picker-label::before,
 .ql-snow .ql-picker.ql-header .ql-picker-item::before {
   content: '文本';
@@ -480,4 +751,11 @@ onMounted(() => {
   white-space: pre-wrap; /* 强制换行 */
   word-break: break-all; /* 防止长单词或URL溢出 */
 }
+.editor .ql-editor img,
+.editor .ql-editor video {
+  max-width: 100%; /* 自适应编辑器宽度,不溢出 */
+}
+.editor .ql-editor video {
+  margin: 8px 0;
+}
 </style>