Browse Source

节奏练习功能

黄琪勇 1 week ago
parent
commit
36c60e9a6a
29 changed files with 650 additions and 16 deletions
  1. 3 0
      .env.devProd
  2. 3 0
      .env.development
  3. 3 0
      .env.production
  4. 3 0
      .env.staging
  5. 2 0
      src/config/index.ts
  6. 19 1
      src/hooks/useCreateElement.ts
  7. 9 0
      src/messageHooks/mobileScreen.ts
  8. 35 0
      src/messageHooks/rhythmPractice.ts
  9. 23 2
      src/types/slides.ts
  10. 3 1
      src/views/Editor/Canvas/EditableElement.vue
  11. 6 2
      src/views/Editor/Canvas/hooks/useDragElement.ts
  12. 28 1
      src/views/Editor/CanvasTool/index.vue
  13. 7 0
      src/views/Editor/Toolbar/ElementStylePanel/RhythmPracticeStylePanel.vue
  14. 3 1
      src/views/Editor/Toolbar/ElementStylePanel/index.vue
  15. 4 1
      src/views/Editor/Toolbar/index.vue
  16. 3 1
      src/views/Mobile/MobileEditor/MobileEditableElement.vue
  17. 3 1
      src/views/Screen/ScreenElement.vue
  18. 3 1
      src/views/components/ThumbnailSlide/ThumbnailElement.vue
  19. 2 2
      src/views/components/element/listeningPracticeElement/BaseListeningPracticeElement.vue
  20. 2 2
      src/views/components/element/listeningPracticeElement/ScreenListeningPracticeElement.vue
  21. 43 0
      src/views/components/element/rhythmPracticeElement/BaseRhythmPracticeElement.vue
  22. 57 0
      src/views/components/element/rhythmPracticeElement/ScreenRhythmPracticeElement.vue
  23. BIN
      src/views/components/element/rhythmPracticeElement/imgs/musicBg.png
  24. 2 0
      src/views/components/element/rhythmPracticeElement/index.ts
  25. 116 0
      src/views/components/element/rhythmPracticeElement/rhythmPracticeElement.vue
  26. 2 0
      src/views/components/element/rhythmPracticeElement/rhythmPracticeList/index.ts
  27. 138 0
      src/views/components/element/rhythmPracticeElement/rhythmPracticeList/rhythmPracticeList.vue
  28. 2 0
      src/views/components/element/rhythmPracticeElement/rhythmPracticePlayer/index.ts
  29. 126 0
      src/views/components/element/rhythmPracticeElement/rhythmPracticePlayer/rhythmPracticePlayer.vue

+ 3 - 0
.env.devProd

@@ -3,3 +3,6 @@ VITE_APP_URL = "https://dev.kt.colexiu.com"
 
 ## 云教练地址
 VITE_YJL_URL = "https://test.kt.colexiu.com/instrument"
+
+## 移动端app地址
+VITE_CLASSAPP_URL = "https://test.kt.colexiu.com/classroom-app"

+ 3 - 0
.env.development

@@ -3,3 +3,6 @@ VITE_APP_URL = "http://localhost:9527/pptApi"
 
 ## 云教练地址
 VITE_YJL_URL = "https://test.kt.colexiu.com/instrument"
+
+## 移动端app地址
+VITE_CLASSAPP_URL = "https://test.kt.colexiu.com/classroom-app"

+ 3 - 0
.env.production

@@ -3,3 +3,6 @@ VITE_APP_URL = "https://kt.colexiu.com"
 
 ## 云教练地址
 VITE_YJL_URL = "https://mec.colexiu.com/instrument"
+
+## 移动端app地址
+VITE_CLASSAPP_URL = "https://mec.colexiu.com/classroom-app"

+ 3 - 0
.env.staging

@@ -3,3 +3,6 @@ VITE_APP_URL = "https://test.kt.colexiu.com"
 
 ## 云教练地址
 VITE_YJL_URL = "https://test.kt.colexiu.com/instrument"
+
+## 移动端app地址
+VITE_CLASSAPP_URL = "https://test.kt.colexiu.com/classroom-app"

+ 2 - 0
src/config/index.ts

@@ -1,3 +1,5 @@
 export const URL_API = import.meta.env.VITE_APP_URL as string
 
 export const YJL_URL_API = import.meta.env.VITE_YJL_URL as string
+
+export const CLASSAPP_URL_API = import.meta.env.VITE_CLASSAPP_URL as string

+ 19 - 1
src/hooks/useCreateElement.ts

@@ -379,6 +379,23 @@ export default () => {
       top: 0
     })
   }
