5 Комити aa207a277a ... 044585073d

Аутор SHA1 Порука Датум
  黄琪勇 044585073d build пре 6 дана
  黄琪勇 56fa73b69a 显示地址修改 пре 6 дана
  黄琪勇 36c60e9a6a 节奏练习功能 пре 1 недеља
  黄琪勇 e934d0e256 Merge branch 'online' of http://git.dayaedu.com/huangqiyong/pptList into hqyNew пре 1 недеља
  黄琪勇 7c996ee367 ppt增加听音练习 пре 1 недеља
60 измењених фајлова са 1338 додато и 26 уклоњено
  1. 3 0
      .env.devProd
  2. 3 0
      .env.development
  3. 3 0
      .env.production
  4. 3 0
      .env.staging
  5. 0 1
      dist/assets/index-B3pVmhZT.js
  6. 0 0
      dist/assets/index-BJIiNyUc.css
  7. 0 0
      dist/assets/index-BSp-j_jh.js
  8. 0 0
      dist/assets/index-BW-t-ggR.js
  9. 0 1
      dist/assets/index-Be_iFCUi.js
  10. 1 1
      dist/assets/index-ClYKavTj.js
  11. 0 0
      dist/assets/index-Clt2Y3kb.css
  12. 1 1
      dist/assets/index-CprDuhRb.js
  13. 0 0
      dist/assets/index-D6BR3Hjv.css
  14. 1 0
      dist/assets/index-DBZ-lstx.js
  15. 1 0
      dist/assets/index-DQzNmDiz.js
  16. 1 1
      dist/assets/index-DqT2Ej8Z.js
  17. 0 0
      dist/assets/index-iPVC8daq.js
  18. 1 1
      dist/assets/index-rEgodukN.js
  19. BIN
      dist/assets/musicBg-CCVrzsBz.png
  20. BIN
      dist/assets/musicBg-CKLQQzw2.png
  21. 1 1
      dist/assets/shapes-CBdwsH9_.js
  22. BIN
      dist/assets/subject-bg-CmCS1TEF.png
  23. 1 1
      dist/index.html
  24. 2 0
      src/config/index.ts
  25. 37 1
      src/hooks/useCreateElement.ts
  26. 9 0
      src/messageHooks/mobileScreen.ts
  27. 35 0
      src/messageHooks/rhythmPractice.ts
  28. 44 1
      src/types/slides.ts
  29. 5 1
      src/views/Editor/Canvas/EditableElement.vue
  30. 10 2
      src/views/Editor/Canvas/hooks/useDragElement.ts
  31. BIN
      src/views/Editor/CanvasTool/imgs/jzlx.png
  32. BIN
      src/views/Editor/CanvasTool/imgs/kzzs.png
  33. BIN
      src/views/Editor/CanvasTool/imgs/tylx.png
  34. 91 8
      src/views/Editor/CanvasTool/index.vue
  35. 7 0
      src/views/Editor/Toolbar/ElementStylePanel/ListeningPracticeStylePanel.vue
  36. 7 0
      src/views/Editor/Toolbar/ElementStylePanel/RhythmPracticeStylePanel.vue
  37. 5 1
      src/views/Editor/Toolbar/ElementStylePanel/index.vue
  38. 4 1
      src/views/Editor/Toolbar/index.vue
  39. 5 1
      src/views/Mobile/MobileEditor/MobileEditableElement.vue
  40. 5 1
      src/views/Screen/ScreenElement.vue
  41. 5 1
      src/views/components/ThumbnailSlide/ThumbnailElement.vue
  42. 51 0
      src/views/components/element/listeningPracticeElement/BaseListeningPracticeElement.vue
  43. 57 0
      src/views/components/element/listeningPracticeElement/ScreenListeningPracticeElement.vue
  44. 2 0
      src/views/components/element/listeningPracticeElement/index.ts
  45. 116 0
      src/views/components/element/listeningPracticeElement/listeningPracticeElement.vue
  46. BIN
      src/views/components/element/listeningPracticeElement/listeningPracticeList/imgs/musicBg.png
  47. BIN
      src/views/components/element/listeningPracticeElement/listeningPracticeList/imgs/subject-bg.png
  48. 2 0
      src/views/components/element/listeningPracticeElement/listeningPracticeList/index.ts
  49. 221 0
      src/views/components/element/listeningPracticeElement/listeningPracticeList/listeningPracticeList.vue
  50. 2 0
      src/views/components/element/listeningPracticeElement/listeningPracticePlayer/index.ts
  51. 110 0
      src/views/components/element/listeningPracticeElement/listeningPracticePlayer/listeningPracticePlayer.vue
  52. 43 0
      src/views/components/element/rhythmPracticeElement/BaseRhythmPracticeElement.vue
  53. 57 0
      src/views/components/element/rhythmPracticeElement/ScreenRhythmPracticeElement.vue
  54. BIN
      src/views/components/element/rhythmPracticeElement/imgs/musicBg.png
  55. 2 0
      src/views/components/element/rhythmPracticeElement/index.ts
  56. 116 0
      src/views/components/element/rhythmPracticeElement/rhythmPracticeElement.vue
  57. 2 0
      src/views/components/element/rhythmPracticeElement/rhythmPracticeList/index.ts
  58. 138 0
      src/views/components/element/rhythmPracticeElement/rhythmPracticeList/rhythmPracticeList.vue
  59. 2 0
      src/views/components/element/rhythmPracticeElement/rhythmPracticePlayer/index.ts
  60. 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://kt.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"

