dsy 3 недель назад
Родитель
Сommit
09143b29f6

+ 1 - 1
.env

@@ -11,4 +11,4 @@ VUE_APP_BookWebSI = '/GCLSBookWebSI/ServiceInterface'
 VUE_APP_EepServer = '/EEPServer/SI'
 
 #version
-VUE_APP_VERSION = '2026.07.31'
+VUE_APP_VERSION = '2026.08.04'

+ 4 - 0
packages/index.js

@@ -18,6 +18,7 @@ import VueSignaturePad from 'vue-signature-pad';
 import BookEep from './BookEep.vue';
 import pkg from './package.json';
 import { setRuntimeServices } from '@/utils/runtime-services';
+import { ensureExtendedFonts } from '@/common/data';
 import EepSvgIcon from '@/common/SvgIcon';
 
 const components = [BookEep];
@@ -62,6 +63,9 @@ const install = (VueInstance, options = {}) => {
     store: options.store,
   });
 
+  // 自动注册扩展字体(惰性加载,与主应用共享 _extendedFontsLoaded 标记,仅首次请求)
+  ensureExtendedFonts();
+
   components.forEach((component) => {
     VueInstance.component(component.name || 'BookEep', component);
   });

+ 1 - 1
packages/package.json

@@ -1,6 +1,6 @@
 {
   "name": "eep-ui",
-  "version": "0.0.32",
+  "version": "0.0.35",
   "main": "eep/eep-ui.umd.js",
   "style": "eep/eep-ui.css",
   "exports": {

+ 8 - 0
src/api/resource.js

@@ -0,0 +1,8 @@
+import { http } from '@/utils/http';
+
+/**
+ * @description 得到扩展字体列表
+ */
+export function GetExtFontList() {
+  return http.post(`${process.env.VUE_APP_EepServer}?MethodName=resource-GetExtFontList`);
+}

+ 95 - 0
src/common/data.js

@@ -1,3 +1,98 @@
+/**
+ * 字体列表(统一管理,供 el-select 下拉框和 TinyMCE font_formats 使用)
+ * label: 显示名称
+ * value: CSS font-family / el-select 绑定值
+ * tinymce: TinyMCE font_formats 格式(不区分大小写)
+ */
+export const fontList = [
+  { label: '楷体', value: '楷体,微软雅黑', tinymce: '楷体=楷体,微软雅黑' },
+  { label: '黑体', value: '黑体,微软雅黑', tinymce: '黑体=黑体,微软雅黑' },
+  { label: '宋体', value: '宋体,微软雅黑', tinymce: '宋体=宋体,微软雅黑' },
+  { label: 'Arial', value: 'Arial,Helvetica,sans-serif', tinymce: 'Arial=arial,helvetica,sans-serif' },
+  {
+    label: 'Times New Roman',
+    value: 'Times New Roman,times,serif',
+    tinymce: 'Times New Roman=times new roman,times,serif',
+  },
+  { label: '拼音', value: 'League', tinymce: '拼音=League' },
+];
+
+// TinyMCE font_formats 字符串(loadExtendedFonts 调用后会自动更新)
+export let fontFormats = fontList.map((f) => f.tinymce).join(';');
+
+// 内置字体数量,用于区分内置 / 扩展字体
+const BUILTIN_FONT_COUNT = fontList.length;
+
+// 扩展字体是否已加载(避免重复请求)
+let _extendedFontsLoaded = false;
+
+// 共享的扩展字体 @font-face CSS 字符串
+// 所有 TinyMCE iframe 通过 content_style 引用,浏览器字体缓存自动去重,不会重复解码
+let sharedFontFacesCss = '';
+
+/**
+ * 获取共享的扩展字体 @font-face CSS,供 TinyMCE content_style 使用。
+ * @returns {string} @font-face CSS 字符串
+ */
+export function getSharedFontFacesCss() {
+  return sharedFontFacesCss;
+}
+
+/**
+ * 加载扩展字体:注入 @font-face 样式并合并到 fontList 中。
+ * 在应用启动时调用一次即可,重复调用会先清除上一次的扩展字体。
+ *
+ * @param {{ font_list: Array<{ name: string, url: string }> }} res — GetExtFontList 接口返回值
+ */
+export function loadExtendedFonts(res) {
+  // 移除上一次注入的扩展字体样式和列表项
+  document.querySelectorAll('style[data-extended-font]').forEach((el) => el.remove());
+  fontList.splice(BUILTIN_FONT_COUNT);
+  sharedFontFacesCss = '';
+
+  const fontListData = res?.font_list;
+  if (!fontListData || !fontListData.length) {
+    fontFormats = fontList.map((f) => f.tinymce).join(';');
+    return;
+  }
+
+  const cssParts = [];
+  fontListData.forEach(({ name, url }) => {
+    // 注入 @font-face 到主文档
+    const style = document.createElement('style');
+    style.setAttribute('data-extended-font', name);
+    style.textContent = `@font-face { font-family: '${name}'; src: url('${url}'); }`;
+    document.head.appendChild(style);
+
+    // 合并到 fontList
+    fontList.push({ label: name, value: name, tinymce: `${name}=${name}` });
+    cssParts.push(`@font-face { font-family: '${name}'; src: url('${url}'); }`);
+  });
+
+  // 生成共享的字体 CSS
+  sharedFontFacesCss = cssParts.join('\n');
+
+  // 更新 TinyMCE font_formats 字符串
+  fontFormats = fontList.map((f) => f.tinymce).join(';');
+}
+
+/**
+ * 确保扩展字体已加载(惰性加载,多次调用只会请求一次)。
+ * 同时支持网页端(HTTP 接口)和 Electron 端。
+ * @returns {Promise<void>}
+ */
+export async function ensureExtendedFonts() {
+  if (_extendedFontsLoaded) return;
+  try {
+    const { GetExtFontList } = await import('@/api/resource');
+    const res = await GetExtFontList();
+    loadExtendedFonts(res);
+    _extendedFontsLoaded = true;
+  } catch (e) {
+    console.error('加载扩展字体失败:', e);
+  }
+}
+
 export const unified_attrib = {
   topic_color: '#F47921', // 主题色
   assist_color: '#FEE7D4', // 辅助色

+ 63 - 60
src/components/RichText.vue

@@ -59,6 +59,7 @@ import { getRandomNumber } from '@/utils';
 import { isNodeType } from '@/utils/validate';
 import { fileUpload } from '@/api/app';
 import { addTone, handleToneValue } from '@/utils/common';
+import { fontFormats, getSharedFontFacesCss } from '@/common/data';
 
 export default {
   name: 'RichText',
@@ -152,6 +153,7 @@ export default {
         left: 0,
       },
       id: getRandomNumber(),
+      editor: null,
       editorIsInited: false,
       editorBeforeInitConfig: {},
       init: {
@@ -160,6 +162,7 @@ export default {
             font-family: 'League';
             src: url('${process.env.BASE_URL}static/font/pinyin.ttf');
           }
+          ${getSharedFontFacesCss()}
           mjx-assistive-mml {
             position: absolute !important;
             width: 1px !important;
@@ -229,6 +232,8 @@ export default {
 
           let isRendered = false; // 标记是否已渲染
           editor.on('init', () => {
+            this.editor = editor;
+            editor.getBody().style.backgroundColor = '#f2f3f5'; // 设置编辑器背景色
             editor.getBody().style.fontSize = this.init.font_size; // 设置默认字体大小
             editor.getBody().style.fontFamily = this.init.font_family; // 设置默认字体
             editor.getBody().style.color = this.fontColor; // 设置默认字体颜色
@@ -496,12 +501,12 @@ export default {
             text: '●',
             tooltip: '着重点',
             onAction: () => {
-              const editor = tinymce.get(this.id);
+              if (!this.editor) return;
 
-              if (editor.formatter.match('emphasisDot')) {
-                editor.formatter.remove('emphasisDot');
+              if (this.editor.formatter.match('emphasisDot')) {
+                this.editor.formatter.remove('emphasisDot');
               } else {
-                editor.formatter.apply('emphasisDot');
+                this.editor.formatter.apply('emphasisDot');
               }
             },
           });
@@ -663,13 +668,7 @@ export default {
             }, 500);
           });
         },
-        font_formats:
-          '楷体=楷体,微软雅黑;' +
-          '黑体=黑体,微软雅黑;' +
-          '宋体=宋体,微软雅黑;' +
-          'Arial=arial,helvetica,sans-serif;' +
-          'Times New Roman=times new roman,times,serif;' +
-          '拼音=League;',
+        font_formats: fontFormats,
         fontsize_formats: '8pt 10pt 12pt 14pt 16pt 18pt 20pt 22pt 24pt 26pt 28pt 30pt 32pt 34pt 36pt',
         // 字数限制
         ax_wordlimit_num: this.wordlimitNum,
@@ -702,9 +701,9 @@ export default {
     isViewNote: {
       handler(newVal) {
         if (newVal) {
-          let editor = tinymce.get(this.id);
+          const editor = this.editor;
           if (editor) {
-            let start = editor.selection.getStart();
+            const start = editor.selection.getStart();
             this.$emit('selectNote', start.getAttribute('data-annotation-id'));
           }
         }
@@ -712,13 +711,12 @@ export default {
     },
     fontSize: {
       handler(newVal) {
-        const editor = tinymce.get(this.id);
+        const editor = this.editor;
         if (!editor || typeof editor.execCommand !== 'function') return;
 
         const applyFontSize = () => {
           try {
             editor.execCommand('FontSize', false, newVal);
-            editor.setContent(this.value); // 触发内容更新,解决 value 在设置后变为空字符串的问题
           } catch (e) {
             // 容错:某些情况下 execCommand 会抛错,忽略即可
             // console.warn('apply fontSize failed', e);
@@ -735,13 +733,12 @@ export default {
     },
     fontFamily: {
       handler(newVal) {
-        const editor = tinymce.get(this.id);
+        const editor = this.editor;
         if (!editor || typeof editor.execCommand !== 'function') return;
 
         const applyFontFamily = () => {
           try {
             editor.execCommand('FontName', false, newVal);
-            editor.setContent(this.value); // 触发内容更新,解决 value 在设置后变为空字符串的问题
           } catch (e) {
             // 容错:忽略因 selection 不可用或其它原因导致的错误
           }
@@ -787,7 +784,6 @@ export default {
     if (this.isFill || this.isViewNote) {
       window.addEventListener('click', this.hideContentmenu);
     }
-    this.setBackgroundColor();
   },
   beforeDestroy() {
     if (this.pageFrom !== 'audit') {
@@ -797,13 +793,14 @@ export default {
     if (this.isFill || this.isViewNote) {
       window.removeEventListener('click', this.hideContentmenu);
     }
+    this.editor = null;
   },
   methods: {
     getRichContent() {
-      return tinymce.get(this.id).getContent();
+      return this.editor?.getContent() || '';
     },
     getRichSelectionContent() {
-      return tinymce.get(this.id).selection.getContent();
+      return this.editor?.selection?.getContent() || '';
     },
     displayToolbar(isTitle, isInit) {
       if (!this.editorIsInited) {
@@ -811,7 +808,7 @@ export default {
         return;
       }
 
-      let editor = tinymce.get(this.id);
+      const editor = this.editor;
       if (!editor) return;
       const header = editor.editorContainer?.querySelector('.tox-editor-header');
       if (header) {
@@ -832,26 +829,26 @@ export default {
     },
 
     smartPreserveLineBreaks(editor, content) {
-      let body = editor.getBody();
-      let originalParagraphs = Array.from(body.getElementsByTagName('p'));
+      const body = editor.getBody();
+      const originalParagraphs = Array.from(body.getElementsByTagName('p'));
 
-      let tempDiv = document.createElement('div');
-      tempDiv.innerHTML = content;
-      let outputParagraphs = Array.from(tempDiv.getElementsByTagName('p'));
+      const parser = new DOMParser();
+      const doc = parser.parseFromString(content, 'text/html');
+      const outputParagraphs = Array.from(doc.getElementsByTagName('p'));
 
       outputParagraphs.forEach((outputP, index) => {
-        let originalP = originalParagraphs[index];
+        const originalP = originalParagraphs[index];
 
         if (originalP && outputP.innerHTML === '') {
           // 判断这个空段落是否应该包含 <br>
-          let shouldHaveBr = this.shouldPreserveLineBreak(originalP, index, originalParagraphs);
+          const shouldHaveBr = this.shouldPreserveLineBreak(originalP, index, originalParagraphs);
           if (shouldHaveBr) {
             outputP.innerHTML = '<br>';
           }
         }
       });
 
-      return tempDiv.innerHTML;
+      return doc.body.innerHTML;
     },
 
     shouldPreserveLineBreak(paragraph, index, allParagraphs) {
@@ -862,8 +859,8 @@ export default {
 
       // 规则2:如果段落位于内容中间(不是第一个或最后一个)
       if (index > 0 && index < allParagraphs.length - 1) {
-        let prevHasContent = allParagraphs[index - 1].textContent.trim() !== '';
-        let nextHasContent = allParagraphs[index + 1].textContent.trim() !== '';
+        const prevHasContent = allParagraphs[index - 1].textContent.trim() !== '';
+        const nextHasContent = allParagraphs[index + 1].textContent.trim() !== '';
 
         if (prevHasContent && nextHasContent) {
           return true;
@@ -884,24 +881,21 @@ export default {
 
     // 设置背景色
     setBackgroundColor() {
-      let iframes = document.getElementsByTagName('iframe');
-      for (let i = 0; i < iframes.length; i++) {
-        let iframe = iframes[i];
-        // 获取 <iframe> 内部的文档对象
-        let iframeDocument = iframe.contentDocument || iframe.contentWindow.document;
-        let bodyElement = iframeDocument.body;
-        if (bodyElement) {
-          // 设置背景色
-          bodyElement.style.backgroundColor = '#f2f3f5';
-        }
+      const editor = this.editor;
+      if (!editor) return;
+      const body = editor.getBody();
+      if (body) {
+        body.style.backgroundColor = '#f2f3f5';
       }
     },
+
     /**
      * 判断内容是否全部加粗
      */
     isAllBold() {
-      let editor = tinymce.get(this.id);
-      let body = editor.getBody();
+      const editor = this.editor;
+      if (!editor) return false;
+      const body = editor.getBody();
       function getTextNodes(node) {
         let textNodes = [];
         if (node.nodeType === 3 && node.nodeValue.trim() !== '') {
@@ -913,7 +907,7 @@ export default {
         }
         return textNodes;
       }
-      let textNodes = getTextNodes(body);
+      const textNodes = getTextNodes(body);
       if (textNodes.length === 0) return false;
       return textNodes.every((node) => {
         let el = node.parentElement;
@@ -928,12 +922,13 @@ export default {
         return false;
       });
     },
+
     /**
      * 批量设置整体富文本格式
      * @param {Array<{type: string, val: string}>} formats 格式数组,每个元素是一个对象,包含 type 和 val
      */
     patchSetRichFormat(formats) {
-      let editor = tinymce.get(this.id);
+      const editor = this.editor;
       if (!editor) return;
       editor.execCommand('SelectAll');
       formats.forEach(({ type, val }) => {
@@ -944,11 +939,12 @@ export default {
 
       if (this.isViewPinyin) {
         this.$nextTick(() => {
-          let styles = this.getFirstCharStyles();
+          const styles = this.getFirstCharStyles();
           this.$emit('createParsedTextInfoPinyin', null, styles);
         });
       }
     },
+
     /**
      * 设置整体富文本格式
      * @param {string} type 格式名称
@@ -956,7 +952,7 @@ export default {
      * @param {boolean} [isSetDefault=false] 是否设置为默认格式
      */
     setRichFormat(type, val, isSetDefault = false) {
-      let editor = tinymce.get(this.id);
+      const editor = this.editor;
       if (!editor) return;
       editor.execCommand('SelectAll');
       this.handleEditorFormatter(editor, type, val);
@@ -1037,7 +1033,7 @@ export default {
         return;
       }
 
-      let editor = tinymce.get(this.id);
+      const editor = this.editor;
       if (!editor) return;
 
       // 获取编辑器内容区域
@@ -1197,8 +1193,9 @@ export default {
     },
     // 删除填空
     deleteContent() {
-      let editor = tinymce.get(this.id);
-      let start = editor.selection.getStart();
+      const editor = this.editor;
+      if (!editor) return;
+      const start = editor.selection.getStart();
       if (isNodeType(start, 'span')) {
         let textContent = start.textContent;
         let content = this.getRichSelectionContent();
@@ -1211,8 +1208,9 @@ export default {
     },
     // 设置填空
     setContent() {
-      let editor = tinymce.get(this.id);
-      let start = editor.selection.getStart();
+      const editor = this.editor;
+      if (!editor) return;
+      const start = editor.selection.getStart();
       let content = this.getRichSelectionContent();
       if (isNodeType(start, 'span')) {
         let textContent = start.textContent;
@@ -1226,8 +1224,9 @@ export default {
     },
     // 折叠选区
     collapse() {
-      let editor = tinymce.get(this.id);
-      let rng = editor.selection.getRng();
+      const editor = this.editor;
+      if (!editor) return;
+      const rng = editor.selection.getRng();
       if (!rng.collapsed) {
         this.hideContentmenu();
         editor.selection.collapse();
@@ -1310,7 +1309,8 @@ export default {
     },
     // 隐藏工具栏抽屉
     hideToolbarDrawer() {
-      let editor = tinymce.get(this.id);
+      const editor = this.editor;
+      if (!editor) return;
       if (editor.queryCommandState('ToggleToolbarDrawer')) {
         editor.execCommand('ToggleToolbarDrawer');
       }
@@ -1328,7 +1328,9 @@ export default {
     },
 
     async mathConfirm(math) {
-      let editor = tinymce.get(this.id);
+      if (this.mathRenderLock) return;
+      const editor = this.editor;
+      if (!editor) return;
       let tmpId = getRandomNumber();
       this.mathRenderLock = true;
       try {
@@ -1348,7 +1350,8 @@ export default {
     async renderMath(id) {
       if (this.mathEleIsInit) return; // 如果公式已经渲染过,返回
       if (window.MathJax) {
-        let editor = tinymce.get(this.id);
+        const editor = this.editor;
+        if (!editor) return;
         let eleMathArs = [];
         if (id) {
           // 插入的时候,会传递ID,执行单个渲染
@@ -1378,7 +1381,7 @@ export default {
 
     // 选中文本打开弹窗
     openExplanatoryNoteDialog() {
-      const editor = tinymce.get(this.id);
+      const editor = this.editor;
       if (!editor) {
         console.error('编辑器未初始化');
         return;
@@ -1515,7 +1518,7 @@ export default {
      * @returns {object} 包含字体、字号、颜色、加粗、下划线、删除线等样式属性的对象
      */
     getFirstCharStyles() {
-      const editor = tinymce.get(this.id);
+      const editor = this.editor;
       if (!editor) return {};
 
       const firstTextNode = this.findFirstTextNode(editor.getBody());
@@ -1589,7 +1592,7 @@ export default {
      * @returns {object} 包含字体、字号、颜色等body初始样式属性的对象
      */
     getBodyInitialStyles() {
-      const editor = tinymce.get(this.id);
+      const editor = this.editor;
       if (!editor) return {};
 
       const body = editor.getBody();

+ 4 - 0
src/main.js

@@ -20,6 +20,7 @@ import VueSignaturePad from 'vue-signature-pad';
 
 import { setupRouterGuard } from '@/router/guard';
 import { setRuntimeServices } from '@/utils/runtime-services';
+import { ensureExtendedFonts } from '@/common/data';
 
 Vue.use(ElementUI, {
   size: 'small',
@@ -36,6 +37,9 @@ setupRouterGuard(router);
 
 Vue.config.productionTip = false;
 
+// 自动注册扩展字体(惰性加载,仅首次请求,支持网页端)
+ensureExtendedFonts();
+
 new Vue({
   router,
   store,

+ 3 - 7
src/views/personal_workbench/project/components/BookUnifiedAttr.vue

@@ -23,12 +23,7 @@
           </el-form-item>
           <el-form-item label="字体">
             <el-select v-model="unified_attrib.font" placeholder="请选择字体">
-              <el-option label="楷体" value="楷体,微软雅黑" />
-              <el-option label="黑体" value="黑体,微软雅黑" />
-              <el-option label="宋体" value="宋体,微软雅黑" />
-              <el-option label="Arial" value="Arial,Helvetica,sans-serif" />
-              <el-option label="Times New Roman" value="Times New Roman,times,serif" />
-              <el-option label="拼音" value="League" />
+              <el-option v-for="font in fontList" :key="font.value" :label="font.label" :value="font.value" />
             </el-select>
           </el-form-item>
           <el-form-item label="字号">
@@ -81,7 +76,7 @@ import ColorPicker from '@/components/ColorPicker.vue';
 import { pinyinPositionList, isEnable } from '@/views/book/courseware/data/common';
 import { GetBookUnifiedAttrib, ApplyBookUnifiedAttrib, SaveBookUnifiedAttrib } from '@/api/book';
 import { ToAuxiliaryColor } from '@/api/app';
-import { unified_attrib } from '@/common/data';
+import { fontList, unified_attrib } from '@/common/data';
 
 export default {
   name: 'BookUnifiedAttrPage',
@@ -101,6 +96,7 @@ export default {
   data() {
     return {
       unified_attrib,
+      fontList,
       fontSizeList: [
         '8pt',
         '10pt',

+ 3 - 7
src/views/personal_workbench/project/components/BookUnifiedTitle.vue

@@ -18,12 +18,7 @@
               <!-- 字体 -->
               <el-form-item label="字体">
                 <el-select v-model="item.font" placeholder="请选择字体" style="width: 120px">
-                  <el-option label="宋体" value="宋体,微软雅黑" />
-                  <el-option label="楷体" value="楷体,微软雅黑" />
-                  <el-option label="黑体" value="黑体,微软雅黑" />
-                  <el-option label="Arial" value="Arial,Helvetica,sans-serif" />
-                  <el-option label="Times New Roman" value="Times New Roman,times,serif" />
-                  <el-option label="拼音" value="League" />
+                  <el-option v-for="font in fontList" :key="font.value" :label="font.label" :value="font.value" />
                 </el-select>
               </el-form-item>
 
@@ -79,7 +74,7 @@
 
 <script>
 import { GetTitleStyle, SaveTitleStyle } from '@/api/book';
-import { unified_title } from '@/common/data';
+import { fontList, unified_title } from '@/common/data';
 
 export default {
   name: 'BookUnifiedTitlePage',
@@ -95,6 +90,7 @@ export default {
   },
   data() {
     return {
+      fontList,
       fontSizeList: [
         '8pt',
         '10pt',