1
0

3 کامیت‌ها 6c9aada5db ... 2ad73cff13

نویسنده SHA1 پیام تاریخ
  shmily1213 2ad73cff13 支持latex数学公式 3 روز پیش
  shmily1213 8c34c81da5 修改知识点树高度计算逻辑 5 روز پیش
  shmily1213 67c88d4be8 添加教材同步练习功能 5 روز پیش
42فایلهای تغییر یافته به همراه3667 افزوده شده و 279 حذف شده
  1. 5 1
      package.json
  2. 0 1
      src/api/flyio.ts
  3. 20 2
      src/api/modules/study.ts
  4. 5 1
      src/common/enum.ts
  5. 4 0
      src/components/ie-picker/ie-picker.vue
  6. 100 100
      src/components/mx-tabs-swiper/mx-tabs-swiper.vue
  7. 82 1
      src/composables/useExam.ts
  8. 5 1
      src/config.ts
  9. 12 0
      src/pages.json
  10. 0 1
      src/pagesMain/pages/index/components/index-banner.vue
  11. 2 2
      src/pagesMain/pages/index/index.vue
  12. 13 3
      src/pagesMain/pages/splash/splash.vue
  13. 87 86
      src/pagesOther/pages/personal-center/setting/setting.vue
  14. 42 38
      src/pagesStudy/components/knowledge-tree-node.vue
  15. 6 15
      src/pagesStudy/components/knowledge-tree.vue
  16. 10 8
      src/pagesStudy/pages/exam-start/components/question-options.vue
  17. 5 3
      src/pagesStudy/pages/exam-start/components/question-parse.vue
  18. 3 2
      src/pagesStudy/pages/exam-start/exam-start.vue
  19. 8 1
      src/pagesStudy/pages/index/compoentns/index-banner.vue
  20. 3 2
      src/pagesStudy/pages/knowledge-practice-detail/knowledge-practice-detail.vue
  21. 3 3
      src/pagesStudy/pages/knowledge-practice-history/knowledge-practice-history.vue
  22. 1 1
      src/pagesStudy/pages/knowledge-practice/knowledge-practice.vue
  23. 64 0
      src/pagesStudy/pages/textbooks-practice-history/textbooks-practice-history.vue
  24. 75 0
      src/pagesStudy/pages/textbooks-practice/textbooks-practice.vue
  25. 7 3
      src/pagesSystem/pages/bind-profile/bind-profile.vue
  26. 5 1
      src/pagesSystem/pages/login/login.vue
  27. 26 1
      src/store/userStore.ts
  28. 13 1
      src/types/index.ts
  29. 0 1
      src/types/study.ts
  30. 1 0
      src/types/transfer.ts
  31. 192 0
      src/uni_modules/mp-html/README.md
  32. 156 0
      src/uni_modules/mp-html/changelog.md
  33. 80 0
      src/uni_modules/mp-html/components/mp-html/latex/index.js
  34. 0 0
      src/uni_modules/mp-html/components/mp-html/latex/katex.min.js
  35. 499 0
      src/uni_modules/mp-html/components/mp-html/mp-html.vue
  36. 425 0
      src/uni_modules/mp-html/components/mp-html/node/node.vue
  37. 1400 0
      src/uni_modules/mp-html/components/mp-html/parser.js
  38. 79 0
      src/uni_modules/mp-html/package.json
  39. 0 0
      src/uni_modules/mp-html/static/app-plus/mp-html/js/handler.js
  40. 0 0
      src/uni_modules/mp-html/static/app-plus/mp-html/js/uni.webview.min.js
  41. 1 0
      src/uni_modules/mp-html/static/app-plus/mp-html/local.html
  42. 228 0
      src/utils/loadFont.ts

+ 5 - 1
package.json

@@ -39,10 +39,13 @@
     "@dcloudio/uni-components": "3.0.0-4070620250821001",
     "@dcloudio/uni-h5": "3.0.0-4070620250821001",
     "@dcloudio/uni-mp-weixin": "3.0.0-4070620250821001",
+    "@rojer/katex-mini": "^1.3.4",
     "@vueuse/core": "^11.2.0",
     "echarts": "^6.0.0",
     "flyio": "^0.6.14",
+    "katex": "^0.16.25",
     "lodash": "^4.17.21",
+    "mp-html": "^2.5.1",
     "pinia": "2",
     "pinia-plugin-persistedstate": "^4.3.0",
     "uqrcodejs": "^4.0.7",
@@ -68,5 +71,6 @@
     "vite": "5.2.8",
     "vite-plugin-compression": "^0.5.1",
     "vite-plugin-uni-polyfill": "^0.1.0"
-  }
+  },
+  "packageManager": "yarn@1.22.22+sha1.ac34549e6aa8e7ead463a7407e1c7390f61a6610"
 }

+ 0 - 1
src/api/flyio.ts

@@ -3,7 +3,6 @@ import Fly from "flyio/dist/npm/wx"
 import config from "@/config";
 import { useUserStore } from '@/store/userStore';
 const { serverBaseUrl } = config;