+ 0 - 1
dist/assets/index-B3pVmhZT.js

@@ -1 +0,0 @@
-import{u as i,ab as l}from"./index-BtxWkYHn.js";import{w as p,bz as d,a5 as g,cx as m,R as t,ae as r,af as u,al as f,ag as S,bG as _}from"./index-FztzMJ0_.js";import"./shapes-E3FiLaW7.js";const b=()=>{function a(o){const{api:e,playState:n}=o.data||{};e==="headerTogge"?window.parent.postMessage({api:"headerTogge",playState:n},"*"):e==="changeTogge"&&window.parent.postMessage({api:"changeTogge"},"*")}p(()=>{window.addEventListener("message",a)}),d(()=>{window.removeEventListener("message",a)})},v=g({__name:"mobileScreen",setup(a){b();const o=m(),e=t(!0),n=t(!1),c=i();return o.setScreenMode("mobileScreen"),c.initPPTData().then(()=>{e.value=!1}),(w,s)=>(r(),u("div",{class:"mobileScreen",onClick:s[0]||(s[0]=k=>n.value=!1)},[e.value?S("",!0):(r(),f(l,{key:0}))]))}}),x=_(v,[["__scopeId","data-v-90b2692a"]]);export{x as default};

Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
dist/assets/index-BJIiNyUc.css


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
dist/assets/index-BSp-j_jh.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
dist/assets/index-BW-t-ggR.js


+ 0 - 1
dist/assets/index-Be_iFCUi.js

@@ -1 +0,0 @@
-import{u as s,ab as r}from"./index-BtxWkYHn.js";import{a5 as c,cx as n,R as p,ae as t,af as _,al as u,ag as i,bG as l}from"./index-FztzMJ0_.js";const m={class:"pptScreen"},d=c({__name:"pptScreen",setup(f){const a=n(),e=p(!0),o=s();return a.setScreenMode("pptScreen"),o.initPPTData().then(()=>{e.value=!1}),(S,k)=>(t(),_("div",m,[e.value?i("",!0):(t(),u(r,{key:0}))]))}}),P=l(d,[["__scopeId","data-v-678905e9"]]);export{P as default};

Разлика између датотеке није приказан због своје велике величине
+ 1 - 1
dist/assets/index-ClYKavTj.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
dist/assets/index-Clt2Y3kb.css


Разлика између датотеке није приказан због своје велике величине
+ 1 - 1
dist/assets/index-CprDuhRb.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
dist/assets/index-D6BR3Hjv.css


+ 1 - 0
dist/assets/index-DBZ-lstx.js

