Преглед на файлове

添加文本播放功能

lex преди 9 месеца
родител
ревизия
c3000cbb14

+ 2 - 1
index.html

@@ -31,7 +31,8 @@
   <!-- windows phone 点击无高光 -->
   <meta name="msapplication-tap-highlight" content="no" />
   <meta name="referrer" content="no-referrer" />
-  <script data-key="responsivevoice.js" src="https://code.responsivevoice.org/responsivevoice.js?key=5tX5zUJU" ></script>
+  <!-- <script data-key="responsivevoice.js" src="https://code.responsivevoice.org/responsivevoice.js?key=5tX5zUJU" ></script> -->
+  <!-- <script data-key="responsivevoice.js" src="/responsivevoice.js?key=5tX5zUJU"></script> -->
   <title>音乐数字课堂</title>
 
   <style>

Файловите разлики са ограничени, защото са твърде много
+ 4436 - 0
responsivevoice.js


+ 53 - 0
src/views/content-information/content-instrument/detail.module.less

@@ -36,6 +36,11 @@
       background: var(--product-color);
     }
 
+
+    .highlight {
+      color: #0378EC;
+    }
+
   }
 
   &> :global(.n-space) {
@@ -394,6 +399,7 @@
         line-height: 18px;
         display: flex;
         align-items: center;
+        cursor: pointer;
       }
 
       .icon {
@@ -424,6 +430,7 @@
     height: 100%;
     padding: 0 27px;
     user-select: text;
+    position: relative;
 
     &>img {
       width: 100%;
@@ -504,3 +511,49 @@
 
   }
 }
+
+
+.selectionCouser {
+  display: flex;
+  align-items: center;
+  position: absolute;
+  &.hide {
+    opacity: 0;
+    visibility: hidden;
+  }
+  .textStart, .textReadOnly {
+    cursor: pointer;
+    background: #1A8CFF;
+    border-radius: 13px;
+    font-weight: 600;
+    font-size: max(13px, 12Px);
+    color: #FFFFFF;
+    line-height: 18px;
+    display: inline-flex;
+    align-items: center;
+    padding: 3px 8px;
+    .icon {
+      margin-left: 4px;
+      display: inline-block;
+    }
+  }
+
+  .textStart {
+    .icon {
+      width: 8px;
+      height: 10px;
+      background: url('../images/icon-speak-arrow.png') no-repeat center;
+      background-size: contain;
+    }
+  }
+  .textReadOnly {
+    margin-left: 10px;
+    .icon {
+
+      width: 9px;
+      height: 9px;
+      background: url('../images/icon-speak-line.png') no-repeat center;
+      background-size: contain;
+    }
+  }
+}

+ 36 - 79
src/views/content-information/content-instrument/detail.tsx

@@ -31,12 +31,14 @@ import TheEmpty from '/src/components/TheEmpty';
 import PlayItem from '../../xiaoku-music/component/play-item';
 import { api_knowledgeWiki_detail } from '../api';
 import { state } from '/src/state';