-console.log(serverBaseUrl)
 const requestConfig = {
   baseURL: serverBaseUrl,
   timeout: 30000,

+ 20 - 2
src/api/modules/study.ts

@@ -68,6 +68,15 @@ export function getKnowledgeList(params: KnowledgeListRequestDTO) {
   return flyio.get('/front/paper/knowledge', params) as Promise<ApiResponse<Knowledge[]>>;
 }
 
+/**
+ * 获取教材同步知识点
+ * @param params 
+ * @returns 
+ */
+export function getTextbooksKnowledgeList() {
+  return flyio.get('/front/paper/courseKnowledge', {}) as Promise<ApiResponse<Knowledge[]>>;
+}
+
 /**
  * 开卷
  * @param params 
@@ -203,6 +212,15 @@ export function correctQuestion(params: { questionid: number, remark: string })
  * @param params 
  * @returns 
  */
-export function getPracticeHistory(params: { directed: boolean }) {
-  return flyio.get('/front/student/record/practice', params) as Promise<ApiResponseList<PracticeHistory>>;
+export function getPracticeHistory() {
+  return flyio.get('/front/student/record/practice', {}) as Promise<ApiResponseList<PracticeHistory>>;
+}
+
+/**
+ * 获取教材同步练习记录
+ * @param params 
+ * @returns 
+ */
+export function getTextbooksPracticeHistory() {
+  return flyio.get('/front/student/record/coursePractice', {}) as Promise<ApiResponseList<PracticeHistory>>;
 }

+ 5 - 1
src/common/enum.ts

@@ -241,7 +241,11 @@ export enum EnumPaperType {
   /**
    * 考试
    */
-  SIMULATED = 'Simulated'
+  SIMULATED = 'Simulated',
+  /**
+   * 教材同步练习
+   */
+  COURSE = 'Course'
 }
 
 export enum EnumReviewMode {

+ 4 - 0
src/components/ie-picker/ie-picker.vue

@@ -182,6 +182,10 @@ const handleClick = () => {
   if (props.disabled) {
     return;
   }
+  if (!props.list.length) {
+    uni.$ie.showToast('暂无数据');
+    return;
+  }
   init();
   isOpen.value = true;
   pickerRef.value.open();

+ 100 - 100
src/components/mx-tabs-swiper/mx-tabs-swiper.vue

@@ -1,65 +1,66 @@
 <template>
-    <!-- NOTE:min-h-1在使用useElementSize时非常重要 -->
-    <view ref="container" class="h-full fx-col flex-1 min-h-1">
-        <uv-tabs :current="current" :list="tabs" :key-name="keyName" v-bind="tabBindings"
-                 class="bg-white bd-b-1" @change="handleTabChange">
-            <template v-if="$slots.tab" #default="scope">
-                <slot name="tab" v-bind="scope"/>
-            </template>
-        </uv-tabs>
-        <uv-line v-if="border"/>
-        <view>
-          <slot name="header"></slot>
-        </view>
-        <view class="flex-1 min-h-1">
-          <!-- :style="{height: swiperHeight+'px'}" -->
-          <swiper :current="current" class="h-full" v-bind="swiperBindings"
-                @change="handleSwiperChange">
-            <swiper-item v-for="t in tabs" :key="t.name">
-                <!-- 延迟渲染 -->
-                <!-- 如果配置相同的template可共享卡槽 -->
-                <!-- 内容如果要支持滚动,请使用css class - .tabs-swiper-content -->
-                <keep-alive v-if="lazy">
-                    <slot v-if="t.show" :name="t.template||template||t.name" v-bind="t"/>
-                </keep-alive>
-                <slot v-else :name="t.template||template||t.name" v-bind="t"/>
-            </swiper-item>
+  <!-- NOTE:min-h-1在使用useElementSize时非常重要 -->
+  <view ref="container" class="h-full fx-col flex-1 min-h-1">
+    <uv-tabs :current="current" :list="tabs" :key-name="keyName" v-bind="tabBindings" class="bg-white bd-b-1"
+      @change="handleTabChange">
+      <template v-if="$slots.tab" #default="scope">
+        <slot name="tab" v-bind="scope" />
+      </template>
+    </uv-tabs>
+    <uv-line v-if="border" />
+    <view>
+      <slot name="header"></slot>
+    </view>
+    <view class="flex-1 min-h-1 relative">
+      <!-- :style="{height: swiperHeight+'px'}" -->
+      <view class="absolute inset-0">
+        <swiper :current="current" class="h-full" v-bind="swiperBindings" @change="handleSwiperChange">
+          <swiper-item v-for="t in tabs" :key="t.name">
+            <!-- 延迟渲染 -->
+            <!-- 如果配置相同的template可共享卡槽 -->
+            <!-- 内容如果要支持滚动,请使用css class - .tabs-swiper-content -->
+            <keep-alive v-if="lazy">
+              <slot v-if="t.show" :name="t.template || template || t.name" v-bind="t" />
+            </keep-alive>
+            <slot v-else :name="t.template || template || t.name" v-bind="t" />
+          </swiper-item>
         </swiper>
-        </view>
+      </view>
     </view>
+  </view>
 </template>
 
 <script setup>
-import {ref, computed, watch} from 'vue'
+import { ref, computed, watch } from 'vue'
 import _ from "lodash";
-import {createPropDefine} from "@/utils";
-import {useElementSize} from "@vueuse/core";
+import { createPropDefine } from "@/utils";
+import { useElementSize } from "@vueuse/core";
 
 const props = defineProps({
-    // support synchronization
-    modelValue: createPropDefine(0, Number),
-    tabs: createPropDefine([], Array),
-    keyName: createPropDefine('name'),
-    // other options of uv-tabs
-    tabOptions: createPropDefine(null, Object),
-    // other uni-app swiper options
-    swiperOptions: createPropDefine(null, Object),
-    // support lazy rendering, default is true
-    lazy: createPropDefine(true, Boolean),
-    // whether cache all tabs? if not use cacheSize, higher priority than cacheSize
-    cacheAll: createPropDefine(false, Boolean),
-    // cached accessed tab. <=0 means cache all.
-    cacheSize: createPropDefine(5, Number),
-    // tab content will render after delay time, recommended for complex dynamic content.
-    // related to lazy, default is true
-    delay: createPropDefine(true, Boolean),
-    preload: createPropDefine(true, Boolean), // 一般情况下preload会非常平滑,但有大请求时可以禁用
-    // bigger than the animation time。 default 400ms.
-    delayTime: createPropDefine(400, Number),
-    border: createPropDefine(false, Boolean),
-    tabsHeight: createPropDefine(44, Number),
-    // 统一指定模板,优先级低于tab.template,因为有时候让tab指定template会破坏原数据结构
-    template: createPropDefine('')
+  // support synchronization
+  modelValue: createPropDefine(0, Number),
+  tabs: createPropDefine([], Array),
+  keyName: createPropDefine('name'),
+  // other options of uv-tabs
+  tabOptions: createPropDefine(null, Object),
+  // other uni-app swiper options
+  swiperOptions: createPropDefine(null, Object),
+  // support lazy rendering, default is true
+  lazy: createPropDefine(true, Boolean),
+  // whether cache all tabs? if not use cacheSize, higher priority than cacheSize
+  cacheAll: createPropDefine(false, Boolean),
+  // cached accessed tab. <=0 means cache all.
+  cacheSize: createPropDefine(5, Number),
+  // tab content will render after delay time, recommended for complex dynamic content.
+  // related to lazy, default is true
+  delay: createPropDefine(true, Boolean),
+  preload: createPropDefine(true, Boolean), // 一般情况下preload会非常平滑,但有大请求时可以禁用
+  // bigger than the animation time。 default 400ms.
+  delayTime: createPropDefine(400, Number),
+  border: createPropDefine(false, Boolean),
+  tabsHeight: createPropDefine(44, Number),
+  // 统一指定模板,优先级低于tab.template,因为有时候让tab指定template会破坏原数据结构
+  template: createPropDefine('')
 })
 const emits = defineEmits(['update:modelValue', 'change'])
 
@@ -67,69 +68,68 @@ const emits = defineEmits(['update:modelValue', 'change'])
 const current = ref(0)
 const cachedTabs = ref([])
 const container = ref(null)
-const {height} = useElementSize(container)
+const { height } = useElementSize(container)
 const swiperHeight = computed(() => height.value - props.tabsHeight - (props.border ? 1 : 0))
 
 const tabBindings = computed(() => {
-    // make some default options for u-tabs here
-    return {
-        scrollable: props.tabs.length > 4,
-        itemStyle: {height: props.tabsHeight + 'px'},
-        ...props.tabOptions
-    }
+  // make some default options for u-tabs here
+  return {
+    scrollable: props.tabs.length > 4,
+    itemStyle: { height: props.tabsHeight + 'px' },
+    ...props.tabOptions
+  }
 })
 const swiperBindings = computed(() => {
-    // make some default options for uni-app swiper here
-    return {
-        ...props.swiperOptions
-    }
+  // make some default options for uni-app swiper here
+  return {
+    ...props.swiperOptions
+  }
 })
 
 // 保持localCurrent与props.current同步,即props优先级更高
-watch(() => props.modelValue, (val) => current.value = val, {immediate: true})
+watch(() => props.modelValue, (val) => current.value = val, { immediate: true })
 
 watch([current, () => props.tabs], ([current]) => {
-    const {cacheAll, cacheSize, tabs, delay, preload, delayTime} = props
-    if (!tabs.length) return // no data, wait for tabs ready.
+  const { cacheAll, cacheSize, tabs, delay, preload, delayTime } = props
+  if (!tabs.length) return // no data, wait for tabs ready.
 
-    // delay control, first tab must render immediately.
-    const effectDelay = cachedTabs.value.length && delay ? delayTime : 0
+  // delay control, first tab must render immediately.
+  const effectDelay = cachedTabs.value.length && delay ? delayTime : 0
 
-    // cache tabs, keep accessed sequence.
-    const next = [current]
-    if (preload) {
-        // keep current -1 +1 in cache list while preload=true.
-        // this will make swiper action much-much smooth.
-        if (current + 1 < tabs.length) next.unshift(current + 1)
-        if (current - 1 >= 0) next.unshift(current - 1)
-    }
-    _.pull(cachedTabs.value, ...next)
-    cachedTabs.value.push(...next)
-    while (!cacheAll && cacheSize && cachedTabs.value.length > cacheSize) {
-        cachedTabs.value.shift()
-    }
+  // cache tabs, keep accessed sequence.
+  const next = [current]
+  if (preload) {
+    // keep current -1 +1 in cache list while preload=true.
+    // this will make swiper action much-much smooth.
+    if (current + 1 < tabs.length) next.unshift(current + 1)
+    if (current - 1 >= 0) next.unshift(current - 1)
+  }
+  _.pull(cachedTabs.value, ...next)
+  cachedTabs.value.push(...next)
+  while (!cacheAll && cacheSize && cachedTabs.value.length > cacheSize) {
+    cachedTabs.value.shift()
+  }
 
-    // reset show property of all tabs
-    if (effectDelay) {
-        setTimeout(() => {
-            tabs.forEach((t, i) => t.show = cachedTabs.value.includes(i))
-        }, effectDelay)
-    } else {
-        tabs.forEach((t, i) => t.show = cachedTabs.value.includes(i))
-    }
-}, {immediate: true})
+  // reset show property of all tabs
+  if (effectDelay) {
+    setTimeout(() => {
+      tabs.forEach((t, i) => t.show = cachedTabs.value.includes(i))
+    }, effectDelay)
+  } else {
+    tabs.forEach((t, i) => t.show = cachedTabs.value.includes(i))
+  }
+}, { immediate: true })
 
-const handleTabChange = function ({index}) {
-    current.value = index
-    emits('update:modelValue', current.value)
-    emits('change', current.value)
+const handleTabChange = function ({ index }) {
+  current.value = index
+  emits('update:modelValue', current.value)
+  emits('change', current.value)
 }
 const handleSwiperChange = function (e) {
-    current.value = e.detail.current
-    emits('update:modelValue', current.value)
-    emits('change', current.value)
+  current.value = e.detail.current
+  emits('update:modelValue', current.value)
+  emits('change', current.value)
 }
 </script>
 
-<style>
-</style>
+<style></style>

+ 82 - 1
src/composables/useExam.ts

@@ -3,6 +3,85 @@ import { getPaper } from '@/api/modules/study';
 import { Study } from "@/types";
 import { Question } from "@/types/study";
 
+/**
+ * @description 解码 HTML 实体
+ * 由于 uv-parse 的 decodeEntity 只支持有限的实体,需要手动解码音标等特殊实体
+ * 使用手动映射表,兼容所有 uni-app 平台(包括小程序)
+ */
+export const decodeHtmlEntities = (str: string): string => {
+  if (!str) return str;
+
+  // 音标和常用 HTML 实体映射表
+  const entityMap: Record<string, string> = {
+    // 音标相关 - 锐音符 (acute)
+    'aacute': 'á',
+    'eacute': 'é',
+    'iacute': 'í',
+    'oacute': 'ó',
+    'uacute': 'ú',
+    // 音标相关 - 重音符 (grave)
+    'agrave': 'à',
+    'egrave': 'è',
+    'igrave': 'ì',
+    'ograve': 'ò',
+    'ugrave': 'ù',
+    // 音标相关 - 扬抑符 (circumflex)
+    'acirc': 'â',
+    'ecirc': 'ê',
+    'icirc': 'î',
+    'ocirc': 'ô',
+    'ucirc': 'û',
+    // 音标相关 - 分音符 (umlaut/diaeresis)
+    'auml': 'ä',
+    'euml': 'ë',
+    'iuml': 'ï',
+    'ouml': 'ö',
+    'uuml': 'ü',
+    // 音标相关 - 波浪符 (tilde)
+    'ntilde': 'ñ',
+    'atilde': 'ã',
+    'otilde': 'õ',
+    // 其他常用实体
+    'amp': '&',
+    'lt': '<',
+    'gt': '>',
+    'quot': '"',
+    'apos': "'",
+    'nbsp': '\u00A0',
+    'copy': '©',
+    'reg': '®',
+    'trade': '™',
+    'mdash': '—',
+    'ndash': '–',
+    'hellip': '…',
+    // 数学符号
+    'times': '×',
+    'divide': '÷',
+    'plusmn': '±',
+  };
+
+  // 处理命名实体(如 &iacute;)
+  // 使用 [a-z0-9] 以支持包含数字的实体名称
+  let result = str.replace(/&([a-z0-9]+);/gi, (match, entity) => {
+    const lowerEntity = entity.toLowerCase();
+    if (entityMap[lowerEntity]) {
+      return entityMap[lowerEntity];
+    }
+    return match; // 如果找不到映射,保持原样
+  });
+
+  // 处理数字实体(如 &#237; 或 &#xED;)
+  result = result.replace(/&#(\d+);/g, (match, num) => {
+    return String.fromCharCode(parseInt(num, 10));
+  });
+
+  result = result.replace(/&#x([0-9a-f]+);/gi, (match, hex) => {
+    return String.fromCharCode(parseInt(hex, 16));
+  });
+
+  return result;
+}
+
 export const useExam = () => {
   const questionTypeDesc: Record<EnumQuestionType, string> = {
     [EnumQuestionType.SINGLE_CHOICE]: '单选题',
@@ -465,8 +544,10 @@ export const useExam = () => {
         answers: item.answers || [],
         subQuestions: item.subQuestions?.map(transerQuestion) || [],
         options: item.options?.map((option, index) => {
+          // 移除选项编号(如 A.)并解码 HTML 实体(如 &iacute; → í)
+          const cleanedOption = option.replace(/[A-Z]\./g, '').replace(/\s/g, ' ');
           return {
-            name: option,
+            name: decodeHtmlEntities(cleanedOption),
             no: orders[index],
             id: index,
             isAnswer: false,

+ 5 - 1
src/config.ts

@@ -6,8 +6,12 @@ const config = {
   serverBaseUrl: '',
   paySiteUrl: '',
   responseErrorCatch: true,
+  defaultOrg: {
+    contactPhone: '400-1797-985',
+    logo: '',
+    orgName: '单招一卡通',
+  }
 };
-console.log(process.env.IE_ENV)
 export const env = {
   development: {
     serverBaseUrl: 'https://dz.shineking.top/prod-api',

+ 12 - 0
src/pages.json

@@ -667,6 +667,18 @@
           "style": {
             "navigationBarTitleText": ""
           }
+        },
+        {
+          "path": "pages/textbooks-practice/textbooks-practice",
+          "style": {
+            "navigationBarTitleText": ""
+          }
+        },
+        {
+          "path": "pages/textbooks-practice-history/textbooks-practice-history",
+          "style": {
+            "navigationBarTitleText": ""
+          }
         }
       ]
     }

+ 0 - 1
src/pagesMain/pages/index/components/index-banner.vue

@@ -43,7 +43,6 @@ const navigateTo = async (item: MenuItem) => {
   }
 }
 const validMenus = computed(() => {
-  console.log(userStore.isAuditor)
   const menus: MenuItem[] = [
     {
       name: '学习备考',

+ 2 - 2
src/pagesMain/pages/index/index.vue

@@ -3,7 +3,7 @@
     <ie-navbar transparent bg-color="#FFFFFF" :placeholder="false" custom-back :click-hover="false">
       <template #headerLeft>
         <view class="flex items-center">
-          <view class="text-36 text-fore-title font-bold">单招一卡通</view>
+          <view class="text-36 text-fore-title font-bold">{{ orgName }}</view>
           <view class="w-6 h-6 rounded-2 bg-black mx-12"></view>
           <view>升学备考好帮手</view>
           <view v-if="userStore.getLocation" class="ml-10 flex items-center gap-x-4" @click="handleChangeLocation">
@@ -18,7 +18,6 @@
       <index-banner />
       <index-guide @detail="handleDetail" />
       <index-news @detail="handleDetail" />
-
     </view>
     <template #tabbar>
       <ie-tabbar :active="0" />
@@ -44,6 +43,7 @@ const { baseStickyTop } = useNavbar();
 const scrollTop = ref(0);
 const isHide = ref(false);
 const userStore = useUserStore();
+const orgName = computed(() => userStore.orgInfo.orgName);
 
 const handleDetail = async (id: number | string, title?: string) => {
   if (('' + id).includes(',')) {

+ 13 - 3
src/pagesMain/pages/splash/splash.vue

@@ -16,6 +16,9 @@ import { useAppStore } from '@/store/appStore';
 import { useAppConfig } from '@/hooks/useAppConfig';
 import { useUserStore } from '@/store/userStore';
 import { useTransferPage } from '@/hooks/useTransferPage';
+import { load } from '@/utils/loadFont';
+
+const splashTimeout = 1200;
 const appStore = useAppStore();
 const userStore = useUserStore();
 const { transferTo } = useTransferPage();
@@ -32,12 +35,19 @@ const handleLoad = () => {
     });
   }
   // 执行初始化的操作:预加载字典、提前校验token是否有效等
-  appStore.init().then(() => {
-    setTimeout(() => {
+  appStore.init().then(async () => {
+    try {
+      const usedTime = await load();
+      if (usedTime < splashTimeout) {
+        await new Promise(resolve => setTimeout(resolve, splashTimeout - usedTime));
+      }
+    } catch (error) {
+      console.error('初始化失败: ', error);
+    } finally {
       transferTo('/pagesMain/pages/index/index', {
         type: 'reLaunch'
       });
-    }, 1200);
+    }
   });
 };
 onLoad(() => {

+ 87 - 86
src/pagesOther/pages/personal-center/setting/setting.vue

@@ -1,104 +1,105 @@
 <template>
-    <view class="page-content">
-        <mx-nav-bar title="关于"/>
-        <view class="fx-col fx-cen-cen py-80">
-            <uv-image src="/static/logo.png" width="80" height="auto" mode="widthFix"/>
-        </view>
-        <uv-cell-group class="bg-white !flex-none">
-            <uv-cell v-for="s in settings" v-bind="s" @click="s.handler()"/>
-        </uv-cell-group>
+  <view class="page-content">
+    <mx-nav-bar title="关于" />
+    <view class="fx-col fx-cen-cen py-80">
+      <uv-image src="/static/logo.png" width="80" height="auto" mode="widthFix" />
     </view>
+    <uv-cell-group class="bg-white !flex-none">
+      <uv-cell v-for="s in settings" v-bind="s" @click="s.handler()" />
+    </uv-cell-group>
+  </view>
 </template>
 
 <script setup>
-import {onMounted, reactive} from 'vue'
-import {useTransfer} from "@/hooks/useTransfer";
-import {useCacheStore} from "@/hooks/useCacheStore";
-import {useEnvStore} from "@/hooks/useEnvStore";
-import {confirmAsync} from "@/utils/uni-helper";
-import {useUserStore} from "@/hooks/useUserStore";
-import {sizeFormat} from "@/utils";
+import { onMounted, reactive } from 'vue'
+import { useTransfer } from "@/hooks/useTransfer";
+import { useCacheStore } from "@/hooks/useCacheStore";
+import { useEnvStore } from "@/hooks/useEnvStore";
+import { confirmAsync } from "@/utils/uni-helper";
+import { useUserStore } from "@/hooks/useUserStore";
+import { sizeFormat } from "@/utils";
+import { useUserStore as newUserStore } from '@/store/userStore';
 
-const {transferToProtocolUser, transferToProtocolPrivacy, transferToIndex, cleanAllTransferCacheData} = useTransfer()
-const {getSize: getCacheSize, gcCache} = useCacheStore()
-const {systemInfo} = useEnvStore()
-const {LogoutPhysical, GetInfo, isLogin} = useUserStore()
+const { transferToProtocolUser, transferToProtocolPrivacy, transferToIndex, cleanAllTransferCacheData } = useTransfer()
+const { getSize: getCacheSize, gcCache } = useCacheStore()
+const { systemInfo } = useEnvStore()
+const { LogoutPhysical, GetInfo, isLogin } = useUserStore()
+const { orgInfo } = newUserStore()
 
-const settings = reactive([
-    {
-        title: '服务协议',
-        icon: '/static/personal/icon-yonghuxieyi@2x.png',
-        isLink: true,
-        handler: () => transferToProtocolUser()
-    },
-    {
-        title: '隐私政策',
-        icon: '/static/personal/icon-yinxixieyi@2x.png',
-        isLink: true,
-        handler: () => transferToProtocolPrivacy()
-    },
-    {
-        title: '清除缓存', //icon_cache
-        icon: '/static/personal/icon_cache.png',
-        rightIcon: 'reload',
-        isLink: true,
-        handler: () => {
-            cleanAllTransferCacheData() // 清理页面大对象
-            GetInfo() // GetInfo也会清除缓存,借机也更新一下用户信息
-            calculateCacheSize()
-        }
-    },
-    {
-        title: '当前版本',
-        icon: '/static/personal/icon-dangqianbanben@2x.png',
-        value: systemInfo.value.appVersion,
-        handler: () => {
-        }
-    },
-    {
-        title: '注销账号',
-        icon: 'trash',
-        isLink: true,
-        handler: async () => {
-          if (!isLogin.value) {
-            const msg = '当前未登录'
-            await confirmAsync(msg)
-            return;
-          }
-            const msg = '是否注销账号,注销后卡号将不能再注册使用系统且永久失效。请谨慎操作!'
-            await confirmAsync(msg)
-            await LogoutPhysical()
-            transferToIndex()
-        }
-    },
-    {
-        title: 'APP备案号',
-        icon: 'empty-permission',
-        value: '湘ICP备18012964号-8A',
-        handler: () => {
-        }
-    },
-    {
-        title: '客服电话',
-        icon: 'server-man',
-        value: '400-1797-985',
-        handler: () => {
-        }
+const settings = computed(() => [
+  {
+    title: '服务协议',
+    icon: '/static/personal/icon-yonghuxieyi@2x.png',
+    isLink: true,
+    handler: () => transferToProtocolUser()
+  },
+  {
+    title: '隐私政策',
+    icon: '/static/personal/icon-yinxixieyi@2x.png',
+    isLink: true,
+    handler: () => transferToProtocolPrivacy()
+  },
+  {
+    title: '清除缓存', //icon_cache
+    icon: '/static/personal/icon_cache.png',
+    rightIcon: 'reload',
+    isLink: true,
+    value: calculateCacheSize(),
+    handler: () => {
+      cleanAllTransferCacheData() // 清理页面大对象
+      GetInfo() // GetInfo也会清除缓存,借机也更新一下用户信息
+      calculateCacheSize()
     }
+  },
+  {
+    title: '当前版本',
+    icon: '/static/personal/icon-dangqianbanben@2x.png',
+    value: systemInfo.value.appVersion,
+    handler: () => {
+    }
+  },
+  {
+    title: '注销账号',
+    icon: 'trash',
+    isLink: true,
+    handler: async () => {
+      if (!isLogin.value) {
+        const msg = '当前未登录'
+        await confirmAsync(msg)
+        return;
+      }
+      const msg = '是否注销账号,注销后卡号将不能再注册使用系统且永久失效。请谨慎操作!'
+      await confirmAsync(msg)
+      await LogoutPhysical()
+      transferToIndex()
+    }
+  },
+  {
+    title: 'APP备案号',
+    icon: 'empty-permission',
+    value: '湘ICP备18012964号-8A',
+    handler: () => {
+    }
+  },
+  {
+    title: '客服电话',
+    icon: 'server-man',
+    value: orgInfo.contactPhone,
+    handler: () => {
+    }
+  }
 ])
 
-onMounted(() => calculateCacheSize())
-
 const calculateCacheSize = function () {
-    gcCache()
-    let size = getCacheSize()
-    settings[2].value = sizeFormat(size)
+  gcCache()
+  const size = getCacheSize()
+  return sizeFormat(size)
 }
 </script>
 
 <style scoped>
 ::v-deep .uv-cell__body {
-    font-size: 16px;
-    padding: 12px 15px;
+  font-size: 16px;
+  padding: 12px 15px;
 }
 </style>

+ 42 - 38
src/pagesStudy/components/knowledge-tree-node.vue

@@ -9,7 +9,7 @@
             <view class="block text-28 text-fore-title font-bold ellipsis-1">{{ nodeData.name }}</view>
             <view class="mt-4 text-24 text-fore-light flex items-center">
               <progress class="w-100 rounded-full overflow-hidden" :percent="getProgressPercent(nodeData)"
-                :show-text="false" activeColor="#31a0fc" backgroundColor="#E3F4FA" />
+                :show-text="false" activeColor="#31a0fc" backgroundColor="#efefef" />
               <text class="ml-10 text-primary">{{ nodeData.finishedCount }}</text>
               <text>/{{ nodeData.questionCount }}道</text>
               <text class="ml-10">正确率</text>
@@ -26,12 +26,11 @@
     </view>
 
     <!-- 子节点容器 -->
-    <view v-if="nodeData.children && nodeData.children.length > 0"
-      :class="['ml-40 overflow-hidden transition-all duration-300 h-0']"
-      :style="{ height: nodeData.actualHeight + 'px' }">
+    <view v-if="nodeData.children && nodeData.children.length > 0" :id="`knowledge-children-${nodeData.id}`"
+      :class="['ml-40 overflow-hidden transition-all duration-300', { 'is-measuring': isMeasuringHeight }]"
+      :style="{ height: measuredHeight + 'px' }">
       <knowledge-tree-node v-for="child in nodeData.children" :key="child.name" :node-data="child"
-        :parent-data="nodeData" @node-click="handleNodeClick" @update-height="handleUpdateHeight"
-        @start-practice="handleChildStartPractice">
+        :parent-data="nodeData" @node-click="handleNodeClick" @start-practice="handleChildStartPractice">
         <template #default>
           <slot></slot>
         </template>
@@ -54,9 +53,10 @@ const props = defineProps({
     default: null
   }
 });
-
+const isMeasuringHeight = ref(false);
+const measuredHeight = ref(0);
 // 定义 emits
-const emit = defineEmits(['nodeClick', 'updateHeight', 'startPractice']);
+const emit = defineEmits(['nodeClick', 'startPractice']);
 
 // 计算子节点高度
 const calculateChildrenHeight = (node: Study.KnowledgeNode): number => {
@@ -78,28 +78,38 @@ const calculateChildrenHeight = (node: Study.KnowledgeNode): number => {
   return height;
 };
 
+const getRect = (selector: string) => {
+  return new Promise((resolve: (rect: { top: number, height: number }) => void) => {
+    const query = uni.createSelectorQuery();
+    query.select(selector).boundingClientRect(function (rect) {
+      resolve(rect as { top: number, height: number });
+    }).exec();
+  });
+}
+
+const measureHeight = () => {
+  isMeasuringHeight.value = true;
+  nextTick(() => {
+    getRect(`#knowledge-children-${props.nodeData.id}`).then((res: any) => {
+      isMeasuringHeight.value = false;
+      setTimeout(() => {
+        nextTick(() => {
+          measuredHeight.value = res?.height ?? 0;
+        });
+      }, 0);
+    });
+  });
+}
+
 // 处理节点点击
 const handleClick = () => {
 
   // 切换展开状态
   props.nodeData.isExpanded = !props.nodeData.isExpanded;
-
-  // 计算并设置实际高度
   if (props.nodeData.isExpanded) {
-    props.nodeData.actualHeight = calculateChildrenHeight(props.nodeData);
+    measureHeight();
   } else {
-    props.nodeData.actualHeight = 0;
-  }
-
-  // 通知父组件节点被点击
-  emit('nodeClick', {
-    node: props.nodeData,
-    parent: props.parentData
-  });
-
-  // 如果有父节点,通知父节点更新高度
-  if (props.parentData) {
-    emit('updateHeight', props.parentData);
+    measuredHeight.value = 0;
   }
 };
 
@@ -119,20 +129,6 @@ const handleNodeClick = (eventData: { node: Study.KnowledgeNode; parent: Study.K
   emit('nodeClick', eventData);
 };
 
-// 处理高度更新事件
-const handleUpdateHeight = (parentNode: Study.KnowledgeNode) => {
-
-  // 重新计算父节点高度
-  if (parentNode.isExpanded) {
-    parentNode.actualHeight = calculateChildrenHeight(parentNode);
-  }
-
-  // 继续向上传递高度更新事件
-  if (props.parentData) {
-    emit('updateHeight', props.parentData);
-  }
-};
-
 const getCorrectRate = (rate: number): number => {
   if (!rate) {
     return 0;
@@ -152,4 +148,12 @@ const getProgressPercent = (nodeData: Study.KnowledgeNode): number => {
 };
 </script>
 
-<style lang="scss" scoped></style>
+<style lang="scss" scoped>
+.is-measuring {
+  opacity: 0;
+  position: fixed;
+  z-index: -1;
+  transform: translateX(-100%);
+  height: auto !important;
+}
+</style>

+ 6 - 15
src/pagesStudy/components/knowledge-tree.vue

@@ -1,7 +1,7 @@
 <template>
   <view class="knowledge-tree">
     <knowledge-tree-node v-for="item in initializedData" :key="item.id" :node-data="item"
-      @node-click="handleNodeClick" @update-height="handleUpdateHeight" @start-practice="handleStartPractice">
+      @node-click="handleNodeClick" @start-practice="handleStartPractice">
       <template #default>
         <slot></slot>
       </template>
@@ -12,14 +12,14 @@
 import * as Study from '@/types/study';
 import KnowledgeTreeNode from './knowledge-tree-node.vue';
 // 定义 emits
-const emit = defineEmits(['update:treeData', 'nodeClick', 'startPractice']);
+const emit = defineEmits(['nodeClick', 'startPractice']);
 
 // 定义 props
 const props = defineProps({
   treeData: {
     type: Array as PropType<Study.KnowledgeNode[]>,
     default: () => []
-  }
+  },
 });
 
 // 初始化后的数据
@@ -33,22 +33,19 @@ const ensureItemProperties = (item: Study.KnowledgeNode) => {
   if (item.isLeaf === undefined) {
     item.isLeaf = !item.children || item.children.length === 0;
   }
-  if (item.actualHeight === undefined) {
-    item.actualHeight = 0;
-  }
   // 递归处理子项
   item.children?.forEach(child => ensureItemProperties(child));
 }
 
 // 初始化数据
 const initializeData = (sourceData: Study.KnowledgeNode[]): Study.KnowledgeNode[] => {
-  return sourceData.map(item => {
+  return sourceData.map((item, index) => {
+    const oldItem = initializedData.value[index];
     const children = initializeData(item.children || []);
     return {
       ...item,
-      isExpanded: item.isExpanded ?? false,
+      isExpanded: item.id === oldItem?.id ? oldItem?.isExpanded ?? false : false,
       isLeaf: !item.children || item.children.length === 0,
-      actualHeight: item.actualHeight ?? 0,
       children
     };
   });
@@ -60,12 +57,6 @@ const handleNodeClick = (eventData: { node: Study.KnowledgeNode; parent: Study.K
   emit('nodeClick', eventData);
 };
 
-// 处理高度更新事件
-const handleUpdateHeight = (node: Study.KnowledgeNode) => {
-  // 通知父组件数据已更新
-  emit('update:treeData', initializedData.value);
-};
-
 // 处理开始练习事件
 const handleStartPractice = (node: Study.KnowledgeNode) => {
   emit('startPractice', node);

+ 10 - 8
src/pagesStudy/pages/exam-start/components/question-options.vue

@@ -1,7 +1,7 @@
 <template>
   <view class="question-options">
-    <view class="question-option" v-for="option in question.options" :class="getStyleClass(option)" :key="option.id"
-      @click="handleSelect(option)">
+    <view class="question-option" v-for="option in question.options" :class="getStyleClass(option)"
+      :key="question.id + '_' + option.id" @click="handleSelect(option)">
       <template v-if="!isReadOnly">
         <view v-if="!isOnlySubjective" class="question-option-index">{{ option.no }}</view>
         <view v-else>
@@ -15,8 +15,7 @@
         <view v-else class="question-option-index">{{ option.no }}</view>
       </view>
       <view class="question-option-content">
-        <uv-parse :content="getOptionContent(option)" containerStyle="display:inline"
-          contentStyle="word-break:break-word;"></uv-parse>
+        <mp-html :content="option.name" />
       </view>
     </view>
     <!-- 添加不会选项 -->
@@ -40,7 +39,7 @@
       </view>
       <view class="mt-30 mb-20 text-24 text-white bg-primary w-fit mx-auto px-20 py-12 rounded-full text-center"
         @click="handleShowParse">
-        查看解析
+        {{ question.showParse ? '收起解析' : '查看解析' }}
       </view>
     </view>
   </view>
@@ -111,9 +110,12 @@ const getStyleClass = (option: Study.QuestionOption) => {
   return customClass;
 };
 
-const getOptionContent = (option: Study.QuestionOption) => {
-  // sb 问题,浪费几个小时
-  return option.name.replace(/\s/g, ' ');
+/**
+ * 获取选项内容
+ * HTML 实体解码已在 useExam 的 setQuestionList 中统一处理
+ */
+const getOptionContent = (optionContent: string) => {
+  return optionContent || '';
 }
 
 // 多选题要手动提交才能认为是作答结束

+ 5 - 3
src/pagesStudy/pages/exam-start/components/question-parse.vue

@@ -4,18 +4,20 @@
     <view v-if="isOnlySubjective" class="mb-20">
       <view class="text-30 text-fore-title font-bold">答案</view>
       <view class="mt-10 text-26 text-fore-light">
-        <uv-parse :content="question.answer2 || '略'"></uv-parse>
+        <!-- <uv-parse :content="question.answer2 || '略'"></uv-parse> -->
+        <mp-html :content="decodeHtmlEntities(question.answer2 || '略')" />
       </view>
     </view>
     <view class="text-30 text-fore-title font-bold">解析</view>
     <view class="mt-10 text-26 text-fore-light">
-      <uv-parse :content="question.parse || '暂无解析'"></uv-parse>
+      <!-- <uv-parse :content="question.parse || '暂无解析'"></uv-parse> -->
+      <mp-html :content="decodeHtmlEntities(question.parse || '暂无解析')" />
     </view>
   </view>
 </template>
 <script lang="ts" setup>
 import { EnumQuestionType, EnumReviewMode } from '@/common/enum';
-import { useExam } from '@/composables/useExam';
+import { useExam, decodeHtmlEntities } from '@/composables/useExam';
 import { Study, Transfer } from '@/types';
 import { EXAM_DATA, EXAM_PAGE_OPTIONS } from '@/types/injectionSymbols';
 

+ 3 - 2
src/pagesStudy/pages/exam-start/exam-start.vue

@@ -210,10 +210,11 @@ const handleSubmit = (tempSave: boolean = false) => {
           await nextTick();
           transferTo('/pagesStudy/pages/knowledge-practice-detail/knowledge-practice-detail', {
             data: {
+              paperType: prevData.value.paperType,
               examineeId: examineeId.value,
               name: prevData.value.practiceInfo?.name,
               directed: prevData.value.practiceInfo?.directed
-            },
+            } as Transfer.PracticeResultPageOptions,
             type: 'redirectTo'
           });
         }, 2500);
@@ -359,7 +360,7 @@ const handleGuideClose = () => {
 const loadData = async () => {
   uni.$ie.showLoading();
   const { paperType } = prevData.value;
-  if (paperType === EnumPaperType.PRACTICE) {
+  if (paperType === EnumPaperType.PRACTICE || paperType === EnumPaperType.COURSE) {
     loadPracticeData();
   } else if (paperType === EnumPaperType.SIMULATED) {
     loadSimulationData();

+ 8 - 1
src/pagesStudy/pages/index/compoentns/index-banner.vue

@@ -30,7 +30,10 @@
 import { useTransferPage } from '@/hooks/useTransferPage';
 import { getStudyPlan, getDirectedSchool } from '@/api/modules/study';
 import { OPEN_VIP_POPUP } from '@/types/injectionSymbols';
+import { useUserStore } from '@/store/userStore';
+
 const { transferTo } = useTransferPage();
+const userStore = useUserStore();
 const openVipPopup = inject(OPEN_VIP_POPUP);
 const handleOpenPlan = async () => {
   const { data } = await getStudyPlan();
@@ -45,7 +48,11 @@ const handleOpenPlan = async () => {
   }
 };
 const handleTest = () => {
-
+  if (userStore.isVip || userStore.isTeacher) {
+    transferTo('/pagesStudy/pages/textbooks-practice/textbooks-practice');
+  } else {
+    openVipPopup?.();
+  }
 };
 </script>
 <style lang="scss" scoped></style>

+ 3 - 2
src/pagesStudy/pages/knowledge-practice-detail/knowledge-practice-detail.vue

@@ -81,7 +81,7 @@ const handleQuestionDetail = (item: Study.Question) => {
   transferTo('/pagesStudy/pages/exam-start/exam-start', {
     data: {
       name: paperName.value,
-      paperType: EnumPaperType.PRACTICE,
+      paperType: prevData.value.paperType || EnumPaperType.PRACTICE,
       readonly: true,
       questionId: item.id,
       practiceInfo: {
@@ -96,12 +96,13 @@ const handleQuestionDetail = (item: Study.Question) => {
 const handleStartPractice = () => {
   const { knowledgeId } = examineeData.value || {};
   if (!knowledgeId) {
+    console.error('knowledgeId is null');
     return;
   }
   transferTo('/pagesStudy/pages/exam-start/exam-start', {
     data: {
       name: paperName.value,
-      paperType: EnumPaperType.PRACTICE,
+      paperType: prevData.value.paperType || EnumPaperType.PRACTICE,
       practiceInfo: {
         name: prevData.value.name,
         relateId: knowledgeId,

+ 3 - 3
src/pagesStudy/pages/knowledge-practice-history/knowledge-practice-history.vue

@@ -28,12 +28,14 @@ import { useTransferPage } from '@/hooks/useTransferPage';
 import { getPracticeHistory } from '@/api/modules/study';
 import { Study } from '@/types';
 import { Transfer } from '@/types';
+import { EnumPaperType } from '@/common/enum';
 const { prevData, transferTo } = useTransferPage<{}, Transfer.PracticeResultPageOptions>();
 const { baseStickyTop } = useNavbar();
 const historyList = ref<Study.PracticeHistory[]>([]);
 const handleViewHistory = (value: Study.PracticeHistory) => {
   transferTo('/pagesStudy/pages/knowledge-practice-detail/knowledge-practice-detail', {
     data: {
+      paperType: EnumPaperType.PRACTICE,
       examineeId: value.examineeId,
       name: value.paperName,
       directed: value.directed === 1
@@ -43,9 +45,7 @@ const handleViewHistory = (value: Study.PracticeHistory) => {
 const loadData = async () => {
   uni.$ie.showLoading();
   try {
-    const { rows } = await getPracticeHistory({
-      directed: true
-    });
+    const { rows } = await getPracticeHistory();
     historyList.value = rows.map(item => {
       return {
         ...item,

+ 1 - 1
src/pagesStudy/pages/knowledge-practice/knowledge-practice.vue

@@ -91,7 +91,7 @@ const handleStartPractice = async (node: Study.KnowledgeNode) => {
 watch(() => currentSubjectIndex.value, () => {
   loadKnowledgeList();
 }, {
-  immediate: true
+  immediate: false
 });
 
 const loadData = async () => {

+ 64 - 0
src/pagesStudy/pages/textbooks-practice-history/textbooks-practice-history.vue

@@ -0,0 +1,64 @@
+<template>
+  <ie-page bg-color="#F6F8FA" :fix-height="true">
+    <ie-navbar title="教材同步练习记录" />
+    <view class="mt-20">
+      <view v-for="(item, index) in historyList" :key="index"
+        class="bg-white px-40 py-30 flex items-center sibling-border-top">
+        <view class="flex-1">
+          <view class="text-28">
+            <text class=" text-fore-light">知识点:</text>
+            <text class="text-fore-title">{{ item.paperName }}</text>
+          </view>
+          <view class="mt-10 text-28">
+            <text class=" text-fore-light">完成时间:</text>
+            <text class="text-fore-title">{{ item.endTime }}</text>
+          </view>
+        </view>
+        <view class="text-24 text-white bg-primary w-fit px-40 py-12 rounded-full text-center"
+          @click="handleViewHistory(item)">
+          查看
+        </view>
+      </view>
+    </view>
+  </ie-page>
+</template>
+<script lang="ts" setup>
+import { useNavbar } from '@/hooks/useNavbar';
+import { useTransferPage } from '@/hooks/useTransferPage';
+import { getTextbooksPracticeHistory } from '@/api/modules/study';
+import { Study } from '@/types';
+import { Transfer } from '@/types';
+import { EnumPaperType } from '@/common/enum';
+const { prevData, transferTo } = useTransferPage<{}, Transfer.PracticeResultPageOptions>();
+const { baseStickyTop } = useNavbar();
+const historyList = ref<Study.PracticeHistory[]>([]);
+const handleViewHistory = (value: Study.PracticeHistory) => {
+  transferTo('/pagesStudy/pages/knowledge-practice-detail/knowledge-practice-detail', {
+    data: {
+      paperType: EnumPaperType.COURSE,
+      examineeId: value.examineeId,
+      name: value.paperName,
+      directed: value.directed === 1
+    } as Transfer.PracticeResultPageOptions,
+  });
+}
+const loadData = async () => {
+  uni.$ie.showLoading();
+  try {
+    const { rows } = await getTextbooksPracticeHistory();
+    historyList.value = rows.map(item => {
+      return {
+        ...item,
+        endTime: uni.$uv.timeFormat(item.endTime, 'yyyy-mm-dd hh:MM:ss')
+      } as Study.PracticeHistory;
+    });
+  } finally {
+    uni.$ie.hideLoading();
+  }
+}
+onLoad(() => {
+  console.log(prevData.value)
+  loadData();
+});
+</script>
+<style lang="scss" scoped></style>

+ 75 - 0
src/pagesStudy/pages/textbooks-practice/textbooks-practice.vue

@@ -0,0 +1,75 @@
+<template>
+  <ie-page ref="iePageRef">
+    <ie-navbar :title="pageTitle" />
+    <view class="px-30 py-16 bg-back">
+      <view class="flex items-center justify-end gap-x-4" @click="handleViewHistory">
+        <uv-icon name="clock" size="16" color="#31A0FC"></uv-icon>
+        <text class="text-28 text-primary">查看记录</text>
+        <uv-icon name="arrow-right" size="16" color="#31A0FC"></uv-icon>
+      </view>
+    </view>
+    <view class="px-40">
+      <knowledgeTree :tree-data="treeData" @start-practice="handleStartPractice" />
+    </view>
+  </ie-page>
+</template>
+
+<script lang="ts" setup>
+import IePage from '@/components/ie-page/ie-page.vue';
+import { useTransferPage } from '@/hooks/useTransferPage';
+import { getTextbooksKnowledgeList } from '@/api/modules/study';
+import knowledgeTree from '@/pagesStudy/components/knowledge-tree.vue';
+import * as Study from '@/types/study';
+import { EnumPaperType } from '@/common/enum';
+import { useUserStore } from '@/store/userStore';
+const { prevData, transferTo } = useTransferPage();
+
+const userStore = useUserStore();
+const iePageRef = ref<InstanceType<typeof IePage>>();
+const pageTitle = computed(() => {
+  return '教材同步练习';
+});
+
+const treeData = ref<Study.KnowledgeNode[]>([]);
+
+const handleViewHistory = () => {
+  transferTo('/pagesStudy/pages/textbooks-practice-history/textbooks-practice-history', {
+    data: {}
+  });
+}
+const loadKnowledgeList = async () => {
+  try {
+    uni.$ie.showLoading();
+    const { data } = await getTextbooksKnowledgeList();
+    treeData.value = data as Study.KnowledgeNode[];
+  } catch (error) {
+    console.log(error);
+  } finally {
+    uni.$ie.hideLoading();
+  }
+}
+
+const handleStartPractice = async (node: Study.KnowledgeNode) => {
+  const isVip = await userStore.checkVip();
+  if (isVip) {
+    transferTo('/pagesStudy/pages/exam-start/exam-start', {
+      data: {
+        name: '教材同步练习-' + node.name,
+        paperType: EnumPaperType.COURSE,
+        practiceInfo: {
+          name: node.name,
+          relateId: node.id
+        },
+      }
+    });
+  } else {
+    iePageRef.value?.showVipPopup();
+  }
+}
+
+onShow(() => {
+  loadKnowledgeList();
+});
+</script>
+
+<style></style>

+ 7 - 3
src/pagesSystem/pages/bind-profile/bind-profile.vue

@@ -131,7 +131,7 @@ const isBindMode = computed(() => [EnumBindScene.LOGIN_BIND, EnumBindScene.REGIS
 const isSchoolDisabled = computed(() => isBindMode.value && prevData.value.cardInfo.assignSchoolId);
 const isProvinceDisabled = computed(() => isBindMode.value && prevData.value.cardInfo.assignLocation);
 const isExamTypeDisabled = computed(() => (isBindMode.value && prevData.value.cardInfo.assignExamType) || !examTypeForm.value.location);
-
+const contactPhone = computed(() => userStore.orgInfo.contactPhone);
 const inputPlaceholder = computed(() => {
   return isBindMode.value ? '请输入(提交后不可修改)' : '请输入';
 });
@@ -144,12 +144,16 @@ const showCulture = computed(() => {
   return examTypeForm.value.examType === EnumExamType.OHS;
 });
 const handleNoSchool = () => {
+  if (!contactPhone.value) {
+    uni.$ie.showToast('请联系客服处理');
+    return;
+  }
   uni.showActionSheet({
     title: '联系客服处理',
-    itemList: ['拨打电话:400-1797-985'],
+    itemList: [`拨打电话:${contactPhone.value}`],
     success: (res) => {
       uni.makePhoneCall({
-        phoneNumber: '400-1797-985'
+        phoneNumber: contactPhone.value
       });
     }
   });

+ 5 - 1
src/pagesSystem/pages/login/login.vue

@@ -147,7 +147,7 @@ const handleLogin = async () => {
     submitLogin();
   } else if (loginType.value === 'card') {
     captchaRef.value.open();
-    userStore.rememberPwd = !!rememberPassword.value[0];
+    userStore.rememberLoginInfo(!!rememberPassword.value[0], cardNo.value, cardPassword.value);
     // submitLogin();
   }
 }
@@ -251,6 +251,10 @@ const handleValid = (data: { code: string; uuid: string }) => {
 
 onLoad(() => {
   rememberPassword.value[0] = userStore.rememberPwd;
+  if (userStore.rememberPwd) {
+    cardNo.value = userStore.cardNo;
+    cardPassword.value = userStore.cardPassword;
+  }
 });
 </script>
 

+ 26 - 1
src/store/userStore.ts

@@ -2,6 +2,7 @@ import { defineStore } from 'pinia';
 import { useTransferPage } from '@/hooks/useTransferPage';
 import { ref, computed } from 'vue';
 import { getUserInfo } from '@/api/modules/login';
+import config from '@/config';
 
 import { Study, UserStoreState } from '@/types';
 import { UserInfo, VipCardInfo } from '@/types/user';
@@ -31,7 +32,12 @@ export const useUserStore = defineStore('ie-user', {
       location: '',
       examType: '',
     },
+    org: {
+      ...config.defaultOrg,
+    },
     rememberPwd: false,
+    cardPassword: '',
+    cardNo: '',
     directedSchoolList: [],
     practiceSettings: {
       reviewMode: EnumReviewMode.AFTER_SUBMIT,
@@ -66,6 +72,9 @@ export const useUserStore = defineStore('ie-user', {
       }
       return {} as VipCardInfo;
     },
+    orgInfo(state: UserStoreState) {
+      return state.org;
+    },
     isStudent(): boolean {
       return this.userInfo.userType === EnumUserType.STUDENT;
     },
@@ -170,7 +179,7 @@ export const useUserStore = defineStore('ie-user', {
     },
     async getUserInfo() {
       const res = await getUserInfo();
-      const { data, isDefaultModifyPwd, isPasswordExpired, card } = res;
+      const { data, isDefaultModifyPwd, isPasswordExpired, card, org } = res;
       if (data) {
         this.user = data;
         if (card) {
@@ -179,6 +188,9 @@ export const useUserStore = defineStore('ie-user', {
         this.isDefaultModifyPwd = isDefaultModifyPwd;
         this.isPasswordExpired = isPasswordExpired;
       }
+      if (org) {
+        this.org = org;
+      }
     },
     /**
      * 保存定向学校列表
@@ -214,11 +226,24 @@ export const useUserStore = defineStore('ie-user', {
         });
       });
     },
+    rememberLoginInfo(remember: boolean, cardNo: string, cardPassword: string) {
+      this.rememberPwd = remember;
+      if (remember) {
+        this.cardNo = cardNo;
+        this.cardPassword = cardPassword;
+      } else {
+        this.cardNo = '';
+        this.cardPassword = '';
+      }
+    },
     logout() {
       this.accessToken = null;
       this.user = null;
       this.isDefaultModifyPwd = false;
       this.isPasswordExpired = false;
+      this.org = {
+        ...config.defaultOrg,
+      };
       oldUserStore.Logout();
       // const { transferTo } = useTransferPage();
       // setTimeout(() => {

+ 13 - 1
src/types/index.ts

@@ -16,6 +16,12 @@ export interface ApiResponse<T> {
   user?: User.UserInfo;
   isDefaultModifyPwd?: boolean;
   isPasswordExpired?: boolean;
+  // 以下是机构信息
+  org?: {
+    contactPhone: string;
+    logo: string;
+    orgName: string;
+  }
 }
 
 export interface ApiCaptchaResponse {
@@ -38,7 +44,6 @@ export interface TreeData {
   children?: TreeData[];
   isExpanded?: boolean;
   isLeaf?: boolean;
-  actualHeight?: number;
 }
 
 export interface TableConfig {
@@ -86,7 +91,14 @@ export interface UserStoreState {
     location: string;
     examType: string;
   };
+  org: {
+    contactPhone: string;
+    logo: string;
+    orgName: string;
+  };
   rememberPwd: boolean;
+  cardPassword: string;
+  cardNo: string;
   directedSchoolList: Study.DirectedSchool[];
   practiceSettings: Study.PracticeSettings;
 }

+ 0 - 1
src/types/study.ts

@@ -93,7 +93,6 @@ export interface Knowledge {
 export type KnowledgeNode = Pick<Knowledge, 'id' | 'name' | 'status' | 'questionCount' | 'finishedCount' | 'finishedRatio'> & {
   isExpanded: boolean;
   isLeaf: boolean;
-  actualHeight: number;
   children: KnowledgeNode[];
 }
 

+ 1 - 0
src/types/transfer.ts

@@ -9,6 +9,7 @@ export interface PracticeResultPageOptions {
   examineeId: number;
   name: string;
   directed: boolean;
+  paperType: EnumPaperType;
 }
 
 /**

+ 192 - 0
src/uni_modules/mp-html/README.md

@@ -0,0 +1,192 @@
+## 为减小组件包的大小,默认组件包中不包含编辑、latex 公式等扩展功能,需要使用扩展功能的请参考下方的 插件扩展 栏的说明
+
+## 功能介绍
+- 全端支持(含 `v3、NVUE`)
+- 支持丰富的标签(包括 `table`、`video`、`svg` 等)
+- 支持丰富的事件效果(自动预览图片、链接处理等)
+- 支持设置占位图(加载中、出错时、预览时)
+- 支持锚点跳转、长按复制等丰富功能
+- 支持大部分 *html* 实体
+- 丰富的插件(关键词搜索、内容编辑、`latex` 公式等)
+- 效率高、容错性强且轻量化
+
+查看 [功能介绍](https://jin-yufeng.github.io/mp-html/#/overview/feature) 了解更多
+
+## 使用方法
+- `uni_modules` 方式  
+  1. 点击右上角的 `使用 HBuilder X 导入插件` 按钮直接导入项目或点击 `下载插件 ZIP` 按钮下载插件包并解压到项目的 `uni_modules/mp-html` 目录下  
+  2. 在需要使用页面的 `(n)vue` 文件中添加  
+     ```html
+     <!-- 不需要引入,可直接使用 -->
+     <mp-html :content="html" />
+     ```
+     ```javascript
+     export default {
+       data() {
+         return {
+           html: '<div>Hello World!</div>'
+         }
+       }
+     }
+     ```
+  3. 需要更新版本时在 `HBuilder X` 中右键 `uni_modules/mp-html` 目录选择 `从插件市场更新` 即可  
+
+- 源码方式  
+  1. 从 [github](https://github.com/jin-yufeng/mp-html/tree/master/dist/uni-app) 或 [gitee](https://gitee.com/jin-yufeng/mp-html/tree/master/dist/uni-app) 下载源码  
+     插件市场的 **非 uni_modules 版本** 无法更新,不建议从插件市场获取  
+  2. 在需要使用页面的 `(n)vue` 文件中添加  
+     ```html
+     <mp-html :content="html" />
+     ```
+     ```javascript
+     import mpHtml from '@/components/mp-html/mp-html'
+     export default {
+       // HBuilderX 2.5.5+ 可以通过 easycom 自动引入
+       components: {
+         mpHtml
+       },
+       data() {
+         return {
+           html: '<div>Hello World!</div>'
+         }
+       }
+     }
+     ```
+
+- npm 方式  
+  1. 在项目根目录下执行  
+     ```bash
+     npm install mp-html
+     ```
+  2. 在需要使用页面的 `(n)vue` 文件中添加  
+     ```html
+     <mp-html :content="html" />
+     ```
+     ```javascript
+     import mpHtml from 'mp-html/dist/uni-app/components/mp-html/mp-html'
+     export default {
+       // 不可省略
+       components: {
+         mpHtml
+       },
+       data() {
+         return {
+           html: '<div>Hello World!</div>'
+         }
+       }
+     }
+     ```
+  3. 需要更新版本时执行以下命令即可  
+     ```bash
+     npm update mp-html
+     ```
+  
+  使用 *cli* 方式运行的项目,通过 *npm* 方式引入时,需要在 *vue.config.js* 中配置 *transpileDependencies*,详情可见 [#330](https://github.com/jin-yufeng/mp-html/issues/330#issuecomment-913617687)  
+  如果在 **nvue** 中使用还要将 `dist/uni-app/static` 目录下的内容拷贝到项目的 `static` 目录下,否则无法运行  
+
+查看 [快速开始](https://jin-yufeng.github.io/mp-html/#/overview/quickstart) 了解更多
+
+## 组件属性
+
+| 属性 | 类型 | 默认值 | 说明 |
+|:---:|:---:|:---:|---|
+| container-style | String |  | 容器的样式([2.1.0+](https://jin-yufeng.github.io/mp-html/#/changelog/changelog#v210)) |
+| content | String |  | 用于渲染的 html 字符串 |
+| copy-link | Boolean | true | 是否允许外部链接被点击时自动复制 |
+| domain | String |  | 主域名(用于链接拼接) |
+| error-img | String |  | 图片出错时的占位图链接 |
+| lazy-load | Boolean | false | 是否开启图片懒加载 |
+| loading-img | String |  | 图片加载过程中的占位图链接 |
+| pause-video | Boolean | true | 是否在播放一个视频时自动暂停其他视频 |
+| preview-img | Boolean | true | 是否允许图片被点击时自动预览 |
+| scroll-table | Boolean | false | 是否给每个表格添加一个滚动层使其能单独横向滚动 |
+| selectable | Boolean | false | 是否开启文本长按复制 |
+| set-title | Boolean | true | 是否将 title 标签的内容设置到页面标题 |
+| show-img-menu | Boolean | true | 是否允许图片被长按时显示菜单 |
+| tag-style | Object |  | 设置标签的默认样式 |
+| use-anchor | Boolean | false | 是否使用锚点链接 |
+
+查看 [属性](https://jin-yufeng.github.io/mp-html/#/basic/prop) 了解更多
+
+## 组件事件
+
+| 名称 | 触发时机 |
+|:---:|---|
+| load | dom 树加载完毕时 |
+| ready | 图片加载完毕时 |
+| error | 发生渲染错误时 |
+| imgtap | 图片被点击时 |
+| linktap | 链接被点击时 |
+| play | 音视频播放时 |
+
+查看 [事件](https://jin-yufeng.github.io/mp-html/#/basic/event) 了解更多
+
+## api
+组件实例上提供了一些 `api` 方法可供调用
+
+| 名称 | 作用 |
+|:---:|---|
+| in | 将锚点跳转的范围限定在一个 scroll-view 内 |
+| navigateTo | 锚点跳转 |
+| getText | 获取文本内容 |
+| getRect | 获取富文本内容的位置和大小 |
+| setContent | 设置富文本内容 |
+| imgList | 获取所有图片的数组 |
+| pauseMedia | 暂停播放音视频([2.2.2+](https://jin-yufeng.github.io/mp-html/#/changelog/changelog#v222)) |
+| setPlaybackRate | 设置音视频播放速率([2.4.0+](https://jin-yufeng.github.io/mp-html/#/changelog/changelog#v240)) |
+
+查看 [api](https://jin-yufeng.github.io/mp-html/#/advanced/api) 了解更多
+
+## 插件扩展  
+除基本功能外,本组件还提供了丰富的扩展,可按照需要选用
+
+| 名称 | 作用 |
+|:---:|---|
+| audio | 音乐播放器 |
+| editable | 富文本 **编辑**([示例项目](https://mp-html.oss-cn-hangzhou.aliyuncs.com/editable.zip)) |
+| emoji | 解析 emoji |
+| highlight | 代码块高亮显示 |
+| markdown | 渲染 markdown |
+| search | 关键词搜索 |
+| style | 匹配 style 标签中的样式 |
+| txv-video | 使用腾讯视频 |
+| img-cache | 图片缓存 by [@PentaTea](https://github.com/PentaTea) |
+| latex | 渲染 latex 公式 by [@Zeng-J](https://github.com/Zeng-J) |
+
+从插件市场导入的包中 **不含有** 扩展插件,使用插件需通过微信小程序 `富文本插件` 获取或参考以下方法进行打包:  
+1. 获取完整组件包  
+   ```bash
+   npm install mp-html
+   ```
+2. 编辑 `tools/config.js` 中的 `plugins` 项,选择需要的插件  
+3. 生成新的组件包  
+   在 `node_modules/mp-html` 目录下执行  
+   ```bash
+   npm install
+   npm run build:uni-app
+   ```
+4. 拷贝 `dist/uni-app` 中的内容到项目根目录  
+
+查看 [插件](https://jin-yufeng.github.io/mp-html/#/advanced/plugin) 了解更多
+
+## 关于 nvue
+`nvue` 使用原生渲染,不支持部分 `css` 样式,为实现和 `html` 相同的效果,组件内部通过 `web-view` 进行渲染,性能上差于原生,根据 `weex` 官方建议,`web` 标签仅应用在非常规的降级场景。因此,如果通过原生的方式(如 `richtext`)能够满足需要,则不建议使用本组件,如果有较多的富文本内容,则可以直接使用 `vue` 页面  
+由于渲染方式与其他端不同,有以下限制:  
+1. 不支持 `lazy-load` 属性
+2. 视频不支持全屏播放
+3. 如果在 `flex-direction: row` 的容器中使用,需要给组件设置宽度或设置 `flex: 1` 占满剩余宽度
+
+纯 `nvue` 模式下,[此问题](https://ask.dcloud.net.cn/question/119678) 修复前,不支持通过 `uni_modules` 引入,需要本地引入(将 [dist/uni-app](https://github.com/jin-yufeng/mp-html/tree/master/dist/uni-app) 中的内容拷贝到项目根目录下)  
+
+
+## 问题反馈
+遇到问题时,请先查阅 [常见问题](https://jin-yufeng.github.io/mp-html/#/question/faq) 和 [issue](https://github.com/jin-yufeng/mp-html/issues) 中是否已有相同的问题  
+可通过 [issue](https://github.com/jin-yufeng/mp-html/issues/new/choose) 、插件问答或发送邮件到 [mp_html@126.com](mailto:mp_html@126.com) 提问,不建议在评论区提问(不方便回复)  
+提问请严格按照 [issue 模板](https://github.com/jin-yufeng/mp-html/issues/new/choose) ,描述清楚使用环境、`html` 内容或可复现的 `demo` 项目以及复现方式,对于 **描述不清**、**无法复现** 或重复的问题将不予回复  
+
+欢迎加入 `QQ` 交流群:  
+群1(已满):`699734691`  
+群2(已满):`778239129`  
+群3:`960265313`  
+
+查看 [问题反馈](https://jin-yufeng.github.io/mp-html/#/question/feedback) 了解更多

+ 156 - 0
src/uni_modules/mp-html/changelog.md

@@ -0,0 +1,156 @@
+## v2.5.1(2025-04-20)
+1. `U` 适配鸿蒙 `APP` [详细](https://github.com/jin-yufeng/mp-html/issues/615)
+2. `U` 微信小程序替换废弃 `api` `getSystemInfoSync` [详细](https://github.com/jin-yufeng/mp-html/issues/613)
+3. `F` 修复了 `app` 端播放视频可能报错的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/617)
+4. `F` 修复了 `latex` 插件可能出现 `xxx can be used only in display mode` 的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/632)
+5. `F` 修复了 `uni-app` 包 `latex` 公式可能不显示的问题  [#599](https://github.com/jin-yufeng/mp-html/issues/599)、[#627](https://github.com/jin-yufeng/mp-html/issues/627)
+## v2.5.0(2024-04-22)
+1. `U` `play` 事件增加返回 `src` 等信息 [详细](https://github.com/jin-yufeng/mp-html/issues/526)
+2. `U` `preview-img` 属性支持设置为 `all` 开启 `base64` 图片预览 [详细](https://github.com/jin-yufeng/mp-html/issues/536)
+3. `U` `editable` 插件增加简易模式(点击文字直接编辑)
+4. `U` `latex` 插件支持块级公式 [详细](https://github.com/jin-yufeng/mp-html/issues/582)
+5. `F` 修复了表格部分情况下背景丢失的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/587)
+6. `F` 修复了部分 `svg` 无法显示的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/591)
+7. `F` 修复了 `h5` 和 `app` 端部分情况下样式无法识别的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/518)
+8. `F` 修复了 `latex` 插件部分情况下显示不正确的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/580)
+9. `F` 修复了 `editable` 插件表格无法删除的问题
+10. `F` 修复了 `editable` 插件 `vue3` `h5` 端点击图片报错的问题
+11. `F` 修复了 `editable` 插件点击表格没有菜单栏的问题
+## v2.4.3(2024-01-21)
+1. `A` 增加 [card](https://jin-yufeng.gitee.io/mp-html/#/advanced/plugin#card) 插件 [详细](https://github.com/jin-yufeng/mp-html/pull/533) by [@whoooami](https://github.com/whoooami)
+2. `F` 修复了 `svg` 中包含 `foreignobject` 可能不显示的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/523)
+3. `F` 修复了合并单元格的表格部分情况下显示不正确的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/561)
+4. `F` 修复了 `img` 标签设置 `object-fit` 无效的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/567)
+5. `F` 修复了 `latex` 插件公式会换行的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/540) 
+6. `F` 修复了 `editable` 和 `audio` 插件共用时点击 `audio` 无法编辑的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/529) by [@whoooami](https://github.com/whoooami)
+7. `F` 修复了微信小程序部分情况下图片会报错 `replace of undefined` 的问题
+8. `F` 修复了快手小程序图片不显示的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/571)
+## v2.4.2(2023-05-14)
+1. `A` `editable` 插件支持修改文字颜色 [详细](https://github.com/jin-yufeng/mp-html/issues/254)
+2. `F` 修复了 `svg` 中有 `style` 不生效的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/505)
+3. `F` 修复了使用旧版编译器可能报错 `Bad attr nodes` 的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/472)
+4. `F` 修复了 `app` 端可能出现无法读取 `lazyLoad` 的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/513)
+5. `F` 修复了 `editable` 插件在点击换图时未拼接 `domain` 的问题 [详细](https://github.com/jin-yufeng/mp-html/pull/497) by [@TwoKe945](https://github.com/TwoKe945)
+6. `F` 修复了 `latex` 插件部分情况下不显示的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/515) 
+7. `F` 修复了 `editable` 插件点击音视频时其他标签框不消失的问题
+## v2.4.1(2022-12-25)
+1. `F` 修复了没有图片时 `ready` 事件可能不触发的问题
+2. `F` 修复了加载过程中可能出现 `Root label not found` 错误的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/470)
+3. `F` 修复了 `audio` 插件退出页面可能会报错的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/457)
+4. `F` 修复了 `vue3` 运行到 `app` 在 `HBuilder X 3.6.10` 以上报错的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/480)
+5. `F` 修复了 `nvue` 端链接中包含 `%22` 时可能无法显示的问题
+6. `F` 修复了 `vue3` 使用 `highlight` 插件可能报错的问题
+## v2.4.0(2022-08-27)
+1. `A` 增加了 [setPlaybackRate](https://jin-yufeng.gitee.io/mp-html/#/advanced/api#setPlaybackRate) 的 `api`,可以设置音视频的播放速率 [详细](https://github.com/jin-yufeng/mp-html/issues/452)
+2. `A` 示例小程序代码开源 [详细](https://github.com/jin-yufeng/mp-html-demo)
+3. `U` 优化 `ready` 事件触发时机,未设置懒加载的情况下基本可以准确触发 [详细](https://github.com/jin-yufeng/mp-html/issues/195)
+4. `U` `highlight` 插件在编辑状态下不进行高亮处理,便于编辑
+5. `F` 修复了 `flex` 布局下图片大小可能不正确的问题
+6. `F` 修复了 `selectable` 属性没有设置 `force` 也可能出现渲染异常的问题
+7. `F` 修复了表格中的图片大小可能不正确的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/448)
+8. `F` 修复了含有合并单元格的表格可能无法设置竖直对齐的问题
+9. `F` 修复了 `editable` 插件在 `scroll-view` 中使用时工具条位置可能不正确的问题
+10. `F` 修复了 `vue3` 使用 [search](advanced/plugin#search) 插件可能导致错误换行的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/449)
+## v2.3.2(2022-08-13)
+1. `A` 增加 [latex](https://jin-yufeng.gitee.io/mp-html/#/advanced/plugin#latex) 插件,可以渲染数学公式 [详细](https://github.com/jin-yufeng/mp-html/pull/447) by [@Zeng-J](https://github.com/Zeng-J)
+2. `U` 优化根节点下有很多标签的长内容渲染速度
+3. `U` `highlight` 插件适配 `lang-xxx` 格式
+4. `F` 修复了 `table` 标签设置 `border` 属性后可能无法修改边框样式的问题 [详细](https://github.com/jin-yufeng/mp-html/pull/439) by [@zouxingjie](https://github.com/zouxingjie)
+5. `F` 修复了 `editable` 插件输入连续空格无效的问题
+6. `F` 修复了 `vue3` 图片设置 `inline` 会报错的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/438)
+7. `F` 修复了 `vue3` 使用 `table` 可能报错的问题
+## v2.3.1(2022-05-20)
+1. `U` `app` 端支持使用本地图片
+2. `U` 优化了微信小程序 `selectable` 属性在 `ios` 端的处理 [详细](https://jin-yufeng.gitee.io/mp-html/#/basic/prop#selectable)
+3. `F` 修复了 `editable` 插件不在顶部时 `tooltip` 位置可能错误的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/430)
+4. `F` 修复了 `vue3` 运行到微信小程序可能报错丢失内容的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/414)
+5. `F` 修复了 `vue3` 部分标签可能被错误换行的问题
+6. `F` 修复了 `editable` 插件 `app` 端插入视频无法预览的问题
+## v2.3.0(2022-04-01)
+1. `A` 增加了 `play` 事件,音视频播放时触发,可用于与页面其他音视频进行互斥播放 [详细](basic/event#play)
+2. `U` `show-img-menu` 属性支持控制预览时是否长按弹出菜单
+3. `U` 优化 `wxs` 处理,提高渲染性能 [详细](https://developers.weixin.qq.com/community/develop/article/doc/0006cc2b204740f601bd43fa25a413)  
+4. `U` `video` 标签支持 `object-fit` 属性
+5. `U` 增加支持一些常用实体编码 [详细](https://github.com/jin-yufeng/mp-html/issues/418)
+6. `F` 修复了图片仅设置高度可能不显示的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/410)
+7. `F` 修复了 `video` 标签高度设置为 `auto` 不显示的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/411)
+8. `F` 修复了使用 `grid` 布局时可能样式错误的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/413)
+9. `F` 修复了含有合并单元格的表格部分情况下显示异常的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/417)
+10. `F` 修复了 `editable` 插件连续插入内容时顺序不正确的问题
+11. `F` 修复了 `uni-app` 包 `vue3` 使用 `audio` 插件报错的问题
+12. `F` 修复了 `uni-app` 包 `highlight` 插件使用自定义的 `prism.min.js` 报错的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/416)
+## v2.2.2(2022-02-26)
+1. `A` 增加了 [pauseMedia](https://jin-yufeng.gitee.io/mp-html/#/advanced/api#pauseMedia) 的 `api`,可用于暂停播放音视频 [详细](https://github.com/jin-yufeng/mp-html/issues/317)
+2. `U` 优化了长内容的加载速度  
+3. `U` 适配 `vue3` [#389](https://github.com/jin-yufeng/mp-html/issues/389)、[#398](https://github.com/jin-yufeng/mp-html/pull/398) by [@zhouhuafei](https://github.com/zhouhuafei)、[#400](https://github.com/jin-yufeng/mp-html/issues/400)
+4. `F` 修复了小程序端图片高度设置为百分比时可能不显示的问题
+5. `F` 修复了 `highlight` 插件部分情况下可能显示不完整的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/403)
+## v2.2.1(2021-12-24)
+1. `A` `editable` 插件增加上下移动标签功能
+2. `U` `editable` 插件支持在文本中间光标处插入内容
+3. `F` 修复了 `nvue` 端设置 `margin` 后可能导致高度不正确的问题
+4. `F` 修复了 `highlight` 插件使用压缩版的 `prism.css` 可能导致背景失效的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/367)
+5. `F` 修复了编辑状态下使用 `emoji` 插件内容为空时可能报错的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/371)
+6. `F` 修复了使用 `editable` 插件后将 `selectable` 属性设置为 `force` 不生效的问题
+## v2.2.0(2021-10-12)
+1. `A` 增加 `customElements` 配置项,便于添加自定义功能性标签 [详细](https://github.com/jin-yufeng/mp-html/issues/350)
+2. `A` `editable` 插件增加切换音视频自动播放状态的功能 [详细](https://github.com/jin-yufeng/mp-html/pull/341) by [@leeseett](https://github.com/leeseett)
+3. `A` `editable` 插件删除媒体标签时触发 `remove` 事件,便于删除已上传的文件
+4. `U` `editable` 插件 `insertImg` 方法支持同时插入多张图片 [详细](https://github.com/jin-yufeng/mp-html/issues/342)
+5. `U` `editable` 插入图片和音视频时支持拼接 `domian` 主域名
+6. `F` 修复了内部链接参数中包含 `://` 时被认为是外部链接的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/356)
+7. `F` 修复了部分 `svg` 标签名或属性名大小写不正确时不生效的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/351)
+8. `F` 修复了 `nvue` 页面运行到非 `app` 平台时可能样式错误的问题
+## v2.1.5(2021-08-13)
+1. `A` 增加支持标签的 `dir` 属性
+2. `F` 修复了 `ruby` 标签文字与拼音没有居中对齐的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/325)
+3. `F` 修复了音视频标签内有 `a` 标签时可能无法播放的问题
+4. `F` 修复了 `externStyle` 中的 `class` 名包含下划线或数字时可能失效的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/326)
+5. `F` 修复了 `h5` 端引入 `externStyle` 可能不生效的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/326)
+## v2.1.4(2021-07-14)
+1. `F` 修复了 `rt` 标签无法设置样式的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/318)
+2. `F` 修复了表格中有单元格同时合并行和列时可能显示不正确的问题
+3. `F` 修复了 `app` 端无法关闭图片长按菜单的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/322)
+4. `F` 修复了 `editable` 插件只能添加图片链接不能修改的问题 [详细](https://github.com/jin-yufeng/mp-html/pull/312) by [@leeseett](https://github.com/leeseett)
+## v2.1.3(2021-06-12)
+1. `A` `editable` 插件增加 `insertTable` 方法
+2. `U` `editable` 插件支持编辑表格中的空白单元格 [详细](https://github.com/jin-yufeng/mp-html/issues/310)
+3. `F` 修复了 `externStyle` 中使用伪类可能失效的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/298)
+4. `F` 修复了多个组件同时使用时 `tag-style` 属性时可能互相影响的问题 [详细](https://github.com/jin-yufeng/mp-html/pull/305) by [@woodguoyu](https://github.com/woodguoyu)
+5. `F` 修复了包含 `linearGradient` 的 `svg` 可能无法显示的问题
+6. `F` 修复了编译到头条小程序时可能报错的问题
+7. `F` 修复了 `nvue` 端不触发 `click` 事件的问题
+8. `F` 修复了 `editable` 插件尾部插入时无法撤销的问题
+9. `F` 修复了 `editable` 插件的 `insertHtml` 方法只能在末尾插入的问题
+10. `F` 修复了 `editable` 插件插入音频不显示的问题
+## v2.1.2(2021-04-24)
+1. `A` 增加了 [img-cache](https://jin-yufeng.gitee.io/mp-html/#/advanced/plugin#img-cache) 插件,可以在 `app` 端缓存图片 [详细](https://github.com/jin-yufeng/mp-html/issues/292) by [@PentaTea](https://github.com/PentaTea)
+2. `U` 支持通过 `container-style` 属性设置 `white-space` 来保留连续空格和换行符 [详细](https://jin-yufeng.gitee.io/mp-html/#/question/faq#space)
+3. `U` 代码风格符合 [standard](https://standardjs.com) 标准
+4. `U` `editable` 插件编辑状态下支持预览视频 [详细](https://github.com/jin-yufeng/mp-html/issues/286)
+5. `F` 修复了 `svg` 标签内嵌 `svg` 时无法显示的问题
+6. `F` 修复了编译到支付宝和头条小程序时部分区域不可复制的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/291)
+## v2.1.1(2021-04-09)
+1. 修复了对 `p` 标签设置 `tag-style` 可能不生效的问题
+2. 修复了 `svg` 标签中的文本无法显示的问题
+3. 修复了使用 `editable` 插件编辑表格时可能报错的问题
+4. 修复了使用 `highlight` 插件运行到头条小程序时可能没有样式的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/280)
+5. 修复了使用 `editable` 插件 `editable` 属性为 `false` 时会报错的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/284)
+6. 修复了 `style` 插件连续子选择器失效的问题
+7. 修复了 `editable` 插件无法修改图片和字体大小的问题
+## v2.1.0.2(2021-03-21)
+修复了 `nvue` 端使用可能报错的问题
+## v2.1.0(2021-03-20)
+1. `A` 增加了 [container-style](https://jin-yufeng.gitee.io/mp-html/#/basic/prop#container-style) 属性 [详细](https://gitee.com/jin-yufeng/mp-html/pulls/1)
+2. `A` 增加支持 `strike` 标签
+3. `A` `editable` 插件增加 `placeholder` 属性 [详细](https://jin-yufeng.gitee.io/mp-html/#/advanced/plugin#editable)
+4. `A` `editable` 插件增加 `insertHtml` 方法 [详细](https://jin-yufeng.gitee.io/mp-html/#/advanced/plugin#editable)
+5. `U` 外部样式支持标签名选择器 [详细](https://jin-yufeng.gitee.io/mp-html/#/overview/quickstart#setting)
+6. `F` 修复了 `nvue` 端部分情况下可能不显示的问题
+## v2.0.5(2021-03-12)
+1. `U` [linktap](https://jin-yufeng.gitee.io/mp-html/#/basic/event#linktap) 事件增加返回内部文本内容 `innerText` [详细](https://github.com/jin-yufeng/mp-html/issues/271)
+2. `U` [selectable](https://jin-yufeng.gitee.io/mp-html/#/basic/prop#selectable) 属性设置为 `force` 时能够在微信 `iOS` 端生效(文本块会变成 `inline-block`) [详细](https://github.com/jin-yufeng/mp-html/issues/267)
+3. `F` 修复了部分情况下竖向无法滚动的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/182)
+4. `F` 修复了多次修改富文本数据时部分内容可能不显示的问题
+5. `F` 修复了 [腾讯视频](https://jin-yufeng.gitee.io/mp-html/#/advanced/plugin#txv-video) 插件可能无法播放的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/265)
+6. `F` 修复了 [highlight](https://jin-yufeng.gitee.io/mp-html/#/advanced/plugin#highlight) 插件没有设置高亮语言时没有应用默认样式的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/276) by [@fuzui](https://github.com/fuzui)

+ 80 - 0
src/uni_modules/mp-html/components/mp-html/latex/index.js

@@ -0,0 +1,80 @@
+/**
+ * @fileoverview latex 插件
+ * katex.min.js来源 https://github.com/rojer95/katex-mini
+ */
+import parse from './katex.min'
+
+function Latex () {
+
+}
+
+Latex.prototype.onParse = function (node, vm) {
+  // $...$包裹的内容为latex公式
+  if (!vm.options.editable && node.type === 'text' && node.text.includes('$')) {
+    const part = node.text.split(/(\${1,2})/)
+    const children = []
+    let status = 0
+    for (let i = 0; i < part.length; i++) {
+      if (i % 2 === 0) {
+        // 文本内容
+        if (part[i]) {
+          if (status === 0) {
+            children.push({
+              type: 'text',
+              text: part[i]
+            })
+          } else {
+            if (status === 1) {
+              // 行内公式
+              const nodes = parse.default(part[i])
+              children.push({
+                name: 'span',
+                attrs: {},
+                l: 'T',
+                f: 'display:inline-block',
+                children: nodes
+              })
+            } else {
+              // 块公式
+              const nodes = parse.default(part[i], {
+                displayMode: true
+              })
+              children.push({
+                name: 'div',
+                attrs: {
+                  style: 'text-align:center'
+                },
+                children: nodes
+              })
+            }
+          }
+        }
+      } else {
+        // 分隔符
+        if (part[i] === '$' && part[i + 2] === '$') {
+          // 行内公式
+          status = 1
+          part[i + 2] = ''
+        } else if (part[i] === '$$' && part[i + 2] === '$$') {
+          // 块公式
+          status = 2
+          part[i + 2] = ''
+        } else {
+          if (part[i] && part[i] !== '$$') {
+            // 普通$符号
+            part[i + 1] = part[i] + part[i + 1]
+          }
+          // 重置状态
+          status = 0
+        }
+      }
+    }
+    delete node.type
+    delete node.text
+    node.name = 'span'
+    node.attrs = {}
+    node.children = children
+  }
+}
+
+export default Latex

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
src/uni_modules/mp-html/components/mp-html/latex/katex.min.js


+ 499 - 0
src/uni_modules/mp-html/components/mp-html/mp-html.vue

@@ -0,0 +1,499 @@
+<template>
+  <view id="_root" :class="(selectable?'_select ':'')+'_root'" :style="containerStyle">
+    <slot v-if="!nodes[0]" />
+    <!-- #ifndef APP-PLUS-NVUE -->
+    <node v-else :childs="nodes" :opts="[lazyLoad,loadingImg,errorImg,showImgMenu,selectable]" name="span" />
+    <!-- #endif -->
+    <!-- #ifdef APP-PLUS-NVUE -->
+    <web-view ref="web" src="/static/app-plus/mp-html/local.html" :style="'margin-top:-2px;height:' + height + 'px'" @onPostMessage="_onMessage" />
+    <!-- #endif -->
+  </view>
+</template>
+
+<script>
+/**
+ * mp-html v2.5.1
+ * @description 富文本组件
+ * @tutorial https://github.com/jin-yufeng/mp-html
+ * @property {String} container-style 容器的样式
+ * @property {String} content 用于渲染的 html 字符串
+ * @property {Boolean} copy-link 是否允许外部链接被点击时自动复制
+ * @property {String} domain 主域名,用于拼接链接
+ * @property {String} error-img 图片出错时的占位图链接
+ * @property {Boolean} lazy-load 是否开启图片懒加载
+ * @property {string} loading-img 图片加载过程中的占位图链接
+ * @property {Boolean} pause-video 是否在播放一个视频时自动暂停其他视频
+ * @property {Boolean} preview-img 是否允许图片被点击时自动预览
+ * @property {Boolean} scroll-table 是否给每个表格添加一个滚动层使其能单独横向滚动
+ * @property {Boolean | String} selectable 是否开启长按复制
+ * @property {Boolean} set-title 是否将 title 标签的内容设置到页面标题
+ * @property {Boolean} show-img-menu 是否允许图片被长按时显示菜单
+ * @property {Object} tag-style 标签的默认样式
+ * @property {Boolean | Number} use-anchor 是否使用锚点链接
+ * @event {Function} load dom 结构加载完毕时触发
+ * @event {Function} ready 所有图片加载完毕时触发
+ * @event {Function} imgtap 图片被点击时触发
+ * @event {Function} linktap 链接被点击时触发
+ * @event {Function} play 音视频播放时触发
+ * @event {Function} error 媒体加载出错时触发
+ */
+// #ifndef APP-PLUS-NVUE
+import node from './node/node'
+// #endif
+import Parser from './parser'
+import latex from './latex/index.js'
+const plugins=[latex,]
+// #ifdef APP-PLUS-NVUE
+const dom = weex.requireModule('dom')
+// #endif
+export default {
+  name: 'mp-html',
+  data () {
+    return {
+      nodes: [],
+      // #ifdef APP-PLUS-NVUE
+      height: 3
+      // #endif
+    }
+  },
+  props: {
+    containerStyle: {
+      type: String,
+      default: ''
+    },
+    content: {
+      type: String,
+      default: ''
+    },
+    copyLink: {
+      type: [Boolean, String],
+      default: true
+    },
+    domain: String,
+    errorImg: {
+      type: String,
+      default: ''
+    },
+    lazyLoad: {
+      type: [Boolean, String],
+      default: false
+    },
+    loadingImg: {
+      type: String,
+      default: ''
+    },
+    pauseVideo: {
+      type: [Boolean, String],
+      default: true
+    },
+    previewImg: {
+      type: [Boolean, String],
+      default: true
+    },
+    scrollTable: [Boolean, String],
+    selectable: [Boolean, String],
+    setTitle: {
+      type: [Boolean, String],
+      default: true
+    },
+    showImgMenu: {
+      type: [Boolean, String],
+      default: true
+    },
+    tagStyle: Object,
+    useAnchor: [Boolean, Number]
+  },
+  // #ifdef VUE3
+  emits: ['load', 'ready', 'imgtap', 'linktap', 'play', 'error'],
+  // #endif
+  // #ifndef APP-PLUS-NVUE
+  components: {
+    node
+  },
+  // #endif
+  watch: {
+    content (content) {
+      this.setContent(content)
+    }
+  },
+  created () {
+    this.plugins = []
+    for (let i = plugins.length; i--;) {
+      this.plugins.push(new plugins[i](this))
+    }
+  },
+  mounted () {
+    if (this.content && !this.nodes.length) {
+      this.setContent(this.content)
+    }
+  },
+  beforeDestroy () {
+    this._hook('onDetached')
+  },
+  methods: {
+    /**
+     * @description 将锚点跳转的范围限定在一个 scroll-view 内
+     * @param {Object} page scroll-view 所在页面的示例
+     * @param {String} selector scroll-view 的选择器
+     * @param {String} scrollTop scroll-view scroll-top 属性绑定的变量名
+     */
+    in (page, selector, scrollTop) {
+      // #ifndef APP-PLUS-NVUE
+      if (page && selector && scrollTop) {
+        this._in = {
+          page,
+          selector,
+          scrollTop
+        }
+      }
+      // #endif
+    },
+
+    /**
+     * @description 锚点跳转
+     * @param {String} id 要跳转的锚点 id
+     * @param {Number} offset 跳转位置的偏移量
+     * @returns {Promise}
+     */
+    navigateTo (id, offset) {
+      return new Promise((resolve, reject) => {
+        if (!this.useAnchor) {
+          reject(Error('Anchor is disabled'))
+          return
+        }
+        offset = offset || parseInt(this.useAnchor) || 0
+        // #ifdef APP-PLUS-NVUE
+        if (!id) {
+          dom.scrollToElement(this.$refs.web, {
+            offset
+          })
+          resolve()
+        } else {
+          this._navigateTo = {
+            resolve,
+            reject,
+            offset
+          }
+          this.$refs.web.evalJs('uni.postMessage({data:{action:"getOffset",offset:(document.getElementById(' + id + ')||{}).offsetTop}})')
+        }
+        // #endif
+        // #ifndef APP-PLUS-NVUE
+        let deep = ' '
+        // #ifdef MP-WEIXIN || MP-QQ || MP-TOUTIAO
+        deep = '>>>'
+        // #endif
+        const selector = uni.createSelectorQuery()
+          // #ifndef MP-ALIPAY
+          .in(this._in ? this._in.page : this)
+          // #endif
+          .select((this._in ? this._in.selector : '._root') + (id ? `${deep}#${id}` : '')).boundingClientRect()
+        if (this._in) {
+          selector.select(this._in.selector).scrollOffset()
+            .select(this._in.selector).boundingClientRect()
+        } else {
+          // 获取 scroll-view 的位置和滚动距离
+          selector.selectViewport().scrollOffset() // 获取窗口的滚动距离
+        }
+        selector.exec(res => {
+          if (!res[0]) {
+            reject(Error('Label not found'))
+            return
+          }
+          const scrollTop = res[1].scrollTop + res[0].top - (res[2] ? res[2].top : 0) + offset
+          if (this._in) {
+            // scroll-view 跳转
+            this._in.page[this._in.scrollTop] = scrollTop
+          } else {
+            // 页面跳转
+            uni.pageScrollTo({
+              scrollTop,
+              duration: 300
+            })
+          }
+          resolve()
+        })
+        // #endif
+      })
+    },
+
+    /**
+     * @description 获取文本内容
+     * @return {String}
+     */
+    getText (nodes) {
+      let text = '';
+      (function traversal (nodes) {
+        for (let i = 0; i < nodes.length; i++) {
+          const node = nodes[i]
+          if (node.type === 'text') {
+            text += node.text.replace(/&amp;/g, '&')
+          } else if (node.name === 'br') {
+            text += '\n'
+          } else {
+            // 块级标签前后加换行
+            const isBlock = node.name === 'p' || node.name === 'div' || node.name === 'tr' || node.name === 'li' || (node.name[0] === 'h' && node.name[1] > '0' && node.name[1] < '7')
+            if (isBlock && text && text[text.length - 1] !== '\n') {
+              text += '\n'
+            }
+            // 递归获取子节点的文本
+            if (node.children) {
+              traversal(node.children)
+            }
+            if (isBlock && text[text.length - 1] !== '\n') {
+              text += '\n'
+            } else if (node.name === 'td' || node.name === 'th') {
+              text += '\t'
+            }
+          }
+        }
+      })(nodes || this.nodes)
+      return text
+    },
+
+    /**
+     * @description 获取内容大小和位置
+     * @return {Promise}
+     */
+    getRect () {
+      return new Promise((resolve, reject) => {
+        uni.createSelectorQuery()
+          // #ifndef MP-ALIPAY
+          .in(this)
+          // #endif
+          .select('#_root').boundingClientRect().exec(res => res[0] ? resolve(res[0]) : reject(Error('Root label not found')))
+      })
+    },
+
+    /**
+     * @description 暂停播放媒体
+     */
+    pauseMedia () {
+      for (let i = (this._videos || []).length; i--;) {
+        this._videos[i].pause()
+      }
+      // #ifdef APP-PLUS
+      const command = 'for(var e=document.getElementsByTagName("video"),i=e.length;i--;)e[i].pause()'
+      // #ifndef APP-PLUS-NVUE
+      let page = this.$parent
+      while (!page.$scope) page = page.$parent
+      page.$scope.$getAppWebview().evalJS(command)
+      // #endif
+      // #ifdef APP-PLUS-NVUE
+      this.$refs.web.evalJs(command)
+      // #endif
+      // #endif
+    },
+
+    /**
+     * @description 设置媒体播放速率
+     * @param {Number} rate 播放速率
+     */
+    setPlaybackRate (rate) {
+      this.playbackRate = rate
+      for (let i = (this._videos || []).length; i--;) {
+        this._videos[i].playbackRate(rate)
+      }
+      // #ifdef APP-PLUS
+      const command = 'for(var e=document.getElementsByTagName("video"),i=e.length;i--;)e[i].playbackRate=' + rate
+      // #ifndef APP-PLUS-NVUE
+      let page = this.$parent
+      while (!page.$scope) page = page.$parent
+      page.$scope.$getAppWebview().evalJS(command)
+      // #endif
+      // #ifdef APP-PLUS-NVUE
+      this.$refs.web.evalJs(command)
+      // #endif
+      // #endif
+    },
+
+    /**
+     * @description 设置内容
+     * @param {String} content html 内容
+     * @param {Boolean} append 是否在尾部追加
+     */
+    setContent (content, append) {
+      if (!append || !this.imgList) {
+        this.imgList = []
+      }
+      const nodes = new Parser(this).parse(content)
+      // #ifdef APP-PLUS-NVUE
+      if (this._ready) {
+        this._set(nodes, append)
+      }
+      // #endif
+      this.$set(this, 'nodes', append ? (this.nodes || []).concat(nodes) : nodes)
+
+      // #ifndef APP-PLUS-NVUE
+      this._videos = []
+      this.$nextTick(() => {
+        this._hook('onLoad')
+        this.$emit('load')
+      })
+
+      if (this.lazyLoad || this.imgList._unloadimgs < this.imgList.length / 2) {
+        // 设置懒加载,每 350ms 获取高度,不变则认为加载完毕
+        let height = 0
+        const callback = rect => {
+          if (!rect || !rect.height) rect = {}
+          // 350ms 总高度无变化就触发 ready 事件
+          if (rect.height === height) {
+            this.$emit('ready', rect)
+          } else {
+            height = rect.height
+            setTimeout(() => {
+              this.getRect().then(callback).catch(callback)
+            }, 350)
+          }
+        }
+        this.getRect().then(callback).catch(callback)
+      } else {
+        // 未设置懒加载,等待所有图片加载完毕
+        if (!this.imgList._unloadimgs) {
+          this.getRect().then(rect => {
+            this.$emit('ready', rect)
+          }).catch(() => {
+            this.$emit('ready', {})
+          })
+        }
+      }
+      // #endif
+    },
+
+    /**
+     * @description 调用插件钩子函数
+     */
+    _hook (name) {
+      for (let i = plugins.length; i--;) {
+        if (this.plugins[i][name]) {
+          this.plugins[i][name]()
+        }
+      }
+    },
+
+    // #ifdef APP-PLUS-NVUE
+    /**
+     * @description 设置内容
+     */
+    _set (nodes, append) {
+      this.$refs.web.evalJs('setContent(' + JSON.stringify(nodes).replace(/%22/g, '') + ',' + JSON.stringify([this.containerStyle.replace(/(?:margin|padding)[^;]+/g, ''), this.errorImg, this.loadingImg, this.pauseVideo, this.scrollTable, this.selectable]) + ',' + append + ')')
+    },
+
+    /**
+     * @description 接收到 web-view 消息
+     */
+    _onMessage (e) {
+      const message = e.detail.data[0]
+      switch (message.action) {
+        // web-view 初始化完毕
+        case 'onJSBridgeReady':
+          this._ready = true
+          if (this.nodes) {
+            this._set(this.nodes)
+          }
+          break
+        // 内容 dom 加载完毕
+        case 'onLoad':
+          this.height = message.height
+          this._hook('onLoad')
+          this.$emit('load')
+          break
+        // 所有图片加载完毕
+        case 'onReady':
+          this.getRect().then(res => {
+            this.$emit('ready', res)
+          }).catch(() => {
+            this.$emit('ready', {})
+          })
+          break
+        // 总高度发生变化
+        case 'onHeightChange':
+          this.height = message.height
+          break
+        // 图片点击
+        case 'onImgTap':
+          this.$emit('imgtap', message.attrs)
+          if (this.previewImg) {
+            uni.previewImage({
+              current: parseInt(message.attrs.i),
+              urls: this.imgList
+            })
+          }
+          break
+        // 链接点击
+        case 'onLinkTap': {
+          const href = message.attrs.href
+          this.$emit('linktap', message.attrs)
+          if (href) {
+            // 锚点跳转
+            if (href[0] === '#') {
+              if (this.useAnchor) {
+                dom.scrollToElement(this.$refs.web, {
+                  offset: message.offset
+                })
+              }
+            } else if (href.includes('://')) {
+              // 打开外链
+              if (this.copyLink) {
+                plus.runtime.openWeb(href)
+              }
+            } else {
+              uni.navigateTo({
+                url: href,
+                fail () {
+                  uni.switchTab({
+                    url: href
+                  })
+                }
+              })
+            }
+          }
+          break
+        }
+        case 'onPlay':
+          this.$emit('play')
+          break
+        // 获取到锚点的偏移量
+        case 'getOffset':
+          if (typeof message.offset === 'number') {
+            dom.scrollToElement(this.$refs.web, {
+              offset: message.offset + this._navigateTo.offset
+            })
+            this._navigateTo.resolve()
+          } else {
+            this._navigateTo.reject(Error('Label not found'))
+          }
+          break
+        // 点击
+        case 'onClick':
+          this.$emit('tap')
+          this.$emit('click')
+          break
+        // 出错
+        case 'onError':
+          this.$emit('error', {
+            source: message.source,
+            attrs: message.attrs
+          })
+      }
+    }
+    // #endif
+  }
+}
+</script>
+
+<style>
+/* #ifndef APP-PLUS-NVUE */
+/* 根节点样式 */
+._root {
+  padding: 1px 0;
+  overflow-x: auto;
+  overflow-y: hidden;
+  -webkit-overflow-scrolling: touch;
+}
+
+/* 长按复制 */
+._select {
+  user-select: text;
+}
+/* #endif */
+</style>

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 425 - 0
src/uni_modules/mp-html/components/mp-html/node/node.vue


+ 1400 - 0
src/uni_modules/mp-html/components/mp-html/parser.js

@@ -0,0 +1,1400 @@
+/**
+ * @fileoverview html 解析器
+ */
+
+// 配置
+const config = {
+  // 信任的标签(保持标签名不变)
+  trustTags: makeMap('a,abbr,ad,audio,b,blockquote,br,code,col,colgroup,dd,del,dl,dt,div,em,fieldset,h1,h2,h3,h4,h5,h6,hr,i,img,ins,label,legend,li,ol,p,q,ruby,rt,source,span,strong,sub,sup,table,tbody,td,tfoot,th,thead,tr,title,ul,video'),
+
+  // 块级标签(转为 div,其他的非信任标签转为 span)
+  blockTags: makeMap('address,article,aside,body,caption,center,cite,footer,header,html,nav,pre,section'),
+
+  // #ifdef (MP-WEIXIN || MP-QQ || APP-PLUS || MP-360) && VUE3
+  // 行内标签
+  inlineTags: makeMap('abbr,b,big,code,del,em,i,ins,label,q,small,span,strong,sub,sup'),
+  // #endif
+
+  // 要移除的标签
+  ignoreTags: makeMap('area,base,canvas,embed,frame,head,iframe,input,link,map,meta,param,rp,script,source,style,textarea,title,track,wbr'),
+
+  // 自闭合的标签
+  voidTags: makeMap('area,base,br,col,circle,ellipse,embed,frame,hr,img,input,line,link,meta,param,path,polygon,rect,source,track,use,wbr'),
+
+  // html 实体
+  entities: {
+    lt: '<',
+    gt: '>',
+    quot: '"',
+    apos: "'",
+    ensp: '\u2002',
+    emsp: '\u2003',
+    nbsp: '\xA0',
+    semi: ';',
+    ndash: '–',
+    mdash: '—',
+    middot: '·',
+    lsquo: '‘',
+    rsquo: '’',
+    ldquo: '“',
+    rdquo: '”',
+    bull: '•',
+    hellip: '…',
+    larr: '←',
+    uarr: '↑',
+    rarr: '→',
+    darr: '↓'
+  },
+
+  // 默认的标签样式
+  tagStyle: {
+    // #ifndef APP-PLUS-NVUE
+    address: 'font-style:italic',
+    big: 'display:inline;font-size:1.2em',
+    caption: 'display:table-caption;text-align:center',
+    center: 'text-align:center',
+    cite: 'font-style:italic',
+    dd: 'margin-left:40px',
+    mark: 'background-color:yellow',
+    pre: 'font-family:monospace;white-space:pre',
+    s: 'text-decoration:line-through',
+    small: 'display:inline;font-size:0.8em',
+    strike: 'text-decoration:line-through',
+    u: 'text-decoration:underline'
+    // #endif
+  },
+
+  // svg 大小写对照表
+  svgDict: {
+    animatetransform: 'animateTransform',
+    lineargradient: 'linearGradient',
+    viewbox: 'viewBox',
+    attributename: 'attributeName',
+    repeatcount: 'repeatCount',
+    repeatdur: 'repeatDur',
+    foreignobject: 'foreignObject'
+  }
+}
+const tagSelector={}
+let windowWidth, system
+// #ifdef MP-WEIXIN
+if (uni.canIUse('getWindowInfo')) {
+  windowWidth = uni.getWindowInfo().windowWidth
+  system = uni.getDeviceInfo().system
+} else {
+// #endif
+  const systemInfo = uni.getSystemInfoSync()
+  windowWidth = systemInfo.windowWidth
+  // #ifdef MP-WEIXIN
+  system = systemInfo.system
+}
+// #endif
+const blankChar = makeMap(' ,\r,\n,\t,\f')
+let idIndex = 0
+
+// #ifdef H5 || APP-PLUS
+config.ignoreTags.iframe = undefined
+config.trustTags.iframe = true
+config.ignoreTags.embed = undefined
+config.trustTags.embed = true
+// #endif
+// #ifdef APP-PLUS-NVUE
+config.ignoreTags.source = undefined
+config.ignoreTags.style = undefined
+// #endif
+
+/**
+ * @description 创建 map
+ * @param {String} str 逗号分隔
+ */
+function makeMap (str) {
+  const map = Object.create(null)
+  const list = str.split(',')
+  for (let i = list.length; i--;) {
+    map[list[i]] = true
+  }
+  return map
+}
+
+/**
+ * @description 解码 html 实体
+ * @param {String} str 要解码的字符串
+ * @param {Boolean} amp 要不要解码 &amp;
+ * @returns {String} 解码后的字符串
+ */
+function decodeEntity (str, amp) {
+  let i = str.indexOf('&')
+  while (i !== -1) {
+    const j = str.indexOf(';', i + 3)
+    let code
+    if (j === -1) break
+    if (str[i + 1] === '#') {
+      // &#123; 形式的实体
+      code = parseInt((str[i + 2] === 'x' ? '0' : '') + str.substring(i + 2, j))
+      if (!isNaN(code)) {
+        str = str.substr(0, i) + String.fromCharCode(code) + str.substr(j + 1)
+      }
+    } else {
+      // &nbsp; 形式的实体
+      code = str.substring(i + 1, j)
+      if (config.entities[code] || (code === 'amp' && amp)) {
+        str = str.substr(0, i) + (config.entities[code] || '&') + str.substr(j + 1)
+      }
+    }
+    i = str.indexOf('&', i + 1)
+  }
+  return str
+}
+
+/**
+ * @description 合并多个块级标签,加快长内容渲染
+ * @param {Array} nodes 要合并的标签数组
+ */
+function mergeNodes (nodes) {
+  let i = nodes.length - 1
+  for (let j = i; j >= -1; j--) {
+    if (j === -1 || nodes[j].c || !nodes[j].name || (nodes[j].name !== 'div' && nodes[j].name !== 'p' && nodes[j].name[0] !== 'h') || (nodes[j].attrs.style || '').includes('inline')) {
+      if (i - j >= 5) {
+        nodes.splice(j + 1, i - j, {
+          name: 'div',
+          attrs: {},
+          children: nodes.slice(j + 1, i + 1)
+        })
+      }
+      i = j - 1
+    }
+  }
+}
+
+/**
+ * @description html 解析器
+ * @param {Object} vm 组件实例
+ */
+function Parser (vm) {
+  this.options = vm || {}
+  this.tagStyle = Object.assign({}, config.tagStyle, this.options.tagStyle)
+  this.imgList = vm.imgList || []
+  this.imgList._unloadimgs = 0
+  this.plugins = vm.plugins || []
+  this.attrs = Object.create(null)
+  this.stack = []
+  this.nodes = []
+  this.pre = (this.options.containerStyle || '').includes('white-space') && this.options.containerStyle.includes('pre') ? 2 : 0
+}
+
+/**
+ * @description 执行解析
+ * @param {String} content 要解析的文本
+ */
+Parser.prototype.parse = function (content) {
+  // 插件处理
+  for (let i = this.plugins.length; i--;) {
+    if (this.plugins[i].onUpdate) {
+      content = this.plugins[i].onUpdate(content, config) || content
+    }
+  }
+
+  new Lexer(this).parse(content)
+  // 出栈未闭合的标签
+  while (this.stack.length) {
+    this.popNode()
+  }
+  if (this.nodes.length > 50) {
+    mergeNodes(this.nodes)
+  }
+  return this.nodes
+}
+
+/**
+ * @description 将标签暴露出来(不被 rich-text 包含)
+ */
+Parser.prototype.expose = function () {
+  // #ifndef APP-PLUS-NVUE
+  for (let i = this.stack.length; i--;) {
+    const item = this.stack[i]
+    if (item.c || item.name === 'a' || item.name === 'video' || item.name === 'audio') return
+    item.c = 1
+  }
+  // #endif
+}
+
+/**
+ * @description 处理插件
+ * @param {Object} node 要处理的标签
+ * @returns {Boolean} 是否要移除此标签
+ */
+Parser.prototype.hook = function (node) {
+  for (let i = this.plugins.length; i--;) {
+    if (this.plugins[i].onParse && this.plugins[i].onParse(node, this) === false) {
+      return false
+    }
+  }
+  return true
+}
+
+/**
+ * @description 将链接拼接上主域名
+ * @param {String} url 需要拼接的链接
+ * @returns {String} 拼接后的链接
+ */
+Parser.prototype.getUrl = function (url) {
+  const domain = this.options.domain
+  if (url[0] === '/') {
+    if (url[1] === '/') {
+      // // 开头的补充协议名
+      url = (domain ? domain.split('://')[0] : 'http') + ':' + url
+    } else if (domain) {
+      // 否则补充整个域名
+      url = domain + url
+    } /* #ifdef APP-PLUS */ else {
+      url = plus.io.convertLocalFileSystemURL(url)
+    } /* #endif */
+  } else if (!url.includes('data:') && !url.includes('://')) {
+    if (domain) {
+      url = domain + '/' + url
+    } /* #ifdef APP-PLUS */ else {
+      url = plus.io.convertLocalFileSystemURL(url)
+    } /* #endif */
+  }
+  return url
+}
+
+/**
+ * @description 解析样式表
+ * @param {Object} node 标签
+ * @returns {Object}
+ */
+Parser.prototype.parseStyle = function (node) {
+  const attrs = node.attrs
+  const list = (this.tagStyle[node.name] || '').split(';').concat((attrs.style || '').split(';'))
+  const styleObj = {}
+  let tmp = ''
+
+  if (attrs.id && !this.xml) {
+    // 暴露锚点
+    if (this.options.useAnchor) {
+      this.expose()
+    } else if (node.name !== 'img' && node.name !== 'a' && node.name !== 'video' && node.name !== 'audio') {
+      attrs.id = undefined
+    }
+  }
+
+  // 转换 width 和 height 属性
+  if (attrs.width) {
+    styleObj.width = parseFloat(attrs.width) + (attrs.width.includes('%') ? '%' : 'px')
+    attrs.width = undefined
+  }
+  if (attrs.height) {
+    styleObj.height = parseFloat(attrs.height) + (attrs.height.includes('%') ? '%' : 'px')
+    attrs.height = undefined
+  }
+
+  for (let i = 0, len = list.length; i < len; i++) {
+    const info = list[i].split(':')
+    if (info.length < 2) continue
+    const key = info.shift().trim().toLowerCase()
+    let value = info.join(':').trim()
+    if ((value[0] === '-' && value.lastIndexOf('-') > 0) || value.includes('safe')) {
+      // 兼容性的 css 不压缩
+      tmp += `;${key}:${value}`
+    } else if (!styleObj[key] || value.includes('import') || !styleObj[key].includes('import')) {
+      // 重复的样式进行覆盖
+      if (value.includes('url')) {
+        // 填充链接
+        let j = value.indexOf('(') + 1
+        if (j) {
+          while (value[j] === '"' || value[j] === "'" || blankChar[value[j]]) {
+            j++
+          }
+          value = value.substr(0, j) + this.getUrl(value.substr(j))
+        }
+      } else if (value.includes('rpx')) {
+        // 转换 rpx(rich-text 内部不支持 rpx)
+        value = value.replace(/[0-9.]+\s*rpx/g, $ => parseFloat($) * windowWidth / 750 + 'px')
+      }
+      styleObj[key] = value
+    }
+  }
+
+  node.attrs.style = tmp
+  return styleObj
+}
+
+/**
+ * @description 解析到标签名
+ * @param {String} name 标签名
+ * @private
+ */
+Parser.prototype.onTagName = function (name) {
+  this.tagName = this.xml ? name : name.toLowerCase()
+  if (this.tagName === 'svg') {
+    this.xml = (this.xml || 0) + 1 // svg 标签内大小写敏感
+    config.ignoreTags.style = undefined // svg 标签内 style 可用
+  }
+}
+
+/**
+ * @description 解析到属性名
+ * @param {String} name 属性名
+ * @private
+ */
+Parser.prototype.onAttrName = function (name) {
+  name = this.xml ? name : name.toLowerCase()
+  // #ifdef (VUE3 && (H5 || APP-PLUS)) || APP-PLUS-NVUE
+  if (name.includes('?') || name.includes(';')) {
+    this.attrName = undefined
+    return
+  }
+  // #endif
+  if (name.substr(0, 5) === 'data-') {
+    if (name === 'data-src' && !this.attrs.src) {
+      // data-src 自动转为 src
+      this.attrName = 'src'
+    } else if (this.tagName === 'img' || this.tagName === 'a') {
+      // a 和 img 标签保留 data- 的属性,可以在 imgtap 和 linktap 事件中使用
+      this.attrName = name
+    } else {
+      // 剩余的移除以减小大小
+      this.attrName = undefined
+    }
+  } else {
+    this.attrName = name
+    this.attrs[name] = 'T' // boolean 型属性缺省设置
+  }
+}
+
+/**
+ * @description 解析到属性值
+ * @param {String} val 属性值
+ * @private
+ */
+Parser.prototype.onAttrVal = function (val) {
+  const name = this.attrName || ''
+  if (name === 'style' || name === 'href') {
+    // 部分属性进行实体解码
+    this.attrs[name] = decodeEntity(val, true)
+  } else if (name.includes('src')) {
+    // 拼接主域名
+    this.attrs[name] = this.getUrl(decodeEntity(val, true))
+  } else if (name) {
+    this.attrs[name] = val
+  }
+}
+
+/**
+ * @description 解析到标签开始
+ * @param {Boolean} selfClose 是否有自闭合标识 />
+ * @private
+ */
+Parser.prototype.onOpenTag = function (selfClose) {
+  // 拼装 node
+  const node = Object.create(null)
+  node.name = this.tagName
+  node.attrs = this.attrs
+  // 避免因为自动 diff 使得 type 被设置为 null 导致部分内容不显示
+  if (this.options.nodes.length) {
+    node.type = 'node'
+  }
+  this.attrs = Object.create(null)
+
+  const attrs = node.attrs
+  const parent = this.stack[this.stack.length - 1]
+  const siblings = parent ? parent.children : this.nodes
+  const close = this.xml ? selfClose : config.voidTags[node.name]
+
+  // 替换标签名选择器
+  if (tagSelector[node.name]) {
+    attrs.class = tagSelector[node.name] + (attrs.class ? ' ' + attrs.class : '')
+  }
+
+  // 转换 embed 标签
+  if (node.name === 'embed') {
+    // #ifndef H5 || APP-PLUS
+    const src = attrs.src || ''
+    // 按照后缀名和 type 将 embed 转为 video 或 audio
+    if (src.includes('.mp4') || src.includes('.3gp') || src.includes('.m3u8') || (attrs.type || '').includes('video')) {
+      node.name = 'video'
+    } else if (src.includes('.mp3') || src.includes('.wav') || src.includes('.aac') || src.includes('.m4a') || (attrs.type || '').includes('audio')) {
+      node.name = 'audio'
+    }
+    if (attrs.autostart) {
+      attrs.autoplay = 'T'
+    }
+    attrs.controls = 'T'
+    // #endif
+    // #ifdef H5 || APP-PLUS
+    this.expose()
+    // #endif
+  }
+
+  // #ifndef APP-PLUS-NVUE
+  // 处理音视频
+  if (node.name === 'video' || node.name === 'audio') {
+    // 设置 id 以便获取 context
+    if (node.name === 'video' && !attrs.id) {
+      attrs.id = 'v' + idIndex++
+    }
+    // 没有设置 controls 也没有设置 autoplay 的自动设置 controls
+    if (!attrs.controls && !attrs.autoplay) {
+      attrs.controls = 'T'
+    }
+    // 用数组存储所有可用的 source
+    node.src = []
+    if (attrs.src) {
+      node.src.push(attrs.src)
+      attrs.src = undefined
+    }
+    this.expose()
+  }
+  // #endif
+
+  // 处理自闭合标签
+  if (close) {
+    if (!this.hook(node) || config.ignoreTags[node.name]) {
+      // 通过 base 标签设置主域名
+      if (node.name === 'base' && !this.options.domain) {
+        this.options.domain = attrs.href
+      } /* #ifndef APP-PLUS-NVUE */ else if (node.name === 'source' && parent && (parent.name === 'video' || parent.name === 'audio') && attrs.src) {
+        // 设置 source 标签(仅父节点为 video 或 audio 时有效)
+        parent.src.push(attrs.src)
+      } /* #endif */
+      return
+    }
+
+    // 解析 style
+    const styleObj = this.parseStyle(node)
+
+    // 处理图片
+    if (node.name === 'img') {
+      if (attrs.src) {
+        // 标记 webp
+        if (attrs.src.includes('webp')) {
+          node.webp = 'T'
+        }
+        // data url 图片如果没有设置 original-src 默认为不可预览的小图片
+        if (attrs.src.includes('data:') && this.options.previewImg !== 'all' && !attrs['original-src']) {
+          attrs.ignore = 'T'
+        }
+        if (!attrs.ignore || node.webp || attrs.src.includes('cloud://')) {
+          for (let i = this.stack.length; i--;) {
+            const item = this.stack[i]
+            if (item.name === 'a') {
+              node.a = item.attrs
+            }
+            if (item.name === 'table' && !node.webp && !attrs.src.includes('cloud://')) {
+              if (!styleObj.display || styleObj.display.includes('inline')) {
+                node.t = 'inline-block'
+              } else {
+                node.t = styleObj.display
+              }
+              styleObj.display = undefined
+            }
+            // #ifndef H5 || APP-PLUS
+            const style = item.attrs.style || ''
+            if (style.includes('flex:') && !style.includes('flex:0') && !style.includes('flex: 0') && (!styleObj.width || parseInt(styleObj.width) > 100)) {
+              styleObj.width = '100% !important'
+              styleObj.height = ''
+              for (let j = i + 1; j < this.stack.length; j++) {
+                this.stack[j].attrs.style = (this.stack[j].attrs.style || '').replace('inline-', '')
+              }
+            } else if (style.includes('flex') && styleObj.width === '100%') {
+              for (let j = i + 1; j < this.stack.length; j++) {
+                const style = this.stack[j].attrs.style || ''
+                if (!style.includes(';width') && !style.includes(' width') && style.indexOf('width') !== 0) {
+                  styleObj.width = ''
+                  break
+                }
+              }
+            } else if (style.includes('inline-block')) {
+              if (styleObj.width && styleObj.width[styleObj.width.length - 1] === '%') {
+                item.attrs.style += ';max-width:' + styleObj.width
+                styleObj.width = ''
+              } else {
+                item.attrs.style += ';max-width:100%'
+              }
+            }
+            // #endif
+            item.c = 1
+          }
+          attrs.i = this.imgList.length.toString()
+          let src = attrs['original-src'] || attrs.src
+          // #ifndef H5 || MP-ALIPAY || APP-PLUS || MP-360
+          if (this.imgList.includes(src)) {
+            // 如果有重复的链接则对域名进行随机大小写变换避免预览时错位
+            let i = src.indexOf('://')
+            if (i !== -1) {
+              i += 3
+              let newSrc = src.substr(0, i)
+              for (; i < src.length; i++) {
+                if (src[i] === '/') break
+                newSrc += Math.random() > 0.5 ? src[i].toUpperCase() : src[i]
+              }
+              newSrc += src.substr(i)
+              src = newSrc
+            }
+          }
+          // #endif
+          this.imgList.push(src)
+          if (!node.t) {
+            this.imgList._unloadimgs += 1
+          }
+          // #ifdef H5 || APP-PLUS
+          if (this.options.lazyLoad) {
+            attrs['data-src'] = attrs.src
+            attrs.src = undefined
+          }
+          // #endif
+        }
+      }
+      if (styleObj.display === 'inline') {
+        styleObj.display = ''
+      }
+      // #ifndef APP-PLUS-NVUE
+      if (attrs.ignore) {
+        styleObj['max-width'] = styleObj['max-width'] || '100%'
+        attrs.style += ';-webkit-touch-callout:none'
+      }
+      // #endif
+      // 设置的宽度超出屏幕,为避免变形,高度转为自动
+      if (parseInt(styleObj.width) > windowWidth) {
+        styleObj.height = undefined
+      }
+      // 记录是否设置了宽高
+      if (!isNaN(parseInt(styleObj.width))) {
+        node.w = 'T'
+      }
+      if (!isNaN(parseInt(styleObj.height)) && (!styleObj.height.includes('%') || (parent && (parent.attrs.style || '').includes('height')))) {
+        node.h = 'T'
+      }
+      if (node.w && node.h && styleObj['object-fit']) {
+        if (styleObj['object-fit'] === 'contain') {
+          node.m = 'aspectFit'
+        } else if (styleObj['object-fit'] === 'cover') {
+          node.m = 'aspectFill'
+        }
+      }
+    } else if (node.name === 'svg') {
+      siblings.push(node)
+      this.stack.push(node)
+      this.popNode()
+      return
+    }
+    for (const key in styleObj) {
+      if (styleObj[key]) {
+        attrs.style += `;${key}:${styleObj[key].replace(' !important', '')}`
+      }
+    }
+    attrs.style = attrs.style.substr(1) || undefined
+    // #ifdef (MP-WEIXIN || MP-QQ) && VUE3
+    if (!attrs.style) {
+      delete attrs.style
+    }
+    // #endif
+  } else {
+    if ((node.name === 'pre' || ((attrs.style || '').includes('white-space') && attrs.style.includes('pre'))) && this.pre !== 2) {
+      this.pre = node.pre = 1
+    }
+    node.children = []
+    this.stack.push(node)
+  }
+
+  // 加入节点树
+  siblings.push(node)
+}
+
+/**
+ * @description 解析到标签结束
+ * @param {String} name 标签名
+ * @private
+ */
+Parser.prototype.onCloseTag = function (name) {
+  // 依次出栈到匹配为止
+  name = this.xml ? name : name.toLowerCase()
+  let i
+  for (i = this.stack.length; i--;) {
+    if (this.stack[i].name === name) break
+  }
+  if (i !== -1) {
+    while (this.stack.length > i) {
+      this.popNode()
+    }
+  } else if (name === 'p' || name === 'br') {
+    const siblings = this.stack.length ? this.stack[this.stack.length - 1].children : this.nodes
+    siblings.push({
+      name,
+      attrs: {
+        class: tagSelector[name] || '',
+        style: this.tagStyle[name] || ''
+      }
+    })
+  }
+}
+
+/**
+ * @description 处理标签出栈
+ * @private
+ */
+Parser.prototype.popNode = function () {
+  const node = this.stack.pop()
+  let attrs = node.attrs
+  const children = node.children
+  const parent = this.stack[this.stack.length - 1]
+  const siblings = parent ? parent.children : this.nodes
+
+  if (!this.hook(node) || config.ignoreTags[node.name]) {
+    // 获取标题
+    if (node.name === 'title' && children.length && children[0].type === 'text' && this.options.setTitle) {
+      uni.setNavigationBarTitle({
+        title: children[0].text
+      })
+    }
+    siblings.pop()
+    return
+  }
+
+  if (node.pre && this.pre !== 2) {
+    // 是否合并空白符标识
+    this.pre = node.pre = undefined
+    for (let i = this.stack.length; i--;) {
+      if (this.stack[i].pre) {
+        this.pre = 1
+      }
+    }
+  }
+
+  const styleObj = {}
+
+  // 转换 svg
+  if (node.name === 'svg') {
+    if (this.xml > 1) {
+      // 多层 svg 嵌套
+      this.xml--
+      return
+    }
+    // #ifdef APP-PLUS-NVUE
+    (function traversal (node) {
+      if (node.name) {
+        // 调整 svg 的大小写
+        node.name = config.svgDict[node.name] || node.name
+        for (const item in node.attrs) {
+          if (config.svgDict[item]) {
+            node.attrs[config.svgDict[item]] = node.attrs[item]
+            node.attrs[item] = undefined
+          }
+        }
+        for (let i = 0; i < (node.children || []).length; i++) {
+          traversal(node.children[i])
+        }
+      }
+    })(node)
+    // #endif
+    // #ifndef APP-PLUS-NVUE
+    let src = ''
+    const style = attrs.style
+    attrs.style = ''
+    attrs.xmlns = 'http://www.w3.org/2000/svg';
+    (function traversal (node) {
+      if (node.type === 'text') {
+        src += node.text
+        return
+      }
+      const name = config.svgDict[node.name] || node.name
+      if (name === 'foreignObject') {
+        for (const child of (node.children || [])) {
+          if (child.attrs && !child.attrs.xmlns) {
+            child.attrs.xmlns = 'http://www.w3.org/1999/xhtml'
+            break
+          }
+        }
+      }
+      src += '<' + name
+      for (const item in node.attrs) {
+        const val = node.attrs[item]
+        if (val) {
+          src += ` ${config.svgDict[item] || item}="${val.replace(/"/g, '')}"`
+        }
+      }
+      if (!node.children) {
+        src += '/>'
+      } else {
+        src += '>'
+        for (let i = 0; i < node.children.length; i++) {
+          traversal(node.children[i])
+        }
+        src += '</' + name + '>'
+      }
+    })(node)
+    node.name = 'img'
+    node.attrs = {
+      src: 'data:image/svg+xml;utf8,' + src.replace(/#/g, '%23'),
+      style,
+      ignore: 'T'
+    }
+    node.children = undefined
+    // #endif
+    this.xml = false
+    config.ignoreTags.style = true
+    return
+  }
+
+  // #ifndef APP-PLUS-NVUE
+  // 转换 align 属性
+  if (attrs.align) {
+    if (node.name === 'table') {
+      if (attrs.align === 'center') {
+        styleObj['margin-inline-start'] = styleObj['margin-inline-end'] = 'auto'
+      } else {
+        styleObj.float = attrs.align
+      }
+    } else {
+      styleObj['text-align'] = attrs.align
+    }
+    attrs.align = undefined
+  }
+
+  // 转换 dir 属性
+  if (attrs.dir) {
+    styleObj.direction = attrs.dir
+    attrs.dir = undefined
+  }
+
+  // 转换 font 标签的属性
+  if (node.name === 'font') {
+    if (attrs.color) {
+      styleObj.color = attrs.color
+      attrs.color = undefined
+    }
+    if (attrs.face) {
+      styleObj['font-family'] = attrs.face
+      attrs.face = undefined
+    }
+    if (attrs.size) {
+      let size = parseInt(attrs.size)
+      if (!isNaN(size)) {
+        if (size < 1) {
+          size = 1
+        } else if (size > 7) {
+          size = 7
+        }
+        styleObj['font-size'] = ['x-small', 'small', 'medium', 'large', 'x-large', 'xx-large', 'xxx-large'][size - 1]
+      }
+      attrs.size = undefined
+    }
+  }
+  // #endif
+
+  // 一些编辑器的自带 class
+  if ((attrs.class || '').includes('align-center')) {
+    styleObj['text-align'] = 'center'
+  }
+
+  Object.assign(styleObj, this.parseStyle(node))
+
+  if (node.name !== 'table' && parseInt(styleObj.width) > windowWidth) {
+    styleObj['max-width'] = '100%'
+    styleObj['box-sizing'] = 'border-box'
+  }
+
+  // #ifndef APP-PLUS-NVUE
+  if (config.blockTags[node.name]) {
+    node.name = 'div'
+  } else if (!config.trustTags[node.name] && !this.xml) {
+    // 未知标签转为 span,避免无法显示
+    node.name = 'span'
+  }
+
+  if (node.name === 'a' || node.name === 'ad'
+    // #ifdef H5 || APP-PLUS
+    || node.name === 'iframe' // eslint-disable-line
+    // #endif
+  ) {
+    this.expose()
+  } else if (node.name === 'video') {
+    if ((styleObj.height || '').includes('auto')) {
+      styleObj.height = undefined
+    }
+    /* #ifdef APP-PLUS */
+    let str = '<video style="width:100%;height:100%"'
+    for (const item in attrs) {
+      if (attrs[item]) {
+        str += ' ' + item + '="' + attrs[item] + '"'
+      }
+    }
+    if (this.options.pauseVideo) {
+      str += ' onplay="this.dispatchEvent(new CustomEvent(\'vplay\',{bubbles:!0}));for(var e=document.getElementsByTagName(\'video\'),t=0;t<e.length;t++)e[t]!=this&&e[t].pause()"'
+    }
+    str += '>'
+    for (let i = 0; i < node.src.length; i++) {
+      str += '<source src="' + node.src[i] + '">'
+    }
+    str += '</video>'
+    node.html = str
+    /* #endif */
+  } else if ((node.name === 'ul' || node.name === 'ol') && node.c) {
+    // 列表处理
+    const types = {
+      a: 'lower-alpha',
+      A: 'upper-alpha',
+      i: 'lower-roman',
+      I: 'upper-roman'
+    }
+    if (types[attrs.type]) {
+      attrs.style += ';list-style-type:' + types[attrs.type]
+      attrs.type = undefined
+    }
+    for (let i = children.length; i--;) {
+      if (children[i].name === 'li') {
+        children[i].c = 1
+      }
+    }
+  } else if (node.name === 'table') {
+    // 表格处理
+    // cellpadding、cellspacing、border 这几个常用表格属性需要通过转换实现
+    let padding = parseFloat(attrs.cellpadding)
+    let spacing = parseFloat(attrs.cellspacing)
+    const border = parseFloat(attrs.border)
+    const bordercolor = styleObj['border-color']
+    const borderstyle = styleObj['border-style']
+    if (node.c) {
+      // padding 和 spacing 默认 2
+      if (isNaN(padding)) {
+        padding = 2
+      }
+      if (isNaN(spacing)) {
+        spacing = 2
+      }
+    }
+    if (border) {
+      attrs.style += `;border:${border}px ${borderstyle || 'solid'} ${bordercolor || 'gray'}`
+    }
+    if (node.flag && node.c) {
+      // 有 colspan 或 rowspan 且含有链接的表格通过 grid 布局实现
+      styleObj.display = 'grid'
+      if (styleObj['border-collapse'] === 'collapse') {
+        styleObj['border-collapse'] = undefined
+        spacing = 0
+      }
+      if (spacing) {
+        styleObj['grid-gap'] = spacing + 'px'
+        styleObj.padding = spacing + 'px'
+      } else if (border) {
+        // 无间隔的情况下避免边框重叠
+        attrs.style += ';border-left:0;border-top:0'
+      }
+
+      const width = [] // 表格的列宽
+      const trList = [] // tr 列表
+      const cells = [] // 保存新的单元格
+      const map = {}; // 被合并单元格占用的格子
+
+      (function traversal (nodes) {
+        for (let i = 0; i < nodes.length; i++) {
+          if (nodes[i].name === 'tr') {
+            trList.push(nodes[i])
+          } else if (nodes[i].name === 'colgroup') {
+            let colI = 1
+            for (const col of (nodes[i].children || [])) {
+              if (col.name === 'col') {
+                const style = col.attrs.style || ''
+                const start = style.indexOf('width') ? style.indexOf(';width') : 0
+                // 提取出宽度
+                if (start !== -1) {
+                  let end = style.indexOf(';', start + 6)
+                  if (end === -1) {
+                    end = style.length
+                  }
+                  width[colI] = style.substring(start ? start + 7 : 6, end)
+                }
+                colI += 1
+              }
+            }
+          } else {
+            traversal(nodes[i].children || [])
+          }
+        }
+      })(children)
+
+      for (let row = 1; row <= trList.length; row++) {
+        let col = 1
+        for (let j = 0; j < trList[row - 1].children.length; j++) {
+          const td = trList[row - 1].children[j]
+          if (td.name === 'td' || td.name === 'th') {
+            // 这个格子被上面的单元格占用,则列号++
+            while (map[row + '.' + col]) {
+              col++
+            }
+            let style = td.attrs.style || ''
+            let start = style.indexOf('width') ? style.indexOf(';width') : 0
+            // 提取出 td 的宽度
+            if (start !== -1) {
+              let end = style.indexOf(';', start + 6)
+              if (end === -1) {
+                end = style.length
+              }
+              if (!td.attrs.colspan) {
+                width[col] = style.substring(start ? start + 7 : 6, end)
+              }
+              style = style.substr(0, start) + style.substr(end)
+            }
+            // 设置竖直对齐
+            style += ';display:flex'
+            start = style.indexOf('vertical-align')
+            if (start !== -1) {
+              const val = style.substr(start + 15, 10)
+              if (val.includes('middle')) {
+                style += ';align-items:center'
+              } else if (val.includes('bottom')) {
+                style += ';align-items:flex-end'
+              }
+            } else {
+              style += ';align-items:center'
+            }
+            // 设置水平对齐
+            start = style.indexOf('text-align')
+            if (start !== -1) {
+              const val = style.substr(start + 11, 10)
+              if (val.includes('center')) {
+                style += ';justify-content: center'
+              } else if (val.includes('right')) {
+                style += ';justify-content: right'
+              }
+            }
+            style = (border ? `;border:${border}px ${borderstyle || 'solid'} ${bordercolor || 'gray'}` + (spacing ? '' : ';border-right:0;border-bottom:0') : '') + (padding ? `;padding:${padding}px` : '') + ';' + style
+            // 处理列合并
+            if (td.attrs.colspan) {
+              style += `;grid-column-start:${col};grid-column-end:${col + parseInt(td.attrs.colspan)}`
+              if (!td.attrs.rowspan) {
+                style += `;grid-row-start:${row};grid-row-end:${row + 1}`
+              }
+              col += parseInt(td.attrs.colspan) - 1
+            }
+            // 处理行合并
+            if (td.attrs.rowspan) {
+              style += `;grid-row-start:${row};grid-row-end:${row + parseInt(td.attrs.rowspan)}`
+              if (!td.attrs.colspan) {
+                style += `;grid-column-start:${col};grid-column-end:${col + 1}`
+              }
+              // 记录下方单元格被占用
+              for (let rowspan = 1; rowspan < td.attrs.rowspan; rowspan++) {
+                for (let colspan = 0; colspan < (td.attrs.colspan || 1); colspan++) {
+                  map[(row + rowspan) + '.' + (col - colspan)] = 1
+                }
+              }
+            }
+            if (style) {
+              td.attrs.style = style
+            }
+            cells.push(td)
+            col++
+          }
+        }
+        if (row === 1) {
+          let temp = ''
+          for (let i = 1; i < col; i++) {
+            temp += (width[i] ? width[i] : 'auto') + ' '
+          }
+          styleObj['grid-template-columns'] = temp
+        }
+      }
+      node.children = cells
+    } else {
+      // 没有使用合并单元格的表格通过 table 布局实现
+      if (node.c) {
+        styleObj.display = 'table'
+      }
+      if (!isNaN(spacing)) {
+        styleObj['border-spacing'] = spacing + 'px'
+      }
+      if (border || padding) {
+        // 遍历
+        (function traversal (nodes) {
+          for (let i = 0; i < nodes.length; i++) {
+            const td = nodes[i]
+            if (td.name === 'th' || td.name === 'td') {
+              if (border) {
+                td.attrs.style = `border:${border}px ${borderstyle || 'solid'} ${bordercolor || 'gray'};${td.attrs.style || ''}`
+              }
+              if (padding) {
+                td.attrs.style = `padding:${padding}px;${td.attrs.style || ''}`
+              }
+            } else if (td.children) {
+              traversal(td.children)
+            }
+          }
+        })(children)
+      }
+    }
+    // 给表格添加一个单独的横向滚动层
+    if (this.options.scrollTable && !(attrs.style || '').includes('inline')) {
+      const table = Object.assign({}, node)
+      node.name = 'div'
+      node.attrs = {
+        style: 'overflow:auto'
+      }
+      node.children = [table]
+      attrs = table.attrs
+    }
+  } else if ((node.name === 'tbody' || node.name === 'tr') && node.flag && node.c) {
+    node.flag = undefined;
+    (function traversal (nodes) {
+      for (let i = 0; i < nodes.length; i++) {
+        if (nodes[i].name === 'td') {
+          // 颜色样式设置给单元格避免丢失
+          for (const style of ['color', 'background', 'background-color']) {
+            if (styleObj[style]) {
+              nodes[i].attrs.style = style + ':' + styleObj[style] + ';' + (nodes[i].attrs.style || '')
+            }
+          }
+        } else {
+          traversal(nodes[i].children || [])
+        }
+      }
+    })(children)
+  } else if ((node.name === 'td' || node.name === 'th') && (attrs.colspan || attrs.rowspan)) {
+    for (let i = this.stack.length; i--;) {
+      if (this.stack[i].name === 'table' || this.stack[i].name === 'tbody' || this.stack[i].name === 'tr') {
+        this.stack[i].flag = 1 // 指示含有合并单元格
+      }
+    }
+  } else if (node.name === 'ruby') {
+    // 转换 ruby
+    node.name = 'span'
+    for (let i = 0; i < children.length - 1; i++) {
+      if (children[i].type === 'text' && children[i + 1].name === 'rt') {
+        children[i] = {
+          name: 'div',
+          attrs: {
+            style: 'display:inline-block;text-align:center'
+          },
+          children: [{
+            name: 'div',
+            attrs: {
+              style: 'font-size:50%;' + (children[i + 1].attrs.style || '')
+            },
+            children: children[i + 1].children
+          }, children[i]]
+        }
+        children.splice(i + 1, 1)
+      }
+    }
+  } else if (node.c) {
+    (function traversal (node) {
+      node.c = 2
+      for (let i = node.children.length; i--;) {
+        const child = node.children[i]
+        // #ifdef (MP-WEIXIN || MP-QQ || APP-PLUS || MP-360) && VUE3
+        if (child.name && (config.inlineTags[child.name] || ((child.attrs.style || '').includes('inline') && child.children)) && !child.c) {
+          traversal(child)
+        }
+        // #endif
+        if (!child.c || child.name === 'table') {
+          node.c = 1
+        }
+      }
+    })(node)
+  }
+
+  if ((styleObj.display || '').includes('flex') && !node.c) {
+    for (let i = children.length; i--;) {
+      const item = children[i]
+      if (item.f) {
+        item.attrs.style = (item.attrs.style || '') + item.f
+        item.f = undefined
+      }
+    }
+  }
+  // flex 布局时部分样式需要提取到 rich-text 外层
+  const flex = parent && ((parent.attrs.style || '').includes('flex') || (parent.attrs.style || '').includes('grid'))
+    // #ifdef MP-WEIXIN
+    // 检查基础库版本 virtualHost 是否可用
+    && !(node.c && wx.getNFCAdapter) // eslint-disable-line
+    // #endif
+    // #ifndef MP-WEIXIN || MP-QQ || MP-BAIDU || MP-TOUTIAO
+    && !node.c // eslint-disable-line
+  // #endif
+  if (flex) {
+    node.f = ';max-width:100%'
+  }
+
+  if (children.length >= 50 && node.c && !(styleObj.display || '').includes('flex')) {
+    mergeNodes(children)
+  }
+  // #endif
+
+  for (const key in styleObj) {
+    if (styleObj[key]) {
+      const val = `;${key}:${styleObj[key].replace(' !important', '')}`
+      /* #ifndef APP-PLUS-NVUE */
+      if (flex && ((key.includes('flex') && key !== 'flex-direction') || key === 'align-self' || key.includes('grid') || styleObj[key][0] === '-' || (key.includes('width') && val.includes('%')))) {
+        node.f += val
+        if (key === 'width') {
+          attrs.style += ';width:100%'
+        }
+      } else /* #endif */ {
+        attrs.style += val
+      }
+    }
+  }
+  attrs.style = attrs.style.substr(1) || undefined
+  // #ifdef (MP-WEIXIN || MP-QQ) && VUE3
+  for (const key in attrs) {
+    if (!attrs[key]) {
+      delete attrs[key]
+    }
+  }
+  // #endif
+}
+
+/**
+ * @description 解析到文本
+ * @param {String} text 文本内容
+ */
+Parser.prototype.onText = function (text) {
+  if (!this.pre) {
+    // 合并空白符
+    let trim = ''
+    let flag
+    for (let i = 0, len = text.length; i < len; i++) {
+      if (!blankChar[text[i]]) {
+        trim += text[i]
+      } else {
+        if (trim[trim.length - 1] !== ' ') {
+          trim += ' '
+        }
+        if (text[i] === '\n' && !flag) {
+          flag = true
+        }
+      }
+    }
+    // 去除含有换行符的空串
+    if (trim === ' ') {
+      if (flag) return
+      // #ifdef VUE3
+      else {
+        const parent = this.stack[this.stack.length - 1]
+        if (parent && parent.name[0] === 't') return
+      }
+      // #endif
+    }
+    text = trim
+  }
+  const node = Object.create(null)
+  node.type = 'text'
+  // #ifdef (MP-BAIDU || MP-ALIPAY || MP-TOUTIAO) && VUE3
+  node.attrs = {}
+  // #endif
+  node.text = decodeEntity(text)
+  if (this.hook(node)) {
+    // #ifdef MP-WEIXIN
+    if (this.options.selectable === 'force' && system.includes('iOS') && !uni.canIUse('rich-text.user-select')) {
+      this.expose()
+    }
+    // #endif
+    const siblings = this.stack.length ? this.stack[this.stack.length - 1].children : this.nodes
+    siblings.push(node)
+  }
+}
+
+/**
+ * @description html 词法分析器
+ * @param {Object} handler 高层处理器
+ */
+function Lexer (handler) {
+  this.handler = handler
+}
+
+/**
+ * @description 执行解析
+ * @param {String} content 要解析的文本
+ */
+Lexer.prototype.parse = function (content) {
+  this.content = content || ''
+  this.i = 0 // 标记解析位置
+  this.start = 0 // 标记一个单词的开始位置
+  this.state = this.text // 当前状态
+  for (let len = this.content.length; this.i !== -1 && this.i < len;) {
+    this.state()
+  }
+}
+
+/**
+ * @description 检查标签是否闭合
+ * @param {String} method 如果闭合要进行的操作
+ * @returns {Boolean} 是否闭合
+ * @private
+ */
+Lexer.prototype.checkClose = function (method) {
+  const selfClose = this.content[this.i] === '/'
+  if (this.content[this.i] === '>' || (selfClose && this.content[this.i + 1] === '>')) {
+    if (method) {
+      this.handler[method](this.content.substring(this.start, this.i))
+    }
+    this.i += selfClose ? 2 : 1
+    this.start = this.i
+    this.handler.onOpenTag(selfClose)
+    if (this.handler.tagName === 'script') {
+      this.i = this.content.indexOf('</', this.i)
+      if (this.i !== -1) {
+        this.i += 2
+        this.start = this.i
+      }
+      this.state = this.endTag
+    } else {
+      this.state = this.text
+    }
+    return true
+  }
+  return false
+}
+
+/**
+ * @description 文本状态
+ * @private
+ */
+Lexer.prototype.text = function () {
+  this.i = this.content.indexOf('<', this.i) // 查找最近的标签
+  if (this.i === -1) {
+    // 没有标签了
+    if (this.start < this.content.length) {
+      this.handler.onText(this.content.substring(this.start, this.content.length))
+    }
+    return
+  }
+  const c = this.content[this.i + 1]
+  if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')) {
+    // 标签开头
+    if (this.start !== this.i) {
+      this.handler.onText(this.content.substring(this.start, this.i))
+    }
+    this.start = ++this.i
+    this.state = this.tagName
+  } else if (c === '/' || c === '!' || c === '?') {
+    if (this.start !== this.i) {
+      this.handler.onText(this.content.substring(this.start, this.i))
+    }
+    const next = this.content[this.i + 2]
+    if (c === '/' && ((next >= 'a' && next <= 'z') || (next >= 'A' && next <= 'Z'))) {
+      // 标签结尾
+      this.i += 2
+      this.start = this.i
+      this.state = this.endTag
+      return
+    }
+    // 处理注释
+    let end = '-->'
+    if (c !== '!' || this.content[this.i + 2] !== '-' || this.content[this.i + 3] !== '-') {
+      end = '>'
+    }
+    this.i = this.content.indexOf(end, this.i)
+    if (this.i !== -1) {
+      this.i += end.length
+      this.start = this.i
+    }
+  } else {
+    this.i++
+  }
+}
+
+/**
+ * @description 标签名状态
+ * @private
+ */
+Lexer.prototype.tagName = function () {
+  if (blankChar[this.content[this.i]]) {
+    // 解析到标签名
+    this.handler.onTagName(this.content.substring(this.start, this.i))
+    while (blankChar[this.content[++this.i]]);
+    if (this.i < this.content.length && !this.checkClose()) {
+      this.start = this.i
+      this.state = this.attrName
+    }
+  } else if (!this.checkClose('onTagName')) {
+    this.i++
+  }
+}
+
+/**
+ * @description 属性名状态
+ * @private
+ */
+Lexer.prototype.attrName = function () {
+  let c = this.content[this.i]
+  if (blankChar[c] || c === '=') {
+    // 解析到属性名
+    this.handler.onAttrName(this.content.substring(this.start, this.i))
+    let needVal = c === '='
+    const len = this.content.length
+    while (++this.i < len) {
+      c = this.content[this.i]
+      if (!blankChar[c]) {
+        if (this.checkClose()) return
+        if (needVal) {
+          // 等号后遇到第一个非空字符
+          this.start = this.i
+          this.state = this.attrVal
+          return
+        }
+        if (this.content[this.i] === '=') {
+          needVal = true
+        } else {
+          this.start = this.i
+          this.state = this.attrName
+          return
+        }
+      }
+    }
+  } else if (!this.checkClose('onAttrName')) {
+    this.i++
+  }
+}
+
+/**
+ * @description 属性值状态
+ * @private
+ */
+Lexer.prototype.attrVal = function () {
+  const c = this.content[this.i]
+  const len = this.content.length
+  if (c === '"' || c === "'") {
+    // 有冒号的属性
+    this.start = ++this.i
+    this.i = this.content.indexOf(c, this.i)
+    if (this.i === -1) return
+    this.handler.onAttrVal(this.content.substring(this.start, this.i))
+  } else {
+    // 没有冒号的属性
+    for (; this.i < len; this.i++) {
+      if (blankChar[this.content[this.i]]) {
+        this.handler.onAttrVal(this.content.substring(this.start, this.i))
+        break
+      } else if (this.checkClose('onAttrVal')) return
+    }
+  }
+  while (blankChar[this.content[++this.i]]);
+  if (this.i < len && !this.checkClose()) {
+    this.start = this.i
+    this.state = this.attrName
+  }
+}
+
+/**
+ * @description 结束标签状态
+ * @returns {String} 结束的标签名
+ * @private
+ */
+Lexer.prototype.endTag = function () {
+  const c = this.content[this.i]
+  if (blankChar[c] || c === '>' || c === '/') {
+    this.handler.onCloseTag(this.content.substring(this.start, this.i))
+    if (c !== '>') {
+      this.i = this.content.indexOf('>', this.i)
+      if (this.i === -1) return
+    }
+    this.start = ++this.i
+    this.state = this.text
+  } else {
+    this.i++
+  }
+}
+
+export default Parser

+ 79 - 0
src/uni_modules/mp-html/package.json

@@ -0,0 +1,79 @@
+{
+    "id": "mp-html",
+    "displayName": "mp-html 富文本组件【全端支持,支持编辑、latex等扩展】",
+    "version": "v2.5.1",
+    "description": "一个强大的富文本组件,高效轻量,功能丰富",
+    "keywords": [
+        "富文本",
+        "编辑器",
+        "html",
+        "rich-text",
+        "editor"
+    ],
+    "repository": "https://github.com/jin-yufeng/mp-html",
+    "dcloudext": {
+        "sale": {
+            "regular": {
+                "price": "0.00"
+            },
+            "sourcecode": {
+                "price": "0.00"
+            }
+        },
+        "contact": {
+            "qq": ""
+        },
+        "declaration": {
+            "ads": "无",
+            "data": "无",
+            "permissions": "无"
+        },
+        "npmurl": "https://www.npmjs.com/package/mp-html",
+        "type": "component-vue"
+    },
+    "uni_modules": {
+        "platforms": {
+            "cloud": {
+                "tcb": "y",
+                "aliyun": "y",
+                "alipay": "n"
+            },
+            "client": {
+                "App": {
+                    "app-vue": "y",
+                    "app-nvue": "y",
+                    "app-harmony": "u",
+                    "app-uvue": "u"
+                },
+                "H5-mobile": {
+                    "Safari": "y",
+                    "Android Browser": "y",
+                    "微信浏览器(Android)": "y",
+                    "QQ浏览器(Android)": "y"
+                },
+                "H5-pc": {
+                    "Chrome": "y",
+                    "IE": "u",
+                    "Edge": "y",
+                    "Firefox": "y",
+                    "Safari": "y"
+                },
+                "小程序": {
+                    "微信": "y",
+                    "阿里": "y",
+                    "百度": "y",
+                    "字节跳动": "y",
+                    "QQ": "y"
+                },
+                "快应用": {
+                    "华为": "y",
+                    "联盟": "y"
+                },
+                "Vue": {
+                    "vue2": "y",
+                    "vue3": "y"
+                }
+            }
+        }
+    }
+}

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
src/uni_modules/mp-html/static/app-plus/mp-html/js/handler.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
src/uni_modules/mp-html/static/app-plus/mp-html/js/uni.webview.min.js


+ 1 - 0
src/uni_modules/mp-html/static/app-plus/mp-html/local.html

@@ -0,0 +1 @@
+<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=1,user-scalable=no"><style>body,html{width:100%;height:100%;overflow-x:scroll;overflow-y:hidden}body{margin:0}video{width:300px;height:225px}img{max-width:100%;-webkit-touch-callout:none}</style></head><body><div id="content" style="overflow:hidden"></div><script type="text/javascript" src="./js/uni.webview.min.js"></script><script type="text/javascript" src="./js/handler.js"></script></body>

+ 228 - 0
src/utils/loadFont.ts

@@ -0,0 +1,228 @@
+type FontConfig = {
+  family: string;
+  source: string;
+  desc: {
+    style: string;
+    weight: string;
+    variant: string;
+  };
+}
+const fonts: FontConfig[] = [
+  {
+    family: 'KaTeX_AMS',
+    source: 'url("https://mingxuejingbang.oss-cn-beijing.aliyuncs.com/ie/iePlus/fonts/KaTeX_AMS-Regular.woff")',
+    desc: {
+      style: 'normal',
+      weight: '400',
+      variant: 'normal'
+    }
+  },
+  {
+    family: 'KaTeX_Math',
+    source: 'url("https://mingxuejingbang.oss-cn-beijing.aliyuncs.com/ie/iePlus/fonts/KaTeX_Math-Italic.woff")',
+    desc: {
+      style: 'italic',
+      weight: '400',
+      variant: 'normal'
+    }
+  },
+  {
+    family: 'KaTeX_Caligraphic',
+    source: 'url("https://mingxuejingbang.oss-cn-beijing.aliyuncs.com/ie/iePlus/fonts/KaTeX_Caligraphic-Bold.woff")',
+    desc: {
+      style: 'normal',
+      weight: '700',
+      variant: 'normal'
+    }
+  },
+  {
+    family: 'KaTeX_Caligraphic',
+    source: 'url("https://mingxuejingbang.oss-cn-beijing.aliyuncs.com/ie/iePlus/fonts/KaTeX_Caligraphic-Regular.woff")',
+    desc: {
+      style: 'normal',
+      weight: '400',
+      variant: 'normal'
+    }
+  },
+  {
+    family: 'KaTeX_Fraktur',
+    source: 'url("https://mingxuejingbang.oss-cn-beijing.aliyuncs.com/ie/iePlus/fonts/KaTeX_Fraktur-Bold.woff")',
+    desc: {
+      style: 'normal',
+      weight: '700',
+      variant: 'normal'
+    }
+  },
+  {
+    family: 'KaTeX_Fraktur',
+    source: 'url("https://mingxuejingbang.oss-cn-beijing.aliyuncs.com/ie/iePlus/fonts/KaTeX_Fraktur-Regular.woff")',
+    desc: {
+      style: 'normal',
+      weight: '400',
+      variant: 'normal'
+    }
+  },
+  {
+    family: 'KaTeX_Main',
+    source: 'url("https://mingxuejingbang.oss-cn-beijing.aliyuncs.com/ie/iePlus/fonts/KaTeX_Main-Regular.woff")',
+    desc: {
+      style: 'normal',
+      weight: '400',
+      variant: 'normal'
+    }
+  },
+  {
+    family: 'KaTeX_Main',
+    source: 'url("https://mingxuejingbang.oss-cn-beijing.aliyuncs.com/ie/iePlus/fonts/KaTeX_Main-Bold.woff")',
+    desc: {
+      style: 'normal',
+      weight: '700',
+      variant: 'normal'
+    }
+  },
+  {
+    family: 'KaTeX_Main',
+    source: 'url("https://mingxuejingbang.oss-cn-beijing.aliyuncs.com/ie/iePlus/fonts/KaTeX_Main-BoldItalic.woff")',
+    desc: {
+      style: 'italic',
+      weight: '700',
+      variant: 'normal'
+    }
+  },
+  {
+    family: 'KaTeX_Main',
+    source: 'url("https://mingxuejingbang.oss-cn-beijing.aliyuncs.com/ie/iePlus/fonts/KaTeX_Main-Italic.woff")',
+    desc: {
+      style: 'italic',
+      weight: '400',
+      variant: 'normal'
+    }
+  },
+  {
+    family: 'KaTeX_Math',
+    source: 'url("https://mingxuejingbang.oss-cn-beijing.aliyuncs.com/ie/iePlus/fonts/KaTeX_Math-Italic.woff")',
+    desc: {
+      style: 'italic',
+      weight: '400',
+      variant: 'normal'
+    }
+  },
+  {
+    family: 'KaTeX_Math',
+    source: 'url("https://mingxuejingbang.oss-cn-beijing.aliyuncs.com/ie/iePlus/fonts/KaTeX_Math-BoldItalic.woff")',
+    desc: {
+      style: 'italic',
+      weight: '700',
+      variant: 'normal'
+    }
+  },
+  {
+    family: 'KaTeX_SansSerif',
+    source: 'url("https://mingxuejingbang.oss-cn-beijing.aliyuncs.com/ie/iePlus/fonts/KaTeX_SansSerif-Bold.woff")',
+    desc: {
+      style: 'normal',
+      weight: '700',
+      variant: 'normal'
+    }
+  },
+  {
+    family: 'KaTeX_SansSerif',
+    source: 'url("https://mingxuejingbang.oss-cn-beijing.aliyuncs.com/ie/iePlus/fonts/KaTeX_SansSerif-Regular.woff")',
+    desc: {
+      style: 'normal',
+      weight: '400',
+      variant: 'normal'
+    }
+  },
+  {
+    family: 'KaTeX_SansSerif',
+    source: 'url("https://mingxuejingbang.oss-cn-beijing.aliyuncs.com/ie/iePlus/fonts/KaTeX_SansSerif-Italic.woff")',
+    desc: {
+      style: 'italic',
+      weight: '400',
+      variant: 'normal'
+    }
+  },
+  {
+    family: 'KaTeX_Script',
+    source: 'url("https://mingxuejingbang.oss-cn-beijing.aliyuncs.com/ie/iePlus/fonts/KaTeX_Script-Regular.woff")',
+    desc: {
+      style: 'normal',
+      weight: '400',
+      variant: 'normal'
+    }
+  },
+  {
+    family: 'KaTeX_Size1',
+    source: 'url("https://mingxuejingbang.oss-cn-beijing.aliyuncs.com/ie/iePlus/fonts/KaTeX_Size1-Regular.woff")',
+    desc: {
+      style: 'normal',
+      weight: '400',
+      variant: 'normal'
+    }
+  },
+  {
+    family: 'KaTeX_Size2',
+    source: 'url("https://mingxuejingbang.oss-cn-beijing.aliyuncs.com/ie/iePlus/fonts/KaTeX_Size2-Regular.woff")',
+    desc: {
+      style: 'normal',
+      weight: '400',
+      variant: 'normal'
+    }
+  },
+  {
+    family: 'KaTeX_Size3',
+    source: 'url("https://mingxuejingbang.oss-cn-beijing.aliyuncs.com/ie/iePlus/fonts/KaTeX_Size3-Regular.woff")',
+    desc: {
+      style: 'normal',
+      weight: '400',
+      variant: 'normal'
+    }
+  },
+  {
+    family: 'KaTeX_Size4',
+    source: 'url("https://mingxuejingbang.oss-cn-beijing.aliyuncs.com/ie/iePlus/fonts/KaTeX_Size4-Regular.woff")',
+    desc: {
+      style: 'normal',
+      weight: '400',
+      variant: 'normal'
+    }
+  },
+  {
+    family: 'KaTeX_Typewriter',
+    source: 'url("https://mingxuejingbang.oss-cn-beijing.aliyuncs.com/ie/iePlus/fonts/KaTeX_Typewriter-Regular.woff")',
+    desc: {
+      style: 'normal',
+      weight: '400',
+      variant: 'normal'
+    }
+  }
+];
+function loadFont(font: FontConfig) {
+  return new Promise((resolve, reject) => {
+    uni.loadFontFace({
+      family: font.family,
+      source: font.source,
+      desc: font.desc,
+      success() {
+        resolve(true);
+      },
+      fail(err) {
+        reject(err);
+      }
+    });
+  });
+}
+export function load(): Promise<number> {
+  return new Promise((resolve, reject) => {
+    const startTime = Date.now();
+    const promises = fonts.map(font => loadFont(font));
+    Promise.all(promises).then(results => {
+      resolve(Date.now() - startTime);
+      console.log(`字体加载成功,共加载${fonts.length}个字体,用时:${(Date.now() - startTime) / 1000}s`);
+    }).catch(err => {
+      reject(err);
+      console.log(`字体加载失败: ${err}`);
+    });
+  });
+}

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است