@@ -0,0 +1 @@
+import{u as c,ad as l}from"./index-ClYKavTj.js";import{w as p,bz as d,a5 as g,cx as m,R as t,ae as r,af as u,al as f,ag as w,bG as k}from"./index-CprDuhRb.js";import"./shapes-CBdwsH9_.js";const S=()=>{function a(o){const{api:e,playState:s}=o.data||{};e==="headerTogge"?window.parent.postMessage({api:"headerTogge",playState:s},"*"):e==="changeTogge"?window.parent.postMessage({api:"changeTogge"},"*"):["clickTempo","clickViewFigner"].includes(e)&&window.parent.postMessage({api:e},"*")}p(()=>{window.addEventListener("message",a)}),d(()=>{window.removeEventListener("message",a)})},_=g({__name:"mobileScreen",setup(a){S();const o=m(),e=t(!0),s=t(!1),i=c();return o.setScreenMode("mobileScreen"),i.initPPTData().then(()=>{e.value=!1}),(v,n)=>(r(),u("div",{class:"mobileScreen",onClick:n[0]||(n[0]=b=>s.value=!1)},[e.value?w("",!0):(r(),f(l,{key:0}))]))}}),x=k(_,[["__scopeId","data-v-90b2692a"]]);export{x as default};

+ 1 - 0
dist/assets/index-DQzNmDiz.js

@@ -0,0 +1 @@
+import{u as s,ad as r}from"./index-ClYKavTj.js";import{a5 as c,cx as n,R as p,ae as t,af as _,al as u,ag as i,bG as d}from"./index-CprDuhRb.js";const l={class:"pptScreen"},m=c({__name:"pptScreen",setup(f){const a=n(),e=p(!0),o=s();return a.setScreenMode("pptScreen"),o.initPPTData().then(()=>{e.value=!1}),(S,k)=>(t(),_("div",l,[e.value?i("",!0):(t(),u(r,{key:0}))]))}}),P=d(m,[["__scopeId","data-v-678905e9"]]);export{P as default};

+ 1 - 1
dist/assets/index-D1ZYVrMR.js → dist/assets/index-DqT2Ej8Z.js

@@ -1 +1 @@
-import{_ as a}from"./404-R0y5dkvZ.js";import{a5 as o,el as t,ae as r,af as c,bH as _,bI as d,ak as s,bG as p}from"./index-FztzMJ0_.js";const i=e=>(_("data-v-dda25179"),e=e(),d(),e),n={class:"errorPage"},m=i(()=>s("div",{class:"error"},[s("img",{src:a,class:"img",alt:""}),s("div",{class:"tit"},"页面找不到了~")],-1)),l=[m],f=o({__name:"errorPage",setup(e){return t(),(u,h)=>(r(),c("div",n,l))}}),I=p(f,[["__scopeId","data-v-dda25179"]]);export{I as default};
+import{_ as a}from"./404-R0y5dkvZ.js";import{a5 as o,em as t,ae as r,af as c,bH as _,bI as d,ak as s,bG as p}from"./index-CprDuhRb.js";const i=e=>(_("data-v-dda25179"),e=e(),d(),e),n={class:"errorPage"},m=i(()=>s("div",{class:"error"},[s("img",{src:a,class:"img",alt:""}),s("div",{class:"tit"},"页面找不到了~")],-1)),l=[m],f=o({__name:"errorPage",setup(e){return t(),(u,h)=>(r(),c("div",n,l))}}),I=p(f,[["__scopeId","data-v-dda25179"]]);export{I as default};

Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
dist/assets/index-iPVC8daq.js


+ 1 - 1
dist/assets/index-Nn-e80Sz.js → dist/assets/index-rEgodukN.js