+
+  /**
+   * 节奏练习
+   */
+  const createRhythmPracticeElement = (dataJson: string) => {
+    createElement({
+      type: "elf",
+      subtype: "elf-rhythm-practice",
+      dataJson,
+      id: nanoid(10),
+      width: viewportSize.value,
+      height: viewportSize.value * viewportRatio.value,
+      rotate: 0,
+      left: 0,
+      top: 0
+    })
+  }
   return {
     createImageElement,
     createChartElement,
@@ -391,6 +408,7 @@ export default () => {
     createAudioElement,
     createCloudCoachElement,
     createEnjoyElement,
-    createListeningPracticeElement
+    createListeningPracticeElement,
+    createRhythmPracticeElement
   }
 }

+ 9 - 0
src/messageHooks/mobileScreen.ts

@@ -13,12 +13,21 @@ export const changeToggeMes = () => {
         "*"
       )
     } else if (api === "changeTogge") {
+      // 云教练切换
       window.parent.postMessage(
         {
           api: "changeTogge"
         },
         "*"
       )
+    } else if (["clickTempo", "clickViewFigner"].includes(api)) {
+      // 节奏练习 听音练习 切换
+      window.parent.postMessage(
+        {
+          api
+        },
+        "*"
+      )
     }
   }
   onMounted(() => {

+ 35 - 0
src/messageHooks/rhythmPractice.ts

@@ -0,0 +1,35 @@
+import { onMounted, onUnmounted } from "vue"
+
+export const rhythmPracticeMes = () => {
+  let resFun: (value: string) => void
+
+  // 获取节奏练习设置
+  function getRhythmPracticeSetting(rhythmPracticeRef?: HTMLIFrameElement): Promise<string> {
+    return new Promise(res => {
+      resFun = res
+      if (rhythmPracticeRef) {
+        rhythmPracticeRef.contentWindow?.postMessage({ api: "getTempoSetting" }, "*")
+      } else {
+        resFun("")
+      }
+    })
+  }
+  function handleMessage(event: any) {
+    const { api, data } = event.data || {}
+    if (api === "getTempoSetting") {
+      const dataRes = data ? JSON.parse(data) : {}
+      resFun(JSON.stringify(dataRes.setting) || "")
+    }
+  }
+
+  onMounted(() => {
+    window.addEventListener("message", handleMessage)
+  })
+  onUnmounted(() => {
+    window.removeEventListener("message", handleMessage)
+  })
+
+  return {
+    getRhythmPracticeSetting
+  }
+}

+ 23 - 2
src/types/slides.ts

@@ -36,7 +36,8 @@ export const enum ElementSubtypeTypes {
   AUDIO = "elf-audio",
   SING_PLAY = "elf-sing-play",
   ENJOY = "elf-enjoy",
-  LISTENING_PRACTICE = "elf-listening-practice"
+  LISTENING_PRACTICE = "elf-listening-practice",
+  RHYTHM_PRACTICE = "elf-rhythm-practice"
 }
 
 /**
@@ -670,7 +671,10 @@ export interface PPTEnjoyElement extends PPTBaseElement {
  *
  * code: 乐器code
  *
- * 乐器img
+ * instrumentImg:乐器图片
+ *
+ * name: 乐器名称
+ *
  *
  */
 export interface PPTListeningPracticeElement extends PPTBaseElement {
@@ -681,6 +685,22 @@ export interface PPTListeningPracticeElement extends PPTBaseElement {
   instrumentImg: string
 }
 
+/**
+ * 节奏练习
+ *
+ * type: elf
+ *
+ * subtype: elf-rhythm-practice
+ *
+ * dataJson:默认配置设置
+ *
+ */
+export interface PPTRhythmPracticeElement extends PPTBaseElement {
+  type: "elf"
+  subtype: "elf-rhythm-practice"
+  dataJson: string
+}
+
 export type PPTElement =
   | PPTTextElement
   | PPTImageElement
@@ -694,6 +714,7 @@ export type PPTElement =
   | PPTCloudCoachElement
   | PPTEnjoyElement
   | PPTListeningPracticeElement
+  | PPTRhythmPracticeElement
 
 export type AnimationType = "in" | "out" | "attention"
 export type AnimationTrigger = "click" | "meantime" | "auto"

+ 3 - 1
src/views/Editor/Canvas/EditableElement.vue

@@ -38,6 +38,7 @@ import AudioElement from "@/views/components/element/AudioElement/index.vue"
 import cloudCoachElement from "@/views/components/element/cloudCoachElement"
 import enjoyElement from "@/views/components/element/enjoyElement"
 import listeningPracticeElement from "@/views/components/element/listeningPracticeElement"
+import rhythmPracticeElement from "@/views/components/element/rhythmPracticeElement"
 
 const props = defineProps<{
   elementInfo: PPTElement
@@ -63,7 +64,8 @@ const currentElementComponent = computed<unknown>(() => {
     [ElementSubtypeTypes.VIDEO]: VideoElement,
     [ElementSubtypeTypes.SING_PLAY]: cloudCoachElement,
     [ElementSubtypeTypes.ENJOY]: enjoyElement,
-    [ElementSubtypeTypes.LISTENING_PRACTICE]: listeningPracticeElement
+    [ElementSubtypeTypes.LISTENING_PRACTICE]: listeningPracticeElement,
+    [ElementSubtypeTypes.RHYTHM_PRACTICE]: rhythmPracticeElement
   }
   return elementTypeMap[props.elementInfo.type] || elementSubtypeMap[props.elementInfo.subtype] || null
 })

+ 6 - 2
src/views/Editor/Canvas/hooks/useDragElement.ts

@@ -22,7 +22,11 @@ export default (elementList: Ref<PPTElement[]>, alignmentLines: Ref<AlignmentLin
     let isMouseDown = true
     /* 选中移动的时候 云教练模块标记移动中 */
     elementList.value.map(item => {
-      if (activeElementIdList.value.includes(item.id) && item.type === "elf" && ["elf-sing-play", "elf-listening-practice"].includes(item.subtype)) {
+      if (
+        activeElementIdList.value.includes(item.id) &&
+        item.type === "elf" &&
+        ["elf-sing-play", "elf-listening-practice", "elf-rhythm-practice"].includes(item.subtype)
+      ) {
         item.isMove = true
       }
     })
@@ -294,7 +298,7 @@ export default (elementList: Ref<PPTElement[]>, alignmentLines: Ref<AlignmentLin
         if (
           activeElementIdList.value.includes(item.id) &&
           item.type === "elf" &&
-          ["elf-sing-play", "elf-listening-practice"].includes(item.subtype)
+          ["elf-sing-play", "elf-listening-practice", "elf-rhythm-practice"].includes(item.subtype)
         ) {
           item.isMove = false
         }

+ 28 - 1
src/views/Editor/CanvasTool/index.vue

@@ -313,6 +313,26 @@
         "
       />
     </Modal>
+    <Modal
+      :contentStyle="{
+        width: '1100px',
+        height: '750px',
+        boxShadow: '0px 2px 10px 0px rgba(0,0,0,0.08)',
+        borderRadius: '16px',
+        border: '1px solid #DEDEDE',
+        padding: '0'
+      }"
+      v-model:visible="rhythmPracticeVisible"
+    >
+      <rhythmPracticeList
+        @update="handleAddRhythmPractice"
+        @close="
+          () => {
+            rhythmPracticeVisible = false
+          }
+        "
+      />
+    </Modal>
   </div>
 </template>
 
@@ -341,6 +361,7 @@ import { ElUpload, ElMessage, type UploadRequestOptions } from "element-plus"
 import cloudCoachList from "@/views/components/element/cloudCoachElement/cloudCoachList"
 import resourcesList from "@/views/components/element/enjoyElement/resourcesList"
 import listeningPracticeList from "@/views/components/element/listeningPracticeElement/listeningPracticeList"
+import rhythmPracticeList from "@/views/components/element/rhythmPracticeElement/rhythmPracticeList"
 import fileUpload from "@/utils/oss-file-upload"
 import usePptWork from "@/store/pptWork"
 
@@ -374,7 +395,8 @@ const {
   createAudioElement,
   createCloudCoachElement,
   createEnjoyElement,
-  createListeningPracticeElement
+  createListeningPracticeElement,
+  createRhythmPracticeElement
 } = useCreateElement()
 
 const insertImageElement = (files: FileList) => {
@@ -427,6 +449,11 @@ function handleAddListeningPractice(item: Record<string, any>) {
   createListeningPracticeElement(item.code, item.img, item.name)
   listeningPracticeVisible.value = false
 }
+// 处理节奏练习
+function handleAddRhythmPractice(value: string) {
+  createRhythmPracticeElement(value)
+  rhythmPracticeVisible.value = false
+}
 // 处理资源创建
 function handleResources(item: Record<string, any>) {
   if (item.type === "SONG") {

+ 7 - 0
src/views/Editor/Toolbar/ElementStylePanel/RhythmPracticeStylePanel.vue

@@ -0,0 +1,7 @@
+<template>
+  <div class="RhythmPracticeStylePanel">节奏练习配置</div>
+</template>
+
+<script setup lang="ts"></script>
+
+<style lang="scss" scoped></style>

+ 3 - 1
src/views/Editor/Toolbar/ElementStylePanel/index.vue

@@ -23,6 +23,7 @@ import MultiStylePanel from "./MultiStylePanel.vue"
 import CloudCoachStylePanel from "./CloudCoachStylePanel.vue"
 import EnjoyStylePanel from "./EnjoyStylePanel.vue"
 import ListeningPracticeStylePanel from "./ListeningPracticeStylePanel.vue"
+import RhythmPracticeStylePanel from "./RhythmPracticeStylePanel.vue"
 
 const panelMap = {
   [ElementTypes.TEXT]: TextStylePanel,
@@ -39,7 +40,8 @@ const elementSubtypeMap = {
   [ElementSubtypeTypes.VIDEO]: VideoStylePanel,
   [ElementSubtypeTypes.SING_PLAY]: CloudCoachStylePanel,
   [ElementSubtypeTypes.ENJOY]: EnjoyStylePanel,
-  [ElementSubtypeTypes.LISTENING_PRACTICE]: ListeningPracticeStylePanel
+  [ElementSubtypeTypes.LISTENING_PRACTICE]: ListeningPracticeStylePanel,
+  [ElementSubtypeTypes.RHYTHM_PRACTICE]: RhythmPracticeStylePanel
 }
 const { activeElementIdList, activeElementList, handleElement, activeGroupElementId } = storeToRefs(useMainStore())
 

+ 4 - 1
src/views/Editor/Toolbar/index.vue

@@ -39,7 +39,10 @@ const elementTabs = computed<ElementTabs[]>(() => {
       { label: "动画", key: ToolbarStates.EL_ANIMATION }
     ]
   }
-  if (handleElement.value?.type === "elf" && ["elf-sing-play", "elf-enjoy", "elf-listening-practice"].includes(handleElement.value?.subtype)) {
+  if (
+    handleElement.value?.type === "elf" &&
+    ["elf-sing-play", "elf-enjoy", "elf-listening-practice", "elf-rhythm-practice"].includes(handleElement.value?.subtype)
+  ) {
     return [
       { label: "位置", key: ToolbarStates.EL_POSITION },
       { label: "动画", key: ToolbarStates.EL_ANIMATION }

+ 3 - 1
src/views/Mobile/MobileEditor/MobileEditableElement.vue

@@ -25,6 +25,7 @@ import AudioElement from "@/views/components/element/AudioElement/index.vue"
 import cloudCoachElement from "@/views/components/element/cloudCoachElement"
 import enjoyElement from "@/views/components/element/enjoyElement"
 import listeningPracticeElement from "@/views/components/element/listeningPracticeElement"
+import rhythmPracticeElement from "@/views/components/element/rhythmPracticeElement"
 
 const props = defineProps<{
   elementInfo: PPTElement
@@ -48,7 +49,8 @@ const currentElementComponent = computed<unknown>(() => {
     [ElementSubtypeTypes.VIDEO]: VideoElement,
     [ElementSubtypeTypes.SING_PLAY]: cloudCoachElement,
     [ElementSubtypeTypes.ENJOY]: enjoyElement,
-    [ElementSubtypeTypes.LISTENING_PRACTICE]: listeningPracticeElement
+    [ElementSubtypeTypes.LISTENING_PRACTICE]: listeningPracticeElement,
+    [ElementSubtypeTypes.RHYTHM_PRACTICE]: rhythmPracticeElement
   }
   return elementTypeMap[props.elementInfo.type] || elementSubtypeMap[props.elementInfo.subtype] || null
 })

+ 3 - 1
src/views/Screen/ScreenElement.vue

@@ -35,6 +35,7 @@ import ScreenAudioElement from "@/views/components/element/AudioElement/ScreenAu
 import ScreenCloudCoachElement from "@/views/components/element/cloudCoachElement/ScreenCloudCoachElement.vue"
 import ScreenEnjoyElement from "@/views/components/element/enjoyElement/ScreenEnjoyElement.vue"
 import ScreenListeningPracticeElement from "@/views/components/element/listeningPracticeElement/ScreenListeningPracticeElement.vue"
+import ScreenRhythmPracticeElement from "@/views/components/element/rhythmPracticeElement/ScreenRhythmPracticeElement.vue"
 
 const props = defineProps<{
   elementInfo: PPTElement
@@ -60,7 +61,8 @@ const currentElementComponent = computed<unknown>(() => {
     [ElementSubtypeTypes.VIDEO]: ScreenVideoElement,
     [ElementSubtypeTypes.SING_PLAY]: ScreenCloudCoachElement,
     [ElementSubtypeTypes.ENJOY]: ScreenEnjoyElement,
-    [ElementSubtypeTypes.LISTENING_PRACTICE]: ScreenListeningPracticeElement
+    [ElementSubtypeTypes.LISTENING_PRACTICE]: ScreenListeningPracticeElement,
+    [ElementSubtypeTypes.RHYTHM_PRACTICE]: ScreenRhythmPracticeElement
   }
   return elementTypeMap[props.elementInfo.type] || elementSubtypeMap[props.elementInfo.subtype] || null
 })

+ 3 - 1
src/views/components/ThumbnailSlide/ThumbnailElement.vue

@@ -26,6 +26,7 @@ import BaseAudioElement from "@/views/components/element/AudioElement/BaseAudioE
 import BaseCloudCoachElement from "@/views/components/element/cloudCoachElement/BaseCloudCoachElement.vue"
 import BaseEnjoyElement from "@/views/components/element/enjoyElement/BaseEnjoyElement.vue"
 import BaseListeningPracticeElement from "@/views/components/element/listeningPracticeElement/BaseListeningPracticeElement.vue"
+import BaseRhythmPracticeElement from "@/views/components/element/rhythmPracticeElement/BaseRhythmPracticeElement.vue"
 
 const props = defineProps<{
   elementInfo: PPTElement
@@ -48,7 +49,8 @@ const currentElementComponent = computed<unknown>(() => {
     [ElementSubtypeTypes.VIDEO]: BaseVideoElement,
     [ElementSubtypeTypes.SING_PLAY]: BaseCloudCoachElement,
     [ElementSubtypeTypes.ENJOY]: BaseEnjoyElement,
-    [ElementSubtypeTypes.LISTENING_PRACTICE]: BaseListeningPracticeElement
+    [ElementSubtypeTypes.LISTENING_PRACTICE]: BaseListeningPracticeElement,
+    [ElementSubtypeTypes.RHYTHM_PRACTICE]: BaseRhythmPracticeElement
   }
   return elementTypeMap[props.elementInfo.type] || elementSubtypeMap[props.elementInfo.subtype] || null
 })

+ 2 - 2
src/views/components/element/listeningPracticeElement/BaseListeningPracticeElement.vue

@@ -1,6 +1,6 @@
 <template>
   <div
-    class="base-element-cloudCoach"
+    class="base-element-listeningPractice"
     :style="{
       top: elementInfo.top + 'px',
       left: elementInfo.left + 'px',
@@ -25,7 +25,7 @@ defineProps<{
 </script>
 
 <style lang="scss" scoped>
-.base-element-cloudCoach {
+.base-element-listeningPractice {
   position: absolute;
 }
 .rotate-wrapper {

+ 2 - 2
src/views/components/element/listeningPracticeElement/ScreenListeningPracticeElement.vue

@@ -1,6 +1,6 @@
 <template>
   <div
-    class="base-element-cloudCoach screen-element-cloudCoach"
+    class="base-element-cloudCoach screen-element-listeningPractice"
     :style="{
       top: elementInfo.top + 'px',
       left: elementInfo.left + 'px',
@@ -43,7 +43,7 @@ const inCurrentSlide = computed(() => currentSlide.value.id === slideId.value)
 </script>
 
 <style lang="scss" scoped>
-.screen-element-cloudCoach {
+.screen-element-listeningPractice {
   position: absolute;
 }
 .rotate-wrapper {

+ 43 - 0
src/views/components/element/rhythmPracticeElement/BaseRhythmPracticeElement.vue

@@ -0,0 +1,43 @@
+<template>
+  <div
+    class="base-element-rhythmPractice"
+    :style="{
+      top: elementInfo.top + 'px',
+      left: elementInfo.left + 'px',
+      width: elementInfo.width + 'px',
+      height: elementInfo.height + 'px'
+    }"
+  >
+    <div class="rotate-wrapper" :style="{ transform: `rotate(${elementInfo.rotate}deg)` }">
+      <div class="element-content"></div>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import type { PPTListeningPracticeElement } from "@/types/slides"
+
+defineProps<{
+  elementInfo: PPTListeningPracticeElement
+}>()
+</script>
+
+<style lang="scss" scoped>
+.base-element-rhythmPractice {
+  position: absolute;
+}
+.rotate-wrapper {
+  width: 100%;
+  height: 100%;
+}
+.element-content {
+  width: 100%;
+  height: 100%;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  background: url("./imgs/musicBg.png") no-repeat;
+  background-size: cover;
+  position: relative;
+}
+</style>

+ 57 - 0
src/views/components/element/rhythmPracticeElement/ScreenRhythmPracticeElement.vue

@@ -0,0 +1,57 @@
+<template>
+  <div
+    class="base-element-cloudCoach screen-element-rhythmPractice"
+    :style="{
+      top: elementInfo.top + 'px',
+      left: elementInfo.left + 'px',
+      width: elementInfo.width + 'px',
+      height: elementInfo.height + 'px'
+    }"
+  >
+    <div class="rotate-wrapper" :style="{ transform: `rotate(${elementInfo.rotate}deg)` }">
+      <div class="element-content">
+        <rhythmPracticePlayer
+          v-if="inCurrentSlide"
+          :width="elementInfo.width"
+          :height="elementInfo.height"
+          :scale="scale"
+          :data-json="elementInfo.dataJson"
+        />
+      </div>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { computed, inject, ref } from "vue"
+import { storeToRefs } from "pinia"
+import { useSlidesStore } from "@/store"
+import type { PPTRhythmPracticeElement } from "@/types/slides"
+import { injectKeySlideId, injectKeySlideScale } from "@/types/injectKey"
+import rhythmPracticePlayer from "./rhythmPracticePlayer"
+
+defineProps<{
+  elementInfo: PPTRhythmPracticeElement
+}>()
+
+const { currentSlide } = storeToRefs(useSlidesStore())
+
+const scale = inject(injectKeySlideScale) || ref(1)
+const slideId = inject(injectKeySlideId) || ref("")
+
+const inCurrentSlide = computed(() => currentSlide.value.id === slideId.value)
+</script>
+
+<style lang="scss" scoped>
+.screen-element-rhythmPractice {
+  position: absolute;
+}
+.rotate-wrapper {
+  width: 100%;
+  height: 100%;
+}
+.element-content {
+  width: 100%;
+  height: 100%;
+}
+</style>

BIN
src/views/components/element/rhythmPracticeElement/imgs/musicBg.png


+ 2 - 0
src/views/components/element/rhythmPracticeElement/index.ts

@@ -0,0 +1,2 @@
+import rhythmPracticeElement from "./rhythmPracticeElement.vue"
+export default rhythmPracticeElement

+ 116 - 0
src/views/components/element/rhythmPracticeElement/rhythmPracticeElement.vue

@@ -0,0 +1,116 @@
+<template>
+  <div
+    class="rhythmPracticeElement"
+    :class="{ lock: elementInfo.lock }"
+    :style="{
+      top: elementInfo.top + 'px',
+      left: elementInfo.left + 'px',
+      width: elementInfo.width + 'px',
+      height: elementInfo.height + 'px'
+    }"
+  >
+    <div class="rotate-wrapper" :style="{ transform: `rotate(${elementInfo.rotate}deg)` }">
+      <div
+        class="element-content"
+        v-contextmenu="contextmenus"
+        @mousedown="$event => handleSelectElement($event)"
+        @touchstart="$event => handleSelectElement($event)"
+      >
+        <div
+          v-if="isShowMask"
+          @mousedown.stop="$event => handleSelectElement($event, false)"
+          @touchstart.stop="$event => handleSelectElement($event, false)"
+          class="mask"
+        ></div>
+        <rhythmPracticePlayer :width="elementInfo.width" :height="elementInfo.height" :scale="canvasScale" :dataJson="elementInfo.dataJson" />
+        <div
+          :class="['handler-border', item]"
+          v-for="item in ['t', 'b', 'l', 'r']"
+          :key="item"
+          @mousedown="$event => handleSelectElement($event)"
+          @touchstart="$event => handleSelectElement($event)"
+        ></div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { storeToRefs } from "pinia"
+import { useMainStore } from "@/store"
+import type { PPTRhythmPracticeElement } from "@/types/slides"
+import type { ContextmenuItem } from "@/components/Contextmenu/types"
+import rhythmPracticePlayer from "./rhythmPracticePlayer"
+import { computed } from "vue"
+
+const props = defineProps<{
+  elementInfo: PPTRhythmPracticeElement
+  selectElement: (e: MouseEvent | TouchEvent, element: PPTRhythmPracticeElement, canMove?: boolean) => void
+  contextmenus: () => ContextmenuItem[] | null
+}>()
+
+const mainStore = useMainStore()
+const { canvasScale } = storeToRefs(mainStore)
+
+/* 当没有选中 或者 拖动过程中加一个遮罩,以免事件进入 iframe 无法触发*/
+const isShowMask = computed(() => {
+  return mainStore.handleElementId !== props.elementInfo.id || props.elementInfo.isMove
+})
+const handleSelectElement = (e: MouseEvent | TouchEvent, canMove = true) => {
+  if (props.elementInfo.lock) return
+  e.stopPropagation()
+
+  props.selectElement(e, props.elementInfo, canMove)
+}
+</script>
+
+<style lang="scss" scoped>
+.rhythmPracticeElement {
+  position: absolute;
+  &.lock .handler-border {
+    cursor: default;
+  }
+}
+.rotate-wrapper {
+  width: 100%;
+  height: 100%;
+}
+.element-content {
+  width: 100%;
+  height: 100%;
+  position: relative;
+  .mask {
+    position: absolute;
+    width: 100%;
+    height: 100%;
+  }
+}
+.handler-border {
+  position: absolute;
+  cursor: move;
+  &.t {
+    width: 100%;
+    height: 10px;
+    top: 0;
+    left: 0;
+  }
+  &.b {
+    width: 100%;
+    height: 10px;
+    bottom: 0;
+    left: 0;
+  }
+  &.l {
+    width: 10px;
+    height: 100%;
+    left: 0;
+    top: 0;
+  }
+  &.r {
+    width: 10px;
+    height: 100%;
+    right: 0;
+    top: 0;
+  }
+}
+</style>

+ 2 - 0
src/views/components/element/rhythmPracticeElement/rhythmPracticeList/index.ts

@@ -0,0 +1,2 @@
+import rhythmPracticeList from "./rhythmPracticeList.vue"
+export default rhythmPracticeList

+ 138 - 0
src/views/components/element/rhythmPracticeElement/rhythmPracticeList/rhythmPracticeList.vue

@@ -0,0 +1,138 @@
+<template>
+  <div class="rhythmPracticeList">
+    <div class="headCon">
+      <div class="headLeft">
+        <img class="tipImg" src="@/views/Editor/CanvasTool/imgs/jzlx.png" alt="" />
+        <div class="title">节奏练习</div>
+      </div>
+      <div class="headright">
+        <img @click="emits('close')" class="closeBtn" src="../../cloudCoachElement/cloudCoachList/imgs/close.png" alt="" />
+      </div>
+    </div>
+    <div class="content">
+      <rhythmPracticePlayer @handleIframeLoad="handleIframeLoad" :settingMode="true" ref="rhythmPracticePlayerDom" :width="1098" :height="618" />
+    </div>
+    <div class="btnCon">
+      <div class="cancelBtn" @click="emits('close')">取消</div>
+      <div class="addBtn" :class="{ disabled: loading }" @click="handleAddrhythmPractice">添加</div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import rhythmPracticePlayer from "../rhythmPracticePlayer"
+import { ref } from "vue"
+import { rhythmPracticeMes } from "@/messageHooks/rhythmPractice"
+
+const emits = defineEmits<{
+  (event: "update", value: string): void
+  (event: "close"): void
+}>()
+
+const loading = ref(true)
+const rhythmPracticePlayerDom = ref<InstanceType<typeof rhythmPracticePlayer>>()
+const { getRhythmPracticeSetting } = rhythmPracticeMes()
+
+async function handleAddrhythmPractice() {
+  if (loading.value) return
+  try {
+    const rhythmPracticeSetting = await getRhythmPracticeSetting(rhythmPracticePlayerDom.value?.rhythmPracticeIframeDom)
+    emits("update", rhythmPracticeSetting)
+  } catch (e) {
+    console.log(e)
+  }
+}
+function handleIframeLoad() {
+  loading.value = false
+}
+</script>
+
+<style lang="scss" scoped>
+.rhythmPracticeList {
+  width: 100%;
+  height: 100%;
+  .headCon {
+    width: 100%;
+    height: 64px;
+    border-bottom: 1px solid #eaeaea;
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    .headLeft {
+      margin-left: 30px;
+      display: flex;
+      align-items: center;
+      .tipImg {
+        width: 24px;
+        height: 24px;
+      }
+      .title {
+        font-weight: 600;
+        font-size: 18px;
+        color: #131415;
+        margin-left: 8px;
+      }
+    }
+    .headright {
+      margin-right: 30px;
+      display: flex;
+      align-items: center;
+      .closeBtn {
+        width: 24px;
+        height: 24px;
+        cursor: pointer;
+        &:hover {
+          opacity: 0.8;
+        }
+      }
+    }
+  }
+  .content {
+    width: 100%;
+    height: calc(100% - 130px);
+  }
+  .btnCon {
+    width: 100%;
+    height: 66px;
+    padding: 0 30px;
+    display: flex;
+    justify-content: flex-end;
+    align-items: center;
+    .addBtn {
+      width: 76px;
+      height: 34px;
+      background: linear-gradient(312deg, #1b7af8 0%, #3cbbff 100%);
+      border-radius: 6px;
+      line-height: 34px;
+      text-align: center;
+      font-weight: 600;
+      font-size: 14px;
+      color: #ffffff;
+      cursor: pointer;
+      &:hover {
+        opacity: 0.8;
+      }
+      &.disabled {
+        opacity: 0.7;
+        cursor: default;
+      }
+    }
+    .cancelBtn {
+      margin-right: 12px;
+      width: 76px;
+      height: 34px;
+      background: #f1f2f6;
+      border-radius: 6px;
+      line-height: 34px;
+      text-align: center;
+      font-weight: 600;
+      font-size: 14px;
+      color: #1e2022;
+      cursor: pointer;
+      &:hover {
+        opacity: 0.8;
+      }
+    }
+  }
+}
+</style>

+ 2 - 0
src/views/components/element/rhythmPracticeElement/rhythmPracticePlayer/index.ts

@@ -0,0 +1,2 @@
+import rhythmPracticePlayer from "./rhythmPracticePlayer.vue"
+export default rhythmPracticePlayer

+ 126 - 0
src/views/components/element/rhythmPracticeElement/rhythmPracticePlayer/rhythmPracticePlayer.vue

@@ -0,0 +1,126 @@
+<template>
+  <div
+    class="rhythmPracticePlayer"
+    :style="{
+      width: width * scale + 'px',
+      height: height * scale + 'px',
+      transform: `scale(${1 / scale})`
+    }"
+  >
+    <div v-if="loading" class="loading-overlay">
+      <div class="loadingBox">
+        <div class="loadingItem"></div>
+        <div class="loadingItem"></div>
+        <div class="loadingItem"></div>
+        <div class="loadingItem"></div>
+      </div>
+    </div>
+    <iframe ref="rhythmPracticeIframeDom" class="musicIframe" frameborder="0" :src="url" @load="handleIframeLoad"></iframe>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, computed } from "vue"
+import { getToken } from "@/libs/auth"
+import { CLASSAPP_URL_API } from "@/config/index"
+import queryParams from "@/queryParams"
+
+const props = withDefaults(
+  defineProps<{
+    width: number
+    height: number
+    scale?: number
+    settingMode?: boolean
+    dataJson?: string
+  }>(),
+  {
+    scale: 1,
+    settingMode: false
+  }
+)
+const emits = defineEmits<{
+  (event: "handleIframeLoad"): void
+}>()
+
+const url = computed(() => {
+  // 当是设置模式的时候
+  if (props.settingMode) {
+    return `${CLASSAPP_URL_API}/#/tempo-practice?v=${Date.now()}&Authorization=${getToken()}&platform=modal`
+  }
+  if (queryParams.fromType == "CLASS") {
+    return `${CLASSAPP_URL_API}/#/tempo-practice?v=${Date.now()}&Authorization=${getToken()}&modeType=courseware&dataJson=${props.dataJson}`
+  } else {
+    return `${CLASSAPP_URL_API}/#/tempo-practice?v=${Date.now()}&Authorization=${getToken()}&platform=modal&dataJson=${props.dataJson}&win=pc`
+  }
+})
+
+const rhythmPracticeIframeDom = ref<HTMLIFrameElement>()
+// 先关闭这个功能
+const loading = ref(true)
+function handleIframeLoad() {
+  loading.value = false
+  emits("handleIframeLoad")
+}
+
+defineExpose({
+  rhythmPracticeIframeDom
+})
+</script>
+
+<style lang="scss" scoped>
+.rhythmPracticePlayer {
+  width: 100%;
+  height: 100%;
+  position: relative;
+  overflow: hidden;
+  user-select: none;
+  line-height: 1;
+  transform-origin: 0 0;
+  .musicIframe {
+    width: 100%;
+    height: 100%;
+  }
+  .loading-overlay {
+    position: absolute;
+    top: 0;
+    left: 0;
+    width: 100%;
+    height: 100%;
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    flex-direction: column;
+    z-index: 10;
+    color: #fff;
+    background: url("../cloudCoachList/imgs/musicBg.png");
+    background-size: cover;
+    .loadingBox {
+      width: 30px;
+      height: 30px;
+      display: flex;
+      justify-content: space-between;
+      flex-wrap: wrap;
+      align-content: space-between;
+      animation: rotate 1.5s linear infinite;
+      .loadingItem {
+        width: 12px;
+        height: 12px;
+        border-radius: 50%;
+        background: #20bdff;
+        opacity: 0.5;
+        &:nth-child(2) {
+          opacity: 1;
+        }
+      }
+    }
+    @keyframes rotate {
+      from {
+        transform: rotate(0deg);
+      }
+      to {
+        transform: rotate(360deg);
+      }
+    }
+  }
+}
+</style>