+import { useSpeak } from '../useSpeak';
 
 export default defineComponent({
   name: 'instrument-detail',
   setup() {
     const route = useRoute();
     const router = useRouter();
+    const speak = useSpeak();
     const forms = reactive({
       page: 1,
       rows: 20,
@@ -56,9 +58,7 @@ export default defineComponent({
       showPreivew: false,
       previewUrl: '',
       showCloseBtn: true,
-      fontSize: 18, // 默认18
-
-      isSpeak: false // 是否在播放
+      fontSize: 18 // 默认18
     });
 
     /** 选中的item */
@@ -127,83 +127,22 @@ export default defineComponent({
         /<video/gi,
         '<video style="width: 100% !important;" controlslist="nodownload"'
       );
-      data.details = res.data;
-      data.loading = false;
-    };
-
-    const getSleectText = () => {
-      const selection: any = window.getSelection();
-      console.log(selection.toString());
 
-      // console.log(document.querySelector('#musicContent')?.textContent);
+      // 使用 DOMParser 解析 HTML 字符串
+      const parser = new DOMParser();
+      const doc = parser.parseFromString(res.data.intros, 'text/html');
 
-      if (selection.rangeCount > 0) {
-        const range = selection.getRangeAt(0);
-        let container = range.commonAncestorContainer;
-        if (container.nodeType === 3) {
-          // 文本节点
-          container = container.parentNode;
-        }
-        const rect = container.getBoundingClientRect();
-        const startOffset = range.startOffset;
-        const endOffset = range.endOffset;
-
-        // 计算选中部分在容器内的起始和结束位置
-        const startX =
-          rect.left +
-          (startOffset > 0
-            ? (container.offsetWidth * startOffset) /
-              container.textContent.length
-            : 0);
-        const endX =
-          rect.left +
-          (endOffset > 0
-            ? (container.offsetWidth * endOffset) / container.textContent.length
-            : 0);
-
-        console.log({
-          startX: startX,
-          startY: rect.top,
-          endX: endX,
-          endY: rect.bottom
-        });
-      }
-    };
+      // 提取并分割 HTML 文档中的内容
+      document
+        .querySelector('#musicContent')
+        ?.appendChild(speak.processNode(doc.body));
 
-    const onCloseSpeak = () => {};
-    const onAllSpeak = () => {
-      try {
-        const text = document.querySelector('#musicContent')?.textContent;
-        // 事件监听(如果需要)
-        // @ts-ignore
-        responsiveVoice.speak(text, 'Chinese Male', {
-          onstart: () => {
-            console.log('开始朗读');
-            data.isSpeak = true;
-          },
-          onend: () => {
-            console.log('朗读结束');
-            data.isSpeak = false;
-          },
-          onerror: e => {
-            console.error('朗读错误:', e);
-            data.isSpeak = false;
-          }
-        });
-      } catch (e: any) {
-        console.log(e, '12');
-      }
+      data.details = res.data;
+      data.loading = false;
     };
 
     onMounted(async () => {
-      // init();
       getDetail();
-
-      document.addEventListener('mouseup', getSleectText);
-    });
-
-    onUnmounted(() => {
-      document.removeEventListener('mouseup', getSleectText);
     });
     return () => (
       <div class={styles.container}>
@@ -351,22 +290,40 @@ export default defineComponent({
                   </div>
 
                   <div class={styles.musicTitleRight}>
-                    {data.isSpeak ? (
-                      <span class={styles.textClose} onClick={onCloseSpeak}>
+                    {speak.isSpeak.value ? (
+                      <span
+                        class={styles.textClose}
+                        onClick={speak.onCloseSpeak}>
                         <i class={styles.icon}></i>关闭朗读
                       </span>
                     ) : (
-                      <span class={styles.textRead} onClick={onAllSpeak}>
+                      <span class={styles.textRead} onClick={speak.onAllSpeak}>
                         <i class={styles.icon}></i>全文朗读
                       </span>
                     )}
                   </div>
                 </div>
                 <div
-                  class={styles.musicContent}
+                  class={[styles.musicContent]}
                   id="musicContent"
-                  v-html={data.details?.intros}
-                  style={{ fontSize: data.fontSize + 'px' }}></div>
+                  style={{ fontSize: data.fontSize + 'px' }}>
+                  {/* 选中的内容 */}
+                  <div
+                    id="selectionCouser"
+                    class={[
+                      styles.selectionCouser,
+                      !speak.showDom.value && styles.hide
+                    ]}>
+                    <span class={styles.textStart} onClick={speak.onTextStart}>
+                      开始朗读<i class={styles.icon}></i>
+                    </span>
+                    <span
+                      class={styles.textReadOnly}
+                      onClick={speak.onTextReadOnly}>
+                      只读这段<i class={styles.icon}></i>
+                    </span>
+                  </div>
+                </div>
               </div>
 
               <div class={styles.changeSizeSection}>

+ 420 - 321
src/views/content-information/content-knowledge/index.module.less

@@ -1,321 +1,420 @@
-.container {
-  display: flex;
-  flex-direction: column;
-  height: 100%;
-
-  .iconBack {
-    width: 36px;
-    height: 36px;
-  }
-
-  :global {
-    .n-breadcrumb>ul {
-      display: flex;
-      align-items: center;
-
-      .n-breadcrumb-item {
-        display: flex;
-        align-items: center;
-      }
-
-      .n-breadcrumb-item__separator {
-        display: none;
-      }
-
-      .n-breadcrumb-item__link {
-        padding: 5px 18px;
-        background: #FFFFFF;
-        border-radius: 16px;
-        color: #21225D;
-        line-height: 20px;
-      }
-    }
-
-    .n-breadcrumb .n-breadcrumb-item:last-child .n-breadcrumb-item__link {
-      color: #fff;
-      background: var(--product-color);
-    }
-
-  }
-
-  &> :global(.n-space) {
-    height: 36px;
-    flex-shrink: 0;
-  }
-
-  .separator {
-    width: 9px;
-    height: 15px;
-    margin: 0 16px;
-  }
-}
-
-.wrap {
-  padding-top: 15px;
-  flex: 1;
-  transition: padding .3s;
-  overflow: hidden;
-
-  &.wrapBottom {
-    padding-bottom: 108px;
-
-  }
-}
-
-.contentWrap {
-  position: relative;
-  flex: 1;
-  display: flex;
-  padding: 0 55px 0 0;
-  overflow: hidden;
-  gap: 0 24px;
-}
-
-.content {
-  display: flex;
-  flex-direction: column;
-  height: 100%;
-  border-radius: 20px;
-  // max-height: 90vh;
-}
-
-.contentWrap {
-  :global {
-    .n-scrollbar-container {
-      max-height: 100%;
-    }
-  }
-
-  .scrollBar {
-    margin-top: 12px;
-    padding: 0 20px;
-    // max-height: calc(100% - 64px - 52px - 36px);
-
-    &.empty {
-      display: flex;
-      align-items: center;
-      justify-content: center;
-    }
-  }
-
-
-  .directoryList {
-    width: 360px;
-    background: #FFFFFF;
-    border-radius: 17px;
-    flex-shrink: 0;
-    height: 100%;
-    overflow-x: hidden;
-    overflow-y: auto;
-
-    &::-webkit-scrollbar {
-      width: 0;
-      display: none;
-    }
-  }
-
-  .treeParent {
-    transition: height 1s ease-in-out;
-  }
-
-  .treeChild {
-    line-height: 54px;
-  }
-
-  .treeItem {
-    display: flex;
-    align-items: center;
-    line-height: 54px;
-    border-radius: 10px;
-    padding: 0 5px;
-    cursor: pointer;
-    border-radius: 10px;
-    font-size: max(17px, 13Px);
-
-    // &:hover {
-    //   background: #F5F6FA;
-    // }
-    &.childItem:hover {
-
-      // background: #E8F4FF;
-
-      .title {
-        color: var(--n-color);
-      }
-    }
-
-    .title {
-      padding-left: 8px;
-      overflow: hidden;
-      white-space: nowrap;
-      text-overflow: ellipsis;
-      max-width: 280px !important;
-      color: rgba(0, 0, 0, .5);
-      display: flex;
-      align-items: center;
-
-      .dir {
-        flex-shrink: 1;
-        display: inline-block;
-        width: 16px;
-        height: 18px;
-        background: url('../../prepare-lessons/components/directory-main/images/icon-d.png') no-repeat center;
-        background-size: contain;
-        margin-right: 6px;
-      }
-
-      &.titleSelect {
-        color: var(--n-color);
-        font-weight: bold;
-
-        .dir {
-          background: url('../../prepare-lessons/components/directory-main/images/icon-d-active.png') no-repeat center;
-          background-size: contain;
-        }
-      }
-    }
-
-    .arrow {
-      display: inline-block;
-      width: 14px;
-      height: 15px;
-      background: url('../../prepare-lessons/components/directory-main/images/arrow-default.png') no-repeat center;
-      background-size: contain;
-
-      &.arrowSelect {
-        background: url('../../prepare-lessons/components/directory-main/images/arrow-active.png') no-repeat center;
-        background-size: contain;
-      }
-    }
-
-    .childArrow {
-      width: 12px;
-    }
-
-    &.childItem {
-      padding-left: 30px;
-      font-size: max(15px, 12Px);
-
-      .title {
-        color: #131415;
-      }
-    }
-
-    &.childSelect {
-      background: #E8F4FF;
-
-      .title {
-        color: var(--n-color);
-        font-weight: bold;
-      }
-    }
-  }
-}
-
-.musicStaff {
-  // display: flex;
-  // flex-direction: column;
-  // position: relative;
-  // left: -8px;
-  flex: 1;
-  background-color: #fff;
-  border-radius: 16px;
-  // height: 100%;
-  z-index: 1;
-  overflow: hidden;
-  padding: 27px 0 27px 27px;
-
-  &::-webkit-scrollbar {
-    width: 0;
-    display: none;
-  }
-
-  .empty {
-    :global {
-      .n-spin-content {
-        min-height: 100%;
-        display: flex;
-        align-items: center;
-        justify-content: center;
-      }
-    }
-  }
-
-  :global {
-    .n-spin-container {
-      overflow-y: auto;
-      height: 100%;
-    }
-
-  }
-
-  .musicTitle {
-    padding: 27px 27px 13px;
-    font-size: 20px;
-    font-weight: 600;
-    color: #000000;
-    line-height: 30px;
-    text-align: center;
-  }
-
-  .musicContent {
-    flex: 1;
-    // overflow-y: auto;
-    // height: 100%;
-    // padding: 27px;
-    padding-right: 27px;
-
-    &>img {
-      width: 100%;
-    }
-
-    section,
-    &>div {
-      font-size: inherit !important;
-    }
-  }
-}
-
-.changeSizeSection {
-  position: absolute;
-  right: 10px;
-  bottom: 50%;
-  width: 35px;
-  transform: translate(0, 50%);
-  background: #fff;
-  border-radius: 7px;
-  display: flex;
-  align-items: center;
-  flex-direction: column;
-  padding: 13px 0;
-
-  .iconT {
-    width: 15px;
-    height: 15px;
-  }
-
-  .iconAddT,
-  .iconPlusT {
-    width: 23px;
-    height: 23px;
-    cursor: pointer;
-  }
-
-  .iconAddT {
-    margin-top: 13px;
-    margin-bottom: 8px;
-  }
-
-  .iconPlusT {
-    margin-top: 8px;
-  }
-
-  :global {
-    .n-slider {
-      height: 125px;
-      --n-handle-size: 15px !important;
-      --n-rail-height: 0 !important;
-    }
-
-  }
-}
+.container {
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+
+  .iconBack {
+    width: 36px;
+    height: 36px;
+  }
+
+  :global {
+    .n-breadcrumb>ul {
+      display: flex;
+      align-items: center;
+
+      .n-breadcrumb-item {
+        display: flex;
+        align-items: center;
+      }
+
+      .n-breadcrumb-item__separator {
+        display: none;
+      }
+
+      .n-breadcrumb-item__link {
+        padding: 5px 18px;
+        background: #FFFFFF;
+        border-radius: 16px;
+        color: #21225D;
+        line-height: 20px;
+      }
+    }
+
+    .n-breadcrumb .n-breadcrumb-item:last-child .n-breadcrumb-item__link {
+      color: #fff;
+      background: var(--product-color);
+    }
+
+    .highlight {
+      color: #0378EC;
+    }
+  }
+
+  &> :global(.n-space) {
+    height: 36px;
+    flex-shrink: 0;
+  }
+
+  .separator {
+    width: 9px;
+    height: 15px;
+    margin: 0 16px;
+  }
+}
+
+.wrap {
+  padding-top: 15px;
+  flex: 1;
+  transition: padding .3s;
+  overflow: hidden;
+
+  &.wrapBottom {
+    padding-bottom: 108px;
+
+  }
+}
+
+.contentWrap {
+  position: relative;
+  flex: 1;
+  display: flex;
+  padding: 0 55px 0 0;
+  overflow: hidden;
+  gap: 0 24px;
+}
+
+.content {
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+  border-radius: 20px;
+  // max-height: 90vh;
+}
+
+.contentWrap {
+  :global {
+    .n-scrollbar-container {
+      max-height: 100%;
+    }
+  }
+
+  .scrollBar {
+    margin-top: 12px;
+    padding: 0 20px;
+    // max-height: calc(100% - 64px - 52px - 36px);
+
+    &.empty {
+      display: flex;
+      align-items: center;
+      justify-content: center;
+    }
+  }
+
+
+  .directoryList {
+    width: 360px;
+    background: #FFFFFF;
+    border-radius: 17px;
+    flex-shrink: 0;
+    height: 100%;
+    overflow-x: hidden;
+    overflow-y: auto;
+
+    &::-webkit-scrollbar {
+      width: 0;
+      display: none;
+    }
+  }
+
+  .treeParent {
+    transition: height 1s ease-in-out;
+  }
+
+  .treeChild {
+    line-height: 54px;
+  }
+
+  .treeItem {
+    display: flex;
+    align-items: center;
+    line-height: 54px;
+    border-radius: 10px;
+    padding: 0 5px;
+    cursor: pointer;
+    border-radius: 10px;
+    font-size: max(17px, 13Px);
+
+    // &:hover {
+    //   background: #F5F6FA;
+    // }
+    &.childItem:hover {
+
+      // background: #E8F4FF;
+
+      .title {
+        color: var(--n-color);
+      }
+    }
+
+    .title {
+      padding-left: 8px;
+      overflow: hidden;
+      white-space: nowrap;
+      text-overflow: ellipsis;
+      max-width: 280px !important;
+      color: rgba(0, 0, 0, .5);
+      display: flex;
+      align-items: center;
+
+      .dir {
+        flex-shrink: 1;
+        display: inline-block;
+        width: 16px;
+        height: 18px;
+        background: url('../../prepare-lessons/components/directory-main/images/icon-d.png') no-repeat center;
+        background-size: contain;
+        margin-right: 6px;
+      }
+
+      &.titleSelect {
+        color: var(--n-color);
+        font-weight: bold;
+
+        .dir {
+          background: url('../../prepare-lessons/components/directory-main/images/icon-d-active.png') no-repeat center;
+          background-size: contain;
+        }
+      }
+    }
+
+    .arrow {
+      display: inline-block;
+      width: 14px;
+      height: 15px;
+      background: url('../../prepare-lessons/components/directory-main/images/arrow-default.png') no-repeat center;
+      background-size: contain;
+
+      &.arrowSelect {
+        background: url('../../prepare-lessons/components/directory-main/images/arrow-active.png') no-repeat center;
+        background-size: contain;
+      }
+    }
+
+    .childArrow {
+      width: 12px;
+    }
+
+    &.childItem {
+      padding-left: 30px;
+      font-size: max(15px, 12Px);
+
+      .title {
+        color: #131415;
+      }
+    }
+
+    &.childSelect {
+      background: #E8F4FF;
+
+      .title {
+        color: var(--n-color);
+        font-weight: bold;
+      }
+    }
+  }
+}
+
+.musicStaff {
+  // display: flex;
+  // flex-direction: column;
+  // position: relative;
+  // left: -8px;
+  flex: 1;
+  background-color: #fff;
+  border-radius: 16px;
+  // height: 100%;
+  z-index: 1;
+  overflow: hidden;
+  padding: 54px 0 27px 27px;
+  position: relative;
+
+  &::-webkit-scrollbar {
+    width: 0;
+    display: none;
+  }
+
+  .empty {
+    :global {
+      .n-spin-content {
+        min-height: 100%;
+        display: flex;
+        align-items: center;
+        justify-content: center;
+      }
+    }
+  }
+
+  :global {
+    .n-spin-container {
+      overflow-y: auto;
+      height: 100%;
+    }
+
+  }
+
+  .musicTitle {
+    padding: 27px 27px 13px;
+    font-size: 20px;
+    font-weight: 600;
+    color: #000000;
+    line-height: 30px;
+    text-align: center;
+  }
+
+  .musicContent {
+    flex: 1;
+    // overflow-y: auto;
+    // height: 100%;
+    // padding: 27px;
+    padding-right: 27px;
+    user-select: text;
+      position: relative;
+
+    &>img {
+      width: 100%;
+    }
+
+    section,
+    &>div {
+      font-size: inherit !important;
+    }
+  }
+
+
+  .musicTitleRight {
+    position: absolute;
+    top: 15px;
+    right: 15px;
+    z-index: 9;
+    .textRead,
+    .textClose {
+      padding: 7px 8px;
+      background: #E8F4FF;
+      border-radius: 13px;
+      font-weight: 500;
+      font-size: max(13px, 12Px);
+      color: #0378EC;
+      line-height: 18px;
+      display: flex;
+      align-items: center;
+      cursor: pointer;
+    }
+
+    .icon {
+      display: inline-block;
+      margin-right: 5px;
+      width: 15px;
+      height: 15px;
+    }
+
+    .textRead {
+      .icon {
+        background: url('../images/icon-speak-sound.png');
+        background-size: contain;
+      }
+    }
+
+    .textClose {
+      .icon {
+        background: url('../images/icon-speak-close.png');
+        background-size: contain;
+      }
+    }
+  }
+}
+
+.changeSizeSection {
+  position: absolute;
+  right: 10px;
+  bottom: 50%;
+  width: 35px;
+  transform: translate(0, 50%);
+  background: #fff;
+  border-radius: 7px;
+  display: flex;
+  align-items: center;
+  flex-direction: column;
+  padding: 13px 0;
+
+  .iconT {
+    width: 15px;
+    height: 15px;
+  }
+
+  .iconAddT,
+  .iconPlusT {
+    width: 23px;
+    height: 23px;
+    cursor: pointer;
+  }
+
+  .iconAddT {
+    margin-top: 13px;
+    margin-bottom: 8px;
+  }
+
+  .iconPlusT {
+    margin-top: 8px;
+  }
+
+  :global {
+    .n-slider {
+      height: 125px;
+      --n-handle-size: 15px !important;
+      --n-rail-height: 0 !important;
+    }
+
+  }
+}
+
+.selectionCouser {
+  display: flex;
+  align-items: center;
+  position: absolute;
+
+  &.hide {
+    opacity: 0;
+    visibility: hidden;
+  }
+
+  .textStart,
+  .textReadOnly {
+    cursor: pointer;
+    background: #1A8CFF;
+    border-radius: 13px;
+    font-weight: 600;
+    font-size: max(13px, 12Px);
+    color: #FFFFFF;
+    line-height: 18px;
+    display: inline-flex;
+    align-items: center;
+    padding: 3px 8px;
+
+    .icon {
+      margin-left: 4px;
+      display: inline-block;
+    }
+  }
+
+  .textStart {
+    .icon {
+      width: 8px;
+      height: 10px;
+      background: url('../images/icon-speak-arrow.png') no-repeat center;
+      background-size: contain;
+    }
+  }
+
+  .textReadOnly {
+    margin-left: 10px;
+
+    .icon {
+
+      width: 9px;
+      height: 9px;
+      background: url('../images/icon-speak-line.png') no-repeat center;
+      background-size: contain;
+    }
+  }
+}

+ 283 - 236
src/views/content-information/content-knowledge/index.tsx

@@ -1,236 +1,283 @@
-import { defineComponent, onMounted, reactive, ref } from 'vue';
-import styles from './index.module.less';
-import {
-  NBreadcrumb,
-  NBreadcrumbItem,
-  // NScrollbar,
-  NSlider,
-  NSpace,
-  NSpin
-} from 'naive-ui';
-import icon_back from '../../xiaoku-music/images/icon_back.png';
-// import icon_default from '../../xiaoku-music/images/icon_default.png';
-// import icon_separator from '../../xiaoku-music/images/icon_separator.png';
-import iconT from '../images/icon-t.png';
-import iconAddT from '../images/icon-add-t.png';
-import iconPlusT from '../images/icon-plus-t.png';
-import {
-  api_lessonCoursewareDetail_listKnowledge,
-  api_lessonCoursewareKnowledgeDetail
-} from '../api';
-import TheEmpty from '/src/components/TheEmpty';
-import { useRouter } from 'vue-router';
-
-export default defineComponent({
-  name: 'cotnent-knowledge',
-  setup() {
-    const router = useRouter();
-    const show = ref(false);
-    const content = ref(false);
-    const musicContentRef = ref();
-    const state = reactive({
-      fontSize: 18,
-      tableList: [] as any,
-      selectKey: null,
-      details: {} as any
-    });
-
-    const getDetails = async () => {
-      show.value = true;
-      content.value = true;
-      try {
-        const { data } = await api_lessonCoursewareDetail_listKnowledge({
-          type: 'COURSEWARE'
-        });
-
-        state.tableList = data || [];
-        if (state.tableList.length) {
-          const item =
-            state.tableList[0].lessonCoursewareDetailKnowledgeDetailList;
-          state.tableList[0].selected = true;
-          if (item && item.length) {
-            const child = item[0];
-            state.selectKey = child.id;
-            await getDetail();
-          }
-        }
-      } catch {
-        //
-      }
-      content.value = false;
-      show.value = false;
-    };
-
-    const getDetail = async () => {
-      content.value = true;
-      try {
-        const { data } = await api_lessonCoursewareKnowledgeDetail({
-          id: state.selectKey
-        });
-
-        state.details = data;
-      } catch {
-        //
-      }
-      content.value = false;
-    };
-
-    onMounted(() => {
-      getDetails();
-    });
-    return () => (
-      <div class={styles.container}>
-        <NSpace align="center" wrapItem={false} size={16}>
-          <img
-            style={{ cursor: 'pointer' }}
-            src={icon_back}
-            class={styles.iconBack}
-            onClick={() => {
-              //
-              router.push('/');
-            }}
-          />
-          <NBreadcrumb separator="">
-            <NBreadcrumbItem
-              onClick={() => {
-                //
-              }}>
-              乐理知识
-            </NBreadcrumbItem>
-          </NBreadcrumb>
-        </NSpace>
-        <div class={[styles.wrap]}>
-          <div class={styles.content}>
-            <div class={styles.contentWrap}>
-              <div class={styles.directoryList}>
-                <div
-                  class={[
-                    styles.scrollBar,
-                    !show.value && state.tableList.length <= 0
-                      ? styles.empty
-                      : ''
-                  ]}
-                  style={{ minHeight: '100%' }}>
-                  <NSpin show={show.value}>
-                    <div class={[styles.listSection]}>
-                      {state.tableList.map((item: any, index: number) => (
-                        <div class={styles.treeParent} key={'parent' + index}>
-                          <div
-                            class={[styles.treeItem, styles.parentItem]}
-                            onClick={() => {
-                              state.tableList.forEach((child: any) => {
-                                if (item.id !== child.id) {
-                                  child.selected = false;
-                                }
-                              });
-                              item.selected = item.selected ? false : true;
-                            }}>
-                            {item.lessonCoursewareDetailKnowledgeDetailList &&
-                              item.lessonCoursewareDetailKnowledgeDetailList
-                                .length > 0 && (
-                                <span
-                                  class={[
-                                    styles.arrow,
-                                    item.selected ? styles.arrowSelect : ''
-                                  ]}></span>
-                              )}
-                            <p
-                              class={[
-                                styles.title,
-                                item.selected ? styles.titleSelect : ''
-                              ]}>
-                              <span
-                                class={[
-                                  styles.dir,
-                                  item.selected ? styles.dirSelect : ''
-                                ]}></span>
-                              {item.name}
-                            </p>
-                          </div>
-
-                          {item.selected &&
-                            item.lessonCoursewareDetailKnowledgeDetailList &&
-                            item.lessonCoursewareDetailKnowledgeDetailList.map(
-                              (child: any, j: number) => (
-                                <div
-                                  key={'child' + j}
-                                  class={[
-                                    styles.treeItem,
-                                    styles.childItem,
-                                    styles.animation,
-                                    state.selectKey === child.id
-                                      ? styles.childSelect
-                                      : ''
-                                  ]}
-                                  onClick={() => {
-                                    if (state.selectKey === child.id) return;
-                                    state.selectKey = child.id;
-                                    getDetail();
-                                    musicContentRef.value.$el.scrollTo(0, 0);
-                                  }}>
-                                  <span class={styles.childArrow}></span>
-                                  <p class={styles.title}>{child.name}</p>
-                                </div>
-                              )
-                            )}
-                        </div>
-                      ))}
-                    </div>
-                  </NSpin>
-                  {!show.value && state.tableList.length <= 0 && (
-                    <TheEmpty style={{ height: '100%' }} />
-                  )}
-                </div>
-              </div>
-
-              <div class={styles.musicStaff}>
-                <NSpin
-                  show={content.value}
-                  ref={musicContentRef}
-                  class={
-                    !content.value && !state.details?.desc ? styles.empty : ''
-                  }>
-                  {state.details?.desc ? (
-                    <div
-                      class={styles.musicContent}
-                      v-html={state.details?.desc}
-                      style={{ fontSize: state.fontSize + 'px' }}></div>
-                  ) : (
-                    ''
-                  )}
-                  {!content.value && !state.details?.desc && <TheEmpty />}
-                </NSpin>
-              </div>
-
-              <div class={styles.changeSizeSection}>
-                <img src={iconT} class={styles.iconT} />
-                <img
-                  src={iconAddT}
-                  class={styles.iconAddT}
-                  onClick={() => {
-                    if (state.fontSize >= 32) return;
-                    state.fontSize += 1;
-                  }}
-                />
-                <NSlider
-                  v-model:value={state.fontSize}
-                  vertical
-                  min={12}
-                  max={32}
-                />
-                <img
-                  src={iconPlusT}
-                  class={styles.iconPlusT}
-                  onClick={() => {
-                    if (state.fontSize <= 12) return;
-                    state.fontSize -= 1;
-                  }}
-                />
-              </div>
-            </div>
-          </div>
-        </div>
-      </div>
-    );
-  }
-});
+import { defineComponent, onMounted, reactive, ref } from 'vue';
+import styles from './index.module.less';
+import {
+  NBreadcrumb,
+  NBreadcrumbItem,
+  // NScrollbar,
+  NSlider,
+  NSpace,
+  NSpin
+} from 'naive-ui';
+import icon_back from '../../xiaoku-music/images/icon_back.png';
+// import icon_default from '../../xiaoku-music/images/icon_default.png';
+// import icon_separator from '../../xiaoku-music/images/icon_separator.png';
+import iconT from '../images/icon-t.png';
+import iconAddT from '../images/icon-add-t.png';
+import iconPlusT from '../images/icon-plus-t.png';
+import {
+  api_lessonCoursewareDetail_listKnowledge,
+  api_lessonCoursewareKnowledgeDetail
+} from '../api';
+import TheEmpty from '/src/components/TheEmpty';
+import { useRouter } from 'vue-router';
+import { useSpeak } from '../useSpeak';
+
+export default defineComponent({
+  name: 'cotnent-knowledge',
+  setup() {
+    const router = useRouter();
+    const show = ref(false);
+    const content = ref(false);
+    const speak = useSpeak();
+    const musicContentRef = ref();
+    const state = reactive({
+      fontSize: 18,
+      tableList: [] as any,
+      selectKey: null,
+      details: {} as any
+    });
+
+    const getDetails = async () => {
+      show.value = true;
+      content.value = true;
+      try {
+        const { data } = await api_lessonCoursewareDetail_listKnowledge({
+          type: 'COURSEWARE'
+        });
+
+        state.tableList = data || [];
+        if (state.tableList.length) {
+          const item =
+            state.tableList[0].lessonCoursewareDetailKnowledgeDetailList;
+          state.tableList[0].selected = true;
+          if (item && item.length) {
+            const child = item[0];
+            state.selectKey = child.id;
+            await getDetail();
+          }
+        }
+      } catch {
+        //
+      }
+      content.value = false;
+      show.value = false;
+    };
+
+    const getDetail = async () => {
+      content.value = true;
+      try {
+        const { data } = await api_lessonCoursewareKnowledgeDetail({
+          id: state.selectKey
+        });
+        // 使用 DOMParser 解析 HTML 字符串
+        const parser = new DOMParser();
+        const doc = parser.parseFromString(data.desc, 'text/html');
+
+        const hasChilds = document.querySelectorAll('.only-child-select');
+        if (hasChilds.length > 0) {
+          hasChilds.forEach(child => {
+            child.remove();
+          });
+        }
+
+        const childNodes = doc.body.childNodes;
+        childNodes?.forEach((node: any) => {
+          node?.classList.add('only-child-select');
+        });
+
+        // 提取并分割 HTML 文档中的内容
+        document
+          .querySelector('#musicContent')
+          ?.appendChild(speak.processNode(doc.body));
+        state.details = data;
+      } catch {
+        //
+      }
+      content.value = false;
+    };
+
+    onMounted(() => {
+      getDetails();
+    });
+    return () => (
+      <div class={styles.container}>
+        <NSpace align="center" wrapItem={false} size={16}>
+          <img
+            style={{ cursor: 'pointer' }}
+            src={icon_back}
+            class={styles.iconBack}
+            onClick={() => {
+              //
+              router.push('/');
+            }}
+          />
+          <NBreadcrumb separator="">
+            <NBreadcrumbItem
+              onClick={() => {
+                //
+              }}>
+              乐理知识
+            </NBreadcrumbItem>
+          </NBreadcrumb>
+        </NSpace>
+        <div class={[styles.wrap]}>
+          <div class={styles.content}>
+            <div class={styles.contentWrap}>
+              <div class={styles.directoryList}>
+                <div
+                  class={[
+                    styles.scrollBar,
+                    !show.value && state.tableList.length <= 0
+                      ? styles.empty
+                      : ''
+                  ]}
+                  style={{ minHeight: '100%' }}>
+                  <NSpin show={show.value}>
+                    <div class={[styles.listSection]}>
+                      {state.tableList.map((item: any, index: number) => (
+                        <div class={styles.treeParent} key={'parent' + index}>
+                          <div
+                            class={[styles.treeItem, styles.parentItem]}
+                            onClick={() => {
+                              state.tableList.forEach((child: any) => {
+                                if (item.id !== child.id) {
+                                  child.selected = false;
+                                }
+                              });
+                              item.selected = item.selected ? false : true;
+                            }}>
+                            {item.lessonCoursewareDetailKnowledgeDetailList &&
+                              item.lessonCoursewareDetailKnowledgeDetailList
+                                .length > 0 && (
+                                <span
+                                  class={[
+                                    styles.arrow,
+                                    item.selected ? styles.arrowSelect : ''
+                                  ]}></span>
+                              )}
+                            <p
+                              class={[
+                                styles.title,
+                                item.selected ? styles.titleSelect : ''
+                              ]}>
+                              <span
+                                class={[
+                                  styles.dir,
+                                  item.selected ? styles.dirSelect : ''
+                                ]}></span>
+                              {item.name}
+                            </p>
+                          </div>
+
+                          {item.selected &&
+                            item.lessonCoursewareDetailKnowledgeDetailList &&
+                            item.lessonCoursewareDetailKnowledgeDetailList.map(
+                              (child: any, j: number) => (
+                                <div
+                                  key={'child' + j}
+                                  class={[
+                                    styles.treeItem,
+                                    styles.childItem,
+                                    styles.animation,
+                                    state.selectKey === child.id
+                                      ? styles.childSelect
+                                      : ''
+                                  ]}
+                                  onClick={() => {
+                                    if (state.selectKey === child.id) return;
+                                    state.selectKey = child.id;
+                                    getDetail();
+                                    musicContentRef.value.$el.scrollTo(0, 0);
+                                  }}>
+                                  <span class={styles.childArrow}></span>
+                                  <p class={styles.title}>{child.name}</p>
+                                </div>
+                              )
+                            )}
+                        </div>
+                      ))}
+                    </div>
+                  </NSpin>
+                  {!show.value && state.tableList.length <= 0 && (
+                    <TheEmpty style={{ height: '100%' }} />
+                  )}
+                </div>
+              </div>
+
+              <div class={styles.musicStaff}>
+                <div class={styles.musicTitleRight}>
+                  {speak.isSpeak.value ? (
+                    <span class={styles.textClose} onClick={speak.onCloseSpeak}>
+                      <i class={styles.icon}></i>关闭朗读
+                    </span>
+                  ) : (
+                    <span class={styles.textRead} onClick={speak.onAllSpeak}>
+                      <i class={styles.icon}></i>全文朗读
+                    </span>
+                  )}
+                </div>
+                <NSpin
+                  show={content.value}
+                  ref={musicContentRef}
+                  class={
+                    !content.value && !state.details?.desc ? styles.empty : ''
+                  }>
+                  <div
+                    class={styles.musicContent}
+                    id="musicContent"
+                    style={{ fontSize: state.fontSize + 'px' }}>
+                    {/* 选中的内容 */}
+                    <div
+                      id="selectionCouser"
+                      class={[
+                        styles.selectionCouser,
+                        !speak.showDom.value && styles.hide
+                      ]}>
+                      <span
+                        class={styles.textStart}
+                        onClick={speak.onTextStart}>
+                        开始朗读<i class={styles.icon}></i>
+                      </span>
+                      <span
+                        class={styles.textReadOnly}
+                        onClick={speak.onTextReadOnly}>
+                        只读这段<i class={styles.icon}></i>
+                      </span>
+                    </div>
+                  </div>
+                  {!content.value && !state.details?.desc && <TheEmpty />}
+                </NSpin>
+              </div>
+
+              <div class={styles.changeSizeSection}>
+                <img src={iconT} class={styles.iconT} />
+                <img
+                  src={iconAddT}
+                  class={styles.iconAddT}
+                  onClick={() => {
+                    if (state.fontSize >= 32) return;
+                    state.fontSize += 1;
+                  }}
+                />
+                <NSlider
+                  v-model:value={state.fontSize}
+                  vertical
+                  min={12}
+                  max={32}
+                />
+                <img
+                  src={iconPlusT}
+                  class={styles.iconPlusT}
+                  onClick={() => {
+                    if (state.fontSize <= 12) return;
+                    state.fontSize -= 1;
+                  }}
+                />
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+    );
+  }
+});

+ 671 - 571
src/views/content-information/content-music/detail.module.less

@@ -1,571 +1,671 @@
-.container {
-  display: flex;
-  flex-direction: column;
-  height: 100%;
-
-  .iconBack {
-    width: 36px;
-    height: 36px;
-  }
-
-  :global {
-    .n-breadcrumb>ul {
-      display: flex;
-      align-items: center;
-
-      .n-breadcrumb-item {
-        display: flex;
-        align-items: center;
-      }
-
-      .n-breadcrumb-item__separator {
-        display: none;
-      }
-
-      .n-breadcrumb-item__link {
-        padding: 5px 18px;
-        background: #FFFFFF;
-        border-radius: 16px;
-        color: #21225D;
-        line-height: 20px;
-      }
-    }
-
-    .n-breadcrumb .n-breadcrumb-item:last-child .n-breadcrumb-item__link {
-      color: #fff;
-      background: var(--product-color);
-    }
-
-  }
-
-  &> :global(.n-space) {
-    height: 36px;
-    flex-shrink: 0;
-  }
-
-  .separator {
-    width: 9px;
-    height: 15px;
-    margin: 0 16px;
-  }
-}
-
-.wrap {
-  padding-top: 15px;
-  flex: 1;
-  transition: padding .3s;
-  overflow: hidden;
-
-  &.wrapBottom {
-    padding-bottom: 108px;
-
-  }
-}
-
-.content {
-  display: flex;
-  flex-direction: column;
-  height: 100%;
-  background: #DDF2FF;
-  border-radius: 20px;
-  // max-height: 90vh;
-}
-
-.tools {
-  padding: 20px;
-  display: flex;
-  align-items: center;
-  flex-shrink: 0;
-
-  :global {
-    .n-input {
-      margin-left: auto;
-      width: 361px;
-    }
-
-    .n-input__input-el {
-      height: 100%;
-      line-height: 100%;
-    }
-  }
-}
-
-.contentWrap {
-  position: relative;
-  flex: 1;
-  display: flex;
-  padding: 20px 55px 20px 20px;
-  overflow: hidden;
-  gap: 0 32px;
-}
-
-.musicList {
-  background-color: #fff;
-  border-radius: 16px;
-
-  width: 470px;
-  min-width: 294px;
-  height: 100%;
-  overflow-x: hidden;
-  overflow-y: auto;
-  min-width: 330Px;
-
-  &::-webkit-scrollbar {
-    width: 0;
-    display: none;
-  }
-
-  .instrumentGroup {
-    padding-top: 27px;
-    padding-bottom: 20px;
-    display: flex;
-    align-items: center;
-    justify-content: center;
-    flex-direction: column;
-
-    .instrumentImg {
-      width: 125px;
-      height: 125px;
-      overflow: hidden;
-      border-radius: 50%;
-    }
-
-    .instrumentName {
-      padding: 13px 0 5px;
-      font-size: max(18px, 14Px);
-      font-weight: 600;
-      color: #131415;
-      line-height: 25px;
-      letter-spacing: 1px;
-    }
-
-    .instrumentTag {
-      font-size: max(13px, 12Px);
-      color: #777777;
-      line-height: 18px;
-    }
-  }
-
-
-
-  .wrapList {
-    width: 470px;
-    padding: 0 17px;
-    min-width: 294px;
-    min-height: 100%;
-    // background: #fff;
-    border-radius: 16px;
-
-    :global {
-      .n-empty .n-empty__description {
-        font-size: calc(14px, 12Px);
-      }
-    }
-
-
-    .titlec {
-      padding: 20px 0;
-      font-size: max(18px, 14Px);
-      font-weight: 600;
-      color: #000000;
-      line-height: 25px;
-      border-top: 1px solid #F2F2F2;
-      display: flex;
-      align-items: center;
-    }
-
-    .icon2 {
-      width: 23px;
-      height: 23px;
-      margin-right: 8px;
-      background: url('../images/icon-2.png') no-repeat center;
-      background-size: contain;
-    }
-  }
-
-  .empty {
-    display: flex;
-    align-items: center;
-    justify-content: center;
-    height: 50vh;
-    // height: 100%;
-  }
-}
-
-.itemContainer {
-  width: 100%;
-  border-radius: 16px;
-  padding: 4px 8px;
-  // background-color: #fff;
-
-  &:first-child {
-    padding-top: 8px;
-  }
-
-  &:last-child {
-    // border-radius: 0 0 16px 16px;
-    padding-bottom: 8px;
-  }
-}
-
-.item {
-  position: relative;
-  display: flex;
-  align-items: center;
-  padding: 10px;
-  border-radius: 12px;
-
-  cursor: pointer;
-
-  &:hover {
-    background-color: rgba(0, 0, 0, .05);
-  }
-
-  &.active {
-    background-color: #DDF2FF;
-
-    .arrow {
-      opacity: 1;
-    }
-  }
-
-  .img {
-    position: relative;
-    width: 60px;
-    height: 60px;
-    border-radius: 18px;
-    margin-right: 12px;
-    // box-shadow: 0 0 10px 4px rgba(27, 35, 55, .1);
-    overflow: hidden;
-    flex-shrink: 0;
-
-    :global {
-      .n-image {
-        width: 60px;
-        height: 60px;
-      }
-    }
-
-    img {
-      transition: opacity .3s;
-      opacity: 0;
-      height: 100%;
-      width: 100%;
-    }
-
-    img[data-loaded="true"] {
-      opacity: 1;
-    }
-  }
-
-  .title {
-    flex: 1;
-    overflow: hidden;
-    display: flex;
-    flex-direction: column;
-    align-items: flex-start;
-
-    .titleName {
-      font-size: calc(17px, 12Px);
-      font-weight: 600;
-      color: #131415;
-      line-height: 28px;
-      width: 100%;
-    }
-
-    .titleDes {
-      font-size: 14px;
-      font-weight: 400;
-      color: #777777;
-      line-height: 20px;
-      max-width: 100%;
-      white-space: nowrap;
-      text-overflow: ellipsis;
-      overflow: hidden;
-    }
-  }
-
-  .btn {
-    margin-left: auto;
-    width: 84px;
-    height: 40px;
-    background: linear-gradient(to right, #44CAFF, #259DFE);
-    border: none;
-    padding: 0;
-    font-weight: bold !important;
-    flex-shrink: 0;
-    min-width: 62px;
-    min-height: 30px;
-
-    :global {
-      .n-button__content {
-        &>img {
-          margin-left: 10px;
-          width: 9px;
-          height: 12px;
-        }
-      }
-    }
-  }
-
-  .arrow {
-    position: absolute;
-    top: 50%;
-    right: 12px;
-    transform: translate(124%, -50%);
-    opacity: 0;
-  }
-
-  .showPlayLoading {
-    opacity: 0;
-  }
-
-}
-
-.loadingWrap {
-  display: flex;
-  justify-content: center;
-  min-height: 80px;
-}
-
-.musicStaff {
-  display: flex;
-  flex-direction: column;
-  position: relative;
-  left: -8px;
-  flex: 1;
-  background-color: #fff;
-  border-radius: 16px;
-  padding-bottom: 18px;
-  z-index: 1;
-  overflow: hidden;
-
-  &::-webkit-scrollbar {
-    width: 0;
-    display: none;
-  }
-
-
-  .musicTitle {
-    padding: 27px 27px 13px;
-    font-size: max(18px, 14Px);
-    font-weight: 600;
-    color: #000000;
-    line-height: 25px;
-    display: flex;
-    align-items: center;
-
-    .icon1 {
-      display: inline-block;
-      width: 23px;
-      height: 23px;
-      margin-right: 8px;
-      background: url('../images/icon-1.png') no-repeat center;
-      background-size: contain;
-    }
-  }
-
-  .musicContent {
-    flex: 1;
-    overflow-y: auto;
-    height: 100%;
-    padding: 0 27px;
-
-    &>img {
-      width: 100%;
-    }
-
-    section,
-    &>div {
-      font-size: inherit !important;
-    }
-  }
-}
-
-.staffImgs {
-  flex: 1;
-  overflow-y: auto;
-  height: 100%;
-  padding: 0 30px;
-
-  &>img {
-    width: 100%;
-  }
-}
-
-
-:global {
-
-  .van-fade-enter-active,
-  .van-fade-leave-active {
-    transition: all 0.3s;
-  }
-
-  .van-fade-enter-from,
-  .van-fade-leave-to {
-    opacity: 0;
-  }
-}
-
-.changeSizeSection {
-  position: absolute;
-  right: 10px;
-  bottom: 50%;
-  width: 35px;
-  transform: translate(0, 50%);
-  background: #fff;
-  border-radius: 7px;
-  display: flex;
-  align-items: center;
-  flex-direction: column;
-  padding: 13px 0;
-
-  .iconT {
-    width: 15px;
-    height: 15px;
-  }
-
-  .iconAddT,
-  .iconPlusT {
-    width: 23px;
-    height: 23px;
-    cursor: pointer;
-  }
-
-  .iconAddT {
-    margin-top: 13px;
-    margin-bottom: 8px;
-  }
-
-  .iconPlusT {
-    margin-top: 8px;
-  }
-
-  :global {
-    .n-slider {
-      height: 125px;
-      --n-handle-size: 15px !important;
-      --n-rail-height: 0 !important;
-    }
-
-  }
-}
-
-.musicTop,
-.musicInfo {
-  display: flex;
-  align-items: center;
-  flex-direction: column;
-  padding-top: 36px;
-  padding-bottom: 22px;
-}
-
-.musicInfo {
-  flex-direction: row;
-}
-
-.musicImg {
-  position: relative;
-  width: 100px;
-  height: 100px;
-  border-radius: 2px;
-  z-index: 9;
-  margin-right: 54px;
-  margin-left: 31px;
-
-  .img {
-    position: relative;
-    z-index: 9;
-    width: 100px;
-    height: 100px;
-    border-radius: 2px;
-  }
-
-  .panSection {
-    position: absolute;
-    right: -44px;
-    top: 5px;
-    width: 95px;
-    height: 95px;
-    z-index: 0;
-    display: flex;
-    align-items: center;
-    justify-content: center;
-
-    .img2 {
-      position: relative;
-      z-index: 1;
-      width: 64px;
-      height: 64px;
-      border-radius: 50%;
-    }
-  }
-
-
-  .iconPan {
-    position: absolute;
-    left: 0;
-    right: 0;
-    width: 95px;
-    height: 95px;
-    z-index: 0;
-  }
-
-  &::before {
-    content: ' ';
-    position: absolute;
-    top: 0;
-    left: 0;
-    z-index: 10;
-    display: inline-block;
-    width: 5px;
-    height: 100px;
-    background: linear-gradient(270deg, rgba(0, 0, 0, 0.18) 0%, rgba(255, 255, 255, 0) 100%);
-  }
-
-  &::after {
-    content: ' ';
-    position: absolute;
-    left: -31px;
-    bottom: 0;
-    z-index: 8;
-    width: 148px;
-    height: 16px;
-    background: linear-gradient(180deg, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0.3) 100%);
-    filter: blur(2.3328px);
-    border-radius: 50%;
-  }
-}
-
-.info {
-  text-align: left;
-}
-
-.musicInfo {
-  // width: 500px;
-
-  .name {
-    font-size: max(21px, 15Px);
-    font-weight: 600;
-    color: #131415;
-    line-height: 25px;
-    padding-bottom: 8px;
-    max-width: 220px;
-  }
-
-  .c {
-    font-size: max(13px, 12Px);
-    color: #777777;
-    line-height: 18px;
-
-    span {
-      flex-shrink: 0;
-    }
-
-    &>div {
-      display: flex;
-      margin-right: 20px;
-      max-width: 220px;
-    }
-  }
-}
+.container {
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+
+  .iconBack {
+    width: 36px;
+    height: 36px;
+  }
+
+  :global {
+    .n-breadcrumb>ul {
+      display: flex;
+      align-items: center;
+
+      .n-breadcrumb-item {
+        display: flex;
+        align-items: center;
+      }
+
+      .n-breadcrumb-item__separator {
+        display: none;
+      }
+
+      .n-breadcrumb-item__link {
+        padding: 5px 18px;
+        background: #FFFFFF;
+        border-radius: 16px;
+        color: #21225D;
+        line-height: 20px;
+      }
+    }
+
+    .n-breadcrumb .n-breadcrumb-item:last-child .n-breadcrumb-item__link {
+      color: #fff;
+      background: var(--product-color);
+    }
+
+    .highlight {
+      color: #0378EC;
+    }
+  }
+
+  &> :global(.n-space) {
+    height: 36px;
+    flex-shrink: 0;
+  }
+
+  .separator {
+    width: 9px;
+    height: 15px;
+    margin: 0 16px;
+  }
+}
+
+.wrap {
+  padding-top: 15px;
+  flex: 1;
+  transition: padding .3s;
+  overflow: hidden;
+
+  &.wrapBottom {
+    padding-bottom: 108px;
+
+  }
+}
+
+.content {
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+  background: #DDF2FF;
+  border-radius: 20px;
+  // max-height: 90vh;
+}
+
+.tools {
+  padding: 20px;
+  display: flex;
+  align-items: center;
+  flex-shrink: 0;
+
+  :global {
+    .n-input {
+      margin-left: auto;
+      width: 361px;
+    }
+
+    .n-input__input-el {
+      height: 100%;
+      line-height: 100%;
+    }
+  }
+}
+
+.contentWrap {
+  position: relative;
+  flex: 1;
+  display: flex;
+  padding: 20px 55px 20px 20px;
+  overflow: hidden;
+  gap: 0 32px;
+}
+
+.musicList {
+  background-color: #fff;
+  border-radius: 16px;
+
+  width: 470px;
+  min-width: 294px;
+  height: 100%;
+  overflow-x: hidden;
+  overflow-y: auto;
+  min-width: 330Px;
+
+  &::-webkit-scrollbar {
+    width: 0;
+    display: none;
+  }
+
+  .instrumentGroup {
+    padding-top: 27px;
+    padding-bottom: 20px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    flex-direction: column;
+
+    .instrumentImg {
+      width: 125px;
+      height: 125px;
+      overflow: hidden;
+      border-radius: 50%;
+    }
+
+    .instrumentName {
+      padding: 13px 0 5px;
+      font-size: max(18px, 14Px);
+      font-weight: 600;
+      color: #131415;
+      line-height: 25px;
+      letter-spacing: 1px;
+    }
+
+    .instrumentTag {
+      font-size: max(13px, 12Px);
+      color: #777777;
+      line-height: 18px;
+    }
+  }
+
+
+
+  .wrapList {
+    width: 470px;
+    padding: 0 17px;
+    min-width: 294px;
+    min-height: 100%;
+    // background: #fff;
+    border-radius: 16px;
+
+    :global {
+      .n-empty .n-empty__description {
+        font-size: calc(14px, 12Px);
+      }
+    }
+
+
+    .titlec {
+      padding: 20px 0;
+      font-size: max(18px, 14Px);
+      font-weight: 600;
+      color: #000000;
+      line-height: 25px;
+      border-top: 1px solid #F2F2F2;
+      display: flex;
+      align-items: center;
+    }
+
+    .icon2 {
+      width: 23px;
+      height: 23px;
+      margin-right: 8px;
+      background: url('../images/icon-2.png') no-repeat center;
+      background-size: contain;
+    }
+  }
+
+  .empty {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    height: 50vh;
+    // height: 100%;
+  }
+}
+
+.itemContainer {
+  width: 100%;
+  border-radius: 16px;
+  padding: 4px 8px;
+  // background-color: #fff;
+
+  &:first-child {
+    padding-top: 8px;
+  }
+
+  &:last-child {
+    // border-radius: 0 0 16px 16px;
+    padding-bottom: 8px;
+  }
+}
+
+.item {
+  position: relative;
+  display: flex;
+  align-items: center;
+  padding: 10px;
+  border-radius: 12px;
+
+  cursor: pointer;
+
+  &:hover {
+    background-color: rgba(0, 0, 0, .05);
+  }
+
+  &.active {
+    background-color: #DDF2FF;
+
+    .arrow {
+      opacity: 1;
+    }
+  }
+
+  .img {
+    position: relative;
+    width: 60px;
+    height: 60px;
+    border-radius: 18px;
+    margin-right: 12px;
+    // box-shadow: 0 0 10px 4px rgba(27, 35, 55, .1);
+    overflow: hidden;
+    flex-shrink: 0;
+
+    :global {
+      .n-image {
+        width: 60px;
+        height: 60px;
+      }
+    }
+
+    img {
+      transition: opacity .3s;
+      opacity: 0;
+      height: 100%;
+      width: 100%;
+    }
+
+    img[data-loaded="true"] {
+      opacity: 1;
+    }
+  }
+
+  .title {
+    flex: 1;
+    overflow: hidden;
+    display: flex;
+    flex-direction: column;
+    align-items: flex-start;
+
+    .titleName {
+      font-size: calc(17px, 12Px);
+      font-weight: 600;
+      color: #131415;
+      line-height: 28px;
+      width: 100%;
+    }
+
+    .titleDes {
+      font-size: 14px;
+      font-weight: 400;
+      color: #777777;
+      line-height: 20px;
+      max-width: 100%;
+      white-space: nowrap;
+      text-overflow: ellipsis;
+      overflow: hidden;
+    }
+  }
+
+  .btn {
+    margin-left: auto;
+    width: 84px;
+    height: 40px;
+    background: linear-gradient(to right, #44CAFF, #259DFE);
+    border: none;
+    padding: 0;
+    font-weight: bold !important;
+    flex-shrink: 0;
+    min-width: 62px;
+    min-height: 30px;
+
+    :global {
+      .n-button__content {
+        &>img {
+          margin-left: 10px;
+          width: 9px;
+          height: 12px;
+        }
+      }
+    }
+  }
+
+  .arrow {
+    position: absolute;
+    top: 50%;
+    right: 12px;
+    transform: translate(124%, -50%);
+    opacity: 0;
+  }
+
+  .showPlayLoading {
+    opacity: 0;
+  }
+
+}
+
+.loadingWrap {
+  display: flex;
+  justify-content: center;
+  min-height: 80px;
+}
+
+.musicStaff {
+  display: flex;
+  flex-direction: column;
+  position: relative;
+  left: -8px;
+  flex: 1;
+  background-color: #fff;
+  border-radius: 16px;
+  padding-bottom: 18px;
+  z-index: 1;
+  overflow: hidden;
+
+  &::-webkit-scrollbar {
+    width: 0;
+    display: none;
+  }
+
+
+  .musicTitle {
+    padding: 27px 27px 13px;
+    font-size: max(18px, 14Px);
+    font-weight: 600;
+    color: #000000;
+    line-height: 25px;
+
+    .icon1 {
+      display: inline-block;
+      width: 23px;
+      height: 23px;
+      margin-right: 8px;
+      background: url('../images/icon-1.png') no-repeat center;
+      background-size: contain;
+    }
+
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+
+    .musicTitleLeft {
+      display: flex;
+      align-items: center;
+    }
+
+    .musicTitleRight {
+      .textRead,
+      .textClose {
+        padding: 7px 8px;
+        background: #E8F4FF;
+        border-radius: 13px;
+        font-weight: 500;
+        font-size: max(13px, 12Px);
+        color: #0378EC;
+        line-height: 18px;
+        display: flex;
+        align-items: center;
+        cursor: pointer;
+      }
+
+      .icon {
+        display: inline-block;
+        margin-right: 5px;
+        width: 15px;
+        height: 15px;
+      }
+
+      .textRead {
+        .icon {
+          background: url('../images/icon-speak-sound.png');
+          background-size: contain;
+        }
+      }
+
+      .textClose {
+        .icon {
+          background: url('../images/icon-speak-close.png');
+          background-size: contain;
+        }
+      }
+    }
+  }
+
+  .musicContent {
+    flex: 1;
+    overflow-y: auto;
+    height: 100%;
+    padding: 0 27px;
+    user-select: text;
+    position: relative;
+
+    &>img {
+      width: 100%;
+    }
+
+    section,
+    &>div {
+      font-size: inherit !important;
+    }
+  }
+}
+
+.staffImgs {
+  flex: 1;
+  overflow-y: auto;
+  height: 100%;
+  padding: 0 30px;
+
+  &>img {
+    width: 100%;
+  }
+}
+
+
+:global {
+
+  .van-fade-enter-active,
+  .van-fade-leave-active {
+    transition: all 0.3s;
+  }
+
+  .van-fade-enter-from,
+  .van-fade-leave-to {
+    opacity: 0;
+  }
+}
+
+.changeSizeSection {
+  position: absolute;
+  right: 10px;
+  bottom: 50%;
+  width: 35px;
+  transform: translate(0, 50%);
+  background: #fff;
+  border-radius: 7px;
+  display: flex;
+  align-items: center;
+  flex-direction: column;
+  padding: 13px 0;
+
+  .iconT {
+    width: 15px;
+    height: 15px;
+  }
+
+  .iconAddT,
+  .iconPlusT {
+    width: 23px;
+    height: 23px;
+    cursor: pointer;
+  }
+
+  .iconAddT {
+    margin-top: 13px;
+    margin-bottom: 8px;
+  }
+
+  .iconPlusT {
+    margin-top: 8px;
+  }
+
+  :global {
+    .n-slider {
+      height: 125px;
+      --n-handle-size: 15px !important;
+      --n-rail-height: 0 !important;
+    }
+
+  }
+}
+
+.musicTop,
+.musicInfo {
+  display: flex;
+  align-items: center;
+  flex-direction: column;
+  padding-top: 36px;
+  padding-bottom: 22px;
+}
+
+.musicInfo {
+  flex-direction: row;
+}
+
+.musicImg {
+  position: relative;
+  width: 100px;
+  height: 100px;
+  border-radius: 2px;
+  z-index: 9;
+  margin-right: 54px;
+  margin-left: 31px;
+
+  .img {
+    position: relative;
+    z-index: 9;
+    width: 100px;
+    height: 100px;
+    border-radius: 2px;
+  }
+
+  .panSection {
+    position: absolute;
+    right: -44px;
+    top: 5px;
+    width: 95px;
+    height: 95px;
+    z-index: 0;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+
+    .img2 {
+      position: relative;
+      z-index: 1;
+      width: 64px;
+      height: 64px;
+      border-radius: 50%;
+    }
+  }
+
+
+  .iconPan {
+    position: absolute;
+    left: 0;
+    right: 0;
+    width: 95px;
+    height: 95px;
+    z-index: 0;
+  }
+
+  &::before {
+    content: ' ';
+    position: absolute;
+    top: 0;
+    left: 0;
+    z-index: 10;
+    display: inline-block;
+    width: 5px;
+    height: 100px;
+    background: linear-gradient(270deg, rgba(0, 0, 0, 0.18) 0%, rgba(255, 255, 255, 0) 100%);
+  }
+
+  &::after {
+    content: ' ';
+    position: absolute;
+    left: -31px;
+    bottom: 0;
+    z-index: 8;
+    width: 148px;
+    height: 16px;
+    background: linear-gradient(180deg, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0.3) 100%);
+    filter: blur(2.3328px);
+    border-radius: 50%;
+  }
+}
+
+.info {
+  text-align: left;
+}
+
+.musicInfo {
+  // width: 500px;
+
+  .name {
+    font-size: max(21px, 15Px);
+    font-weight: 600;
+    color: #131415;
+    line-height: 25px;
+    padding-bottom: 8px;
+    max-width: 220px;
+  }
+
+  .c {
+    font-size: max(13px, 12Px);
+    color: #777777;
+    line-height: 18px;
+
+    span {
+      flex-shrink: 0;
+    }
+
+    &>div {
+      display: flex;
+      margin-right: 20px;
+      max-width: 220px;
+    }
+  }
+}
+
+.selectionCouser {
+  display: flex;
+  align-items: center;
+  position: absolute;
+
+  &.hide {
+    opacity: 0;
+    visibility: hidden;
+  }
+
+  .textStart,
+  .textReadOnly {
+    cursor: pointer;
+    background: #1A8CFF;
+    border-radius: 13px;
+    font-weight: 600;
+    font-size: max(13px, 12Px);
+    color: #FFFFFF;
+    line-height: 18px;
+    display: inline-flex;
+    align-items: center;
+    padding: 3px 8px;
+
+    .icon {
+      margin-left: 4px;
+      display: inline-block;
+    }
+  }
+
+  .textStart {
+    .icon {
+      width: 8px;
+      height: 10px;
+      background: url('../images/icon-speak-arrow.png') no-repeat center;
+      background-size: contain;
+    }
+  }
+
+  .textReadOnly {
+    margin-left: 10px;
+
+    .icon {
+
+      width: 9px;
+      height: 9px;
+      background: url('../images/icon-speak-line.png') no-repeat center;
+      background-size: contain;
+    }
+  }
+}

+ 384 - 342
src/views/content-information/content-music/detail.tsx

@@ -1,342 +1,384 @@
-import {
-  NBreadcrumb,
-  NBreadcrumbItem,
-  NButton,
-  NImage,
-  NSlider,
-  NSpace,
-  NSpin
-} from 'naive-ui';
-import { computed, defineComponent, onMounted, reactive } from 'vue';
-import styles from './detail.module.less';
-import icon_back from '../../xiaoku-music/images/icon_back.png';
-import icon_arrow from '../../xiaoku-music/images/icon_arrow.png';
-import icon_play from '../../xiaoku-music/images/icon_play.png';
-import icon_pause from '../../xiaoku-music/images/icon_pause.png';
-import icon_default from '../../xiaoku-music/images/icon_default.png';
-import icon_separator from '../../xiaoku-music/images/icon_separator.png';
-import iconT from '../images/icon-t.png';
-import iconAddT from '../images/icon-add-t.png';
-import iconPlusT from '../images/icon-plus-t.png';
-import musicBg from '../../xiaoku-music/images/icon_default.png';
-import iconPan from '../images/icon-pan.png';
-import { useRoute, useRouter } from 'vue-router';
-import PlayLoading from '../../xiaoku-music/component/play-loading';
-import TheNoticeBar from '/src/components/TheNoticeBar';
-import TheEmpty from '/src/components/TheEmpty';
-import PlayItem from '../../xiaoku-music/component/play-item';
-import { api_knowledgeWiki_detail } from '../api';
-import { state } from '/src/state';
-
-export default defineComponent({
-  name: 'instrument-detail',
-  setup() {
-    const route = useRoute();
-    const router = useRouter();
-    const forms = reactive({
-      page: 1,
-      rows: 20,
-      status: true,
-      name: '', // 关键词
-      type: route.query.type
-    });
-    const data = reactive({
-      loading: false,
-      finshed: false,
-      reshing: false,
-      details: {} as any,
-      list: [] as any,
-      listActive: 0,
-      playState: 'pause' as 'play' | 'pause',
-      showPlayer: false,
-      showPreivew: false,
-      previewUrl: '',
-      showCloseBtn: true,
-      fontSize: 18 // 默认18
-    });
-
-    /** 选中的item */
-    const activeItem = computed(() => {
-      return data.list[data.listActive] || {};
-    });
-
-    /** 播放曲目 */
-    const handlePlay = (item: any) => {
-      const index = data.list.findIndex((_item: any) => _item.id === item.id);
-      if (index > -1) {
-        if (data.listActive === index) {
-          data.playState = data.playState === 'play' ? 'pause' : 'play';
-        } else {
-          data.playState = 'play';
-        }
-        data.showPlayer = true;
-        data.listActive = index;
-      }
-    };
-
-    /** 音频控制 */
-    const handleChangeAudio = (
-      type: 'play' | 'pause' | 'pre' | 'next' | 'favitor'
-    ) => {
-      if (type === 'play') {
-        data.playState = 'play';
-      } else if (type === 'pause') {
-        data.playState = 'pause';
-      } else if (type === 'pre') {
-        if (data.list[data.listActive - 1]) {
-          handlePlay(data.list[data.listActive - 1]);
-        }
-      } else if (type === 'next') {
-        if (data.list[data.listActive + 1]) {
-          handlePlay(data.list[data.listActive + 1]);
-        }
-      }
-    };
-
-    const getDetail = async () => {
-      data.loading = true;
-      let res = {} as any;
-      try {
-        res = await api_knowledgeWiki_detail({ id: route.query.id });
-      } catch (error) {
-        console.log(error);
-      }
-      if (data.reshing) {
-        data.list = [];
-        data.reshing = false;
-      }
-
-      data.finshed = true;
-      data.list = res.data.knowledgeWikiResources || [];
-      data.list.forEach((item: any) => {
-        item.audioFileUrl = item.url;
-        item.musicSheetName = item.name;
-      });
-      const knowledgeWikiCategories = res.data.knowledgeWikiCategories || [];
-      res.data.knowledgeName =
-        knowledgeWikiCategories.length > 0
-          ? knowledgeWikiCategories[0].name
-          : '';
-      res.data.intros = res.data.intros.replace(
-        /<video/gi,
-        '<video style="width: 100% !important;" controlslist="nodownload"'
-      );
-      data.details = res.data;
-      data.loading = false;
-    };
-
-    onMounted(() => {
-      getDetail();
-    });
-    return () => (
-      <div class={styles.container}>
-        <NSpace align="center" wrapItem={false} size={16}>
-          <img
-            style={{ cursor: 'pointer' }}
-            src={icon_back}
-            class={styles.iconBack}
-            onClick={() => {
-              router.push('/content-music');
-            }}
-          />
-          <NBreadcrumb separator="">
-            <NBreadcrumbItem
-              onClick={() => {
-                router.push('/content-music');
-              }}>
-              名曲鉴赏
-            </NBreadcrumbItem>
-            <img class={styles.separator} src={icon_separator} />
-            <NBreadcrumbItem>{route.query.name}</NBreadcrumbItem>
-          </NBreadcrumb>
-        </NSpace>
-
-        <div class={[styles.wrap, data.showPlayer ? styles.wrapBottom : '']}>
-          <div class={styles.content}>
-            <div class={styles.contentWrap}>
-              <div class={[styles.musicList, 'musicList-container']}>
-                <div class={styles.wrapList}>
-                  <div class={styles.musicInfo}>
-                    <div class={styles.musicImg}>
-                      <img
-                        src={data.details?.avatar || musicBg}
-                        class={styles.img}
-                      />
-                      <div class={styles.panSection}>
-                        <img src={iconPan} class={styles.iconPan} />
-                        <img
-                          src={data.details?.avatar || musicBg}
-                          class={styles.img2}
-                        />
-                      </div>
-                    </div>
-
-                    <div class={styles.info}>
-                      <div class={styles.name}>
-                        <TheNoticeBar
-                          text={data.details.name}
-                          style={{ marginRight: '0' }}
-                        />
-                        {/* {data.details.name} */}
-                      </div>
-                      <div class={styles.c}>
-                        {data.details.composers ? (
-                          <div>
-                            <span>作曲:</span>
-                            <TheNoticeBar
-                              text={data.details.composers}
-                              style={{ marginRight: '0' }}
-                            />
-                          </div>
-                        ) : (
-                          ''
-                        )}
-                        {data.details.lyricists ? (
-                          <div>
-                            <span>作词:</span>
-                            <TheNoticeBar
-                              text={data.details.lyricists}
-                              style={{ marginRight: '0' }}
-                            />
-                          </div>
-                        ) : (
-                          ''
-                        )}
-                      </div>
-                    </div>
-                  </div>
-
-                  <div class={styles.titlec}>
-                    <i class={styles.icon2}></i>名曲鉴赏
-                  </div>
-
-                  {data.list.map((item: any, index: any) => {
-                    return (
-                      <div class={styles.itemContainer}>
-                        <div
-                          class={[styles.item]}
-                          onClick={(e: Event) => {
-                            e.stopPropagation();
-                            handlePlay(item);
-                          }}>
-                          <div class={styles.img}>
-                            <NImage
-                              lazy
-                              objectFit="cover"
-                              previewDisabled={true}
-                              src={item.titleImg || icon_default}
-                              onLoad={e => {
-                                (e.target as any).dataset.loaded = 'true';
-                              }}
-                            />
-                            <PlayLoading
-                              class={[
-                                data.listActive === index &&
-                                data.playState === 'play'
-                                  ? ''
-                                  : styles.showPlayLoading
-                              ]}
-                            />
-                          </div>
-                          <div class={styles.title}>
-                            <div class={styles.titleName}>
-                              <TheNoticeBar
-                                text={item.name}
-                                style={{ marginRight: '12px' }}
-                              />
-                            </div>
-                          </div>
-
-                          <NButton
-                            color="#259CFE"
-                            textColor="#fff"
-                            round
-                            class={styles.btn}
-                            type="primary"
-                            onClick={(e: Event) => {
-                              e.stopPropagation();
-                              handlePlay(item);
-                            }}>
-                            播放
-                            <img
-                              src={
-                                data.listActive === index &&
-                                data.playState === 'play'
-                                  ? icon_pause
-                                  : icon_play
-                              }
-                            />
-                          </NButton>
-
-                          <img class={styles.arrow} src={icon_arrow} />
-                        </div>
-                      </div>
-                    );
-                  })}
-                  {!data.finshed && (
-                    <div class={styles.loadingWrap}>
-                      <NSpin show={true}></NSpin>
-                    </div>
-                  )}
-                  {!data.loading && data.list.length === 0 && (
-                    <div class={styles.empty}>
-                      <TheEmpty
-                        description="暂无名曲鉴赏"
-                        style={{ paddingTop: '0px' }}></TheEmpty>
-                    </div>
-                  )}
-                </div>
-              </div>
-
-              <div class={styles.musicStaff}>
-                <div class={styles.musicTitle}>
-                  <i class={styles.icon1}></i>名曲故事
-                </div>
-                <div
-                  class={styles.musicContent}
-                  v-html={data.details?.intros}
-                  style={{ fontSize: data.fontSize + 'px' }}></div>
-              </div>
-
-              <div class={styles.changeSizeSection}>
-                <img src={iconT} class={styles.iconT} />
-                <img
-                  src={iconAddT}
-                  class={styles.iconAddT}
-                  onClick={() => {
-                    if (data.fontSize >= 32) return;
-                    data.fontSize += 1;
-                  }}
-                />
-                <NSlider
-                  v-model:value={data.fontSize}
-                  placement="left"
-                  vertical
-                  min={12}
-                  max={32}
-                />
-                <img
-                  src={iconPlusT}
-                  class={styles.iconPlusT}
-                  onClick={() => {
-                    if (data.fontSize <= 12) return;
-                    data.fontSize -= 1;
-                  }}
-                />
-              </div>
-            </div>
-          </div>
-        </div>
-
-        {data.list.length !== 0 && (
-          <PlayItem
-            show={data.showPlayer}
-            playState={data.playState}
-            item={activeItem.value}
-            onChange={value => handleChangeAudio(value)}
-          />
-        )}
-      </div>
-    );
-  }
-});
+import {
+  NBreadcrumb,
+  NBreadcrumbItem,
+  NButton,
+  NImage,
+  NSlider,
+  NSpace,
+  NSpin
+} from 'naive-ui';
+import { computed, defineComponent, onMounted, reactive } from 'vue';
+import styles from './detail.module.less';
+import icon_back from '../../xiaoku-music/images/icon_back.png';
+import icon_arrow from '../../xiaoku-music/images/icon_arrow.png';
+import icon_play from '../../xiaoku-music/images/icon_play.png';
+import icon_pause from '../../xiaoku-music/images/icon_pause.png';
+import icon_default from '../../xiaoku-music/images/icon_default.png';
+import icon_separator from '../../xiaoku-music/images/icon_separator.png';
+import iconT from '../images/icon-t.png';
+import iconAddT from '../images/icon-add-t.png';
+import iconPlusT from '../images/icon-plus-t.png';
+import musicBg from '../../xiaoku-music/images/icon_default.png';
+import iconPan from '../images/icon-pan.png';
+import { useRoute, useRouter } from 'vue-router';
+import PlayLoading from '../../xiaoku-music/component/play-loading';
+import TheNoticeBar from '/src/components/TheNoticeBar';
+import TheEmpty from '/src/components/TheEmpty';
+import PlayItem from '../../xiaoku-music/component/play-item';
+import { api_knowledgeWiki_detail } from '../api';
+import { useSpeak } from '../useSpeak';
+
+export default defineComponent({
+  name: 'instrument-detail',
+  setup() {
+    const route = useRoute();
+    const router = useRouter();
+    const speak = useSpeak();
+    const forms = reactive({
+      page: 1,
+      rows: 20,
+      status: true,
+      name: '', // 关键词
+      type: route.query.type
+    });
+    const data = reactive({
+      loading: false,
+      finshed: false,
+      reshing: false,
+      details: {} as any,
+      list: [] as any,
+      listActive: 0,
+      playState: 'pause' as 'play' | 'pause',
+      showPlayer: false,
+      showPreivew: false,
+      previewUrl: '',
+      showCloseBtn: true,
+      fontSize: 18 // 默认18
+    });
+
+    /** 选中的item */
+    const activeItem = computed(() => {
+      return data.list[data.listActive] || {};
+    });
+
+    /** 播放曲目 */
+    const handlePlay = (item: any) => {
+      const index = data.list.findIndex((_item: any) => _item.id === item.id);
+      if (index > -1) {
+        if (data.listActive === index) {
+          data.playState = data.playState === 'play' ? 'pause' : 'play';
+        } else {
+          data.playState = 'play';
+        }
+        data.showPlayer = true;
+        data.listActive = index;
+      }
+    };
+
+    /** 音频控制 */
+    const handleChangeAudio = (
+      type: 'play' | 'pause' | 'pre' | 'next' | 'favitor'
+    ) => {
+      if (type === 'play') {
+        data.playState = 'play';
+      } else if (type === 'pause') {
+        data.playState = 'pause';
+      } else if (type === 'pre') {
+        if (data.list[data.listActive - 1]) {
+          handlePlay(data.list[data.listActive - 1]);
+        }
+      } else if (type === 'next') {
+        if (data.list[data.listActive + 1]) {
+          handlePlay(data.list[data.listActive + 1]);
+        }
+      }
+    };
+
+    const getDetail = async () => {
+      data.loading = true;
+      let res = {} as any;
+      try {
+        res = await api_knowledgeWiki_detail({ id: route.query.id });
+      } catch (error) {
+        console.log(error);
+      }
+      if (data.reshing) {
+        data.list = [];
+        data.reshing = false;
+      }
+
+      data.finshed = true;
+      data.list = res.data.knowledgeWikiResources || [];
+      data.list.forEach((item: any) => {
+        item.audioFileUrl = item.url;
+        item.musicSheetName = item.name;
+      });
+      const knowledgeWikiCategories = res.data.knowledgeWikiCategories || [];
+      res.data.knowledgeName =
+        knowledgeWikiCategories.length > 0
+          ? knowledgeWikiCategories[0].name
+          : '';
+      res.data.intros = res.data.intros.replace(
+        /<video/gi,
+        '<video style="width: 100% !important;" controlslist="nodownload"'
+      );
+
+      // 使用 DOMParser 解析 HTML 字符串
+      const parser = new DOMParser();
+      const doc = parser.parseFromString(res.data.intros, 'text/html');
+
+      // 提取并分割 HTML 文档中的内容
+      document
+        .querySelector('#musicContent')
+        ?.appendChild(speak.processNode(doc.body));
+      data.details = res.data;
+      data.loading = false;
+    };
+
+    onMounted(() => {
+      getDetail();
+    });
+    return () => (
+      <div class={styles.container}>
+        <NSpace align="center" wrapItem={false} size={16}>
+          <img
+            style={{ cursor: 'pointer' }}
+            src={icon_back}
+            class={styles.iconBack}
+            onClick={() => {
+              router.push('/content-music');
+            }}
+          />
+          <NBreadcrumb separator="">
+            <NBreadcrumbItem
+              onClick={() => {
+                router.push('/content-music');
+              }}>
+              名曲鉴赏
+            </NBreadcrumbItem>
+            <img class={styles.separator} src={icon_separator} />
+            <NBreadcrumbItem>{route.query.name}</NBreadcrumbItem>
+          </NBreadcrumb>
+        </NSpace>
+
+        <div class={[styles.wrap, data.showPlayer ? styles.wrapBottom : '']}>
+          <div class={styles.content}>
+            <div class={styles.contentWrap}>
+              <div class={[styles.musicList, 'musicList-container']}>
+                <div class={styles.wrapList}>
+                  <div class={styles.musicInfo}>
+                    <div class={styles.musicImg}>
+                      <img
+                        src={data.details?.avatar || musicBg}
+                        class={styles.img}
+                      />
+                      <div class={styles.panSection}>
+                        <img src={iconPan} class={styles.iconPan} />
+                        <img
+                          src={data.details?.avatar || musicBg}
+                          class={styles.img2}
+                        />
+                      </div>
+                    </div>
+
+                    <div class={styles.info}>
+                      <div class={styles.name}>
+                        <TheNoticeBar
+                          text={data.details.name}
+                          style={{ marginRight: '0' }}
+                        />
+                        {/* {data.details.name} */}
+                      </div>
+                      <div class={styles.c}>
+                        {data.details.composers ? (
+                          <div>
+                            <span>作曲:</span>
+                            <TheNoticeBar
+                              text={data.details.composers}
+                              style={{ marginRight: '0' }}
+                            />
+                          </div>
+                        ) : (
+                          ''
+                        )}
+                        {data.details.lyricists ? (
+                          <div>
+                            <span>作词:</span>
+                            <TheNoticeBar
+                              text={data.details.lyricists}
+                              style={{ marginRight: '0' }}
+                            />
+                          </div>
+                        ) : (
+                          ''
+                        )}
+                      </div>
+                    </div>
+                  </div>
+
+                  <div class={styles.titlec}>
+                    <i class={styles.icon2}></i>名曲鉴赏
+                  </div>
+
+                  {data.list.map((item: any, index: any) => {
+                    return (
+                      <div class={styles.itemContainer}>
+                        <div
+                          class={[styles.item]}
+                          onClick={(e: Event) => {
+                            e.stopPropagation();
+                            handlePlay(item);
+                          }}>
+                          <div class={styles.img}>
+                            <NImage
+                              lazy
+                              objectFit="cover"
+                              previewDisabled={true}
+                              src={item.titleImg || icon_default}
+                              onLoad={e => {
+                                (e.target as any).dataset.loaded = 'true';
+                              }}
+                            />
+                            <PlayLoading
+                              class={[
+                                data.listActive === index &&
+                                data.playState === 'play'
+                                  ? ''
+                                  : styles.showPlayLoading
+                              ]}
+                            />
+                          </div>
+                          <div class={styles.title}>
+                            <div class={styles.titleName}>
+                              <TheNoticeBar
+                                text={item.name}
+                                style={{ marginRight: '12px' }}
+                              />
+                            </div>
+                          </div>
+
+                          <NButton
+                            color="#259CFE"
+                            textColor="#fff"
+                            round
+                            class={styles.btn}
+                            type="primary"
+                            onClick={(e: Event) => {
+                              e.stopPropagation();
+                              handlePlay(item);
+                            }}>
+                            播放
+                            <img
+                              src={
+                                data.listActive === index &&
+                                data.playState === 'play'
+                                  ? icon_pause
+                                  : icon_play
+                              }
+                            />
+                          </NButton>
+
+                          <img class={styles.arrow} src={icon_arrow} />
+                        </div>
+                      </div>
+                    );
+                  })}
+                  {!data.finshed && (
+                    <div class={styles.loadingWrap}>
+                      <NSpin show={true}></NSpin>
+                    </div>
+                  )}
+                  {!data.loading && data.list.length === 0 && (
+                    <div class={styles.empty}>
+                      <TheEmpty
+                        description="暂无名曲鉴赏"
+                        style={{ paddingTop: '0px' }}></TheEmpty>
+                    </div>
+                  )}
+                </div>
+              </div>
+
+              <div class={styles.musicStaff}>
+                <div class={styles.musicTitle}>
+                  <div class={styles.musicTitleLeft}>
+                    <i class={styles.icon1}></i>名曲故事
+                  </div>
+                  <div class={styles.musicTitleRight}>
+                    {speak.isSpeak.value ? (
+                      <span
+                        class={styles.textClose}
+                        onClick={speak.onCloseSpeak}>
+                        <i class={styles.icon}></i>关闭朗读
+                      </span>
+                    ) : (
+                      <span class={styles.textRead} onClick={speak.onAllSpeak}>
+                        <i class={styles.icon}></i>全文朗读
+                      </span>
+                    )}
+                  </div>
+                </div>
+                <div
+                  class={styles.musicContent}
+                  id="musicContent"
+                  style={{ fontSize: data.fontSize + 'px' }}>
+                  {/* 选中的内容 */}
+                  <div
+                    id="selectionCouser"
+                    class={[
+                      styles.selectionCouser,
+                      !speak.showDom.value && styles.hide
+                    ]}>
+                    <span class={styles.textStart} onClick={speak.onTextStart}>
+                      开始朗读<i class={styles.icon}></i>
+                    </span>
+                    <span
+                      class={styles.textReadOnly}
+                      onClick={speak.onTextReadOnly}>
+                      只读这段<i class={styles.icon}></i>
+                    </span>
+                  </div>
+                </div>
+              </div>
+
+              <div class={styles.changeSizeSection}>
+                <img src={iconT} class={styles.iconT} />
+                <img
+                  src={iconAddT}
+                  class={styles.iconAddT}
+                  onClick={() => {
+                    if (data.fontSize >= 32) return;
+                    data.fontSize += 1;
+                  }}
+                />
+                <NSlider
+                  v-model:value={data.fontSize}
+                  placement="left"
+                  vertical
+                  min={12}
+                  max={32}
+                />
+                <img
+                  src={iconPlusT}
+                  class={styles.iconPlusT}
+                  onClick={() => {
+                    if (data.fontSize <= 12) return;
+                    data.fontSize -= 1;
+                  }}
+                />
+              </div>
+            </div>
+          </div>
+        </div>
+
+        {data.list.length !== 0 && (
+          <PlayItem
+            show={data.showPlayer}
+            playState={data.playState}
+            item={activeItem.value}
+            onChange={value => handleChangeAudio(value)}
+          />
+        )}
+      </div>
+    );
+  }
+});

+ 405 - 0
src/views/content-information/useSpeak.ts

@@ -0,0 +1,405 @@
+import { onMounted, onUnmounted, reactive, toRefs } from 'vue';
+
+export const useSpeak = () => {
+  const state = reactive({
+    showDom: false,
+    synth: null as any,
+    // 选中的索引
+    selectOptions: {
+      startIndex: 0,
+      anchorOffset: 0, // 开始段落的偏移值
+      endIndex: 0,
+      focusOffset: 0 // 结束段落的偏移值
+    },
+    isSpeak: false // 是否在播放
+  });
+
+  // 函数:递归处理节点
+  const processNode = (node: any) => {
+    const result = document.createDocumentFragment();
+    node.childNodes.forEach((child: any) => {
+      if (child.nodeType === Node.TEXT_NODE) {
+        // 按标点符号分割文本
+        const sentences = child.textContent.split(/(?<=[,,;;。.])\s*/);
+        sentences.forEach((sentence: any) => {
+          if (sentence.trim()) {
+            const customTag = document.createElement('label');
+            customTag.textContent = sentence.trim();
+            customTag.classList.add('speak-label');
+            result.appendChild(customTag);
+          }
+        });
+      } else if (child.nodeType === Node.ELEMENT_NODE) {
+        const element = document.createElement(child.nodeName.toLowerCase());
+        // 复制原有的属性
+        Array.from(child.attributes).forEach((attr: any) => {
+          element.setAttribute(attr.name, attr.value);
+        });
+        // 递归处理子节点
+        const processedChildren = processNode(child);
+        element.appendChild(processedChildren);
+        result.appendChild(element);
+      }
+    });
+
+    return result;
+  };
+
+  // 选中的方向
+  const checkSelectionDirection = (selection: any) => {
+    if (selection.rangeCount > 0) {
+      const anchorNode = selection.anchorNode;
+      const anchorOffset = selection.anchorOffset;
+      const focusNode = selection.focusNode;
+      const focusOffset = selection.focusOffset;
+
+      // 检查是否在同一节点内选择
+      if (anchorNode === focusNode) {
+        if (anchorOffset < focusOffset) {
+          return 'up';
+        } else {
+          return 'down';
+        }
+      } else {
+        // 检查不同节点的选择情况
+        const range = selection.getRangeAt(0);
+        const startContainer = range.startContainer;
+        const endContainer = range.endContainer;
+
+        if (startContainer === anchorNode && endContainer === focusNode) {
+          return 'up';
+        } else {
+          return 'down';
+        }
+      }
+    } else {
+      return 'up';
+    }
+  };
+  const getSelectText = () => {
+    const selection: any = window.getSelection();
+    const range = selection.rangeCount > 0 ? selection.getRangeAt(0) : null;
+    // console.log(selection, 'selection');
+    if (selection.toString().length > 0) {
+      state.showDom = true;
+      const textContainer: any = document.querySelector('#musicContent');
+      const sentences: any =
+        textContainer?.querySelectorAll('label.speak-label');
+      let startIndex = 0,
+        anchorOffset = 0,
+        endIndex = 0,
+        focusOffset = 0;
+      console.log(selection, 'selection');
+
+      const firstNode =
+        checkSelectionDirection(selection) === 'up'
+          ? selection.anchorNode.parentNode
+          : selection.focusNode.parentNode;
+      const endNode =
+        checkSelectionDirection(selection) === 'down'
+          ? selection.anchorNode.parentNode
+          : selection.focusNode.parentNode;
+      if (checkSelectionDirection(selection) === 'up') {
+        anchorOffset = selection.anchorOffset;
+        focusOffset = selection.focusOffset;
+      } else {
+        anchorOffset = selection.focusOffset;
+        focusOffset = selection.anchorOffset;
+      }
+      sentences.forEach((element: any, index: number) => {
+        if (element === firstNode) {
+          startIndex = index;
+          anchorOffset =
+            checkSelectionDirection(selection) === 'up'
+              ? selection.anchorOffset
+              : selection.focusOffset;
+        }
+        if (element === endNode) {
+          endIndex = index;
+          focusOffset =
+            checkSelectionDirection(selection) === 'down'
+              ? selection.anchorOffset
+              : selection.focusOffset;
+          // focusOffset = selection.focusOffset;
+        }
+      });
+
+      state.selectOptions.startIndex = startIndex;
+      state.selectOptions.anchorOffset = anchorOffset;
+      state.selectOptions.endIndex = endIndex;
+      state.selectOptions.focusOffset = focusOffset;
+    } else {
+      state.showDom = false;
+    }
+    // 判断选中的类型
+    setTimeout(() => {
+      if (selection.type !== 'Range') {
+        state.showDom = false;
+      }
+    }, 200);
+
+    if (range && !selection.isCollapsed) {
+      // 获取选中文本的坐标
+      // const rect = range.getBoundingClientRect();
+      const rects = range.getClientRects();
+
+      if (rects.length > 0) {
+        const firstRect = rects[0];
+        const x = firstRect.left;
+        const y = firstRect.top;
+        const bottom = firstRect.bottom;
+        const fHeight = firstRect.height;
+        const musicContent: any = document.querySelector('#musicContent');
+        const parentRect: any = musicContent?.getBoundingClientRect();
+
+        const showDom: any = document.getElementById('selectionCouser');
+        const showDomRect = showDom.getBoundingClientRect();
+
+        // 判断 上边超出边界
+        if (y - parentRect?.top > showDomRect.height + fHeight / 2) {
+          showDom.style.top =
+            (
+              y -
+              parentRect?.top -
+              (showDomRect.height + fHeight / 2) +
+              musicContent?.scrollTop
+            ).toFixed(2) + 'px';
+        } else {
+          console.log(
+            false,
+            parentRect?.bottom -
+              bottom +
+              (showDomRect.height + fHeight / 2) +
+              musicContent?.scrollTop
+          );
+          showDom.style.top =
+            (
+              y -
+              parentRect?.top +
+              (showDomRect.height + fHeight / 2) +
+              musicContent?.scrollTop
+            ).toFixed(2) + 'px';
+        }
+        if (parentRect?.width - (x - parentRect?.left) > showDomRect.width) {
+          // 判断是否选择到最右边 超出边界
+          showDom.style.left = (x - parentRect?.left).toFixed(2) + 'px';
+          showDom.style.right = 'auto';
+        } else {
+          showDom.style.right = '0px';
+          showDom.style.left = 'auto';
+        }
+      }
+    }
+  };
+
+  /** 开始朗读 */
+  const onTextStart = () => {
+    onCloseSpeak();
+    onSpeak({
+      startIndex: state.selectOptions.startIndex,
+      anchorOffset: state.selectOptions.anchorOffset
+    });
+  };
+
+  /** 只读这段 */
+  const onTextReadOnly = () => {
+    onCloseSpeak();
+    onSpeak(state.selectOptions);
+  };
+
+  function clearSelection() {
+    if (window.getSelection) {
+      window.getSelection()?.removeAllRanges(); // 清除选中区域
+    } else if ((document as any).selection) {
+      (document as any).selection.empty(); // 清除选中区域(IE 8 或更早)
+    }
+    state.showDom = false;
+    // state.selectOptions.startIndex = 0;
+    // state.selectOptions.anchorOffset = 0;
+    // state.selectOptions.endIndex = 0;
+    // state.selectOptions.focusOffset = 0;
+  }
+
+  /** 关闭朗读 */
+  const onCloseSpeak = () => {
+    // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+    // @ts-ignore
+    // responsiveVoice.cancel();
+    state.synth?.cancel();
+    state.isSpeak = false;
+    const textContainer: any = document.querySelector('#musicContent');
+    const sentences: any = textContainer?.querySelectorAll('label.speak-label');
+    sentences.forEach((sentence: any, i: number) => {
+      sentence.classList.toggle('highlight', i === -1);
+    });
+    clearSelection();
+  };
+
+  /** 全文朗读 */
+  const onAllSpeak = () => {
+    clearSelection();
+    try {
+      onSpeak({
+        startIndex: 0
+      });
+    } catch (e: any) {
+      console.log(e, '12');
+    }
+  };
+
+  // 开始播放
+  const onSpeak = (options: {
+    startIndex: number;
+    endIndex?: number;
+    anchorOffset?: number;
+    focusOffset?: number;
+  }) => {
+    const textContainer: any = document.querySelector('#musicContent');
+    const sentences: any = textContainer?.querySelectorAll('label.speak-label');
+
+    let currentSentenceIndex = options.startIndex || 0;
+    const end = options.endIndex || sentences.length - 1;
+    // 高亮显示
+    const highlightSentence = (index: number) => {
+      sentences.forEach((sentence: any, i: number) => {
+        sentence.classList.toggle('highlight', i === index);
+      });
+      // 滚动到高亮的部分
+      const highlightText = textContainer?.querySelector('.highlight');
+      // console.log(highlightText, 'highlight');
+      highlightText?.scrollIntoView({
+        behavior: 'smooth',
+        block: 'center'
+      });
+    };
+
+    // 开始播放
+    const speaker = () => {
+      try {
+        state.synth = window.speechSynthesis;
+        // 如果当前正在播放,先暂停
+        if (state.synth.speaking) {
+          state.synth.cancel(); // 取消当前播放
+        }
+
+        let sentence = sentences[currentSentenceIndex].textContent;
+        if (sentence.length <= 0) {
+          console.error('暂无播放内容');
+          return;
+        }
+        // 判断是否为选中的内容播放
+        if (options.startIndex === options.endIndex) {
+          sentence = sentence.substr(
+            options.anchorOffset,
+            (options.focusOffset || 0) - (options.anchorOffset || 0)
+          );
+        } else {
+          if (options.startIndex === currentSentenceIndex) {
+            sentence = sentence.substr(
+              options.anchorOffset,
+              sentence.length - 1
+            );
+          }
+          if (options.endIndex === currentSentenceIndex) {
+            sentence = sentence.substr(0, options.focusOffset);
+          }
+        }
+
+        const utterance = new SpeechSynthesisUtterance();
+        utterance.lang = 'zh-CN';
+        utterance.volume = 1;
+        utterance.rate = 0.8; // 语速 0.1到10
+        utterance.pitch = 1.5; // 范围从0(最小)到2(最大)
+        utterance.text = sentence;
+
+        if (utterance) {
+          utterance.onstart = null;
+          utterance.onend = null;
+          utterance.onerror = null;
+        }
+
+        // console.log(sentence, utterance);
+
+        utterance.onstart = () => {
+          state.isSpeak = true;
+          highlightSentence(currentSentenceIndex);
+        };
+        utterance.onend = () => {
+          console.log('朗读结束');
+          currentSentenceIndex++;
+          if (currentSentenceIndex <= end && state.isSpeak) {
+            speaker(); // 继续下一个句子
+          } else {
+            currentSentenceIndex = 0; // 结束后重置索引
+            highlightSentence(-1); // 清除高亮
+            state.isSpeak = false;
+          }
+        };
+        utterance.onerror = () => {
+          currentSentenceIndex++;
+          if (currentSentenceIndex <= end && state.isSpeak) {
+            speaker(); // 继续下一个句子
+          } else {
+            state.isSpeak = false;
+          }
+        };
+        setTimeout(() => {
+          state.synth.speak(utterance);
+        }, 100);
+      } catch (e) {
+        console.log(e, 'e');
+      }
+
+      // 事件监听(如果需要)
+      // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+      // @ts-ignore
+      // responsiveVoice.speak(sentence, 'Chinese Male', {
+      //   onstart: () => {
+      //     console.log('开始朗读');
+      //     state.isSpeak = true;
+      //     highlightSentence(currentSentenceIndex);
+      //   },
+      //   onend: () => {
+      //     console.log('朗读结束');
+      //     currentSentenceIndex++;
+      //     if (currentSentenceIndex <= end && state.isSpeak) {
+      //       speaker(); // 继续下一个句子
+      //     } else {
+      //       currentSentenceIndex = 0; // 结束后重置索引
+      //       highlightSentence(-1); // 清除高亮
+      //       state.isSpeak = false;
+      //     }
+      //   },
+      //   onerror: (e: any) => {
+      //     console.error('朗读错误:', e);
+      //     currentSentenceIndex++;
+      //     if (currentSentenceIndex <= end && state.isSpeak) {
+      //       speaker(); // 继续下一个句子
+      //     } else {
+      //       state.isSpeak = false;
+      //     }
+      //   }
+      // });
+    };
+
+    speaker();
+  };
+
+  onMounted(async () => {
+    document.addEventListener('mouseup', getSelectText);
+  });
+
+  onUnmounted(() => {
+    document.removeEventListener('mouseup', getSelectText);
+    onCloseSpeak();
+  });
+
+  return {
+    ...toRefs(state),
+    onAllSpeak,
+    onTextStart,
+    onCloseSpeak,
+    onTextReadOnly,
+    processNode
+  };
+};

Някои файлове не бяха показани, защото твърде много файлове са промени