@@ -1 +1 @@
-import{_ as t}from"./404-R0y5dkvZ.js";import{E as c}from"./index-UoNhaH34.js";import{a5 as n,ae as i,af as r,ak as o,aq as _,am as d,P as l,bo as p,bH as m,bI as f,bG as u}from"./index-FztzMJ0_.js";const s=a=>(m("data-v-340bbdaa"),a=a(),f(),a),b={class:"login"},h={class:"error"},g=s(()=>o("img",{src:t,class:"img",alt:""},null,-1)),k=s(()=>o("div",{class:"tit"},"登录已过期或服务器错误!",-1)),v=n({__name:"login",setup(a){function e(){window.close()}return(x,B)=>(i(),r("div",b,[o("div",h,[g,k,_(l(c),{class:"backBtn",type:"primary",plain:"",onClick:e},{default:d(()=>[p("关闭页面")]),_:1})])]))}}),E=u(v,[["__scopeId","data-v-340bbdaa"]]);export{E as default};
+import{_ as t}from"./404-R0y5dkvZ.js";import{E as c}from"./index-BW-t-ggR.js";import{a5 as n,ae as i,af as r,ak as o,aq as _,am as d,P as l,bo as p,bH as m,bI as f,bG as u}from"./index-CprDuhRb.js";const s=a=>(m("data-v-340bbdaa"),a=a(),f(),a),b={class:"login"},h={class:"error"},g=s(()=>o("img",{src:t,class:"img",alt:""},null,-1)),k=s(()=>o("div",{class:"tit"},"登录已过期或服务器错误!",-1)),v=n({__name:"login",setup(a){function e(){window.close()}return(x,B)=>(i(),r("div",b,[o("div",h,[g,k,_(l(c),{class:"backBtn",type:"primary",plain:"",onClick:e},{default:d(()=>[p("关闭页面")]),_:1})])]))}}),E=u(v,[["__scopeId","data-v-340bbdaa"]]);export{E as default};

BIN
dist/assets/musicBg-CCVrzsBz.png


BIN
dist/assets/musicBg-CKLQQzw2.png


+ 1 - 1
dist/assets/shapes-E3FiLaW7.js → dist/assets/shapes-CBdwsH9_.js

@@ -1,4 +1,4 @@
-import{cA as O,bA as j1,cB as h2}from"./index-FztzMJ0_.js";import{ac as W}from"./index-BtxWkYHn.js";var Y1={exports:{}};/*!
+import{cA as O,bA as j1,cB as h2}from"./index-CprDuhRb.js";import{ae as W}from"./index-ClYKavTj.js";var Y1={exports:{}};/*!
  * clipboard.js v2.0.11
  * https://clipboardjs.com/
  *

BIN
dist/assets/subject-bg-CmCS1TEF.png


+ 1 - 1
dist/index.html

@@ -10,7 +10,7 @@
     <meta name="description" content="ppt编辑器" />
     <meta name="keywords" content="ppt,powerpoint,office powerpoint,在线ppt,幻灯片,演示文稿,ppt在线制作,Vue3,TypeScript" />
     <title>PPT</title>
-    <script type="module" crossorigin src="./assets/index-FztzMJ0_.js"></script>
+    <script type="module" crossorigin src="./assets/index-CprDuhRb.js"></script>
     <link rel="stylesheet" crossorigin href="./assets/index-C0x2lxv7.css">
   </head>
   <body>

+ 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

+ 37 - 1
src/hooks/useCreateElement.ts

@@ -361,7 +361,41 @@ export default () => {
       top: 898
     })
   }
+  /**
+   * 听音练习
+   */
+  const createListeningPracticeElement = (code: string, img: string, name: string) => {
+    createElement({
+      type: "elf",
+      subtype: "elf-listening-practice",
+      code,
+      name,
+      instrumentImg: img,
+      id: nanoid(10),
+      width: viewportSize.value,
+      height: viewportSize.value * viewportRatio.value,
+      rotate: 0,
+      left: 0,
+      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,
@@ -373,6 +407,8 @@ export default () => {
     createVideoElement,
     createAudioElement,
     createCloudCoachElement,
-    createEnjoyElement
+    createEnjoyElement,
+    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
+  }
+}

+ 44 - 1
src/types/slides.ts

@@ -35,7 +35,9 @@ export const enum ElementSubtypeTypes {
   VIDEO = "elf-video",
   AUDIO = "elf-audio",
   SING_PLAY = "elf-sing-play",
-  ENJOY = "elf-enjoy"
+  ENJOY = "elf-enjoy",
+  LISTENING_PRACTICE = "elf-listening-practice",
+  RHYTHM_PRACTICE = "elf-rhythm-practice"
 }
 
 /**
@@ -660,6 +662,45 @@ export interface PPTEnjoyElement extends PPTBaseElement {
   }[]
 }
 
+/**
+ * 听音练习
+ *
+ * type: elf
+ *
+ * subtype: elf-listening-practice
+ *
+ * code: 乐器code
+ *
+ * instrumentImg:乐器图片
+ *
+ * name: 乐器名称
+ *
+ *
+ */
+export interface PPTListeningPracticeElement extends PPTBaseElement {
+  type: "elf"
+  subtype: "elf-listening-practice"
+  code: string
+  name: string
+  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
@@ -672,6 +713,8 @@ export type PPTElement =
   | PPTAudioElement
   | PPTCloudCoachElement
   | PPTEnjoyElement
+  | PPTListeningPracticeElement
+  | PPTRhythmPracticeElement
 
 export type AnimationType = "in" | "out" | "attention"
 export type AnimationTrigger = "click" | "meantime" | "auto"

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

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

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

BIN
src/views/Editor/CanvasTool/imgs/jzlx.png


BIN
src/views/Editor/CanvasTool/imgs/kzzs.png


BIN
src/views/Editor/CanvasTool/imgs/tylx.png


+ 91 - 8
src/views/Editor/CanvasTool/index.vue

@@ -61,14 +61,40 @@
         <img class="itemImg" src="./imgs/yp.png" alt="" />
         <div class="tit">乐谱</div>
       </div>
-      <!-- <div class="handler-item">
-        <img class="itemImg" src="./imgs/jzlx.png" alt="" />
-        <div class="tit">节奏练习</div>
+      <div class="handler-item" @click="expandedKnowledgeVisible = true">
+        <img class="itemImg" src="./imgs/kzzs.png" alt="" />
+        <Popover trigger="click" v-model:value="expandedKnowledgeVisible" :offset="10" @click.stop>
+          <template #content>
+            <PopoverMenuItem
+              @click="
+                () => {
+                  expandedKnowledgeVisible = false
+                  listeningPracticeVisible = true
+                }
+              "
+            >
+              <div class="menuItem">
+                <img src="./imgs/tylx.png" alt="" />
+                <div class="tit">听音练习</div>
+              </div>
+            </PopoverMenuItem>
+            <PopoverMenuItem
+              @click="
+                () => {
+                  expandedKnowledgeVisible = false
+                  rhythmPracticeVisible = true
+                }
+              "
+            >
+              <div class="menuItem">
+                <img src="./imgs/jzlx.png" alt="" />
+                <div class="tit">节奏练习</div>
+              </div>
+            </PopoverMenuItem>
+          </template>
+          <div class="tit">扩展知识</div>
+        </Popover>
       </div>
-      <div class="handler-item">
-        <img class="itemImg" src="./imgs/tylx.png" alt="" />
-        <div class="tit">听音练习</div>
-      </div> -->
       <div class="handler-item" @click="resourcesListVisible = true">
         <img class="itemImg" src="./imgs/zyk.png" alt="" />
         <div class="tit">资源库</div>
@@ -267,6 +293,46 @@
         "
       />
     </Modal>
+    <Modal
+      :contentStyle="{
+        width: '742px',
+        height: '570px',
+        boxShadow: '0px 2px 10px 0px rgba(0,0,0,0.08)',
+        borderRadius: '16px',
+        border: '1px solid #DEDEDE',
+        padding: '0'
+      }"
+      v-model:visible="listeningPracticeVisible"
+    >
+      <listeningPracticeList
+        @update="handleAddListeningPractice"
+        @close="
+          () => {
+            listeningPracticeVisible = false
+          }
+        "
+      />
+    </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>
 
@@ -294,6 +360,8 @@ import PopoverMenuItem from "@/components/PopoverMenuItem.vue"
 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"
 
@@ -326,7 +394,9 @@ const {
   createVideoElement,
   createAudioElement,
   createCloudCoachElement,
-  createEnjoyElement
+  createEnjoyElement,
+  createListeningPracticeElement,
+  createRhythmPracticeElement
 } = useCreateElement()
 
 const insertImageElement = (files: FileList) => {
@@ -345,6 +415,9 @@ const shapeMenuVisible = ref(false)
 const cloudCoachVisible = ref(false)
 const resourcesListVisible = ref(false)
 const moreToolsVisible = ref(false)
+const expandedKnowledgeVisible = ref(false)
+const listeningPracticeVisible = ref(false)
+const rhythmPracticeVisible = ref(false)
 
 // 音视频
 function handleUpload(fileData: UploadRequestOptions) {
@@ -371,6 +444,16 @@ function handleCloudCoach(id: string, name: string) {
   createCloudCoachElement(id, name)
   cloudCoachVisible.value = false
 }
+// 处理听音练习创建
+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/ListeningPracticeStylePanel.vue

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

+ 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>

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

@@ -22,6 +22,8 @@ import AudioStylePanel from "./AudioStylePanel.vue"
 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,
@@ -37,7 +39,9 @@ const elementSubtypeMap = {
   [ElementSubtypeTypes.AUDIO]: AudioStylePanel,
   [ElementSubtypeTypes.VIDEO]: VideoStylePanel,
   [ElementSubtypeTypes.SING_PLAY]: CloudCoachStylePanel,
-  [ElementSubtypeTypes.ENJOY]: EnjoyStylePanel
+  [ElementSubtypeTypes.ENJOY]: EnjoyStylePanel,
+  [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"].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 }

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

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

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

@@ -34,6 +34,8 @@ import ScreenVideoElement from "@/views/components/element/VideoElement/ScreenVi
 import ScreenAudioElement from "@/views/components/element/AudioElement/ScreenAudioElement.vue"
 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
@@ -58,7 +60,9 @@ const currentElementComponent = computed<unknown>(() => {
     [ElementSubtypeTypes.AUDIO]: ScreenAudioElement,
     [ElementSubtypeTypes.VIDEO]: ScreenVideoElement,
     [ElementSubtypeTypes.SING_PLAY]: ScreenCloudCoachElement,
-    [ElementSubtypeTypes.ENJOY]: ScreenEnjoyElement
+    [ElementSubtypeTypes.ENJOY]: ScreenEnjoyElement,
+    [ElementSubtypeTypes.LISTENING_PRACTICE]: ScreenListeningPracticeElement,
+    [ElementSubtypeTypes.RHYTHM_PRACTICE]: ScreenRhythmPracticeElement
   }
   return elementTypeMap[props.elementInfo.type] || elementSubtypeMap[props.elementInfo.subtype] || null
 })

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

@@ -25,6 +25,8 @@ import BaseVideoElement from "@/views/components/element/VideoElement/BaseVideoE
 import BaseAudioElement from "@/views/components/element/AudioElement/BaseAudioElement.vue"
 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
@@ -46,7 +48,9 @@ const currentElementComponent = computed<unknown>(() => {
     [ElementSubtypeTypes.AUDIO]: BaseAudioElement,
     [ElementSubtypeTypes.VIDEO]: BaseVideoElement,
     [ElementSubtypeTypes.SING_PLAY]: BaseCloudCoachElement,
-    [ElementSubtypeTypes.ENJOY]: BaseEnjoyElement
+    [ElementSubtypeTypes.ENJOY]: BaseEnjoyElement,
+    [ElementSubtypeTypes.LISTENING_PRACTICE]: BaseListeningPracticeElement,
+    [ElementSubtypeTypes.RHYTHM_PRACTICE]: BaseRhythmPracticeElement
   }
   return elementTypeMap[props.elementInfo.type] || elementSubtypeMap[props.elementInfo.subtype] || null
 })

+ 51 - 0
src/views/components/element/listeningPracticeElement/BaseListeningPracticeElement.vue

@@ -0,0 +1,51 @@
+<template>
+  <div
+    class="base-element-listeningPractice"
+    :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">
+        <img class="img" :src="elementInfo.instrumentImg" alt="" />
+      </div>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import type { PPTListeningPracticeElement } from "@/types/slides"
+
+defineProps<{
+  elementInfo: PPTListeningPracticeElement
+}>()
+</script>
+
+<style lang="scss" scoped>
+.base-element-listeningPractice {
+  position: absolute;
+}
+.rotate-wrapper {
+  width: 100%;
+  height: 100%;
+}
+.element-content {
+  width: 100%;
+  height: 100%;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  background: url("./listeningPracticeList/imgs/musicBg.png") no-repeat;
+  background-size: 100% 100%;
+  position: relative;
+  .img {
+    position: absolute;
+    left: 50%;
+    top: 50%;
+    transform: translate(-50%, -50%);
+  }
+}
+</style>

+ 57 - 0
src/views/components/element/listeningPracticeElement/ScreenListeningPracticeElement.vue

@@ -0,0 +1,57 @@
+<template>
+  <div
+    class="base-element-cloudCoach screen-element-listeningPractice"
+    :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">
+        <listeningPracticePlayer
+          v-if="inCurrentSlide"
+          :width="elementInfo.width"
+          :height="elementInfo.height"
+          :scale="scale"
+          :code="elementInfo.code"
+        />
+      </div>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { computed, inject, ref } from "vue"
+import { storeToRefs } from "pinia"
+import { useSlidesStore } from "@/store"
+import type { PPTListeningPracticeElement } from "@/types/slides"
+import { injectKeySlideId, injectKeySlideScale } from "@/types/injectKey"
+import listeningPracticePlayer from "./listeningPracticePlayer"
+
+defineProps<{
+  elementInfo: PPTListeningPracticeElement
+}>()
+
+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-listeningPractice {
+  position: absolute;
+}
+.rotate-wrapper {
+  width: 100%;
+  height: 100%;
+}
+.element-content {
+  width: 100%;
+  height: 100%;
+}
+</style>

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

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

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

@@ -0,0 +1,116 @@
+<template>
+  <div
+    class="listeningPracticeElement"
+    :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>
+        <listeningPracticePlayer :width="elementInfo.width" :height="elementInfo.height" :scale="canvasScale" :code="elementInfo.code" />
+        <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 { PPTListeningPracticeElement } from "@/types/slides"
+import type { ContextmenuItem } from "@/components/Contextmenu/types"
+import listeningPracticePlayer from "./listeningPracticePlayer"
+import { computed } from "vue"
+
+const props = defineProps<{
+  elementInfo: PPTListeningPracticeElement
+  selectElement: (e: MouseEvent | TouchEvent, element: PPTListeningPracticeElement, 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>
+.listeningPracticeElement {
+  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>

BIN
src/views/components/element/listeningPracticeElement/listeningPracticeList/imgs/musicBg.png


BIN
src/views/components/element/listeningPracticeElement/listeningPracticeList/imgs/subject-bg.png


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

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

+ 221 - 0
src/views/components/element/listeningPracticeElement/listeningPracticeList/listeningPracticeList.vue

@@ -0,0 +1,221 @@
+<template>
+  <div class="listeningPracticeList">
+    <div class="headCon">
+      <div class="headLeft">
+        <img class="tipImg" src="@/views/Editor/CanvasTool/imgs/tylx.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">
+      <div class="tabTools">
+        <div class="tabCon">
+          <div class="tab" @click="handleTabChange(item)" :class="{ active: item.id === tabActive?.id }" v-for="item in tabData" :key="item.id">
+            {{ item.name }}
+          </div>
+        </div>
+      </div>
+      <div class="instrumentsContent">
+        <div class="instrument" v-for="item in tabActive?.instruments || []" :key="item.code" @click="handleAddListeningPractice(item)">
+          <div class="imgBox">
+            <img :src="item.img" />
+          </div>
+          <div class="title">{{ item.name }}</div>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { getSubjectListApi } from "@/api/pptOperate"
+import { httpAjax } from "@/plugins/httpAjax"
+import { ref } from "vue"
+
+const emits = defineEmits<{
+  (event: "update", item: Record<string, any>): void
+  (event: "close"): void
+}>()
+
+function handleAddListeningPractice(item: Record<string, any>) {
+  emits("update", item)
+}
+
+type tabType = {
+  id: number
+  name: string
+  instruments: {
+    code: string
+    name: string
+    img: string
+  }[]
+}
+
+const tabData = ref<tabType[]>([])
+const tabActive = ref<tabType>()
+
+getTabList()
+function getTabList() {
+  httpAjax(getSubjectListApi).then(res => {
+    if (res.code === 200) {
+      const instruments = res.data.reduce((subTab: any[], item: any) => {
+        subTab.push(...item.instruments)
+        return subTab
+      }, [])
+      tabData.value = [
+        {
+          id: -199,
+          name: "全部声部",
+          instruments
+        },
+        ...res.data
+      ]
+      handleTabChange(tabData.value[0])
+    }
+  })
+}
+
+function handleTabChange(activeTab: tabType) {
+  tabActive.value = activeTab
+}
+</script>
+
+<style lang="scss" scoped>
+.listeningPracticeList {
+  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% - 64px);
+    .tabTools {
+      flex-shrink: 0;
+      width: 100%;
+      padding: 24px 30px;
+      display: flex;
+      justify-content: space-between;
+      align-items: center;
+      .tabCon {
+        display: flex;
+        .tab {
+          margin-right: 32px;
+          font-weight: 400;
+          font-size: 16px;
+          color: #8b8d98;
+          line-height: 22px;
+          cursor: pointer;
+          &:hover {
+            opacity: 0.8;
+          }
+          &:last-child {
+            margin-right: 0;
+          }
+          &.active {
+            font-weight: 600;
+            color: #131415;
+            position: relative;
+            &::after {
+              content: "";
+              position: absolute;
+              width: 100%;
+              height: 10px;
+              background: linear-gradient(90deg, #77bbff 0%, rgba(163, 231, 255, 0.22) 100%);
+              bottom: 0;
+              left: 0;
+              z-index: -1;
+            }
+          }
+        }
+      }
+    }
+    .instrumentsContent {
+      display: flex;
+      flex-wrap: wrap;
+      padding: 0 30px;
+      .instrument {
+        margin-right: 40px;
+        margin-bottom: 40px;
+        cursor: pointer;
+        &:nth-child(5n) {
+          margin-right: 0;
+        }
+        &:hover {
+          .imgBox {
+            border-color: #198cfe;
+            transform: scale(1.02);
+            transition: all 0.2s ease;
+          }
+        }
+        .imgBox {
+          width: 104px;
+          height: 104px;
+          border-radius: 12px;
+          border: 2px solid transparent;
+          position: relative;
+          overflow: hidden;
+          &::after {
+            content: "";
+            position: absolute;
+            left: 0;
+            top: 0;
+            width: 100%;
+            height: 100%;
+            background: url("./imgs/subject-bg.png") no-repeat center;
+            z-index: -1;
+          }
+          img {
+            width: 100%;
+            height: 100%;
+            object-fit: contain;
+          }
+        }
+        .title {
+          margin-top: 10px;
+          font-weight: 400;
+          font-size: 14px;
+          color: #131415;
+          line-height: 20px;
+          text-align: center;
+        }
+      }
+    }
+  }
+}
+</style>

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

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

+ 110 - 0
src/views/components/element/listeningPracticeElement/listeningPracticePlayer/listeningPracticePlayer.vue

@@ -0,0 +1,110 @@
+<template>
+  <div
+    class="listeningPracticePlayer"
+    :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 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 { YJL_URL_API } from "@/config/index"
+import queryParams from "@/queryParams"
+
+const props = withDefaults(
+  defineProps<{
+    width: number
+    height: number
+    scale?: number
+    code: string
+  }>(),
+  {
+    scale: 1
+  }
+)
+const url = computed(() => {
+  if (queryParams.fromType === "CLASS") {
+    return `${YJL_URL_API}/#/view-figner?Authorization=${getToken()}&code=${props.code}&type=listenMode&linkSource=class&isPreView=true`
+  } else {
+    return `${YJL_URL_API}/#/view-figner?Authorization=${getToken()}&code=${props.code}&platform=pc&type=listenMode&linkSource=class`
+  }
+})
+
+// 先关闭这个功能
+const loading = ref(true)
+function handleIframeLoad() {
+  loading.value = false
+}
+</script>
+
+<style lang="scss" scoped>
+.listeningPracticePlayer {
+  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>

+ 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>

Неке датотеке нису приказане због велике количине промена