#1 for-test

Gesloten
lihongjun wil 165 commits van jack/beta samenvoegen met jack/feat/english-speaking-for-merge
100 gewijzigde bestanden met toevoegingen van 34103 en 448 verwijderingen
  1. 6 0
      .env
  2. 8 0
      .env.example
  3. 5 0
      .gitignore
  4. 4 0
      env.d.ts
  5. 11967 0
      package-lock.json
  6. 16 1
      package.json
  7. 26 0
      public/pcm-recorder-worklet.js
  8. 3524 0
      public/webcodecs-worker.js
  9. 250 0
      scripts/test-article-auto-format-flow.mjs
  10. 70 0
      scripts/test-article-auto-format.mjs
  11. 238 0
      scripts/test-article-class-summary-auto.mjs
  12. 106 0
      scripts/test-article-errors.mjs
  13. 26 0
      scripts/test-article-reading-data.mjs
  14. 900 0
      scripts/test-article-reading-engine.mjs
  15. 79 0
      scripts/test-article-reading-i18n.mjs
  16. 52 0
      scripts/test-article-reading-model.mjs
  17. 137 0
      scripts/test-article-reading-routing.mjs
  18. 114 0
      scripts/test-article-reading-stream.mjs
  19. 116 0
      scripts/test-article-recorder-sample-rate.mjs
  20. 126 0
      scripts/test-dialogue-stream-fallback.mjs
  21. 79 0
      scripts/test-speaking-api-config.mjs
  22. 16 13
      src/App.vue
  23. BIN
      src/assets/img/tool_photo.png
  24. BIN
      src/assets/img/tool_vote.png
  25. 5 2
      src/assets/styles/prosemirror.scss
  26. 703 146
      src/components/CollapsibleToolbar/componets/aiChat.vue
  27. 181 0
      src/components/CollapsibleToolbar/componets/aiWeb.vue
  28. 7 1
      src/components/CollapsibleToolbar/index.vue
  29. 673 131
      src/components/CollapsibleToolbar/index2.vue
  30. 1651 0
      src/components/CollapsibleToolbar/index22.vue
  31. 1 0
      src/components/ColorButton.vue
  32. 143 0
      src/components/Contextmenu2/MenuContent.vue
  33. 80 0
      src/components/Contextmenu2/index.vue
  34. 14 0
      src/components/Contextmenu2/types.ts
  35. 42 23
      src/components/CreateCourseDialog.vue
  36. 27 2
      src/components/FileInput.vue
  37. 6 4
      src/components/Switch.vue
  38. 38 0
      src/components/TextColorButton2.vue
  39. 17 0
      src/configs/englishSpeakingTools.ts
  40. 1 0
      src/configs/hotkey.ts
  41. 1 0
      src/hooks/useCopyAndPasteElement.ts
  42. 2 1
      src/hooks/useCreateElement.ts
  43. 12 1
      src/hooks/useGlobalHotkey.ts
  44. 87 32
      src/hooks/useImport.ts
  45. 46 1
      src/hooks/usePasteEvent.ts
  46. 1 0
      src/i18n/lang.ts
  47. 64 0
      src/plugins/directive/contextmenu2.ts
  48. 2 0
      src/plugins/directive/index.ts
  49. 4 0
      src/plugins/icon.ts
  50. 1 1
      src/services/config.ts
  51. 39 3
      src/services/course.ts
  52. 111 0
      src/services/speaking.ts
  53. 60 0
      src/store/articleReading.ts
  54. 29 1
      src/store/main.ts
  55. 11 0
      src/store/slides.ts
  56. 168 0
      src/store/speaking.ts
  57. 5 4
      src/tools/aiChat.ts
  58. 112 0
      src/types/articleReading.ts
  59. 348 0
      src/types/englishSpeaking.ts
  60. 3 1
      src/types/slides.ts
  61. 22 0
      src/utils/clipboard.ts
  62. 162 0
      src/utils/components/messageInstruction.vue
  63. 4 0
      src/utils/confirmDialog.ts
  64. 137 18
      src/utils/prosemirror/schema/marks.ts
  65. 111 52
      src/utils/prosemirror/schema/nodes.ts
  66. 479 0
      src/views/Editor/Canvas/ChildPageDisplay.vue
  67. 11 2
      src/views/Editor/Canvas/EditableElement.vue
  68. 10 1
      src/views/Editor/Canvas/Operate/index.vue
  69. 15 0
      src/views/Editor/Canvas/index.vue
  70. 6 2
      src/views/Editor/CanvasTool/WebpageInput.vue
  71. 264 5
      src/views/Editor/CanvasTool/index2.vue
  72. 186 0
      src/views/Editor/EnglishSpeaking/SpeakingPanel.vue
  73. 239 0
      src/views/Editor/EnglishSpeaking/components/AIGenerationForm.vue
  74. 346 0
      src/views/Editor/EnglishSpeaking/components/ArticleFormatDialog.vue
  75. 123 0
      src/views/Editor/EnglishSpeaking/components/CreationModeSwitch.vue
  76. 116 0
      src/views/Editor/EnglishSpeaking/components/CurriculumSelector.vue
  77. 145 0
      src/views/Editor/EnglishSpeaking/components/ExerciseTypeGrid.vue
  78. 136 0
      src/views/Editor/EnglishSpeaking/components/RecommendCard.vue
  79. 62 0
      src/views/Editor/EnglishSpeaking/components/UnitInfoCard.vue
  80. 45 0
      src/views/Editor/EnglishSpeaking/composables/articleAutoFormatRules.ts
  81. 117 0
      src/views/Editor/EnglishSpeaking/composables/useArticleAutoFormat.ts
  82. 827 0
      src/views/Editor/EnglishSpeaking/composables/useArticleReadingEngine.ts
  83. 215 0
      src/views/Editor/EnglishSpeaking/composables/useAudioPlayer.ts
  84. 368 0
      src/views/Editor/EnglishSpeaking/composables/useAudioRecorder.ts
  85. 615 0
      src/views/Editor/EnglishSpeaking/composables/useDialogueEngine.ts
  86. 505 0
      src/views/Editor/EnglishSpeaking/configs/ArticlePracticeConfig.vue
  87. 1082 0
      src/views/Editor/EnglishSpeaking/configs/TopicDiscussionConfig.vue
  88. 16 0
      src/views/Editor/EnglishSpeaking/data/articleReadingTasks.json
  89. 113 0
      src/views/Editor/EnglishSpeaking/data/curriculum.json
  90. 56 0
      src/views/Editor/EnglishSpeaking/data/topicDiscussionTasks.json
  91. 63 0
      src/views/Editor/EnglishSpeaking/layers/Layer1Home.vue
  92. 388 0
      src/views/Editor/EnglishSpeaking/layers/Layer2Speaking.vue
  93. 186 0
      src/views/Editor/EnglishSpeaking/preview/ArticleDetailedReport.vue
  94. 357 0
      src/views/Editor/EnglishSpeaking/preview/ArticleOverallReport.vue
  95. 409 0
      src/views/Editor/EnglishSpeaking/preview/ArticleParagraphCard.vue
  96. 408 0
      src/views/Editor/EnglishSpeaking/preview/ArticleReadingPreview.vue
  97. 829 0
      src/views/Editor/EnglishSpeaking/preview/ArticleReadingView.vue
  98. 60 0
      src/views/Editor/EnglishSpeaking/preview/ArticleStarRating.vue
  99. 374 0
      src/views/Editor/EnglishSpeaking/preview/DetailedReport.vue
  100. 1936 0
      src/views/Editor/EnglishSpeaking/preview/DialogueChatView.vue

+ 6 - 0
.env

@@ -0,0 +1,6 @@
+VITE_AZURE_SPEECH_KEY=5b401f52c9064cec9247f4190cf99ea4
+VITE_AZURE_SPEECH_REGION=eastasia
+# VITE_SPEAKING_API_HOST=http://localhost:8000
+
+# 学生录音上送方式:websocket(默认,流式) | http(单次 POST /speak)
+# VITE_SPEAKING_TRANSPORT=http

+ 8 - 0
.env.example

@@ -0,0 +1,8 @@
+VITE_AZURE_SPEECH_KEY=
+VITE_AZURE_SPEECH_REGION=
+VITE_SPEAKING_API_HOST=https://ppt-english-speaking-api.cocorobo.cn
+
+# 学生录音上送方式:websocket(默认,流式) | http(单次 POST /speak)
+# 留空或 'websocket'/'ws' → WebSocket;'http' → /speak。WS 失败时不会自动降级,
+# 由用户点"重试"按钮触发 /speak 重发。
+VITE_SPEAKING_TRANSPORT=websocket

+ 5 - 0
.gitignore

@@ -29,3 +29,8 @@ coverage
 *.sw?
 
 *.tsbuildinfo
+
+
+.claude
+.agent
+

+ 4 - 0
env.d.ts

@@ -1,2 +1,6 @@
 // eslint-disable-next-line spaced-comment
 /// <reference types="vite/client" />
+
+interface ImportMetaEnv {
+  readonly VITE_SPEAKING_API_HOST?: string
+}

+ 11967 - 0
package-lock.json

@@ -0,0 +1,11967 @@
+{
+  "name": "pptist",
+  "version": "2.0.0",
+  "lockfileVersion": 2,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "pptist",
+      "version": "2.0.0",
+      "dependencies": {
+        "@icon-park/vue-next": "^1.4.2",
+        "@microsoft/fetch-event-source": "^2.0.1",
+        "animate.css": "^4.1.1",
+        "axios": "^1.7.9",
+        "clipboard": "^2.0.11",
+        "crypto-js": "^4.2.0",
+        "dexie": "^4.0.11",
+        "echarts": "^5.6.0",
+        "echarts-wordcloud": "^2.1.0",
+        "file-saver": "^2.0.5",
+        "hfmath": "^0.0.2",
+        "html-to-image": "^1.11.13",
+        "html2canvas": "^1.4.1",
+        "jszip": "^3.10.1",
+        "katex": "^0.16.22",
+        "lodash": "^4.17.21",
+        "markdown-it": "^14.1.1",
+        "mitt": "^3.0.1",
+        "nanoid": "^5.0.7",
+        "number-precision": "^1.6.0",
+        "pinia": "^3.0.2",
+        "pptxgenjs": "^3.12.0",
+        "pptxtojson": "1.12.0",
+        "prosemirror-commands": "^1.6.0",
+        "prosemirror-dropcursor": "^1.8.1",
+        "prosemirror-gapcursor": "^1.3.2",
+        "prosemirror-history": "^1.3.2",
+        "prosemirror-inputrules": "^1.4.0",
+        "prosemirror-keymap": "^1.2.2",
+        "prosemirror-model": "^1.22.2",
+        "prosemirror-schema-basic": "^1.2.3",
+        "prosemirror-schema-list": "^1.4.1",
+        "prosemirror-state": "^1.4.3",
+        "prosemirror-view": "^1.33.9",
+        "qs": "^6.14.0",
+        "rtf.js": "^3.0.9",
+        "svg-arc-to-cubic-bezier": "^3.2.0",
+        "svg-pathdata": "^7.2.0",
+        "tinycolor2": "^1.6.0",
+        "tippy.js": "^6.3.7",
+        "utif": "^3.1.0",
+        "utif2": "^4.1.0",
+        "uuid": "^13.0.0",
+        "vue": "^3.5.17",
+        "vuedraggable": "^4.1.0",
+        "wangeditor": "^4.7.15",
+        "webcodecs-encoder": "^0.3.2",
+        "y-websocket": "^3.0.0",
+        "yjs": "^13.6.27"
+      },
+      "devDependencies": {
+        "@commitlint/cli": "^18.4.3",
+        "@commitlint/config-conventional": "^18.4.3",
+        "@rushstack/eslint-patch": "^1.3.3",
+        "@tsconfig/node18": "^18.2.2",
+        "@types/crypto-js": "^4.2.1",
+        "@types/file-saver": "^2.0.7",
+        "@types/html2canvas": "^1.0.0",
+        "@types/lodash": "^4.14.202",
+        "@types/markdown-it": "^14.1.2",
+        "@types/node": "^18.19.3",
+        "@types/qs": "^6.14.0",
+        "@types/svg-arc-to-cubic-bezier": "^3.2.2",
+        "@types/tinycolor2": "^1.4.6",
+        "@vitejs/plugin-vue": "^5.1.0",
+        "@vue/eslint-config-typescript": "^12.0.0",
+        "@vue/tsconfig": "^0.5.0",
+        "eslint": "^8.49.0",
+        "eslint-plugin-vue": "^9.17.0",
+        "husky": "^8.0.3",
+        "npm-run-all2": "^6.1.1",
+        "sass": "1.69.6",
+        "typescript": "~5.3.0",
+        "vite": "^5.3.5",
+        "vite-plugin-vue-inspector": "^5.4.0",
+        "vue-tsc": "^2.0.29"
+      }
+    },
+    "node_modules/@aashutoshrathi/word-wrap": {
+      "version": "1.2.6",
+      "resolved": "https://registry.npmmirror.com/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz",
+      "integrity": "sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==",
+      "dev": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/@babel/code-frame": {
+      "version": "7.29.0",
+      "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
+      "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-validator-identifier": "^7.28.5",
+        "js-tokens": "^4.0.0",
+        "picocolors": "^1.1.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/compat-data": {
+      "version": "7.29.0",
+      "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz",
+      "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/core": {
+      "version": "7.29.0",
+      "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
+      "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/code-frame": "^7.29.0",
+        "@babel/generator": "^7.29.0",
+        "@babel/helper-compilation-targets": "^7.28.6",
+        "@babel/helper-module-transforms": "^7.28.6",
+        "@babel/helpers": "^7.28.6",
+        "@babel/parser": "^7.29.0",
+        "@babel/template": "^7.28.6",
+        "@babel/traverse": "^7.29.0",
+        "@babel/types": "^7.29.0",
+        "@jridgewell/remapping": "^2.3.5",
+        "convert-source-map": "^2.0.0",
+        "debug": "^4.1.0",
+        "gensync": "^1.0.0-beta.2",
+        "json5": "^2.2.3",
+        "semver": "^6.3.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/babel"
+      }
+    },
+    "node_modules/@babel/core/node_modules/semver": {
+      "version": "6.3.1",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+      "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+      "dev": true,
+      "license": "ISC",
+      "bin": {
+        "semver": "bin/semver.js"
+      }
+    },
+    "node_modules/@babel/generator": {
+      "version": "7.29.1",
+      "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
+      "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/parser": "^7.29.0",
+        "@babel/types": "^7.29.0",
+        "@jridgewell/gen-mapping": "^0.3.12",
+        "@jridgewell/trace-mapping": "^0.3.28",
+        "jsesc": "^3.0.2"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-annotate-as-pure": {
+      "version": "7.27.3",
+      "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz",
+      "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/types": "^7.27.3"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-compilation-targets": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
+      "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/compat-data": "^7.28.6",
+        "@babel/helper-validator-option": "^7.27.1",
+        "browserslist": "^4.24.0",
+        "lru-cache": "^5.1.1",
+        "semver": "^6.3.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-compilation-targets/node_modules/lru-cache": {
+      "version": "5.1.1",
+      "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+      "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "yallist": "^3.0.2"
+      }
+    },
+    "node_modules/@babel/helper-compilation-targets/node_modules/semver": {
+      "version": "6.3.1",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+      "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+      "dev": true,
+      "license": "ISC",
+      "bin": {
+        "semver": "bin/semver.js"
+      }
+    },
+    "node_modules/@babel/helper-compilation-targets/node_modules/yallist": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+      "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/@babel/helper-create-class-features-plugin": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.6.tgz",
+      "integrity": "sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-annotate-as-pure": "^7.27.3",
+        "@babel/helper-member-expression-to-functions": "^7.28.5",
+        "@babel/helper-optimise-call-expression": "^7.27.1",
+        "@babel/helper-replace-supers": "^7.28.6",
+        "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1",
+        "@babel/traverse": "^7.28.6",
+        "semver": "^6.3.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0"
+      }
+    },
+    "node_modules/@babel/helper-create-class-features-plugin/node_modules/semver": {
+      "version": "6.3.1",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+      "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+      "dev": true,
+      "license": "ISC",
+      "bin": {
+        "semver": "bin/semver.js"
+      }
+    },
+    "node_modules/@babel/helper-globals": {
+      "version": "7.28.0",
+      "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
+      "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-member-expression-to-functions": {
+      "version": "7.28.5",
+      "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.28.5.tgz",
+      "integrity": "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/traverse": "^7.28.5",
+        "@babel/types": "^7.28.5"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-module-imports": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
+      "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/traverse": "^7.28.6",
+        "@babel/types": "^7.28.6"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-module-transforms": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
+      "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-module-imports": "^7.28.6",
+        "@babel/helper-validator-identifier": "^7.28.5",
+        "@babel/traverse": "^7.28.6"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0"
+      }
+    },
+    "node_modules/@babel/helper-optimise-call-expression": {
+      "version": "7.27.1",
+      "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz",
+      "integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/types": "^7.27.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-plugin-utils": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz",
+      "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-replace-supers": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.28.6.tgz",
+      "integrity": "sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-member-expression-to-functions": "^7.28.5",
+        "@babel/helper-optimise-call-expression": "^7.27.1",
+        "@babel/traverse": "^7.28.6"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0"
+      }
+    },
+    "node_modules/@babel/helper-skip-transparent-expression-wrappers": {
+      "version": "7.27.1",
+      "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz",
+      "integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/traverse": "^7.27.1",
+        "@babel/types": "^7.27.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-string-parser": {
+      "version": "7.27.1",
+      "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
+      "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-validator-identifier": {
+      "version": "7.28.5",
+      "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
+      "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-validator-option": {
+      "version": "7.27.1",
+      "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
+      "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helpers": {
+      "version": "7.29.2",
+      "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz",
+      "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/template": "^7.28.6",
+        "@babel/types": "^7.29.0"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/parser": {
+      "version": "7.29.2",
+      "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz",
+      "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/types": "^7.29.0"
+      },
+      "bin": {
+        "parser": "bin/babel-parser.js"
+      },
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/@babel/plugin-proposal-decorators": {
+      "version": "7.29.0",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.29.0.tgz",
+      "integrity": "sha512-CVBVv3VY/XRMxRYq5dwr2DS7/MvqPm23cOCjbwNnVrfOqcWlnefua1uUs0sjdKOGjvPUG633o07uWzJq4oI6dA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-create-class-features-plugin": "^7.28.6",
+        "@babel/helper-plugin-utils": "^7.28.6",
+        "@babel/plugin-syntax-decorators": "^7.28.6"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-syntax-decorators": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.28.6.tgz",
+      "integrity": "sha512-71EYI0ONURHJBL4rSFXnITXqXrrY8q4P0q006DPfN+Rk+ASM+++IBXem/ruokgBZR8YNEWZ8R6B+rCb8VcUTqA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.28.6"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-syntax-import-attributes": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.28.6.tgz",
+      "integrity": "sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.28.6"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-syntax-import-meta": {
+      "version": "7.10.4",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz",
+      "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.10.4"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-syntax-jsx": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz",
+      "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.28.6"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-syntax-typescript": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.28.6.tgz",
+      "integrity": "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.28.6"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-typescript": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.28.6.tgz",
+      "integrity": "sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-annotate-as-pure": "^7.27.3",
+        "@babel/helper-create-class-features-plugin": "^7.28.6",
+        "@babel/helper-plugin-utils": "^7.28.6",
+        "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1",
+        "@babel/plugin-syntax-typescript": "^7.28.6"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/runtime": {
+      "version": "7.28.4",
+      "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
+      "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/runtime-corejs3": {
+      "version": "7.28.4",
+      "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.28.4.tgz",
+      "integrity": "sha512-h7iEYiW4HebClDEhtvFObtPmIvrd1SSfpI9EhOeKk4CtIK/ngBWFpuhCzhdmRKtg71ylcue+9I6dv54XYO1epQ==",
+      "dependencies": {
+        "core-js-pure": "^3.43.0"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/template": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
+      "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/code-frame": "^7.28.6",
+        "@babel/parser": "^7.28.6",
+        "@babel/types": "^7.28.6"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/traverse": {
+      "version": "7.29.0",
+      "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz",
+      "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/code-frame": "^7.29.0",
+        "@babel/generator": "^7.29.0",
+        "@babel/helper-globals": "^7.28.0",
+        "@babel/parser": "^7.29.0",
+        "@babel/template": "^7.28.6",
+        "@babel/types": "^7.29.0",
+        "debug": "^4.3.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/types": {
+      "version": "7.29.0",
+      "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
+      "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-string-parser": "^7.27.1",
+        "@babel/helper-validator-identifier": "^7.28.5"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@commitlint/cli": {
+      "version": "18.4.3",
+      "resolved": "https://registry.npmmirror.com/@commitlint/cli/-/cli-18.4.3.tgz",
+      "integrity": "sha512-zop98yfB3A6NveYAZ3P1Mb6bIXuCeWgnUfVNkH4yhIMQpQfzFwseadazOuSn0OOfTt0lWuFauehpm9GcqM5lww==",
+      "dev": true,
+      "dependencies": {
+        "@commitlint/format": "^18.4.3",
+        "@commitlint/lint": "^18.4.3",
+        "@commitlint/load": "^18.4.3",
+        "@commitlint/read": "^18.4.3",
+        "@commitlint/types": "^18.4.3",
+        "execa": "^5.0.0",
+        "lodash.isfunction": "^3.0.9",
+        "resolve-from": "5.0.0",
+        "resolve-global": "1.0.0",
+        "yargs": "^17.0.0"
+      },
+      "bin": {
+        "commitlint": "cli.js"
+      },
+      "engines": {
+        "node": ">=v18"
+      }
+    },
+    "node_modules/@commitlint/cli/node_modules/resolve-from": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmmirror.com/resolve-from/-/resolve-from-5.0.0.tgz",
+      "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/@commitlint/config-conventional": {
+      "version": "18.4.3",
+      "resolved": "https://registry.npmmirror.com/@commitlint/config-conventional/-/config-conventional-18.4.3.tgz",
+      "integrity": "sha512-729eRRaNta7JZF07qf6SAGSghoDEp9mH7yHU0m7ff0q89W97wDrWCyZ3yoV3mcQJwbhlmVmZPTkPcm7qiAu8WA==",
+      "dev": true,
+      "dependencies": {
+        "conventional-changelog-conventionalcommits": "^7.0.2"
+      },
+      "engines": {
+        "node": ">=v18"
+      }
+    },
+    "node_modules/@commitlint/config-validator": {
+      "version": "18.4.3",
+      "resolved": "https://registry.npmmirror.com/@commitlint/config-validator/-/config-validator-18.4.3.tgz",
+      "integrity": "sha512-FPZZmTJBARPCyef9ohRC9EANiQEKSWIdatx5OlgeHKu878dWwpyeFauVkhzuBRJFcCA4Uvz/FDtlDKs008IHcA==",
+      "dev": true,
+      "dependencies": {
+        "@commitlint/types": "^18.4.3",
+        "ajv": "^8.11.0"
+      },
+      "engines": {
+        "node": ">=v18"
+      }
+    },
+    "node_modules/@commitlint/config-validator/node_modules/ajv": {
+      "version": "8.12.0",
+      "resolved": "https://registry.npmmirror.com/ajv/-/ajv-8.12.0.tgz",
+      "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==",
+      "dev": true,
+      "dependencies": {
+        "fast-deep-equal": "^3.1.1",
+        "json-schema-traverse": "^1.0.0",
+        "require-from-string": "^2.0.2",
+        "uri-js": "^4.2.2"
+      }
+    },
+    "node_modules/@commitlint/config-validator/node_modules/json-schema-traverse": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+      "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+      "dev": true
+    },
+    "node_modules/@commitlint/ensure": {
+      "version": "18.4.3",
+      "resolved": "https://registry.npmmirror.com/@commitlint/ensure/-/ensure-18.4.3.tgz",
+      "integrity": "sha512-MI4fwD9TWDVn4plF5+7JUyLLbkOdzIRBmVeNlk4dcGlkrVA+/l5GLcpN66q9LkFsFv6G2X31y89ApA3hqnqIFg==",
+      "dev": true,
+      "dependencies": {
+        "@commitlint/types": "^18.4.3",
+        "lodash.camelcase": "^4.3.0",
+        "lodash.kebabcase": "^4.1.1",
+        "lodash.snakecase": "^4.1.1",
+        "lodash.startcase": "^4.4.0",
+        "lodash.upperfirst": "^4.3.1"
+      },
+      "engines": {
+        "node": ">=v18"
+      }
+    },
+    "node_modules/@commitlint/execute-rule": {
+      "version": "18.4.3",
+      "resolved": "https://registry.npmmirror.com/@commitlint/execute-rule/-/execute-rule-18.4.3.tgz",
+      "integrity": "sha512-t7FM4c+BdX9WWZCPrrbV5+0SWLgT3kCq7e7/GhHCreYifg3V8qyvO127HF796vyFql75n4TFF+5v1asOOWkV1Q==",
+      "dev": true,
+      "engines": {
+        "node": ">=v18"
+      }
+    },
+    "node_modules/@commitlint/format": {
+      "version": "18.4.3",
+      "resolved": "https://registry.npmmirror.com/@commitlint/format/-/format-18.4.3.tgz",
+      "integrity": "sha512-8b+ItXYHxAhRAXFfYki5PpbuMMOmXYuzLxib65z2XTqki59YDQJGpJ/wB1kEE5MQDgSTQWtKUrA8n9zS/1uIDQ==",
+      "dev": true,
+      "dependencies": {
+        "@commitlint/types": "^18.4.3",
+        "chalk": "^4.1.0"
+      },
+      "engines": {
+        "node": ">=v18"
+      }
+    },
+    "node_modules/@commitlint/is-ignored": {
+      "version": "18.4.3",
+      "resolved": "https://registry.npmmirror.com/@commitlint/is-ignored/-/is-ignored-18.4.3.tgz",
+      "integrity": "sha512-ZseOY9UfuAI32h9w342Km4AIaTieeFskm2ZKdrG7r31+c6zGBzuny9KQhwI9puc0J3GkUquEgKJblCl7pMnjwg==",
+      "dev": true,
+      "dependencies": {
+        "@commitlint/types": "^18.4.3",
+        "semver": "7.5.4"
+      },
+      "engines": {
+        "node": ">=v18"
+      }
+    },
+    "node_modules/@commitlint/lint": {
+      "version": "18.4.3",
+      "resolved": "https://registry.npmmirror.com/@commitlint/lint/-/lint-18.4.3.tgz",
+      "integrity": "sha512-18u3MRgEXNbnYkMOWoncvq6QB8/90m9TbERKgdPqVvS+zQ/MsuRhdvHYCIXGXZxUb0YI4DV2PC4bPneBV/fYuA==",
+      "dev": true,
+      "dependencies": {
+        "@commitlint/is-ignored": "^18.4.3",
+        "@commitlint/parse": "^18.4.3",
+        "@commitlint/rules": "^18.4.3",
+        "@commitlint/types": "^18.4.3"
+      },
+      "engines": {
+        "node": ">=v18"
+      }
+    },
+    "node_modules/@commitlint/load": {
+      "version": "18.4.3",
+      "resolved": "https://registry.npmmirror.com/@commitlint/load/-/load-18.4.3.tgz",
+      "integrity": "sha512-v6j2WhvRQJrcJaj5D+EyES2WKTxPpxENmNpNG3Ww8MZGik3jWRXtph0QTzia5ZJyPh2ib5aC/6BIDymkUUM58Q==",
+      "dev": true,
+      "dependencies": {
+        "@commitlint/config-validator": "^18.4.3",
+        "@commitlint/execute-rule": "^18.4.3",
+        "@commitlint/resolve-extends": "^18.4.3",
+        "@commitlint/types": "^18.4.3",
+        "@types/node": "^18.11.9",
+        "chalk": "^4.1.0",
+        "cosmiconfig": "^8.3.6",
+        "cosmiconfig-typescript-loader": "^5.0.0",
+        "lodash.isplainobject": "^4.0.6",
+        "lodash.merge": "^4.6.2",
+        "lodash.uniq": "^4.5.0",
+        "resolve-from": "^5.0.0"
+      },
+      "engines": {
+        "node": ">=v18"
+      }
+    },
+    "node_modules/@commitlint/load/node_modules/resolve-from": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmmirror.com/resolve-from/-/resolve-from-5.0.0.tgz",
+      "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/@commitlint/message": {
+      "version": "18.4.3",
+      "resolved": "https://registry.npmmirror.com/@commitlint/message/-/message-18.4.3.tgz",
+      "integrity": "sha512-ddJ7AztWUIoEMAXoewx45lKEYEOeOlBVWjk8hDMUGpprkuvWULpaXczqdjwVtjrKT3JhhN+gMs8pm5G3vB2how==",
+      "dev": true,
+      "engines": {
+        "node": ">=v18"
+      }
+    },
+    "node_modules/@commitlint/parse": {
+      "version": "18.4.3",
+      "resolved": "https://registry.npmmirror.com/@commitlint/parse/-/parse-18.4.3.tgz",
+      "integrity": "sha512-eoH7CXM9L+/Me96KVcfJ27EIIbA5P9sqw3DqjJhRYuhaULIsPHFs5S5GBDCqT0vKZQDx0DgxhMpW6AQbnKrFtA==",
+      "dev": true,
+      "dependencies": {
+        "@commitlint/types": "^18.4.3",
+        "conventional-changelog-angular": "^7.0.0",
+        "conventional-commits-parser": "^5.0.0"
+      },
+      "engines": {
+        "node": ">=v18"
+      }
+    },
+    "node_modules/@commitlint/read": {
+      "version": "18.4.3",
+      "resolved": "https://registry.npmmirror.com/@commitlint/read/-/read-18.4.3.tgz",
+      "integrity": "sha512-H4HGxaYA6OBCimZAtghL+B+SWu8ep4X7BwgmedmqWZRHxRLcX2q0bWBtUm5FsMbluxbOfrJwOs/Z0ah4roP/GQ==",
+      "dev": true,
+      "dependencies": {
+        "@commitlint/top-level": "^18.4.3",
+        "@commitlint/types": "^18.4.3",
+        "fs-extra": "^11.0.0",
+        "git-raw-commits": "^2.0.11",
+        "minimist": "^1.2.6"
+      },
+      "engines": {
+        "node": ">=v18"
+      }
+    },
+    "node_modules/@commitlint/resolve-extends": {
+      "version": "18.4.3",
+      "resolved": "https://registry.npmmirror.com/@commitlint/resolve-extends/-/resolve-extends-18.4.3.tgz",
+      "integrity": "sha512-30sk04LZWf8+SDgJrbJCjM90gTg2LxsD9cykCFeFu+JFHvBFq5ugzp2eO/DJGylAdVaqxej3c7eTSE64hR/lnw==",
+      "dev": true,
+      "dependencies": {
+        "@commitlint/config-validator": "^18.4.3",
+        "@commitlint/types": "^18.4.3",
+        "import-fresh": "^3.0.0",
+        "lodash.mergewith": "^4.6.2",
+        "resolve-from": "^5.0.0",
+        "resolve-global": "^1.0.0"
+      },
+      "engines": {
+        "node": ">=v18"
+      }
+    },
+    "node_modules/@commitlint/resolve-extends/node_modules/resolve-from": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmmirror.com/resolve-from/-/resolve-from-5.0.0.tgz",
+      "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/@commitlint/rules": {
+      "version": "18.4.3",
+      "resolved": "https://registry.npmmirror.com/@commitlint/rules/-/rules-18.4.3.tgz",
+      "integrity": "sha512-8KIeukDf45BiY+Lul1T0imSNXF0sMrlLG6JpLLKolkmYVQ6PxxoNOriwyZ3UTFFpaVbPy0rcITaV7U9JCAfDTA==",
+      "dev": true,
+      "dependencies": {
+        "@commitlint/ensure": "^18.4.3",
+        "@commitlint/message": "^18.4.3",
+        "@commitlint/to-lines": "^18.4.3",
+        "@commitlint/types": "^18.4.3",
+        "execa": "^5.0.0"
+      },
+      "engines": {
+        "node": ">=v18"
+      }
+    },
+    "node_modules/@commitlint/to-lines": {
+      "version": "18.4.3",
+      "resolved": "https://registry.npmmirror.com/@commitlint/to-lines/-/to-lines-18.4.3.tgz",
+      "integrity": "sha512-fy1TAleik4Zfru1RJ8ZU6cOSvgSVhUellxd3WZV1D5RwHZETt1sZdcA4mQN2y3VcIZsUNKkW0Mq8CM9/L9harQ==",
+      "dev": true,
+      "engines": {
+        "node": ">=v18"
+      }
+    },
+    "node_modules/@commitlint/top-level": {
+      "version": "18.4.3",
+      "resolved": "https://registry.npmmirror.com/@commitlint/top-level/-/top-level-18.4.3.tgz",
+      "integrity": "sha512-E6fJPBLPFL5R8+XUNSYkj4HekIOuGMyJo3mIx2PkYc3clel+pcWQ7TConqXxNWW4x1ugigiIY2RGot55qUq1hw==",
+      "dev": true,
+      "dependencies": {
+        "find-up": "^5.0.0"
+      },
+      "engines": {
+        "node": ">=v18"
+      }
+    },
+    "node_modules/@commitlint/types": {
+      "version": "18.4.3",
+      "resolved": "https://registry.npmmirror.com/@commitlint/types/-/types-18.4.3.tgz",
+      "integrity": "sha512-cvzx+vtY/I2hVBZHCLrpoh+sA0hfuzHwDc+BAFPimYLjJkpHnghQM+z8W/KyLGkygJh3BtI3xXXq+dKjnSWEmA==",
+      "dev": true,
+      "dependencies": {
+        "chalk": "^4.1.0"
+      },
+      "engines": {
+        "node": ">=v18"
+      }
+    },
+    "node_modules/@esbuild/aix-ppc64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
+      "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "aix"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/android-arm": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
+      "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/android-arm64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
+      "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/android-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
+      "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/darwin-arm64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
+      "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/darwin-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
+      "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/freebsd-arm64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
+      "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/freebsd-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
+      "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-arm": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
+      "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-arm64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
+      "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-ia32": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
+      "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-loong64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
+      "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-mips64el": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
+      "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
+      "cpu": [
+        "mips64el"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-ppc64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
+      "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-riscv64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
+      "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-s390x": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
+      "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
+      "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/netbsd-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
+      "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/openbsd-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
+      "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/sunos-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
+      "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "sunos"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/win32-arm64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
+      "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/win32-ia32": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
+      "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/win32-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
+      "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@eslint-community/eslint-utils": {
+      "version": "4.4.0",
+      "resolved": "https://registry.npmmirror.com/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz",
+      "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==",
+      "dev": true,
+      "dependencies": {
+        "eslint-visitor-keys": "^3.3.0"
+      },
+      "engines": {
+        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+      },
+      "peerDependencies": {
+        "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
+      }
+    },
+    "node_modules/@eslint-community/regexpp": {
+      "version": "4.10.0",
+      "resolved": "https://registry.npmmirror.com/@eslint-community/regexpp/-/regexpp-4.10.0.tgz",
+      "integrity": "sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA==",
+      "dev": true,
+      "engines": {
+        "node": "^12.0.0 || ^14.0.0 || >=16.0.0"
+      }
+    },
+    "node_modules/@eslint/eslintrc": {
+      "version": "2.1.4",
+      "resolved": "https://registry.npmmirror.com/@eslint/eslintrc/-/eslintrc-2.1.4.tgz",
+      "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==",
+      "dev": true,
+      "dependencies": {
+        "ajv": "^6.12.4",
+        "debug": "^4.3.2",
+        "espree": "^9.6.0",
+        "globals": "^13.19.0",
+        "ignore": "^5.2.0",
+        "import-fresh": "^3.2.1",
+        "js-yaml": "^4.1.0",
+        "minimatch": "^3.1.2",
+        "strip-json-comments": "^3.1.1"
+      },
+      "engines": {
+        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+      }
+    },
+    "node_modules/@eslint/eslintrc/node_modules/brace-expansion": {
+      "version": "1.1.11",
+      "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.11.tgz",
+      "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+      "dev": true,
+      "dependencies": {
+        "balanced-match": "^1.0.0",
+        "concat-map": "0.0.1"
+      }
+    },
+    "node_modules/@eslint/eslintrc/node_modules/minimatch": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.2.tgz",
+      "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+      "dev": true,
+      "dependencies": {
+        "brace-expansion": "^1.1.7"
+      },
+      "engines": {
+        "node": "*"
+      }
+    },
+    "node_modules/@eslint/js": {
+      "version": "8.56.0",
+      "resolved": "https://registry.npmmirror.com/@eslint/js/-/js-8.56.0.tgz",
+      "integrity": "sha512-gMsVel9D7f2HLkBma9VbtzZRehRogVRfbr++f06nL2vnCGCNlzOD+/MUov/F4p8myyAHspEhVobgjpX64q5m6A==",
+      "dev": true,
+      "engines": {
+        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+      }
+    },
+    "node_modules/@humanwhocodes/config-array": {
+      "version": "0.11.13",
+      "resolved": "https://registry.npmmirror.com/@humanwhocodes/config-array/-/config-array-0.11.13.tgz",
+      "integrity": "sha512-JSBDMiDKSzQVngfRjOdFXgFfklaXI4K9nLF49Auh21lmBWRLIK3+xTErTWD4KU54pb6coM6ESE7Awz/FNU3zgQ==",
+      "dev": true,
+      "dependencies": {
+        "@humanwhocodes/object-schema": "^2.0.1",
+        "debug": "^4.1.1",
+        "minimatch": "^3.0.5"
+      },
+      "engines": {
+        "node": ">=10.10.0"
+      }
+    },
+    "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": {
+      "version": "1.1.11",
+      "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.11.tgz",
+      "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+      "dev": true,
+      "dependencies": {
+        "balanced-match": "^1.0.0",
+        "concat-map": "0.0.1"
+      }
+    },
+    "node_modules/@humanwhocodes/config-array/node_modules/minimatch": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.2.tgz",
+      "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+      "dev": true,
+      "dependencies": {
+        "brace-expansion": "^1.1.7"
+      },
+      "engines": {
+        "node": "*"
+      }
+    },
+    "node_modules/@humanwhocodes/module-importer": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmmirror.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
+      "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
+      "dev": true,
+      "engines": {
+        "node": ">=12.22"
+      }
+    },
+    "node_modules/@humanwhocodes/object-schema": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmmirror.com/@humanwhocodes/object-schema/-/object-schema-2.0.1.tgz",
+      "integrity": "sha512-dvuCeX5fC9dXgJn9t+X5atfmgQAzUOWqS1254Gh0m6i8wKd10ebXkfNKiRK+1GWi/yTvvLDHpoxLr0xxxeslWw==",
+      "dev": true
+    },
+    "node_modules/@icon-park/vue-next": {
+      "version": "1.4.2",
+      "resolved": "https://registry.npmmirror.com/@icon-park/vue-next/-/vue-next-1.4.2.tgz",
+      "integrity": "sha512-+QklF255wkfBOabY+xw6FAI0Bwln/RhdwCunNy/9sKdKuChtaU67QZqU67KGAvZUTeeBgsL+yaHHxqfQeGZXEQ==",
+      "engines": {
+        "node": ">= 8.0.0",
+        "npm": ">= 5.0.0"
+      },
+      "peerDependencies": {
+        "vue": "3.x"
+      }
+    },
+    "node_modules/@jridgewell/gen-mapping": {
+      "version": "0.3.13",
+      "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+      "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/sourcemap-codec": "^1.5.0",
+        "@jridgewell/trace-mapping": "^0.3.24"
+      }
+    },
+    "node_modules/@jridgewell/remapping": {
+      "version": "2.3.5",
+      "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+      "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/gen-mapping": "^0.3.5",
+        "@jridgewell/trace-mapping": "^0.3.24"
+      }
+    },
+    "node_modules/@jridgewell/resolve-uri": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+      "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/@jridgewell/sourcemap-codec": {
+      "version": "1.5.5",
+      "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+      "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+      "license": "MIT"
+    },
+    "node_modules/@jridgewell/trace-mapping": {
+      "version": "0.3.31",
+      "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+      "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/resolve-uri": "^3.1.0",
+        "@jridgewell/sourcemap-codec": "^1.4.14"
+      }
+    },
+    "node_modules/@microsoft/fetch-event-source": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/@microsoft/fetch-event-source/-/fetch-event-source-2.0.1.tgz",
+      "integrity": "sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA=="
+    },
+    "node_modules/@nodelib/fs.scandir": {
+      "version": "2.1.5",
+      "resolved": "https://registry.npmmirror.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
+      "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
+      "dev": true,
+      "dependencies": {
+        "@nodelib/fs.stat": "2.0.5",
+        "run-parallel": "^1.1.9"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/@nodelib/fs.stat": {
+      "version": "2.0.5",
+      "resolved": "https://registry.npmmirror.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
+      "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
+      "dev": true,
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/@nodelib/fs.walk": {
+      "version": "1.2.8",
+      "resolved": "https://registry.npmmirror.com/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
+      "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
+      "dev": true,
+      "dependencies": {
+        "@nodelib/fs.scandir": "2.1.5",
+        "fastq": "^1.6.0"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/@popperjs/core": {
+      "version": "2.11.8",
+      "resolved": "https://registry.npmmirror.com/@popperjs/core/-/core-2.11.8.tgz",
+      "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A=="
+    },
+    "node_modules/@rollup/rollup-android-arm-eabi": {
+      "version": "4.19.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.19.0.tgz",
+      "integrity": "sha512-JlPfZ/C7yn5S5p0yKk7uhHTTnFlvTgLetl2VxqE518QgyM7C9bSfFTYvB/Q/ftkq0RIPY4ySxTz+/wKJ/dXC0w==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "android"
+      ]
+    },
+    "node_modules/@rollup/rollup-android-arm64": {
+      "version": "4.19.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.19.0.tgz",
+      "integrity": "sha512-RDxUSY8D1tWYfn00DDi5myxKgOk6RvWPxhmWexcICt/MEC6yEMr4HNCu1sXXYLw8iAsg0D44NuU+qNq7zVWCrw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "android"
+      ]
+    },
+    "node_modules/@rollup/rollup-darwin-arm64": {
+      "version": "4.19.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.19.0.tgz",
+      "integrity": "sha512-emvKHL4B15x6nlNTBMtIaC9tLPRpeA5jMvRLXVbl/W9Ie7HhkrE7KQjvgS9uxgatL1HmHWDXk5TTS4IaNJxbAA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "darwin"
+      ]
+    },
+    "node_modules/@rollup/rollup-darwin-x64": {
+      "version": "4.19.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.19.0.tgz",
+      "integrity": "sha512-fO28cWA1dC57qCd+D0rfLC4VPbh6EOJXrreBmFLWPGI9dpMlER2YwSPZzSGfq11XgcEpPukPTfEVFtw2q2nYJg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "darwin"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+      "version": "4.19.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.19.0.tgz",
+      "integrity": "sha512-2Rn36Ubxdv32NUcfm0wB1tgKqkQuft00PtM23VqLuCUR4N5jcNWDoV5iBC9jeGdgS38WK66ElncprqgMUOyomw==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+      "version": "4.19.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.19.0.tgz",
+      "integrity": "sha512-gJuzIVdq/X1ZA2bHeCGCISe0VWqCoNT8BvkQ+BfsixXwTOndhtLUpOg0A1Fcx/+eA6ei6rMBzlOz4JzmiDw7JQ==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm64-gnu": {
+      "version": "4.19.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.19.0.tgz",
+      "integrity": "sha512-0EkX2HYPkSADo9cfeGFoQ7R0/wTKb7q6DdwI4Yn/ULFE1wuRRCHybxpl2goQrx4c/yzK3I8OlgtBu4xvted0ug==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm64-musl": {
+      "version": "4.19.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.19.0.tgz",
+      "integrity": "sha512-GlIQRj9px52ISomIOEUq/IojLZqzkvRpdP3cLgIE1wUWaiU5Takwlzpz002q0Nxxr1y2ZgxC2obWxjr13lvxNQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-powerpc64le-gnu": {
+      "version": "4.19.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.19.0.tgz",
+      "integrity": "sha512-N6cFJzssruDLUOKfEKeovCKiHcdwVYOT1Hs6dovDQ61+Y9n3Ek4zXvtghPPelt6U0AH4aDGnDLb83uiJMkWYzQ==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+      "version": "4.19.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.19.0.tgz",
+      "integrity": "sha512-2DnD3mkS2uuam/alF+I7M84koGwvn3ZVD7uG+LEWpyzo/bq8+kKnus2EVCkcvh6PlNB8QPNFOz6fWd5N8o1CYg==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-s390x-gnu": {
+      "version": "4.19.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.19.0.tgz",
+      "integrity": "sha512-D6pkaF7OpE7lzlTOFCB2m3Ngzu2ykw40Nka9WmKGUOTS3xcIieHe82slQlNq69sVB04ch73thKYIWz/Ian8DUA==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-x64-gnu": {
+      "version": "4.19.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.19.0.tgz",
+      "integrity": "sha512-HBndjQLP8OsdJNSxpNIN0einbDmRFg9+UQeZV1eiYupIRuZsDEoeGU43NQsS34Pp166DtwQOnpcbV/zQxM+rWA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-x64-musl": {
+      "version": "4.19.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.19.0.tgz",
+      "integrity": "sha512-HxfbvfCKJe/RMYJJn0a12eiOI9OOtAUF4G6ozrFUK95BNyoJaSiBjIOHjZskTUffUrB84IPKkFG9H9nEvJGW6A==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-arm64-msvc": {
+      "version": "4.19.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.19.0.tgz",
+      "integrity": "sha512-HxDMKIhmcguGTiP5TsLNolwBUK3nGGUEoV/BO9ldUBoMLBssvh4J0X8pf11i1fTV7WShWItB1bKAKjX4RQeYmg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-ia32-msvc": {
+      "version": "4.19.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.19.0.tgz",
+      "integrity": "sha512-xItlIAZZaiG/u0wooGzRsx11rokP4qyc/79LkAOdznGRAbOFc+SfEdfUOszG1odsHNgwippUJavag/+W/Etc6Q==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-x64-msvc": {
+      "version": "4.19.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.19.0.tgz",
+      "integrity": "sha512-xNo5fV5ycvCCKqiZcpB65VMR11NJB+StnxHz20jdqRAktfdfzhgjTiJ2doTDQE/7dqGaV5I7ZGqKpgph6lCIag==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rushstack/eslint-patch": {
+      "version": "1.6.1",
+      "resolved": "https://registry.npmmirror.com/@rushstack/eslint-patch/-/eslint-patch-1.6.1.tgz",
+      "integrity": "sha512-UY+FGM/2jjMkzQLn8pxcHGMaVLh9aEitG3zY2CiY7XHdLiz3bZOwa6oDxNqEMv7zZkV+cj5DOdz0cQ1BP5Hjgw==",
+      "dev": true
+    },
+    "node_modules/@tsconfig/node18": {
+      "version": "18.2.2",
+      "resolved": "https://registry.npmmirror.com/@tsconfig/node18/-/node18-18.2.2.tgz",
+      "integrity": "sha512-d6McJeGsuoRlwWZmVIeE8CUA27lu6jLjvv1JzqmpsytOYYbVi1tHZEnwCNVOXnj4pyLvneZlFlpXUK+X9wBWyw==",
+      "dev": true
+    },
+    "node_modules/@types/crypto-js": {
+      "version": "4.2.1",
+      "resolved": "https://registry.npmmirror.com/@types/crypto-js/-/crypto-js-4.2.1.tgz",
+      "integrity": "sha512-FSPGd9+OcSok3RsM0UZ/9fcvMOXJ1ENE/ZbLfOPlBWj7BgXtEAM8VYfTtT760GiLbQIMoVozwVuisjvsVwqYWw==",
+      "dev": true
+    },
+    "node_modules/@types/dom-webcodecs": {
+      "version": "0.1.18",
+      "resolved": "https://registry.npmmirror.com/@types/dom-webcodecs/-/dom-webcodecs-0.1.18.tgz",
+      "integrity": "sha512-vAvE8C9DGWR+tkb19xyjk1TSUlJ7RUzzp4a9Anu7mwBT+fpyePWK1UxmH14tMO5zHmrnrRIMg5NutnnDztLxgg==",
+      "license": "MIT"
+    },
+    "node_modules/@types/estree": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.5.tgz",
+      "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==",
+      "dev": true
+    },
+    "node_modules/@types/file-saver": {
+      "version": "2.0.7",
+      "resolved": "https://registry.npmmirror.com/@types/file-saver/-/file-saver-2.0.7.tgz",
+      "integrity": "sha512-dNKVfHd/jk0SkR/exKGj2ggkB45MAkzvWCaqLUUgkyjITkGNzH8H+yUwr+BLJUBjZOe9w8X3wgmXhZDRg1ED6A==",
+      "dev": true
+    },
+    "node_modules/@types/html2canvas": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/@types/html2canvas/-/html2canvas-1.0.0.tgz",
+      "integrity": "sha512-BJpVf+FIN9UERmzhbtUgpXj6XBZpG67FMgBLLoj9HZKd9XifcCpSV+UnFcwTZfEyun4U/KmCrrVOG7829L589w==",
+      "deprecated": "This is a stub types definition. html2canvas provides its own type definitions, so you do not need this installed.",
+      "dev": true,
+      "dependencies": {
+        "html2canvas": "*"
+      }
+    },
+    "node_modules/@types/json-schema": {
+      "version": "7.0.15",
+      "resolved": "https://registry.npmmirror.com/@types/json-schema/-/json-schema-7.0.15.tgz",
+      "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
+      "dev": true
+    },
+    "node_modules/@types/linkify-it": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz",
+      "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==",
+      "dev": true
+    },
+    "node_modules/@types/lodash": {
+      "version": "4.14.202",
+      "resolved": "https://registry.npmmirror.com/@types/lodash/-/lodash-4.14.202.tgz",
+      "integrity": "sha512-OvlIYQK9tNneDlS0VN54LLd5uiPCBOp7gS5Z0f1mjoJYBrtStzgmJBxONW3U6OZqdtNzZPmn9BS/7WI7BFFcFQ==",
+      "dev": true
+    },
+    "node_modules/@types/markdown-it": {
+      "version": "14.1.2",
+      "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz",
+      "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==",
+      "dev": true,
+      "dependencies": {
+        "@types/linkify-it": "^5",
+        "@types/mdurl": "^2"
+      }
+    },
+    "node_modules/@types/mdurl": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz",
+      "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==",
+      "dev": true
+    },
+    "node_modules/@types/minimist": {
+      "version": "1.2.5",
+      "resolved": "https://registry.npmmirror.com/@types/minimist/-/minimist-1.2.5.tgz",
+      "integrity": "sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==",
+      "dev": true
+    },
+    "node_modules/@types/node": {
+      "version": "18.19.4",
+      "resolved": "https://registry.npmmirror.com/@types/node/-/node-18.19.4.tgz",
+      "integrity": "sha512-xNzlUhzoHotIsnFoXmJB+yWmBvFZgKCI9TtPIEdYIMM1KWfwuY8zh7wvc1u1OAXlC7dlf6mZVx/s+Y5KfFz19A==",
+      "dependencies": {
+        "undici-types": "~5.26.4"
+      }
+    },
+    "node_modules/@types/normalize-package-data": {
+      "version": "2.4.4",
+      "resolved": "https://registry.npmmirror.com/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz",
+      "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==",
+      "dev": true
+    },
+    "node_modules/@types/qs": {
+      "version": "6.14.0",
+      "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
+      "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==",
+      "dev": true
+    },
+    "node_modules/@types/semver": {
+      "version": "7.5.6",
+      "resolved": "https://registry.npmmirror.com/@types/semver/-/semver-7.5.6.tgz",
+      "integrity": "sha512-dn1l8LaMea/IjDoHNd9J52uBbInB796CDffS6VdIxvqYCPSG0V0DzHp76GpaWnlhg88uYyPbXCDIowa86ybd5A==",
+      "dev": true
+    },
+    "node_modules/@types/svg-arc-to-cubic-bezier": {
+      "version": "3.2.2",
+      "resolved": "https://registry.npmmirror.com/@types/svg-arc-to-cubic-bezier/-/svg-arc-to-cubic-bezier-3.2.2.tgz",
+      "integrity": "sha512-XQtKy9lmkKlV+c3Jelo7kxNPw7qOqIq3GcnOhywGZHF7zw5D5m+Ssigbmf3Turbe/A8Ur+lRh8TYjuxXKvyivw==",
+      "dev": true
+    },
+    "node_modules/@types/tinycolor2": {
+      "version": "1.4.6",
+      "resolved": "https://registry.npmmirror.com/@types/tinycolor2/-/tinycolor2-1.4.6.tgz",
+      "integrity": "sha512-iEN8J0BoMnsWBqjVbWH/c0G0Hh7O21lpR2/+PrvAVgWdzL7eexIFm4JN/Wn10PTcmNdtS6U67r499mlWMXOxNw==",
+      "dev": true
+    },
+    "node_modules/@types/wicg-file-system-access": {
+      "version": "2020.9.8",
+      "resolved": "https://registry.npmmirror.com/@types/wicg-file-system-access/-/wicg-file-system-access-2020.9.8.tgz",
+      "integrity": "sha512-ggMz8nOygG7d/stpH40WVaNvBwuyYLnrg5Mbyf6bmsj/8+gb6Ei4ZZ9/4PNpcPNTT8th9Q8sM8wYmWGjMWLX/A==",
+      "license": "MIT"
+    },
+    "node_modules/@typescript-eslint/eslint-plugin": {
+      "version": "6.16.0",
+      "resolved": "https://registry.npmmirror.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.16.0.tgz",
+      "integrity": "sha512-O5f7Kv5o4dLWQtPX4ywPPa+v9G+1q1x8mz0Kr0pXUtKsevo+gIJHLkGc8RxaZWtP8RrhwhSNIWThnW42K9/0rQ==",
+      "dev": true,
+      "dependencies": {
+        "@eslint-community/regexpp": "^4.5.1",
+        "@typescript-eslint/scope-manager": "6.16.0",
+        "@typescript-eslint/type-utils": "6.16.0",
+        "@typescript-eslint/utils": "6.16.0",
+        "@typescript-eslint/visitor-keys": "6.16.0",
+        "debug": "^4.3.4",
+        "graphemer": "^1.4.0",
+        "ignore": "^5.2.4",
+        "natural-compare": "^1.4.0",
+        "semver": "^7.5.4",
+        "ts-api-utils": "^1.0.1"
+      },
+      "engines": {
+        "node": "^16.0.0 || >=18.0.0"
+      },
+      "peerDependencies": {
+        "@typescript-eslint/parser": "^6.0.0 || ^6.0.0-alpha",
+        "eslint": "^7.0.0 || ^8.0.0"
+      },
+      "peerDependenciesMeta": {
+        "typescript": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@typescript-eslint/parser": {
+      "version": "6.16.0",
+      "resolved": "https://registry.npmmirror.com/@typescript-eslint/parser/-/parser-6.16.0.tgz",
+      "integrity": "sha512-H2GM3eUo12HpKZU9njig3DF5zJ58ja6ahj1GoHEHOgQvYxzoFJJEvC1MQ7T2l9Ha+69ZSOn7RTxOdpC/y3ikMw==",
+      "dev": true,
+      "dependencies": {
+        "@typescript-eslint/scope-manager": "6.16.0",
+        "@typescript-eslint/types": "6.16.0",
+        "@typescript-eslint/typescript-estree": "6.16.0",
+        "@typescript-eslint/visitor-keys": "6.16.0",
+        "debug": "^4.3.4"
+      },
+      "engines": {
+        "node": "^16.0.0 || >=18.0.0"
+      },
+      "peerDependencies": {
+        "eslint": "^7.0.0 || ^8.0.0"
+      },
+      "peerDependenciesMeta": {
+        "typescript": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@typescript-eslint/scope-manager": {
+      "version": "6.16.0",
+      "resolved": "https://registry.npmmirror.com/@typescript-eslint/scope-manager/-/scope-manager-6.16.0.tgz",
+      "integrity": "sha512-0N7Y9DSPdaBQ3sqSCwlrm9zJwkpOuc6HYm7LpzLAPqBL7dmzAUimr4M29dMkOP/tEwvOCC/Cxo//yOfJD3HUiw==",
+      "dev": true,
+      "dependencies": {
+        "@typescript-eslint/types": "6.16.0",
+        "@typescript-eslint/visitor-keys": "6.16.0"
+      },
+      "engines": {
+        "node": "^16.0.0 || >=18.0.0"
+      }
+    },
+    "node_modules/@typescript-eslint/type-utils": {
+      "version": "6.16.0",
+      "resolved": "https://registry.npmmirror.com/@typescript-eslint/type-utils/-/type-utils-6.16.0.tgz",
+      "integrity": "sha512-ThmrEOcARmOnoyQfYkHw/DX2SEYBalVECmoldVuH6qagKROp/jMnfXpAU/pAIWub9c4YTxga+XwgAkoA0pxfmg==",
+      "dev": true,
+      "dependencies": {
+        "@typescript-eslint/typescript-estree": "6.16.0",
+        "@typescript-eslint/utils": "6.16.0",
+        "debug": "^4.3.4",
+        "ts-api-utils": "^1.0.1"
+      },
+      "engines": {
+        "node": "^16.0.0 || >=18.0.0"
+      },
+      "peerDependencies": {
+        "eslint": "^7.0.0 || ^8.0.0"
+      },
+      "peerDependenciesMeta": {
+        "typescript": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@typescript-eslint/types": {
+      "version": "6.16.0",
+      "resolved": "https://registry.npmmirror.com/@typescript-eslint/types/-/types-6.16.0.tgz",
+      "integrity": "sha512-hvDFpLEvTJoHutVl87+MG/c5C8I6LOgEx05zExTSJDEVU7hhR3jhV8M5zuggbdFCw98+HhZWPHZeKS97kS3JoQ==",
+      "dev": true,
+      "engines": {
+        "node": "^16.0.0 || >=18.0.0"
+      }
+    },
+    "node_modules/@typescript-eslint/typescript-estree": {
+      "version": "6.16.0",
+      "resolved": "https://registry.npmmirror.com/@typescript-eslint/typescript-estree/-/typescript-estree-6.16.0.tgz",
+      "integrity": "sha512-VTWZuixh/vr7nih6CfrdpmFNLEnoVBF1skfjdyGnNwXOH1SLeHItGdZDHhhAIzd3ACazyY2Fg76zuzOVTaknGA==",
+      "dev": true,
+      "dependencies": {
+        "@typescript-eslint/types": "6.16.0",
+        "@typescript-eslint/visitor-keys": "6.16.0",
+        "debug": "^4.3.4",
+        "globby": "^11.1.0",
+        "is-glob": "^4.0.3",
+        "minimatch": "9.0.3",
+        "semver": "^7.5.4",
+        "ts-api-utils": "^1.0.1"
+      },
+      "engines": {
+        "node": "^16.0.0 || >=18.0.0"
+      },
+      "peerDependenciesMeta": {
+        "typescript": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@typescript-eslint/utils": {
+      "version": "6.16.0",
+      "resolved": "https://registry.npmmirror.com/@typescript-eslint/utils/-/utils-6.16.0.tgz",
+      "integrity": "sha512-T83QPKrBm6n//q9mv7oiSvy/Xq/7Hyw9SzSEhMHJwznEmQayfBM87+oAlkNAMEO7/MjIwKyOHgBJbxB0s7gx2A==",
+      "dev": true,
+      "dependencies": {
+        "@eslint-community/eslint-utils": "^4.4.0",
+        "@types/json-schema": "^7.0.12",
+        "@types/semver": "^7.5.0",
+        "@typescript-eslint/scope-manager": "6.16.0",
+        "@typescript-eslint/types": "6.16.0",
+        "@typescript-eslint/typescript-estree": "6.16.0",
+        "semver": "^7.5.4"
+      },
+      "engines": {
+        "node": "^16.0.0 || >=18.0.0"
+      },
+      "peerDependencies": {
+        "eslint": "^7.0.0 || ^8.0.0"
+      }
+    },
+    "node_modules/@typescript-eslint/visitor-keys": {
+      "version": "6.16.0",
+      "resolved": "https://registry.npmmirror.com/@typescript-eslint/visitor-keys/-/visitor-keys-6.16.0.tgz",
+      "integrity": "sha512-QSFQLruk7fhs91a/Ep/LqRdbJCZ1Rq03rqBdKT5Ky17Sz8zRLUksqIe9DW0pKtg/Z35/ztbLQ6qpOCN6rOC11A==",
+      "dev": true,
+      "dependencies": {
+        "@typescript-eslint/types": "6.16.0",
+        "eslint-visitor-keys": "^3.4.1"
+      },
+      "engines": {
+        "node": "^16.0.0 || >=18.0.0"
+      }
+    },
+    "node_modules/@ungap/structured-clone": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmmirror.com/@ungap/structured-clone/-/structured-clone-1.2.0.tgz",
+      "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==",
+      "dev": true
+    },
+    "node_modules/@vitejs/plugin-vue": {
+      "version": "5.1.0",
+      "resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-5.1.0.tgz",
+      "integrity": "sha512-QMRxARyrdiwi1mj3AW4fLByoHTavreXq0itdEW696EihXglf1MB3D4C2gBvE0jMPH29ZjC3iK8aIaUMLf4EOGA==",
+      "dev": true,
+      "engines": {
+        "node": "^18.0.0 || >=20.0.0"
+      },
+      "peerDependencies": {
+        "vite": "^5.0.0",
+        "vue": "^3.2.25"
+      }
+    },
+    "node_modules/@volar/language-core": {
+      "version": "2.4.0-alpha.18",
+      "resolved": "https://registry.npmmirror.com/@volar/language-core/-/language-core-2.4.0-alpha.18.tgz",
+      "integrity": "sha512-JAYeJvYQQROmVRtSBIczaPjP3DX4QW1fOqW1Ebs0d3Y3EwSNRglz03dSv0Dm61dzd0Yx3WgTW3hndDnTQqgmyg==",
+      "dev": true,
+      "dependencies": {
+        "@volar/source-map": "2.4.0-alpha.18"
+      }
+    },
+    "node_modules/@volar/source-map": {
+      "version": "2.4.0-alpha.18",
+      "resolved": "https://registry.npmmirror.com/@volar/source-map/-/source-map-2.4.0-alpha.18.tgz",
+      "integrity": "sha512-MTeCV9MUwwsH0sNFiZwKtFrrVZUK6p8ioZs3xFzHc2cvDXHWlYN3bChdQtwKX+FY2HG6H3CfAu1pKijolzIQ8g==",
+      "dev": true
+    },
+    "node_modules/@volar/typescript": {
+      "version": "2.4.0-alpha.18",
+      "resolved": "https://registry.npmmirror.com/@volar/typescript/-/typescript-2.4.0-alpha.18.tgz",
+      "integrity": "sha512-sXh5Y8sqGUkgxpMWUGvRXggxYHAVxg0Pa1C42lQZuPDrW6vHJPR0VCK8Sr7WJsAW530HuNQT/ZIskmXtxjybMQ==",
+      "dev": true,
+      "dependencies": {
+        "@volar/language-core": "2.4.0-alpha.18",
+        "path-browserify": "^1.0.1",
+        "vscode-uri": "^3.0.8"
+      }
+    },
+    "node_modules/@vue/babel-helper-vue-transform-on": {
+      "version": "1.5.0",
+      "resolved": "https://registry.npmjs.org/@vue/babel-helper-vue-transform-on/-/babel-helper-vue-transform-on-1.5.0.tgz",
+      "integrity": "sha512-0dAYkerNhhHutHZ34JtTl2czVQHUNWv6xEbkdF5W+Yrv5pCWsqjeORdOgbtW2I9gWlt+wBmVn+ttqN9ZxR5tzA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@vue/babel-plugin-jsx": {
+      "version": "1.5.0",
+      "resolved": "https://registry.npmjs.org/@vue/babel-plugin-jsx/-/babel-plugin-jsx-1.5.0.tgz",
+      "integrity": "sha512-mneBhw1oOqCd2247O0Yw/mRwC9jIGACAJUlawkmMBiNmL4dGA2eMzuNZVNqOUfYTa6vqmND4CtOPzmEEEqLKFw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-module-imports": "^7.27.1",
+        "@babel/helper-plugin-utils": "^7.27.1",
+        "@babel/plugin-syntax-jsx": "^7.27.1",
+        "@babel/template": "^7.27.2",
+        "@babel/traverse": "^7.28.0",
+        "@babel/types": "^7.28.2",
+        "@vue/babel-helper-vue-transform-on": "1.5.0",
+        "@vue/babel-plugin-resolve-type": "1.5.0",
+        "@vue/shared": "^3.5.18"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      },
+      "peerDependenciesMeta": {
+        "@babel/core": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@vue/babel-plugin-jsx/node_modules/@vue/shared": {
+      "version": "3.5.31",
+      "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.31.tgz",
+      "integrity": "sha512-nBxuiuS9Lj5bPkPbWogPUnjxxWpkRniX7e5UBQDWl6Fsf4roq9wwV+cR7ezQ4zXswNvPIlsdj1slcLB7XCsRAw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@vue/babel-plugin-resolve-type": {
+      "version": "1.5.0",
+      "resolved": "https://registry.npmjs.org/@vue/babel-plugin-resolve-type/-/babel-plugin-resolve-type-1.5.0.tgz",
+      "integrity": "sha512-Wm/60o+53JwJODm4Knz47dxJnLDJ9FnKnGZJbUUf8nQRAtt6P+undLUAVU3Ha33LxOJe6IPoifRQ6F/0RrU31w==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/code-frame": "^7.27.1",
+        "@babel/helper-module-imports": "^7.27.1",
+        "@babel/helper-plugin-utils": "^7.27.1",
+        "@babel/parser": "^7.28.0",
+        "@vue/compiler-sfc": "^3.5.18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sxzz"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@vue/babel-plugin-resolve-type/node_modules/@vue/compiler-core": {
+      "version": "3.5.31",
+      "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.31.tgz",
+      "integrity": "sha512-k/ueL14aNIEy5Onf0OVzR8kiqF/WThgLdFhxwa4e/KF/0qe38IwIdofoSWBTvvxQOesaz6riAFAUaYjoF9fLLQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/parser": "^7.29.2",
+        "@vue/shared": "3.5.31",
+        "entities": "^7.0.1",
+        "estree-walker": "^2.0.2",
+        "source-map-js": "^1.2.1"
+      }
+    },
+    "node_modules/@vue/babel-plugin-resolve-type/node_modules/@vue/compiler-dom": {
+      "version": "3.5.31",
+      "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.31.tgz",
+      "integrity": "sha512-BMY/ozS/xxjYqRFL+tKdRpATJYDTTgWSo0+AJvJNg4ig+Hgb0dOsHPXvloHQ5hmlivUqw1Yt2pPIqp4e0v1GUw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@vue/compiler-core": "3.5.31",
+        "@vue/shared": "3.5.31"
+      }
+    },
+    "node_modules/@vue/babel-plugin-resolve-type/node_modules/@vue/compiler-sfc": {
+      "version": "3.5.31",
+      "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.31.tgz",
+      "integrity": "sha512-M8wpPgR9UJ8MiRGjppvx9uWJfLV7A/T+/rL8s/y3QG3u0c2/YZgff3d6SuimKRIhcYnWg5fTfDMlz2E6seUW8Q==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/parser": "^7.29.2",
+        "@vue/compiler-core": "3.5.31",
+        "@vue/compiler-dom": "3.5.31",
+        "@vue/compiler-ssr": "3.5.31",
+        "@vue/shared": "3.5.31",
+        "estree-walker": "^2.0.2",
+        "magic-string": "^0.30.21",
+        "postcss": "^8.5.8",
+        "source-map-js": "^1.2.1"
+      }
+    },
+    "node_modules/@vue/babel-plugin-resolve-type/node_modules/@vue/compiler-ssr": {
+      "version": "3.5.31",
+      "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.31.tgz",
+      "integrity": "sha512-h0xIMxrt/LHOvJKMri+vdYT92BrK3HFLtDqq9Pr/lVVfE4IyKZKvWf0vJFW10Yr6nX02OR4MkJwI0c1HDa1hog==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@vue/compiler-dom": "3.5.31",
+        "@vue/shared": "3.5.31"
+      }
+    },
+    "node_modules/@vue/babel-plugin-resolve-type/node_modules/@vue/shared": {
+      "version": "3.5.31",
+      "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.31.tgz",
+      "integrity": "sha512-nBxuiuS9Lj5bPkPbWogPUnjxxWpkRniX7e5UBQDWl6Fsf4roq9wwV+cR7ezQ4zXswNvPIlsdj1slcLB7XCsRAw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@vue/babel-plugin-resolve-type/node_modules/entities": {
+      "version": "7.0.1",
+      "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
+      "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
+      "dev": true,
+      "license": "BSD-2-Clause",
+      "engines": {
+        "node": ">=0.12"
+      },
+      "funding": {
+        "url": "https://github.com/fb55/entities?sponsor=1"
+      }
+    },
+    "node_modules/@vue/compiler-core": {
+      "version": "3.5.17",
+      "resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.17.tgz",
+      "integrity": "sha512-Xe+AittLbAyV0pabcN7cP7/BenRBNcteM4aSDCtRvGw0d9OL+HG1u/XHLY/kt1q4fyMeZYXyIYrsHuPSiDPosA==",
+      "dependencies": {
+        "@babel/parser": "^7.27.5",
+        "@vue/shared": "3.5.17",
+        "entities": "^4.5.0",
+        "estree-walker": "^2.0.2",
+        "source-map-js": "^1.2.1"
+      }
+    },
+    "node_modules/@vue/compiler-dom": {
+      "version": "3.5.17",
+      "resolved": "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.17.tgz",
+      "integrity": "sha512-+2UgfLKoaNLhgfhV5Ihnk6wB4ljyW1/7wUIog2puUqajiC29Lp5R/IKDdkebh9jTbTogTbsgB+OY9cEWzG95JQ==",
+      "dependencies": {
+        "@vue/compiler-core": "3.5.17",
+        "@vue/shared": "3.5.17"
+      }
+    },
+    "node_modules/@vue/compiler-sfc": {
+      "version": "3.5.17",
+      "resolved": "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.17.tgz",
+      "integrity": "sha512-rQQxbRJMgTqwRugtjw0cnyQv9cP4/4BxWfTdRBkqsTfLOHWykLzbOc3C4GGzAmdMDxhzU/1Ija5bTjMVrddqww==",
+      "dependencies": {
+        "@babel/parser": "^7.27.5",
+        "@vue/compiler-core": "3.5.17",
+        "@vue/compiler-dom": "3.5.17",
+        "@vue/compiler-ssr": "3.5.17",
+        "@vue/shared": "3.5.17",
+        "estree-walker": "^2.0.2",
+        "magic-string": "^0.30.17",
+        "postcss": "^8.5.6",
+        "source-map-js": "^1.2.1"
+      }
+    },
+    "node_modules/@vue/compiler-ssr": {
+      "version": "3.5.17",
+      "resolved": "https://registry.npmmirror.com/@vue/compiler-ssr/-/compiler-ssr-3.5.17.tgz",
+      "integrity": "sha512-hkDbA0Q20ZzGgpj5uZjb9rBzQtIHLS78mMilwrlpWk2Ep37DYntUz0PonQ6kr113vfOEdM+zTBuJDaceNIW0tQ==",
+      "dependencies": {
+        "@vue/compiler-dom": "3.5.17",
+        "@vue/shared": "3.5.17"
+      }
+    },
+    "node_modules/@vue/compiler-vue2": {
+      "version": "2.7.16",
+      "resolved": "https://registry.npmmirror.com/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz",
+      "integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==",
+      "dev": true,
+      "dependencies": {
+        "de-indent": "^1.0.2",
+        "he": "^1.2.0"
+      }
+    },
+    "node_modules/@vue/devtools-api": {
+      "version": "7.7.2",
+      "resolved": "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-7.7.2.tgz",
+      "integrity": "sha512-1syn558KhyN+chO5SjlZIwJ8bV/bQ1nOVTG66t2RbG66ZGekyiYNmRO7X9BJCXQqPsFHlnksqvPhce2qpzxFnA==",
+      "dependencies": {
+        "@vue/devtools-kit": "^7.7.2"
+      }
+    },
+    "node_modules/@vue/devtools-kit": {
+      "version": "7.7.2",
+      "resolved": "https://registry.npmmirror.com/@vue/devtools-kit/-/devtools-kit-7.7.2.tgz",
+      "integrity": "sha512-CY0I1JH3Z8PECbn6k3TqM1Bk9ASWxeMtTCvZr7vb+CHi+X/QwQm5F1/fPagraamKMAHVfuuCbdcnNg1A4CYVWQ==",
+      "dependencies": {
+        "@vue/devtools-shared": "^7.7.2",
+        "birpc": "^0.2.19",
+        "hookable": "^5.5.3",
+        "mitt": "^3.0.1",
+        "perfect-debounce": "^1.0.0",
+        "speakingurl": "^14.0.1",
+        "superjson": "^2.2.1"
+      }
+    },
+    "node_modules/@vue/devtools-shared": {
+      "version": "7.7.2",
+      "resolved": "https://registry.npmmirror.com/@vue/devtools-shared/-/devtools-shared-7.7.2.tgz",
+      "integrity": "sha512-uBFxnp8gwW2vD6FrJB8JZLUzVb6PNRG0B0jBnHsOH8uKyva2qINY8PTF5Te4QlTbMDqU5K6qtJDr6cNsKWhbOA==",
+      "dependencies": {
+        "rfdc": "^1.4.1"
+      }
+    },
+    "node_modules/@vue/eslint-config-typescript": {
+      "version": "12.0.0",
+      "resolved": "https://registry.npmmirror.com/@vue/eslint-config-typescript/-/eslint-config-typescript-12.0.0.tgz",
+      "integrity": "sha512-StxLFet2Qe97T8+7L8pGlhYBBr8Eg05LPuTDVopQV6il+SK6qqom59BA/rcFipUef2jD8P2X44Vd8tMFytfvlg==",
+      "dev": true,
+      "dependencies": {
+        "@typescript-eslint/eslint-plugin": "^6.7.0",
+        "@typescript-eslint/parser": "^6.7.0",
+        "vue-eslint-parser": "^9.3.1"
+      },
+      "engines": {
+        "node": "^14.17.0 || >=16.0.0"
+      },
+      "peerDependencies": {
+        "eslint": "^6.2.0 || ^7.0.0 || ^8.0.0",
+        "eslint-plugin-vue": "^9.0.0",
+        "typescript": "*"
+      },
+      "peerDependenciesMeta": {
+        "typescript": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@vue/language-core": {
+      "version": "2.0.29",
+      "resolved": "https://registry.npmmirror.com/@vue/language-core/-/language-core-2.0.29.tgz",
+      "integrity": "sha512-o2qz9JPjhdoVj8D2+9bDXbaI4q2uZTHQA/dbyZT4Bj1FR9viZxDJnLcKVHfxdn6wsOzRgpqIzJEEmSSvgMvDTQ==",
+      "dev": true,
+      "dependencies": {
+        "@volar/language-core": "~2.4.0-alpha.18",
+        "@vue/compiler-dom": "^3.4.0",
+        "@vue/compiler-vue2": "^2.7.16",
+        "@vue/shared": "^3.4.0",
+        "computeds": "^0.0.1",
+        "minimatch": "^9.0.3",
+        "muggle-string": "^0.4.1",
+        "path-browserify": "^1.0.1"
+      },
+      "peerDependencies": {
+        "typescript": "*"
+      },
+      "peerDependenciesMeta": {
+        "typescript": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@vue/reactivity": {
+      "version": "3.5.17",
+      "resolved": "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.17.tgz",
+      "integrity": "sha512-l/rmw2STIscWi7SNJp708FK4Kofs97zc/5aEPQh4bOsReD/8ICuBcEmS7KGwDj5ODQLYWVN2lNibKJL1z5b+Lw==",
+      "dependencies": {
+        "@vue/shared": "3.5.17"
+      }
+    },
+    "node_modules/@vue/runtime-core": {
+      "version": "3.5.17",
+      "resolved": "https://registry.npmmirror.com/@vue/runtime-core/-/runtime-core-3.5.17.tgz",
+      "integrity": "sha512-QQLXa20dHg1R0ri4bjKeGFKEkJA7MMBxrKo2G+gJikmumRS7PTD4BOU9FKrDQWMKowz7frJJGqBffYMgQYS96Q==",
+      "dependencies": {
+        "@vue/reactivity": "3.5.17",
+        "@vue/shared": "3.5.17"
+      }
+    },
+    "node_modules/@vue/runtime-dom": {
+      "version": "3.5.17",
+      "resolved": "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.5.17.tgz",
+      "integrity": "sha512-8El0M60TcwZ1QMz4/os2MdlQECgGoVHPuLnQBU3m9h3gdNRW9xRmI8iLS4t/22OQlOE6aJvNNlBiCzPHur4H9g==",
+      "dependencies": {
+        "@vue/reactivity": "3.5.17",
+        "@vue/runtime-core": "3.5.17",
+        "@vue/shared": "3.5.17",
+        "csstype": "^3.1.3"
+      }
+    },
+    "node_modules/@vue/server-renderer": {
+      "version": "3.5.17",
+      "resolved": "https://registry.npmmirror.com/@vue/server-renderer/-/server-renderer-3.5.17.tgz",
+      "integrity": "sha512-BOHhm8HalujY6lmC3DbqF6uXN/K00uWiEeF22LfEsm9Q93XeJ/plHTepGwf6tqFcF7GA5oGSSAAUock3VvzaCA==",
+      "dependencies": {
+        "@vue/compiler-ssr": "3.5.17",
+        "@vue/shared": "3.5.17"
+      },
+      "peerDependencies": {
+        "vue": "3.5.17"
+      }
+    },
+    "node_modules/@vue/shared": {
+      "version": "3.5.17",
+      "resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.17.tgz",
+      "integrity": "sha512-CabR+UN630VnsJO/jHWYBC1YVXyMq94KKp6iF5MQgZJs5I8cmjw6oVMO1oDbtBkENSHSSn/UadWlW/OAgdmKrg=="
+    },
+    "node_modules/@vue/tsconfig": {
+      "version": "0.5.1",
+      "resolved": "https://registry.npmmirror.com/@vue/tsconfig/-/tsconfig-0.5.1.tgz",
+      "integrity": "sha512-VcZK7MvpjuTPx2w6blwnwZAu5/LgBUtejFOi3pPGQFXQN5Ela03FUtd2Qtg4yWGGissVL0dr6Ro1LfOFh+PCuQ==",
+      "dev": true
+    },
+    "node_modules/@yarnpkg/lockfile": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmmirror.com/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz",
+      "integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==",
+      "license": "BSD-2-Clause"
+    },
+    "node_modules/acorn": {
+      "version": "8.11.3",
+      "resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.11.3.tgz",
+      "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==",
+      "dev": true,
+      "bin": {
+        "acorn": "bin/acorn"
+      },
+      "engines": {
+        "node": ">=0.4.0"
+      }
+    },
+    "node_modules/acorn-jsx": {
+      "version": "5.3.2",
+      "resolved": "https://registry.npmmirror.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
+      "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
+      "dev": true,
+      "peerDependencies": {
+        "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
+      }
+    },
+    "node_modules/ajv": {
+      "version": "6.12.6",
+      "resolved": "https://registry.npmmirror.com/ajv/-/ajv-6.12.6.tgz",
+      "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
+      "dev": true,
+      "dependencies": {
+        "fast-deep-equal": "^3.1.1",
+        "fast-json-stable-stringify": "^2.0.0",
+        "json-schema-traverse": "^0.4.1",
+        "uri-js": "^4.2.2"
+      }
+    },
+    "node_modules/animate.css": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmmirror.com/animate.css/-/animate.css-4.1.1.tgz",
+      "integrity": "sha512-+mRmCTv6SbCmtYJCN4faJMNFVNN5EuCTTprDTAo7YzIGji2KADmakjVA3+8mVDkZ2Bf09vayB35lSQIex2+QaQ=="
+    },
+    "node_modules/ansi-regex": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz",
+      "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/ansi-styles": {
+      "version": "4.3.0",
+      "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz",
+      "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+      "dependencies": {
+        "color-convert": "^2.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/anymatch": {
+      "version": "3.1.3",
+      "resolved": "https://registry.npmmirror.com/anymatch/-/anymatch-3.1.3.tgz",
+      "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
+      "dev": true,
+      "dependencies": {
+        "normalize-path": "^3.0.0",
+        "picomatch": "^2.0.4"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/argparse": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz",
+      "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="
+    },
+    "node_modules/array-ify": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmmirror.com/array-ify/-/array-ify-1.0.0.tgz",
+      "integrity": "sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==",
+      "dev": true
+    },
+    "node_modules/array-union": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmmirror.com/array-union/-/array-union-2.1.0.tgz",
+      "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/arrify": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmmirror.com/arrify/-/arrify-1.0.1.tgz",
+      "integrity": "sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA==",
+      "dev": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/asynckit": {
+      "version": "0.4.0",
+      "resolved": "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz",
+      "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
+    },
+    "node_modules/axios": {
+      "version": "1.7.9",
+      "resolved": "https://registry.npmmirror.com/axios/-/axios-1.7.9.tgz",
+      "integrity": "sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw==",
+      "dependencies": {
+        "follow-redirects": "^1.15.6",
+        "form-data": "^4.0.0",
+        "proxy-from-env": "^1.1.0"
+      }
+    },
+    "node_modules/balanced-match": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz",
+      "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+      "dev": true
+    },
+    "node_modules/base64-arraybuffer": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
+      "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
+      "engines": {
+        "node": ">= 0.6.0"
+      }
+    },
+    "node_modules/baseline-browser-mapping": {
+      "version": "2.10.13",
+      "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.13.tgz",
+      "integrity": "sha512-BL2sTuHOdy0YT1lYieUxTw/QMtPBC3pmlJC6xk8BBYVv6vcw3SGdKemQ+Xsx9ik2F/lYDO9tqsFQH1r9PFuHKw==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "bin": {
+        "baseline-browser-mapping": "dist/cli.cjs"
+      },
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/binary-extensions": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmmirror.com/binary-extensions/-/binary-extensions-2.3.0.tgz",
+      "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/birpc": {
+      "version": "0.2.19",
+      "resolved": "https://registry.npmmirror.com/birpc/-/birpc-0.2.19.tgz",
+      "integrity": "sha512-5WeXXAvTmitV1RqJFppT5QtUiz2p1mRSYU000Jkft5ZUCLJIk4uQriYNO50HknxKwM6jd8utNc66K1qGIwwWBQ==",
+      "funding": {
+        "url": "https://github.com/sponsors/antfu"
+      }
+    },
+    "node_modules/boolbase": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmmirror.com/boolbase/-/boolbase-1.0.0.tgz",
+      "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
+      "dev": true
+    },
+    "node_modules/brace-expansion": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.0.1.tgz",
+      "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
+      "dev": true,
+      "dependencies": {
+        "balanced-match": "^1.0.0"
+      }
+    },
+    "node_modules/braces": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmmirror.com/braces/-/braces-3.0.2.tgz",
+      "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==",
+      "dependencies": {
+        "fill-range": "^7.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/browserslist": {
+      "version": "4.28.2",
+      "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
+      "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/browserslist"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/browserslist"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "baseline-browser-mapping": "^2.10.12",
+        "caniuse-lite": "^1.0.30001782",
+        "electron-to-chromium": "^1.5.328",
+        "node-releases": "^2.0.36",
+        "update-browserslist-db": "^1.2.3"
+      },
+      "bin": {
+        "browserslist": "cli.js"
+      },
+      "engines": {
+        "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+      }
+    },
+    "node_modules/call-bind": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmmirror.com/call-bind/-/call-bind-1.0.8.tgz",
+      "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.0",
+        "es-define-property": "^1.0.0",
+        "get-intrinsic": "^1.2.4",
+        "set-function-length": "^1.2.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/call-bind-apply-helpers": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+      "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/call-bound": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+      "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "get-intrinsic": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/callsites": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmmirror.com/callsites/-/callsites-3.1.0.tgz",
+      "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
+      "dev": true,
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/camelcase": {
+      "version": "5.3.1",
+      "resolved": "https://registry.npmmirror.com/camelcase/-/camelcase-5.3.1.tgz",
+      "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
+      "dev": true,
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/camelcase-keys": {
+      "version": "6.2.2",
+      "resolved": "https://registry.npmmirror.com/camelcase-keys/-/camelcase-keys-6.2.2.tgz",
+      "integrity": "sha512-YrwaA0vEKazPBkn0ipTiMpSajYDSe+KjQfrjhcBMxJt/znbvlHd8Pw/Vamaz5EB4Wfhs3SUR3Z9mwRu/P3s3Yg==",
+      "dev": true,
+      "dependencies": {
+        "camelcase": "^5.3.1",
+        "map-obj": "^4.0.0",
+        "quick-lru": "^4.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/caniuse-lite": {
+      "version": "1.0.30001782",
+      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001782.tgz",
+      "integrity": "sha512-dZcaJLJeDMh4rELYFw1tvSn1bhZWYFOt468FcbHHxx/Z/dFidd1I6ciyFdi3iwfQCyOjqo9upF6lGQYtMiJWxw==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/browserslist"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "CC-BY-4.0"
+    },
+    "node_modules/chalk": {
+      "version": "4.1.2",
+      "resolved": "https://registry.npmmirror.com/chalk/-/chalk-4.1.2.tgz",
+      "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+      "dependencies": {
+        "ansi-styles": "^4.1.0",
+        "supports-color": "^7.1.0"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/chokidar": {
+      "version": "3.6.0",
+      "resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-3.6.0.tgz",
+      "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
+      "dev": true,
+      "dependencies": {
+        "anymatch": "~3.1.2",
+        "braces": "~3.0.2",
+        "glob-parent": "~5.1.2",
+        "is-binary-path": "~2.1.0",
+        "is-glob": "~4.0.1",
+        "normalize-path": "~3.0.0",
+        "readdirp": "~3.6.0"
+      },
+      "engines": {
+        "node": ">= 8.10.0"
+      },
+      "funding": {
+        "url": "https://paulmillr.com/funding/"
+      },
+      "optionalDependencies": {
+        "fsevents": "~2.3.2"
+      }
+    },
+    "node_modules/chokidar/node_modules/glob-parent": {
+      "version": "5.1.2",
+      "resolved": "https://registry.npmmirror.com/glob-parent/-/glob-parent-5.1.2.tgz",
+      "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+      "dev": true,
+      "dependencies": {
+        "is-glob": "^4.0.1"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/ci-info": {
+      "version": "3.9.0",
+      "resolved": "https://registry.npmmirror.com/ci-info/-/ci-info-3.9.0.tgz",
+      "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/sibiraj-s"
+        }
+      ],
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/clipboard": {
+      "version": "2.0.11",
+      "resolved": "https://registry.npmmirror.com/clipboard/-/clipboard-2.0.11.tgz",
+      "integrity": "sha512-C+0bbOqkezLIsmWSvlsXS0Q0bmkugu7jcfMIACB+RDEntIzQIkdr148we28AfSloQLRdZlYL/QYyrq05j/3Faw==",
+      "dependencies": {
+        "good-listener": "^1.2.2",
+        "select": "^1.1.2",
+        "tiny-emitter": "^2.0.0"
+      }
+    },
+    "node_modules/cliui": {
+      "version": "8.0.1",
+      "resolved": "https://registry.npmmirror.com/cliui/-/cliui-8.0.1.tgz",
+      "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
+      "dev": true,
+      "dependencies": {
+        "string-width": "^4.2.0",
+        "strip-ansi": "^6.0.1",
+        "wrap-ansi": "^7.0.0"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/codepage": {
+      "version": "1.15.0",
+      "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
+      "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=0.8"
+      }
+    },
+    "node_modules/color-convert": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz",
+      "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+      "dependencies": {
+        "color-name": "~1.1.4"
+      },
+      "engines": {
+        "node": ">=7.0.0"
+      }
+    },
+    "node_modules/color-name": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmmirror.com/color-name/-/color-name-1.1.4.tgz",
+      "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
+    },
+    "node_modules/combined-stream": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz",
+      "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+      "dependencies": {
+        "delayed-stream": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/commander": {
+      "version": "8.3.0",
+      "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
+      "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==",
+      "engines": {
+        "node": ">= 12"
+      }
+    },
+    "node_modules/compare-func": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmmirror.com/compare-func/-/compare-func-2.0.0.tgz",
+      "integrity": "sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==",
+      "dev": true,
+      "dependencies": {
+        "array-ify": "^1.0.0",
+        "dot-prop": "^5.1.0"
+      }
+    },
+    "node_modules/computeds": {
+      "version": "0.0.1",
+      "resolved": "https://registry.npmmirror.com/computeds/-/computeds-0.0.1.tgz",
+      "integrity": "sha512-7CEBgcMjVmitjYo5q8JTJVra6X5mQ20uTThdK+0kR7UEaDrAWEQcRiBtWJzga4eRpP6afNwwLsX2SET2JhVB1Q==",
+      "dev": true
+    },
+    "node_modules/concat-map": {
+      "version": "0.0.1",
+      "resolved": "https://registry.npmmirror.com/concat-map/-/concat-map-0.0.1.tgz",
+      "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
+      "dev": true
+    },
+    "node_modules/conventional-changelog-angular": {
+      "version": "7.0.0",
+      "resolved": "https://registry.npmmirror.com/conventional-changelog-angular/-/conventional-changelog-angular-7.0.0.tgz",
+      "integrity": "sha512-ROjNchA9LgfNMTTFSIWPzebCwOGFdgkEq45EnvvrmSLvCtAw0HSmrCs7/ty+wAeYUZyNay0YMUNYFTRL72PkBQ==",
+      "dev": true,
+      "dependencies": {
+        "compare-func": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=16"
+      }
+    },
+    "node_modules/conventional-changelog-conventionalcommits": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmmirror.com/conventional-changelog-conventionalcommits/-/conventional-changelog-conventionalcommits-7.0.2.tgz",
+      "integrity": "sha512-NKXYmMR/Hr1DevQegFB4MwfM5Vv0m4UIxKZTTYuD98lpTknaZlSRrDOG4X7wIXpGkfsYxZTghUN+Qq+T0YQI7w==",
+      "dev": true,
+      "dependencies": {
+        "compare-func": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=16"
+      }
+    },
+    "node_modules/conventional-commits-parser": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmmirror.com/conventional-commits-parser/-/conventional-commits-parser-5.0.0.tgz",
+      "integrity": "sha512-ZPMl0ZJbw74iS9LuX9YIAiW8pfM5p3yh2o/NbXHbkFuZzY5jvdi5jFycEOkmBW5H5I7nA+D6f3UcsCLP2vvSEA==",
+      "dev": true,
+      "dependencies": {
+        "is-text-path": "^2.0.0",
+        "JSONStream": "^1.3.5",
+        "meow": "^12.0.1",
+        "split2": "^4.0.0"
+      },
+      "bin": {
+        "conventional-commits-parser": "cli.mjs"
+      },
+      "engines": {
+        "node": ">=16"
+      }
+    },
+    "node_modules/convert-source-map": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+      "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/copy-anything": {
+      "version": "3.0.5",
+      "resolved": "https://registry.npmmirror.com/copy-anything/-/copy-anything-3.0.5.tgz",
+      "integrity": "sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==",
+      "dependencies": {
+        "is-what": "^4.1.8"
+      },
+      "engines": {
+        "node": ">=12.13"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/mesqueeb"
+      }
+    },
+    "node_modules/core-js-pure": {
+      "version": "3.47.0",
+      "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.47.0.tgz",
+      "integrity": "sha512-BcxeDbzUrRnXGYIVAGFtcGQVNpFcUhVjr6W7F8XktvQW2iJP9e66GP6xdKotCRFlrxBvNIBrhwKteRXqMV86Nw==",
+      "hasInstallScript": true,
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/core-js"
+      }
+    },
+    "node_modules/core-util-is": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmmirror.com/core-util-is/-/core-util-is-1.0.3.tgz",
+      "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="
+    },
+    "node_modules/cosmiconfig": {
+      "version": "8.3.6",
+      "resolved": "https://registry.npmmirror.com/cosmiconfig/-/cosmiconfig-8.3.6.tgz",
+      "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==",
+      "dev": true,
+      "dependencies": {
+        "import-fresh": "^3.3.0",
+        "js-yaml": "^4.1.0",
+        "parse-json": "^5.2.0",
+        "path-type": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=14"
+      },
+      "peerDependencies": {
+        "typescript": ">=4.9.5"
+      },
+      "peerDependenciesMeta": {
+        "typescript": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/cosmiconfig-typescript-loader": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmmirror.com/cosmiconfig-typescript-loader/-/cosmiconfig-typescript-loader-5.0.0.tgz",
+      "integrity": "sha512-+8cK7jRAReYkMwMiG+bxhcNKiHJDM6bR9FD/nGBXOWdMLuYawjF5cGrtLilJ+LGd3ZjCXnJjR5DkfWPoIVlqJA==",
+      "dev": true,
+      "dependencies": {
+        "jiti": "^1.19.1"
+      },
+      "engines": {
+        "node": ">=v16"
+      },
+      "peerDependencies": {
+        "@types/node": "*",
+        "cosmiconfig": ">=8.2",
+        "typescript": ">=4"
+      }
+    },
+    "node_modules/cosmiconfig/node_modules/json-parse-even-better-errors": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmmirror.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz",
+      "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",
+      "dev": true
+    },
+    "node_modules/cosmiconfig/node_modules/lines-and-columns": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmmirror.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
+      "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
+      "dev": true
+    },
+    "node_modules/cosmiconfig/node_modules/parse-json": {
+      "version": "5.2.0",
+      "resolved": "https://registry.npmmirror.com/parse-json/-/parse-json-5.2.0.tgz",
+      "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==",
+      "dev": true,
+      "dependencies": {
+        "@babel/code-frame": "^7.0.0",
+        "error-ex": "^1.3.1",
+        "json-parse-even-better-errors": "^2.3.0",
+        "lines-and-columns": "^1.1.6"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/cross-spawn": {
+      "version": "7.0.3",
+      "resolved": "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.3.tgz",
+      "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
+      "dependencies": {
+        "path-key": "^3.1.0",
+        "shebang-command": "^2.0.0",
+        "which": "^2.0.1"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/crypto-js": {
+      "version": "4.2.0",
+      "resolved": "https://registry.npmmirror.com/crypto-js/-/crypto-js-4.2.0.tgz",
+      "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q=="
+    },
+    "node_modules/css-line-break": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
+      "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
+      "dependencies": {
+        "utrie": "^1.0.2"
+      }
+    },
+    "node_modules/cssesc": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmmirror.com/cssesc/-/cssesc-3.0.0.tgz",
+      "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
+      "dev": true,
+      "bin": {
+        "cssesc": "bin/cssesc"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/csstype": {
+      "version": "3.1.3",
+      "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.1.3.tgz",
+      "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="
+    },
+    "node_modules/dargs": {
+      "version": "7.0.0",
+      "resolved": "https://registry.npmmirror.com/dargs/-/dargs-7.0.0.tgz",
+      "integrity": "sha512-2iy1EkLdlBzQGvbweYRFxmFath8+K7+AKB0TlhHWkNuH+TmovaMH/Wp7V7R4u7f4SnX3OgLsU9t1NI9ioDnUpg==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/de-indent": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmmirror.com/de-indent/-/de-indent-1.0.2.tgz",
+      "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==",
+      "dev": true
+    },
+    "node_modules/debug": {
+      "version": "4.3.4",
+      "resolved": "https://registry.npmmirror.com/debug/-/debug-4.3.4.tgz",
+      "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
+      "dev": true,
+      "dependencies": {
+        "ms": "2.1.2"
+      },
+      "engines": {
+        "node": ">=6.0"
+      },
+      "peerDependenciesMeta": {
+        "supports-color": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/decamelize": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmmirror.com/decamelize/-/decamelize-1.2.0.tgz",
+      "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
+      "dev": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/decamelize-keys": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmmirror.com/decamelize-keys/-/decamelize-keys-1.1.1.tgz",
+      "integrity": "sha512-WiPxgEirIV0/eIOMcnFBA3/IJZAZqKnwAwWyvvdi4lsr1WCN22nhdf/3db3DoZcUjTV2SqfzIwNyp6y2xs3nmg==",
+      "dev": true,
+      "dependencies": {
+        "decamelize": "^1.1.0",
+        "map-obj": "^1.0.0"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/decamelize-keys/node_modules/map-obj": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmmirror.com/map-obj/-/map-obj-1.0.1.tgz",
+      "integrity": "sha512-7N/q3lyZ+LVCp7PzuxrJr4KMbBE2hW7BT7YNia330OFxIf4d3r5zVpicP2650l7CPN6RM9zOJRl3NGpqSiw3Eg==",
+      "dev": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/deep-is": {
+      "version": "0.1.4",
+      "resolved": "https://registry.npmmirror.com/deep-is/-/deep-is-0.1.4.tgz",
+      "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
+      "dev": true
+    },
+    "node_modules/define-data-property": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmmirror.com/define-data-property/-/define-data-property-1.1.4.tgz",
+      "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
+      "license": "MIT",
+      "dependencies": {
+        "es-define-property": "^1.0.0",
+        "es-errors": "^1.3.0",
+        "gopd": "^1.0.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/delayed-stream": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz",
+      "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+      "engines": {
+        "node": ">=0.4.0"
+      }
+    },
+    "node_modules/delegate": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmmirror.com/delegate/-/delegate-3.2.0.tgz",
+      "integrity": "sha512-IofjkYBZaZivn0V8nnsMJGBr4jVLxHDheKSW88PyxS5QC4Vo9ZbZVvhzlSxY87fVq3STR6r+4cGepyHkcWOQSw=="
+    },
+    "node_modules/dexie": {
+      "version": "4.0.11",
+      "resolved": "https://registry.npmmirror.com/dexie/-/dexie-4.0.11.tgz",
+      "integrity": "sha512-SOKO002EqlvBYYKQSew3iymBoN2EQ4BDw/3yprjh7kAfFzjBYkaMNa/pZvcA7HSWlcKSQb9XhPe3wKyQ0x4A8A=="
+    },
+    "node_modules/dir-glob": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmmirror.com/dir-glob/-/dir-glob-3.0.1.tgz",
+      "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==",
+      "dev": true,
+      "dependencies": {
+        "path-type": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/doctrine": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmmirror.com/doctrine/-/doctrine-3.0.0.tgz",
+      "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==",
+      "dev": true,
+      "dependencies": {
+        "esutils": "^2.0.2"
+      },
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/dot-prop": {
+      "version": "5.3.0",
+      "resolved": "https://registry.npmmirror.com/dot-prop/-/dot-prop-5.3.0.tgz",
+      "integrity": "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==",
+      "dev": true,
+      "dependencies": {
+        "is-obj": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/dunder-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+      "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "gopd": "^1.2.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/echarts": {
+      "version": "5.6.0",
+      "resolved": "https://registry.npmjs.org/echarts/-/echarts-5.6.0.tgz",
+      "integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "tslib": "2.3.0",
+        "zrender": "5.6.1"
+      }
+    },
+    "node_modules/echarts-wordcloud": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/echarts-wordcloud/-/echarts-wordcloud-2.1.0.tgz",
+      "integrity": "sha512-Kt1JmbcROgb+3IMI48KZECK2AP5lG6bSsOEs+AsuwaWJxQom31RTNd6NFYI01E/YaI1PFZeueaupjlmzSQasjQ==",
+      "license": "ISC",
+      "peerDependencies": {
+        "echarts": "^5.0.1"
+      }
+    },
+    "node_modules/electron-to-chromium": {
+      "version": "1.5.330",
+      "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.330.tgz",
+      "integrity": "sha512-jFNydB5kFtYUobh4IkWUnXeyDbjf/r9gcUEXe1xcrcUxIGfTdzPXA+ld6zBRbwvgIGVzDll/LTIiDztEtckSnA==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/emoji-regex": {
+      "version": "8.0.0",
+      "resolved": "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-8.0.0.tgz",
+      "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+      "dev": true
+    },
+    "node_modules/entities": {
+      "version": "4.5.0",
+      "resolved": "https://registry.npmmirror.com/entities/-/entities-4.5.0.tgz",
+      "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
+      "engines": {
+        "node": ">=0.12"
+      },
+      "funding": {
+        "url": "https://github.com/fb55/entities?sponsor=1"
+      }
+    },
+    "node_modules/error-ex": {
+      "version": "1.3.2",
+      "resolved": "https://registry.npmmirror.com/error-ex/-/error-ex-1.3.2.tgz",
+      "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==",
+      "dev": true,
+      "dependencies": {
+        "is-arrayish": "^0.2.1"
+      }
+    },
+    "node_modules/es-define-property": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+      "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-errors": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+      "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-object-atoms": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+      "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+      "dependencies": {
+        "es-errors": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/esbuild": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.21.5.tgz",
+      "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
+      "dev": true,
+      "hasInstallScript": true,
+      "bin": {
+        "esbuild": "bin/esbuild"
+      },
+      "engines": {
+        "node": ">=12"
+      },
+      "optionalDependencies": {
+        "@esbuild/aix-ppc64": "0.21.5",
+        "@esbuild/android-arm": "0.21.5",
+        "@esbuild/android-arm64": "0.21.5",
+        "@esbuild/android-x64": "0.21.5",
+        "@esbuild/darwin-arm64": "0.21.5",
+        "@esbuild/darwin-x64": "0.21.5",
+        "@esbuild/freebsd-arm64": "0.21.5",
+        "@esbuild/freebsd-x64": "0.21.5",
+        "@esbuild/linux-arm": "0.21.5",
+        "@esbuild/linux-arm64": "0.21.5",
+        "@esbuild/linux-ia32": "0.21.5",
+        "@esbuild/linux-loong64": "0.21.5",
+        "@esbuild/linux-mips64el": "0.21.5",
+        "@esbuild/linux-ppc64": "0.21.5",
+        "@esbuild/linux-riscv64": "0.21.5",
+        "@esbuild/linux-s390x": "0.21.5",
+        "@esbuild/linux-x64": "0.21.5",
+        "@esbuild/netbsd-x64": "0.21.5",
+        "@esbuild/openbsd-x64": "0.21.5",
+        "@esbuild/sunos-x64": "0.21.5",
+        "@esbuild/win32-arm64": "0.21.5",
+        "@esbuild/win32-ia32": "0.21.5",
+        "@esbuild/win32-x64": "0.21.5"
+      }
+    },
+    "node_modules/escalade": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+      "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/escape-string-regexp": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+      "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+      "dev": true,
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/eslint": {
+      "version": "8.56.0",
+      "resolved": "https://registry.npmmirror.com/eslint/-/eslint-8.56.0.tgz",
+      "integrity": "sha512-Go19xM6T9puCOWntie1/P997aXxFsOi37JIHRWI514Hc6ZnaHGKY9xFhrU65RT6CcBEzZoGG1e6Nq+DT04ZtZQ==",
+      "dev": true,
+      "dependencies": {
+        "@eslint-community/eslint-utils": "^4.2.0",
+        "@eslint-community/regexpp": "^4.6.1",
+        "@eslint/eslintrc": "^2.1.4",
+        "@eslint/js": "8.56.0",
+        "@humanwhocodes/config-array": "^0.11.13",
+        "@humanwhocodes/module-importer": "^1.0.1",
+        "@nodelib/fs.walk": "^1.2.8",
+        "@ungap/structured-clone": "^1.2.0",
+        "ajv": "^6.12.4",
+        "chalk": "^4.0.0",
+        "cross-spawn": "^7.0.2",
+        "debug": "^4.3.2",
+        "doctrine": "^3.0.0",
+        "escape-string-regexp": "^4.0.0",
+        "eslint-scope": "^7.2.2",
+        "eslint-visitor-keys": "^3.4.3",
+        "espree": "^9.6.1",
+        "esquery": "^1.4.2",
+        "esutils": "^2.0.2",
+        "fast-deep-equal": "^3.1.3",
+        "file-entry-cache": "^6.0.1",
+        "find-up": "^5.0.0",
+        "glob-parent": "^6.0.2",
+        "globals": "^13.19.0",
+        "graphemer": "^1.4.0",
+        "ignore": "^5.2.0",
+        "imurmurhash": "^0.1.4",
+        "is-glob": "^4.0.0",
+        "is-path-inside": "^3.0.3",
+        "js-yaml": "^4.1.0",
+        "json-stable-stringify-without-jsonify": "^1.0.1",
+        "levn": "^0.4.1",
+        "lodash.merge": "^4.6.2",
+        "minimatch": "^3.1.2",
+        "natural-compare": "^1.4.0",
+        "optionator": "^0.9.3",
+        "strip-ansi": "^6.0.1",
+        "text-table": "^0.2.0"
+      },
+      "bin": {
+        "eslint": "bin/eslint.js"
+      },
+      "engines": {
+        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+      }
+    },
+    "node_modules/eslint-plugin-vue": {
+      "version": "9.19.2",
+      "resolved": "https://registry.npmmirror.com/eslint-plugin-vue/-/eslint-plugin-vue-9.19.2.tgz",
+      "integrity": "sha512-CPDqTOG2K4Ni2o4J5wixkLVNwgctKXFu6oBpVJlpNq7f38lh9I80pRTouZSJ2MAebPJlINU/KTFSXyQfBUlymA==",
+      "dev": true,
+      "dependencies": {
+        "@eslint-community/eslint-utils": "^4.4.0",
+        "natural-compare": "^1.4.0",
+        "nth-check": "^2.1.1",
+        "postcss-selector-parser": "^6.0.13",
+        "semver": "^7.5.4",
+        "vue-eslint-parser": "^9.3.1",
+        "xml-name-validator": "^4.0.0"
+      },
+      "engines": {
+        "node": "^14.17.0 || >=16.0.0"
+      },
+      "peerDependencies": {
+        "eslint": "^6.2.0 || ^7.0.0 || ^8.0.0"
+      }
+    },
+    "node_modules/eslint-scope": {
+      "version": "7.2.2",
+      "resolved": "https://registry.npmmirror.com/eslint-scope/-/eslint-scope-7.2.2.tgz",
+      "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==",
+      "dev": true,
+      "dependencies": {
+        "esrecurse": "^4.3.0",
+        "estraverse": "^5.2.0"
+      },
+      "engines": {
+        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+      }
+    },
+    "node_modules/eslint-visitor-keys": {
+      "version": "3.4.3",
+      "resolved": "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
+      "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
+      "dev": true,
+      "engines": {
+        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+      }
+    },
+    "node_modules/eslint/node_modules/brace-expansion": {
+      "version": "1.1.11",
+      "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.11.tgz",
+      "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+      "dev": true,
+      "dependencies": {
+        "balanced-match": "^1.0.0",
+        "concat-map": "0.0.1"
+      }
+    },
+    "node_modules/eslint/node_modules/minimatch": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.2.tgz",
+      "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+      "dev": true,
+      "dependencies": {
+        "brace-expansion": "^1.1.7"
+      },
+      "engines": {
+        "node": "*"
+      }
+    },
+    "node_modules/espree": {
+      "version": "9.6.1",
+      "resolved": "https://registry.npmmirror.com/espree/-/espree-9.6.1.tgz",
+      "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==",
+      "dev": true,
+      "dependencies": {
+        "acorn": "^8.9.0",
+        "acorn-jsx": "^5.3.2",
+        "eslint-visitor-keys": "^3.4.1"
+      },
+      "engines": {
+        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+      }
+    },
+    "node_modules/esquery": {
+      "version": "1.5.0",
+      "resolved": "https://registry.npmmirror.com/esquery/-/esquery-1.5.0.tgz",
+      "integrity": "sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==",
+      "dev": true,
+      "dependencies": {
+        "estraverse": "^5.1.0"
+      },
+      "engines": {
+        "node": ">=0.10"
+      }
+    },
+    "node_modules/esrecurse": {
+      "version": "4.3.0",
+      "resolved": "https://registry.npmmirror.com/esrecurse/-/esrecurse-4.3.0.tgz",
+      "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
+      "dev": true,
+      "dependencies": {
+        "estraverse": "^5.2.0"
+      },
+      "engines": {
+        "node": ">=4.0"
+      }
+    },
+    "node_modules/estraverse": {
+      "version": "5.3.0",
+      "resolved": "https://registry.npmmirror.com/estraverse/-/estraverse-5.3.0.tgz",
+      "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
+      "dev": true,
+      "engines": {
+        "node": ">=4.0"
+      }
+    },
+    "node_modules/estree-walker": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz",
+      "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="
+    },
+    "node_modules/esutils": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmmirror.com/esutils/-/esutils-2.0.3.tgz",
+      "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
+      "dev": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/execa": {
+      "version": "5.1.1",
+      "resolved": "https://registry.npmmirror.com/execa/-/execa-5.1.1.tgz",
+      "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==",
+      "dev": true,
+      "dependencies": {
+        "cross-spawn": "^7.0.3",
+        "get-stream": "^6.0.0",
+        "human-signals": "^2.1.0",
+        "is-stream": "^2.0.0",
+        "merge-stream": "^2.0.0",
+        "npm-run-path": "^4.0.1",
+        "onetime": "^5.1.2",
+        "signal-exit": "^3.0.3",
+        "strip-final-newline": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/fast-deep-equal": {
+      "version": "3.1.3",
+      "resolved": "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+      "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+      "dev": true
+    },
+    "node_modules/fast-glob": {
+      "version": "3.3.2",
+      "resolved": "https://registry.npmmirror.com/fast-glob/-/fast-glob-3.3.2.tgz",
+      "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==",
+      "dev": true,
+      "dependencies": {
+        "@nodelib/fs.stat": "^2.0.2",
+        "@nodelib/fs.walk": "^1.2.3",
+        "glob-parent": "^5.1.2",
+        "merge2": "^1.3.0",
+        "micromatch": "^4.0.4"
+      },
+      "engines": {
+        "node": ">=8.6.0"
+      }
+    },
+    "node_modules/fast-glob/node_modules/glob-parent": {
+      "version": "5.1.2",
+      "resolved": "https://registry.npmmirror.com/glob-parent/-/glob-parent-5.1.2.tgz",
+      "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+      "dev": true,
+      "dependencies": {
+        "is-glob": "^4.0.1"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/fast-json-stable-stringify": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmmirror.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
+      "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
+      "dev": true
+    },
+    "node_modules/fast-levenshtein": {
+      "version": "2.0.6",
+      "resolved": "https://registry.npmmirror.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
+      "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
+      "dev": true
+    },
+    "node_modules/fastq": {
+      "version": "1.16.0",
+      "resolved": "https://registry.npmmirror.com/fastq/-/fastq-1.16.0.tgz",
+      "integrity": "sha512-ifCoaXsDrsdkWTtiNJX5uzHDsrck5TzfKKDcuFFTIrrc/BS076qgEIfoIy1VeZqViznfKiysPYTh/QeHtnIsYA==",
+      "dev": true,
+      "dependencies": {
+        "reusify": "^1.0.4"
+      }
+    },
+    "node_modules/file-entry-cache": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmmirror.com/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
+      "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==",
+      "dev": true,
+      "dependencies": {
+        "flat-cache": "^3.0.4"
+      },
+      "engines": {
+        "node": "^10.12.0 || >=12.0.0"
+      }
+    },
+    "node_modules/file-saver": {
+      "version": "2.0.5",
+      "resolved": "https://registry.npmmirror.com/file-saver/-/file-saver-2.0.5.tgz",
+      "integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA=="
+    },
+    "node_modules/fill-range": {
+      "version": "7.0.1",
+      "resolved": "https://registry.npmmirror.com/fill-range/-/fill-range-7.0.1.tgz",
+      "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==",
+      "dependencies": {
+        "to-regex-range": "^5.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/find-up": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmmirror.com/find-up/-/find-up-5.0.0.tgz",
+      "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
+      "dev": true,
+      "dependencies": {
+        "locate-path": "^6.0.0",
+        "path-exists": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/find-yarn-workspace-root": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmmirror.com/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz",
+      "integrity": "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "micromatch": "^4.0.2"
+      }
+    },
+    "node_modules/flat-cache": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmmirror.com/flat-cache/-/flat-cache-3.2.0.tgz",
+      "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==",
+      "dev": true,
+      "dependencies": {
+        "flatted": "^3.2.9",
+        "keyv": "^4.5.3",
+        "rimraf": "^3.0.2"
+      },
+      "engines": {
+        "node": "^10.12.0 || >=12.0.0"
+      }
+    },
+    "node_modules/flatted": {
+      "version": "3.2.9",
+      "resolved": "https://registry.npmmirror.com/flatted/-/flatted-3.2.9.tgz",
+      "integrity": "sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ==",
+      "dev": true
+    },
+    "node_modules/follow-redirects": {
+      "version": "1.15.9",
+      "resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.15.9.tgz",
+      "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==",
+      "funding": [
+        {
+          "type": "individual",
+          "url": "https://github.com/sponsors/RubenVerborgh"
+        }
+      ],
+      "engines": {
+        "node": ">=4.0"
+      },
+      "peerDependenciesMeta": {
+        "debug": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/form-data": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmmirror.com/form-data/-/form-data-4.0.1.tgz",
+      "integrity": "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==",
+      "dependencies": {
+        "asynckit": "^0.4.0",
+        "combined-stream": "^1.0.8",
+        "mime-types": "^2.1.12"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/fs-extra": {
+      "version": "11.2.0",
+      "resolved": "https://registry.npmmirror.com/fs-extra/-/fs-extra-11.2.0.tgz",
+      "integrity": "sha512-PmDi3uwK5nFuXh7XDTlVnS17xJS7vW36is2+w3xcv8SVxiB4NyATf4ctkVY5bkSjX0Y4nbvZCq1/EjtEyr9ktw==",
+      "dev": true,
+      "dependencies": {
+        "graceful-fs": "^4.2.0",
+        "jsonfile": "^6.0.1",
+        "universalify": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=14.14"
+      }
+    },
+    "node_modules/fs.realpath": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmmirror.com/fs.realpath/-/fs.realpath-1.0.0.tgz",
+      "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
+      "dev": true
+    },
+    "node_modules/fsevents": {
+      "version": "2.3.3",
+      "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz",
+      "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+      "dev": true,
+      "hasInstallScript": true,
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+      }
+    },
+    "node_modules/function-bind": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz",
+      "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="
+    },
+    "node_modules/gensync": {
+      "version": "1.0.0-beta.2",
+      "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
+      "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/get-caller-file": {
+      "version": "2.0.5",
+      "resolved": "https://registry.npmmirror.com/get-caller-file/-/get-caller-file-2.0.5.tgz",
+      "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
+      "dev": true,
+      "engines": {
+        "node": "6.* || 8.* || >= 10.*"
+      }
+    },
+    "node_modules/get-intrinsic": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+      "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "es-define-property": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "es-object-atoms": "^1.1.1",
+        "function-bind": "^1.1.2",
+        "get-proto": "^1.0.1",
+        "gopd": "^1.2.0",
+        "has-symbols": "^1.1.0",
+        "hasown": "^2.0.2",
+        "math-intrinsics": "^1.1.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+      "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+      "dependencies": {
+        "dunder-proto": "^1.0.1",
+        "es-object-atoms": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/get-stream": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmmirror.com/get-stream/-/get-stream-6.0.1.tgz",
+      "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==",
+      "dev": true,
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/git-raw-commits": {
+      "version": "2.0.11",
+      "resolved": "https://registry.npmmirror.com/git-raw-commits/-/git-raw-commits-2.0.11.tgz",
+      "integrity": "sha512-VnctFhw+xfj8Va1xtfEqCUD2XDrbAPSJx+hSrE5K7fGdjZruW7XV+QOrN7LF/RJyvspRiD2I0asWsxFp0ya26A==",
+      "dev": true,
+      "dependencies": {
+        "dargs": "^7.0.0",
+        "lodash": "^4.17.15",
+        "meow": "^8.0.0",
+        "split2": "^3.0.0",
+        "through2": "^4.0.0"
+      },
+      "bin": {
+        "git-raw-commits": "cli.js"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/git-raw-commits/node_modules/hosted-git-info": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmmirror.com/hosted-git-info/-/hosted-git-info-4.1.0.tgz",
+      "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==",
+      "dev": true,
+      "dependencies": {
+        "lru-cache": "^6.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/git-raw-commits/node_modules/lru-cache": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-6.0.0.tgz",
+      "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
+      "dev": true,
+      "dependencies": {
+        "yallist": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/git-raw-commits/node_modules/meow": {
+      "version": "8.1.2",
+      "resolved": "https://registry.npmmirror.com/meow/-/meow-8.1.2.tgz",
+      "integrity": "sha512-r85E3NdZ+mpYk1C6RjPFEMSE+s1iZMuHtsHAqY0DT3jZczl0diWUZ8g6oU7h0M9cD2EL+PzaYghhCLzR0ZNn5Q==",
+      "dev": true,
+      "dependencies": {
+        "@types/minimist": "^1.2.0",
+        "camelcase-keys": "^6.2.2",
+        "decamelize-keys": "^1.1.0",
+        "hard-rejection": "^2.1.0",
+        "minimist-options": "4.1.0",
+        "normalize-package-data": "^3.0.0",
+        "read-pkg-up": "^7.0.1",
+        "redent": "^3.0.0",
+        "trim-newlines": "^3.0.0",
+        "type-fest": "^0.18.0",
+        "yargs-parser": "^20.2.3"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/git-raw-commits/node_modules/normalize-package-data": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmmirror.com/normalize-package-data/-/normalize-package-data-3.0.3.tgz",
+      "integrity": "sha512-p2W1sgqij3zMMyRC067Dg16bfzVH+w7hyegmpIvZ4JNjqtGOVAIvLmjBx3yP7YTe9vKJgkoNOPjwQGogDoMXFA==",
+      "dev": true,
+      "dependencies": {
+        "hosted-git-info": "^4.0.1",
+        "is-core-module": "^2.5.0",
+        "semver": "^7.3.4",
+        "validate-npm-package-license": "^3.0.1"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/git-raw-commits/node_modules/readable-stream": {
+      "version": "3.6.2",
+      "resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-3.6.2.tgz",
+      "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
+      "dev": true,
+      "dependencies": {
+        "inherits": "^2.0.3",
+        "string_decoder": "^1.1.1",
+        "util-deprecate": "^1.0.1"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/git-raw-commits/node_modules/split2": {
+      "version": "3.2.2",
+      "resolved": "https://registry.npmmirror.com/split2/-/split2-3.2.2.tgz",
+      "integrity": "sha512-9NThjpgZnifTkJpzTZ7Eue85S49QwpNhZTq6GRJwObb6jnLFNGB7Qm73V5HewTROPyxD0C29xqmaI68bQtV+hg==",
+      "dev": true,
+      "dependencies": {
+        "readable-stream": "^3.0.0"
+      }
+    },
+    "node_modules/git-raw-commits/node_modules/through2": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmmirror.com/through2/-/through2-4.0.2.tgz",
+      "integrity": "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==",
+      "dev": true,
+      "dependencies": {
+        "readable-stream": "3"
+      }
+    },
+    "node_modules/git-raw-commits/node_modules/type-fest": {
+      "version": "0.18.1",
+      "resolved": "https://registry.npmmirror.com/type-fest/-/type-fest-0.18.1.tgz",
+      "integrity": "sha512-OIAYXk8+ISY+qTOwkHtKqzAuxchoMiD9Udx+FSGQDuiRR+PJKJHc2NJAXlbhkGwTt/4/nKZxELY1w3ReWOL8mw==",
+      "dev": true,
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/git-raw-commits/node_modules/yargs-parser": {
+      "version": "20.2.9",
+      "resolved": "https://registry.npmmirror.com/yargs-parser/-/yargs-parser-20.2.9.tgz",
+      "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==",
+      "dev": true,
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/glob": {
+      "version": "7.2.3",
+      "resolved": "https://registry.npmmirror.com/glob/-/glob-7.2.3.tgz",
+      "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
+      "dev": true,
+      "dependencies": {
+        "fs.realpath": "^1.0.0",
+        "inflight": "^1.0.4",
+        "inherits": "2",
+        "minimatch": "^3.1.1",
+        "once": "^1.3.0",
+        "path-is-absolute": "^1.0.0"
+      },
+      "engines": {
+        "node": "*"
+      }
+    },
+    "node_modules/glob-parent": {
+      "version": "6.0.2",
+      "resolved": "https://registry.npmmirror.com/glob-parent/-/glob-parent-6.0.2.tgz",
+      "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
+      "dev": true,
+      "dependencies": {
+        "is-glob": "^4.0.3"
+      },
+      "engines": {
+        "node": ">=10.13.0"
+      }
+    },
+    "node_modules/glob/node_modules/brace-expansion": {
+      "version": "1.1.11",
+      "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.11.tgz",
+      "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+      "dev": true,
+      "dependencies": {
+        "balanced-match": "^1.0.0",
+        "concat-map": "0.0.1"
+      }
+    },
+    "node_modules/glob/node_modules/minimatch": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.2.tgz",
+      "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+      "dev": true,
+      "dependencies": {
+        "brace-expansion": "^1.1.7"
+      },
+      "engines": {
+        "node": "*"
+      }
+    },
+    "node_modules/global-dirs": {
+      "version": "0.1.1",
+      "resolved": "https://registry.npmmirror.com/global-dirs/-/global-dirs-0.1.1.tgz",
+      "integrity": "sha512-NknMLn7F2J7aflwFOlGdNIuCDpN3VGoSoB+aap3KABFWbHVn1TCgFC+np23J8W2BiZbjfEw3BFBycSMv1AFblg==",
+      "dev": true,
+      "dependencies": {
+        "ini": "^1.3.4"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/globals": {
+      "version": "13.24.0",
+      "resolved": "https://registry.npmmirror.com/globals/-/globals-13.24.0.tgz",
+      "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==",
+      "dev": true,
+      "dependencies": {
+        "type-fest": "^0.20.2"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/globby": {
+      "version": "11.1.0",
+      "resolved": "https://registry.npmmirror.com/globby/-/globby-11.1.0.tgz",
+      "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==",
+      "dev": true,
+      "dependencies": {
+        "array-union": "^2.1.0",
+        "dir-glob": "^3.0.1",
+        "fast-glob": "^3.2.9",
+        "ignore": "^5.2.0",
+        "merge2": "^1.4.1",
+        "slash": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/good-listener": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmmirror.com/good-listener/-/good-listener-1.2.2.tgz",
+      "integrity": "sha512-goW1b+d9q/HIwbVYZzZ6SsTr4IgE+WA44A0GmPIQstuOrgsFcT7VEJ48nmr9GaRtNu0XTKacFLGnBPAM6Afouw==",
+      "dependencies": {
+        "delegate": "^3.1.2"
+      }
+    },
+    "node_modules/gopd": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+      "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/graceful-fs": {
+      "version": "4.2.11",
+      "resolved": "https://registry.npmmirror.com/graceful-fs/-/graceful-fs-4.2.11.tgz",
+      "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="
+    },
+    "node_modules/graphemer": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmmirror.com/graphemer/-/graphemer-1.4.0.tgz",
+      "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==",
+      "dev": true
+    },
+    "node_modules/hard-rejection": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmmirror.com/hard-rejection/-/hard-rejection-2.1.0.tgz",
+      "integrity": "sha512-VIZB+ibDhx7ObhAe7OVtoEbuP4h/MuOTHJ+J8h/eBXotJYl0fBgR72xDFCKgIh22OJZIOVNxBMWuhAr10r8HdA==",
+      "dev": true,
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/has-flag": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmmirror.com/has-flag/-/has-flag-4.0.0.tgz",
+      "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/has-property-descriptors": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmmirror.com/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
+      "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
+      "license": "MIT",
+      "dependencies": {
+        "es-define-property": "^1.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/has-symbols": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+      "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/hasown": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
+      "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
+      "dependencies": {
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/he": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmmirror.com/he/-/he-1.2.0.tgz",
+      "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
+      "dev": true,
+      "bin": {
+        "he": "bin/he"
+      }
+    },
+    "node_modules/hfmath": {
+      "version": "0.0.2",
+      "resolved": "https://registry.npmmirror.com/hfmath/-/hfmath-0.0.2.tgz",
+      "integrity": "sha512-cKUi0yiQLGfLgs8+3Iw5nAiqSH13Knp7vCf0G1vlF5nfiKKO1XmxNagMvyp0F4ZvUNaHpRGTkmc7nowCy1S58g=="
+    },
+    "node_modules/hookable": {
+      "version": "5.5.3",
+      "resolved": "https://registry.npmmirror.com/hookable/-/hookable-5.5.3.tgz",
+      "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="
+    },
+    "node_modules/hosted-git-info": {
+      "version": "7.0.1",
+      "resolved": "https://registry.npmmirror.com/hosted-git-info/-/hosted-git-info-7.0.1.tgz",
+      "integrity": "sha512-+K84LB1DYwMHoHSgaOY/Jfhw3ucPmSET5v98Ke/HdNSw4a0UktWzyW1mjhjpuxxTqOOsfWT/7iVshHmVZ4IpOA==",
+      "dev": true,
+      "dependencies": {
+        "lru-cache": "^10.0.1"
+      },
+      "engines": {
+        "node": "^16.14.0 || >=18.0.0"
+      }
+    },
+    "node_modules/html-to-image": {
+      "version": "1.11.13",
+      "resolved": "https://registry.npmmirror.com/html-to-image/-/html-to-image-1.11.13.tgz",
+      "integrity": "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg=="
+    },
+    "node_modules/html2canvas": {
+      "version": "1.4.1",
+      "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
+      "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
+      "dependencies": {
+        "css-line-break": "^2.1.0",
+        "text-segmentation": "^1.0.3"
+      },
+      "engines": {
+        "node": ">=8.0.0"
+      }
+    },
+    "node_modules/https": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmmirror.com/https/-/https-1.0.0.tgz",
+      "integrity": "sha512-4EC57ddXrkaF0x83Oj8sM6SLQHAWXw90Skqu2M4AEWENZ3F02dFJE/GARA8igO79tcgYqGrD7ae4f5L3um2lgg=="
+    },
+    "node_modules/human-signals": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmmirror.com/human-signals/-/human-signals-2.1.0.tgz",
+      "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==",
+      "dev": true,
+      "engines": {
+        "node": ">=10.17.0"
+      }
+    },
+    "node_modules/husky": {
+      "version": "8.0.3",
+      "resolved": "https://registry.npmmirror.com/husky/-/husky-8.0.3.tgz",
+      "integrity": "sha512-+dQSyqPh4x1hlO1swXBiNb2HzTDN1I2IGLQx1GrBuiqFJfoMrnZWwVmatvSiO+Iz8fBUnf+lekwNo4c2LlXItg==",
+      "dev": true,
+      "bin": {
+        "husky": "lib/bin.js"
+      },
+      "engines": {
+        "node": ">=14"
+      }
+    },
+    "node_modules/ignore": {
+      "version": "5.3.0",
+      "resolved": "https://registry.npmmirror.com/ignore/-/ignore-5.3.0.tgz",
+      "integrity": "sha512-g7dmpshy+gD7mh88OC9NwSGTKoc3kyLAZQRU1mt53Aw/vnvfXnbC+F/7F7QoYVKbV+KNvJx8wArewKy1vXMtlg==",
+      "dev": true,
+      "engines": {
+        "node": ">= 4"
+      }
+    },
+    "node_modules/image-size": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmmirror.com/image-size/-/image-size-1.1.0.tgz",
+      "integrity": "sha512-asnTHw2K8OlqT5kVnQwX+AGKQqpvLo95LbNzQ/C0ln3yzentZmAdd0ygoD004VC4Kkd4PV7J2iaPQkqwp9yuTw==",
+      "dependencies": {
+        "queue": "6.0.2"
+      },
+      "bin": {
+        "image-size": "bin/image-size.js"
+      },
+      "engines": {
+        "node": ">=18.0.0"
+      }
+    },
+    "node_modules/immediate": {
+      "version": "3.0.6",
+      "resolved": "https://registry.npmmirror.com/immediate/-/immediate-3.0.6.tgz",
+      "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="
+    },
+    "node_modules/immutable": {
+      "version": "4.3.7",
+      "resolved": "https://registry.npmmirror.com/immutable/-/immutable-4.3.7.tgz",
+      "integrity": "sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw==",
+      "dev": true
+    },
+    "node_modules/import-fresh": {
+      "version": "3.3.0",
+      "resolved": "https://registry.npmmirror.com/import-fresh/-/import-fresh-3.3.0.tgz",
+      "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==",
+      "dev": true,
+      "dependencies": {
+        "parent-module": "^1.0.0",
+        "resolve-from": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/imurmurhash": {
+      "version": "0.1.4",
+      "resolved": "https://registry.npmmirror.com/imurmurhash/-/imurmurhash-0.1.4.tgz",
+      "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
+      "dev": true,
+      "engines": {
+        "node": ">=0.8.19"
+      }
+    },
+    "node_modules/indent-string": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmmirror.com/indent-string/-/indent-string-4.0.0.tgz",
+      "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/inflight": {
+      "version": "1.0.6",
+      "resolved": "https://registry.npmmirror.com/inflight/-/inflight-1.0.6.tgz",
+      "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
+      "dev": true,
+      "dependencies": {
+        "once": "^1.3.0",
+        "wrappy": "1"
+      }
+    },
+    "node_modules/inherits": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz",
+      "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
+    },
+    "node_modules/ini": {
+      "version": "1.3.8",
+      "resolved": "https://registry.npmmirror.com/ini/-/ini-1.3.8.tgz",
+      "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
+      "dev": true
+    },
+    "node_modules/is-arrayish": {
+      "version": "0.2.1",
+      "resolved": "https://registry.npmmirror.com/is-arrayish/-/is-arrayish-0.2.1.tgz",
+      "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==",
+      "dev": true
+    },
+    "node_modules/is-binary-path": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmmirror.com/is-binary-path/-/is-binary-path-2.1.0.tgz",
+      "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
+      "dev": true,
+      "dependencies": {
+        "binary-extensions": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/is-core-module": {
+      "version": "2.13.1",
+      "resolved": "https://registry.npmmirror.com/is-core-module/-/is-core-module-2.13.1.tgz",
+      "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==",
+      "dev": true,
+      "dependencies": {
+        "hasown": "^2.0.0"
+      }
+    },
+    "node_modules/is-docker": {
+      "version": "2.2.1",
+      "resolved": "https://registry.npmmirror.com/is-docker/-/is-docker-2.2.1.tgz",
+      "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==",
+      "license": "MIT",
+      "bin": {
+        "is-docker": "cli.js"
+      },
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/is-extglob": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz",
+      "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+      "dev": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/is-fullwidth-code-point": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+      "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/is-glob": {
+      "version": "4.0.3",
+      "resolved": "https://registry.npmmirror.com/is-glob/-/is-glob-4.0.3.tgz",
+      "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+      "dev": true,
+      "dependencies": {
+        "is-extglob": "^2.1.1"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/is-number": {
+      "version": "7.0.0",
+      "resolved": "https://registry.npmmirror.com/is-number/-/is-number-7.0.0.tgz",
+      "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+      "engines": {
+        "node": ">=0.12.0"
+      }
+    },
+    "node_modules/is-obj": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmmirror.com/is-obj/-/is-obj-2.0.0.tgz",
+      "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/is-path-inside": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmmirror.com/is-path-inside/-/is-path-inside-3.0.3.tgz",
+      "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/is-plain-obj": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmmirror.com/is-plain-obj/-/is-plain-obj-1.1.0.tgz",
+      "integrity": "sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg==",
+      "dev": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/is-stream": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmmirror.com/is-stream/-/is-stream-2.0.1.tgz",
+      "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/is-text-path": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmmirror.com/is-text-path/-/is-text-path-2.0.0.tgz",
+      "integrity": "sha512-+oDTluR6WEjdXEJMnC2z6A4FRwFoYuvShVVEGsS7ewc0UTi2QtAKMDJuL4BDEVt+5T7MjFo12RP8ghOM75oKJw==",
+      "dev": true,
+      "dependencies": {
+        "text-extensions": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/is-what": {
+      "version": "4.1.16",
+      "resolved": "https://registry.npmmirror.com/is-what/-/is-what-4.1.16.tgz",
+      "integrity": "sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==",
+      "engines": {
+        "node": ">=12.13"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/mesqueeb"
+      }
+    },
+    "node_modules/is-wsl": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmmirror.com/is-wsl/-/is-wsl-2.2.0.tgz",
+      "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==",
+      "license": "MIT",
+      "dependencies": {
+        "is-docker": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/isarray": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmmirror.com/isarray/-/isarray-1.0.0.tgz",
+      "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="
+    },
+    "node_modules/isexe": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz",
+      "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="
+    },
+    "node_modules/isomorphic.js": {
+      "version": "0.2.5",
+      "resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz",
+      "integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==",
+      "funding": {
+        "type": "GitHub Sponsors ❤",
+        "url": "https://github.com/sponsors/dmonad"
+      }
+    },
+    "node_modules/jiti": {
+      "version": "1.21.0",
+      "resolved": "https://registry.npmmirror.com/jiti/-/jiti-1.21.0.tgz",
+      "integrity": "sha512-gFqAIbuKyyso/3G2qhiO2OM6shY6EPP/R0+mkDbyspxKazh8BXDC5FiFsUjlczgdNz/vfra0da2y+aHrusLG/Q==",
+      "dev": true,
+      "bin": {
+        "jiti": "bin/jiti.js"
+      }
+    },
+    "node_modules/js-tokens": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+      "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/js-yaml": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmmirror.com/js-yaml/-/js-yaml-4.1.0.tgz",
+      "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
+      "dev": true,
+      "dependencies": {
+        "argparse": "^2.0.1"
+      },
+      "bin": {
+        "js-yaml": "bin/js-yaml.js"
+      }
+    },
+    "node_modules/jsesc": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
+      "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
+      "dev": true,
+      "license": "MIT",
+      "bin": {
+        "jsesc": "bin/jsesc"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/json-buffer": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmmirror.com/json-buffer/-/json-buffer-3.0.1.tgz",
+      "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
+      "dev": true
+    },
+    "node_modules/json-parse-even-better-errors": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmmirror.com/json-parse-even-better-errors/-/json-parse-even-better-errors-3.0.1.tgz",
+      "integrity": "sha512-aatBvbL26wVUCLmbWdCpeu9iF5wOyWpagiKkInA+kfws3sWdBrTnsvN2CKcyCYyUrc7rebNBlK6+kteg7ksecg==",
+      "dev": true,
+      "engines": {
+        "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+      }
+    },
+    "node_modules/json-schema-traverse": {
+      "version": "0.4.1",
+      "resolved": "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
+      "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
+      "dev": true
+    },
+    "node_modules/json-stable-stringify": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmmirror.com/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz",
+      "integrity": "sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind": "^1.0.8",
+        "call-bound": "^1.0.4",
+        "isarray": "^2.0.5",
+        "jsonify": "^0.0.1",
+        "object-keys": "^1.1.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/json-stable-stringify-without-jsonify": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmmirror.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
+      "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
+      "dev": true
+    },
+    "node_modules/json-stable-stringify/node_modules/isarray": {
+      "version": "2.0.5",
+      "resolved": "https://registry.npmmirror.com/isarray/-/isarray-2.0.5.tgz",
+      "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==",
+      "license": "MIT"
+    },
+    "node_modules/json5": {
+      "version": "2.2.3",
+      "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
+      "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
+      "dev": true,
+      "license": "MIT",
+      "bin": {
+        "json5": "lib/cli.js"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/jsonfile": {
+      "version": "6.1.0",
+      "resolved": "https://registry.npmmirror.com/jsonfile/-/jsonfile-6.1.0.tgz",
+      "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==",
+      "dependencies": {
+        "universalify": "^2.0.0"
+      },
+      "optionalDependencies": {
+        "graceful-fs": "^4.1.6"
+      }
+    },
+    "node_modules/jsonify": {
+      "version": "0.0.1",
+      "resolved": "https://registry.npmmirror.com/jsonify/-/jsonify-0.0.1.tgz",
+      "integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==",
+      "license": "Public Domain",
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/jsonparse": {
+      "version": "1.3.1",
+      "resolved": "https://registry.npmmirror.com/jsonparse/-/jsonparse-1.3.1.tgz",
+      "integrity": "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==",
+      "dev": true,
+      "engines": [
+        "node >= 0.2.0"
+      ]
+    },
+    "node_modules/JSONStream": {
+      "version": "1.3.5",
+      "resolved": "https://registry.npmmirror.com/JSONStream/-/JSONStream-1.3.5.tgz",
+      "integrity": "sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==",
+      "dev": true,
+      "dependencies": {
+        "jsonparse": "^1.2.0",
+        "through": ">=2.2.7 <3"
+      },
+      "bin": {
+        "JSONStream": "bin.js"
+      },
+      "engines": {
+        "node": "*"
+      }
+    },
+    "node_modules/jszip": {
+      "version": "3.10.1",
+      "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
+      "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
+      "license": "(MIT OR GPL-3.0-or-later)",
+      "dependencies": {
+        "lie": "~3.3.0",
+        "pako": "~1.0.2",
+        "readable-stream": "~2.3.6",
+        "setimmediate": "^1.0.5"
+      }
+    },
+    "node_modules/katex": {
+      "version": "0.16.22",
+      "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.22.tgz",
+      "integrity": "sha512-XCHRdUw4lf3SKBaJe4EvgqIuWwkPSo9XoeO8GjQW94Bp7TWv9hNhzZjZ+OH9yf1UmLygb7DIT5GSFQiyt16zYg==",
+      "funding": [
+        "https://opencollective.com/katex",
+        "https://github.com/sponsors/katex"
+      ],
+      "dependencies": {
+        "commander": "^8.3.0"
+      },
+      "bin": {
+        "katex": "cli.js"
+      }
+    },
+    "node_modules/keyv": {
+      "version": "4.5.4",
+      "resolved": "https://registry.npmmirror.com/keyv/-/keyv-4.5.4.tgz",
+      "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
+      "dev": true,
+      "dependencies": {
+        "json-buffer": "3.0.1"
+      }
+    },
+    "node_modules/kind-of": {
+      "version": "6.0.3",
+      "resolved": "https://registry.npmmirror.com/kind-of/-/kind-of-6.0.3.tgz",
+      "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==",
+      "dev": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/klaw-sync": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmmirror.com/klaw-sync/-/klaw-sync-6.0.0.tgz",
+      "integrity": "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ==",
+      "license": "MIT",
+      "dependencies": {
+        "graceful-fs": "^4.1.11"
+      }
+    },
+    "node_modules/kolorist": {
+      "version": "1.8.0",
+      "resolved": "https://registry.npmjs.org/kolorist/-/kolorist-1.8.0.tgz",
+      "integrity": "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/levn": {
+      "version": "0.4.1",
+      "resolved": "https://registry.npmmirror.com/levn/-/levn-0.4.1.tgz",
+      "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
+      "dev": true,
+      "dependencies": {
+        "prelude-ls": "^1.2.1",
+        "type-check": "~0.4.0"
+      },
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/lib0": {
+      "version": "0.2.114",
+      "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.114.tgz",
+      "integrity": "sha512-gcxmNFzA4hv8UYi8j43uPlQ7CGcyMJ2KQb5kZASw6SnAKAf10hK12i2fjrS3Cl/ugZa5Ui6WwIu1/6MIXiHttQ==",
+      "dependencies": {
+        "isomorphic.js": "^0.2.4"
+      },
+      "bin": {
+        "0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js",
+        "0gentesthtml": "bin/gentesthtml.js",
+        "0serve": "bin/0serve.js"
+      },
+      "engines": {
+        "node": ">=16"
+      },
+      "funding": {
+        "type": "GitHub Sponsors ❤",
+        "url": "https://github.com/sponsors/dmonad"
+      }
+    },
+    "node_modules/lie": {
+      "version": "3.3.0",
+      "resolved": "https://registry.npmmirror.com/lie/-/lie-3.3.0.tgz",
+      "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
+      "dependencies": {
+        "immediate": "~3.0.5"
+      }
+    },
+    "node_modules/lines-and-columns": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmmirror.com/lines-and-columns/-/lines-and-columns-2.0.4.tgz",
+      "integrity": "sha512-wM1+Z03eypVAVUCE7QdSqpVIvelbOakn1M0bPDoA4SGWPx3sNDVUiMo3L6To6WWGClB7VyXnhQ4Sn7gxiJbE6A==",
+      "dev": true,
+      "engines": {
+        "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+      }
+    },
+    "node_modules/linkify-it": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz",
+      "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==",
+      "dependencies": {
+        "uc.micro": "^2.0.0"
+      }
+    },
+    "node_modules/locate-path": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmmirror.com/locate-path/-/locate-path-6.0.0.tgz",
+      "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
+      "dev": true,
+      "dependencies": {
+        "p-locate": "^5.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/lodash": {
+      "version": "4.17.21",
+      "resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.17.21.tgz",
+      "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
+    },
+    "node_modules/lodash.camelcase": {
+      "version": "4.3.0",
+      "resolved": "https://registry.npmmirror.com/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz",
+      "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==",
+      "dev": true
+    },
+    "node_modules/lodash.isfunction": {
+      "version": "3.0.9",
+      "resolved": "https://registry.npmmirror.com/lodash.isfunction/-/lodash.isfunction-3.0.9.tgz",
+      "integrity": "sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw==",
+      "dev": true
+    },
+    "node_modules/lodash.isplainobject": {
+      "version": "4.0.6",
+      "resolved": "https://registry.npmmirror.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
+      "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
+      "dev": true
+    },
+    "node_modules/lodash.kebabcase": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmmirror.com/lodash.kebabcase/-/lodash.kebabcase-4.1.1.tgz",
+      "integrity": "sha512-N8XRTIMMqqDgSy4VLKPnJ/+hpGZN+PHQiJnSenYqPaVV/NCqEogTnAdZLQiGKhxX+JCs8waWq2t1XHWKOmlY8g==",
+      "dev": true
+    },
+    "node_modules/lodash.merge": {
+      "version": "4.6.2",
+      "resolved": "https://registry.npmmirror.com/lodash.merge/-/lodash.merge-4.6.2.tgz",
+      "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
+      "dev": true
+    },
+    "node_modules/lodash.mergewith": {
+      "version": "4.6.2",
+      "resolved": "https://registry.npmmirror.com/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz",
+      "integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==",
+      "dev": true
+    },
+    "node_modules/lodash.snakecase": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmmirror.com/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz",
+      "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==",
+      "dev": true
+    },
+    "node_modules/lodash.startcase": {
+      "version": "4.4.0",
+      "resolved": "https://registry.npmmirror.com/lodash.startcase/-/lodash.startcase-4.4.0.tgz",
+      "integrity": "sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==",
+      "dev": true
+    },
+    "node_modules/lodash.uniq": {
+      "version": "4.5.0",
+      "resolved": "https://registry.npmmirror.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz",
+      "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==",
+      "dev": true
+    },
+    "node_modules/lodash.upperfirst": {
+      "version": "4.3.1",
+      "resolved": "https://registry.npmmirror.com/lodash.upperfirst/-/lodash.upperfirst-4.3.1.tgz",
+      "integrity": "sha512-sReKOYJIJf74dhJONhU4e0/shzi1trVbSWDOhKYE5XV2O+H7Sb2Dihwuc7xWxVl+DgFPyTqIN3zMfT9cq5iWDg==",
+      "dev": true
+    },
+    "node_modules/lru-cache": {
+      "version": "10.1.0",
+      "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-10.1.0.tgz",
+      "integrity": "sha512-/1clY/ui8CzjKFyjdvwPWJUYKiFVXG2I2cY0ssG7h4+hwk+XOIX7ZSG9Q7TW8TW3Kp3BUSqgFWBLgL4PJ+Blag==",
+      "dev": true,
+      "engines": {
+        "node": "14 || >=16.14"
+      }
+    },
+    "node_modules/magic-string": {
+      "version": "0.30.21",
+      "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+      "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/sourcemap-codec": "^1.5.5"
+      }
+    },
+    "node_modules/map-obj": {
+      "version": "4.3.0",
+      "resolved": "https://registry.npmmirror.com/map-obj/-/map-obj-4.3.0.tgz",
+      "integrity": "sha512-hdN1wVrZbb29eBGiGjJbeP8JbKjq1urkHJ/LIP/NY48MZ1QVXUsQBV1G1zvYFHn1XE06cwjBsOI2K3Ulnj1YXQ==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/markdown-it": {
+      "version": "14.1.1",
+      "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz",
+      "integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==",
+      "dependencies": {
+        "argparse": "^2.0.1",
+        "entities": "^4.4.0",
+        "linkify-it": "^5.0.0",
+        "mdurl": "^2.0.0",
+        "punycode.js": "^2.3.1",
+        "uc.micro": "^2.1.0"
+      },
+      "bin": {
+        "markdown-it": "bin/markdown-it.mjs"
+      }
+    },
+    "node_modules/math-intrinsics": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+      "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/mdurl": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz",
+      "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w=="
+    },
+    "node_modules/memorystream": {
+      "version": "0.3.1",
+      "resolved": "https://registry.npmmirror.com/memorystream/-/memorystream-0.3.1.tgz",
+      "integrity": "sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==",
+      "dev": true,
+      "engines": {
+        "node": ">= 0.10.0"
+      }
+    },
+    "node_modules/meow": {
+      "version": "12.1.1",
+      "resolved": "https://registry.npmmirror.com/meow/-/meow-12.1.1.tgz",
+      "integrity": "sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw==",
+      "dev": true,
+      "engines": {
+        "node": ">=16.10"
+      }
+    },
+    "node_modules/merge-stream": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmmirror.com/merge-stream/-/merge-stream-2.0.0.tgz",
+      "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==",
+      "dev": true
+    },
+    "node_modules/merge2": {
+      "version": "1.4.1",
+      "resolved": "https://registry.npmmirror.com/merge2/-/merge2-1.4.1.tgz",
+      "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
+      "dev": true,
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/micromatch": {
+      "version": "4.0.5",
+      "resolved": "https://registry.npmmirror.com/micromatch/-/micromatch-4.0.5.tgz",
+      "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==",
+      "dependencies": {
+        "braces": "^3.0.2",
+        "picomatch": "^2.3.1"
+      },
+      "engines": {
+        "node": ">=8.6"
+      }
+    },
+    "node_modules/mime-db": {
+      "version": "1.52.0",
+      "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz",
+      "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mime-types": {
+      "version": "2.1.35",
+      "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz",
+      "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+      "dependencies": {
+        "mime-db": "1.52.0"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mimic-fn": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmmirror.com/mimic-fn/-/mimic-fn-2.1.0.tgz",
+      "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==",
+      "dev": true,
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/min-indent": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmmirror.com/min-indent/-/min-indent-1.0.1.tgz",
+      "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==",
+      "dev": true,
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/minimatch": {
+      "version": "9.0.3",
+      "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.3.tgz",
+      "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==",
+      "dev": true,
+      "dependencies": {
+        "brace-expansion": "^2.0.1"
+      },
+      "engines": {
+        "node": ">=16 || 14 >=14.17"
+      }
+    },
+    "node_modules/minimist": {
+      "version": "1.2.8",
+      "resolved": "https://registry.npmmirror.com/minimist/-/minimist-1.2.8.tgz",
+      "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="
+    },
+    "node_modules/minimist-options": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmmirror.com/minimist-options/-/minimist-options-4.1.0.tgz",
+      "integrity": "sha512-Q4r8ghd80yhO/0j1O3B2BjweX3fiHg9cdOwjJd2J76Q135c+NDxGCqdYKQ1SKBuFfgWbAUzBfvYjPUEeNgqN1A==",
+      "dev": true,
+      "dependencies": {
+        "arrify": "^1.0.1",
+        "is-plain-obj": "^1.1.0",
+        "kind-of": "^6.0.3"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/mitt": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmmirror.com/mitt/-/mitt-3.0.1.tgz",
+      "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="
+    },
+    "node_modules/mp4-muxer": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmmirror.com/mp4-muxer/-/mp4-muxer-2.3.0.tgz",
+      "integrity": "sha512-eupg1QMfcVJy/nZ2smUbC4OcEKdKBaLxKe+oeoDlklB5Dkb+4mv4CdPVYtzo1ZnzBKuVCclDk/znQwFQJIC32g==",
+      "deprecated": "This library is superseded by Mediabunny. Please migrate to it.",
+      "license": "MIT",
+      "dependencies": {
+        "@types/dom-webcodecs": "^0.1.6",
+        "@types/wicg-file-system-access": "^2020.9.5"
+      }
+    },
+    "node_modules/ms": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.2.tgz",
+      "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
+      "dev": true
+    },
+    "node_modules/muggle-string": {
+      "version": "0.4.1",
+      "resolved": "https://registry.npmmirror.com/muggle-string/-/muggle-string-0.4.1.tgz",
+      "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==",
+      "dev": true
+    },
+    "node_modules/nanoid": {
+      "version": "5.0.7",
+      "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-5.0.7.tgz",
+      "integrity": "sha512-oLxFY2gd2IqnjcYyOXD8XGCftpGtZP2AbHbOkthDkvRywH5ayNtPVy9YlOPcHckXzbLTCHpkb7FB+yuxKV13pQ==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "bin": {
+        "nanoid": "bin/nanoid.js"
+      },
+      "engines": {
+        "node": "^18 || >=20"
+      }
+    },
+    "node_modules/natural-compare": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmmirror.com/natural-compare/-/natural-compare-1.4.0.tgz",
+      "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
+      "dev": true
+    },
+    "node_modules/node-releases": {
+      "version": "2.0.36",
+      "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz",
+      "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/normalize-package-data": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmmirror.com/normalize-package-data/-/normalize-package-data-6.0.0.tgz",
+      "integrity": "sha512-UL7ELRVxYBHBgYEtZCXjxuD5vPxnmvMGq0jp/dGPKKrN7tfsBh2IY7TlJ15WWwdjRWD3RJbnsygUurTK3xkPkg==",
+      "dev": true,
+      "dependencies": {
+        "hosted-git-info": "^7.0.0",
+        "is-core-module": "^2.8.1",
+        "semver": "^7.3.5",
+        "validate-npm-package-license": "^3.0.4"
+      },
+      "engines": {
+        "node": "^16.14.0 || >=18.0.0"
+      }
+    },
+    "node_modules/normalize-path": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmmirror.com/normalize-path/-/normalize-path-3.0.0.tgz",
+      "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
+      "dev": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/npm-run-all2": {
+      "version": "6.1.1",
+      "resolved": "https://registry.npmmirror.com/npm-run-all2/-/npm-run-all2-6.1.1.tgz",
+      "integrity": "sha512-lWLbkPZ5BSdXtN8lR+0rc8caKoPdymycpZksyDEC9MOBvfdwTXZ0uVhb7bMcGeXv2/BKtfQuo6Zn3zfc8rxNXA==",
+      "dev": true,
+      "dependencies": {
+        "ansi-styles": "^6.2.1",
+        "cross-spawn": "^7.0.3",
+        "memorystream": "^0.3.1",
+        "minimatch": "^9.0.0",
+        "pidtree": "^0.6.0",
+        "read-pkg": "^8.0.0",
+        "shell-quote": "^1.7.3"
+      },
+      "bin": {
+        "npm-run-all": "bin/npm-run-all/index.js",
+        "npm-run-all2": "bin/npm-run-all/index.js",
+        "run-p": "bin/run-p/index.js",
+        "run-s": "bin/run-s/index.js"
+      },
+      "engines": {
+        "node": "^14.18.0 || >=16.0.0",
+        "npm": ">= 8"
+      }
+    },
+    "node_modules/npm-run-all2/node_modules/ansi-styles": {
+      "version": "6.2.1",
+      "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-6.2.1.tgz",
+      "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==",
+      "dev": true,
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/npm-run-path": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmmirror.com/npm-run-path/-/npm-run-path-4.0.1.tgz",
+      "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==",
+      "dev": true,
+      "dependencies": {
+        "path-key": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/nth-check": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmmirror.com/nth-check/-/nth-check-2.1.1.tgz",
+      "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
+      "dev": true,
+      "dependencies": {
+        "boolbase": "^1.0.0"
+      }
+    },
+    "node_modules/number-precision": {
+      "version": "1.6.0",
+      "resolved": "https://registry.npmmirror.com/number-precision/-/number-precision-1.6.0.tgz",
+      "integrity": "sha512-05OLPgbgmnixJw+VvEh18yNPUo3iyp4BEWJcrLu4X9W05KmMifN7Mu5exYvQXqxxeNWhvIF+j3Rij+HmddM/hQ=="
+    },
+    "node_modules/object-inspect": {
+      "version": "1.13.4",
+      "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+      "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/object-keys": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmmirror.com/object-keys/-/object-keys-1.1.1.tgz",
+      "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/once": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmmirror.com/once/-/once-1.4.0.tgz",
+      "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+      "dev": true,
+      "dependencies": {
+        "wrappy": "1"
+      }
+    },
+    "node_modules/onetime": {
+      "version": "5.1.2",
+      "resolved": "https://registry.npmmirror.com/onetime/-/onetime-5.1.2.tgz",
+      "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==",
+      "dev": true,
+      "dependencies": {
+        "mimic-fn": "^2.1.0"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/open": {
+      "version": "7.4.2",
+      "resolved": "https://registry.npmmirror.com/open/-/open-7.4.2.tgz",
+      "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==",
+      "license": "MIT",
+      "dependencies": {
+        "is-docker": "^2.0.0",
+        "is-wsl": "^2.1.1"
+      },
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/optionator": {
+      "version": "0.9.3",
+      "resolved": "https://registry.npmmirror.com/optionator/-/optionator-0.9.3.tgz",
+      "integrity": "sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==",
+      "dev": true,
+      "dependencies": {
+        "@aashutoshrathi/word-wrap": "^1.2.3",
+        "deep-is": "^0.1.3",
+        "fast-levenshtein": "^2.0.6",
+        "levn": "^0.4.1",
+        "prelude-ls": "^1.2.1",
+        "type-check": "^0.4.0"
+      },
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/orderedmap": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmmirror.com/orderedmap/-/orderedmap-2.1.1.tgz",
+      "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g=="
+    },
+    "node_modules/p-limit": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmmirror.com/p-limit/-/p-limit-3.1.0.tgz",
+      "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
+      "dev": true,
+      "dependencies": {
+        "yocto-queue": "^0.1.0"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/p-locate": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmmirror.com/p-locate/-/p-locate-5.0.0.tgz",
+      "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
+      "dev": true,
+      "dependencies": {
+        "p-limit": "^3.0.2"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/p-try": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmmirror.com/p-try/-/p-try-2.2.0.tgz",
+      "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
+      "dev": true,
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/pako": {
+      "version": "1.0.11",
+      "resolved": "https://registry.npmmirror.com/pako/-/pako-1.0.11.tgz",
+      "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="
+    },
+    "node_modules/parent-module": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmmirror.com/parent-module/-/parent-module-1.0.1.tgz",
+      "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
+      "dev": true,
+      "dependencies": {
+        "callsites": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/parse-json": {
+      "version": "7.1.1",
+      "resolved": "https://registry.npmmirror.com/parse-json/-/parse-json-7.1.1.tgz",
+      "integrity": "sha512-SgOTCX/EZXtZxBE5eJ97P4yGM5n37BwRU+YMsH4vNzFqJV/oWFXXCmwFlgWUM4PrakybVOueJJ6pwHqSVhTFDw==",
+      "dev": true,
+      "dependencies": {
+        "@babel/code-frame": "^7.21.4",
+        "error-ex": "^1.3.2",
+        "json-parse-even-better-errors": "^3.0.0",
+        "lines-and-columns": "^2.0.3",
+        "type-fest": "^3.8.0"
+      },
+      "engines": {
+        "node": ">=16"
+      }
+    },
+    "node_modules/parse-json/node_modules/type-fest": {
+      "version": "3.13.1",
+      "resolved": "https://registry.npmmirror.com/type-fest/-/type-fest-3.13.1.tgz",
+      "integrity": "sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g==",
+      "dev": true,
+      "engines": {
+        "node": ">=14.16"
+      }
+    },
+    "node_modules/patch-package": {
+      "version": "8.0.1",
+      "resolved": "https://registry.npmmirror.com/patch-package/-/patch-package-8.0.1.tgz",
+      "integrity": "sha512-VsKRIA8f5uqHQ7NGhwIna6Bx6D9s/1iXlA1hthBVBEbkq+t4kXD0HHt+rJhf/Z+Ci0F/HCB2hvn0qLdLG+Qxlw==",
+      "license": "MIT",
+      "dependencies": {
+        "@yarnpkg/lockfile": "^1.1.0",
+        "chalk": "^4.1.2",
+        "ci-info": "^3.7.0",
+        "cross-spawn": "^7.0.3",
+        "find-yarn-workspace-root": "^2.0.0",
+        "fs-extra": "^10.0.0",
+        "json-stable-stringify": "^1.0.2",
+        "klaw-sync": "^6.0.0",
+        "minimist": "^1.2.6",
+        "open": "^7.4.2",
+        "semver": "^7.5.3",
+        "slash": "^2.0.0",
+        "tmp": "^0.2.4",
+        "yaml": "^2.2.2"
+      },
+      "bin": {
+        "patch-package": "index.js"
+      },
+      "engines": {
+        "node": ">=14",
+        "npm": ">5"
+      }
+    },
+    "node_modules/patch-package/node_modules/fs-extra": {
+      "version": "10.1.0",
+      "resolved": "https://registry.npmmirror.com/fs-extra/-/fs-extra-10.1.0.tgz",
+      "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
+      "license": "MIT",
+      "dependencies": {
+        "graceful-fs": "^4.2.0",
+        "jsonfile": "^6.0.1",
+        "universalify": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/patch-package/node_modules/slash": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmmirror.com/slash/-/slash-2.0.0.tgz",
+      "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/path-browserify": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz",
+      "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==",
+      "dev": true
+    },
+    "node_modules/path-exists": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmmirror.com/path-exists/-/path-exists-4.0.0.tgz",
+      "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/path-is-absolute": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmmirror.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
+      "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
+      "dev": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/path-key": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmmirror.com/path-key/-/path-key-3.1.1.tgz",
+      "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/path-parse": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmmirror.com/path-parse/-/path-parse-1.0.7.tgz",
+      "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
+      "dev": true
+    },
+    "node_modules/path-type": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmmirror.com/path-type/-/path-type-4.0.0.tgz",
+      "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/perfect-debounce": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmmirror.com/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
+      "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA=="
+    },
+    "node_modules/picocolors": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz",
+      "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
+    },
+    "node_modules/picomatch": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.1.tgz",
+      "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
+      "engines": {
+        "node": ">=8.6"
+      }
+    },
+    "node_modules/pidtree": {
+      "version": "0.6.0",
+      "resolved": "https://registry.npmmirror.com/pidtree/-/pidtree-0.6.0.tgz",
+      "integrity": "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==",
+      "dev": true,
+      "bin": {
+        "pidtree": "bin/pidtree.js"
+      },
+      "engines": {
+        "node": ">=0.10"
+      }
+    },
+    "node_modules/pinia": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmmirror.com/pinia/-/pinia-3.0.2.tgz",
+      "integrity": "sha512-sH2JK3wNY809JOeiiURUR0wehJ9/gd9qFN2Y828jCbxEzKEmEt0pzCXwqiSTfuRsK9vQsOflSdnbdBOGrhtn+g==",
+      "dependencies": {
+        "@vue/devtools-api": "^7.7.2"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/posva"
+      },
+      "peerDependencies": {
+        "typescript": ">=4.4.4",
+        "vue": "^2.7.0 || ^3.5.11"
+      },
+      "peerDependenciesMeta": {
+        "typescript": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/postcss": {
+      "version": "8.5.8",
+      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
+      "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/postcss/"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/postcss"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "nanoid": "^3.3.11",
+        "picocolors": "^1.1.1",
+        "source-map-js": "^1.2.1"
+      },
+      "engines": {
+        "node": "^10 || ^12 || >=14"
+      }
+    },
+    "node_modules/postcss-selector-parser": {
+      "version": "6.0.15",
+      "resolved": "https://registry.npmmirror.com/postcss-selector-parser/-/postcss-selector-parser-6.0.15.tgz",
+      "integrity": "sha512-rEYkQOMUCEMhsKbK66tbEU9QVIxbhN18YiniAwA7XQYTVBqrBy+P2p5JcdqsHgKM2zWylp8d7J6eszocfds5Sw==",
+      "dev": true,
+      "dependencies": {
+        "cssesc": "^3.0.0",
+        "util-deprecate": "^1.0.2"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/postcss/node_modules/nanoid": {
+      "version": "3.3.11",
+      "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz",
+      "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "bin": {
+        "nanoid": "bin/nanoid.cjs"
+      },
+      "engines": {
+        "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+      }
+    },
+    "node_modules/pptxgenjs": {
+      "version": "3.12.0",
+      "resolved": "https://registry.npmmirror.com/pptxgenjs/-/pptxgenjs-3.12.0.tgz",
+      "integrity": "sha512-ZozkYKWb1MoPR4ucw3/aFYlHkVIJxo9czikEclcUVnS4Iw/M+r+TEwdlB3fyAWO9JY1USxJDt0Y0/r15IR/RUA==",
+      "dependencies": {
+        "@types/node": "^18.7.3",
+        "https": "^1.0.0",
+        "image-size": "^1.0.0",
+        "jszip": "^3.7.1"
+      }
+    },
+    "node_modules/pptxtojson": {
+      "version": "1.12.0",
+      "resolved": "https://registry.npmmirror.com/pptxtojson/-/pptxtojson-1.12.0.tgz",
+      "integrity": "sha512-kXdCqXlCLdiGpM3womaQIpGYrQEzl8/jdxSpWWtYNj3Pxvr9n39tGpuBxeZSdenttwzM7Fb/yBKUPzxMFXLIJQ==",
+      "license": "MIT",
+      "dependencies": {
+        "jszip": "^3.10.1",
+        "tinycolor2": "1.6.0",
+        "txml": "^5.1.1"
+      }
+    },
+    "node_modules/prelude-ls": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmmirror.com/prelude-ls/-/prelude-ls-1.2.1.tgz",
+      "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
+      "dev": true,
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/process-nextick-args": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmmirror.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
+      "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="
+    },
+    "node_modules/prosemirror-commands": {
+      "version": "1.6.0",
+      "resolved": "https://registry.npmmirror.com/prosemirror-commands/-/prosemirror-commands-1.6.0.tgz",
+      "integrity": "sha512-xn1U/g36OqXn2tn5nGmvnnimAj/g1pUx2ypJJIe8WkVX83WyJVC5LTARaxZa2AtQRwntu9Jc5zXs9gL9svp/mg==",
+      "dependencies": {
+        "prosemirror-model": "^1.0.0",
+        "prosemirror-state": "^1.0.0",
+        "prosemirror-transform": "^1.0.0"
+      }
+    },
+    "node_modules/prosemirror-dropcursor": {
+      "version": "1.8.1",
+      "resolved": "https://registry.npmmirror.com/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.1.tgz",
+      "integrity": "sha512-M30WJdJZLyXHi3N8vxN6Zh5O8ZBbQCz0gURTfPmTIBNQ5pxrdU7A58QkNqfa98YEjSAL1HUyyU34f6Pm5xBSGw==",
+      "dependencies": {
+        "prosemirror-state": "^1.0.0",
+        "prosemirror-transform": "^1.1.0",
+        "prosemirror-view": "^1.1.0"
+      }
+    },
+    "node_modules/prosemirror-gapcursor": {
+      "version": "1.3.2",
+      "resolved": "https://registry.npmmirror.com/prosemirror-gapcursor/-/prosemirror-gapcursor-1.3.2.tgz",
+      "integrity": "sha512-wtjswVBd2vaQRrnYZaBCbyDqr232Ed4p2QPtRIUK5FuqHYKGWkEwl08oQM4Tw7DOR0FsasARV5uJFvMZWxdNxQ==",
+      "dependencies": {
+        "prosemirror-keymap": "^1.0.0",
+        "prosemirror-model": "^1.0.0",
+        "prosemirror-state": "^1.0.0",
+        "prosemirror-view": "^1.0.0"
+      }
+    },
+    "node_modules/prosemirror-history": {
+      "version": "1.3.2",
+      "resolved": "https://registry.npmmirror.com/prosemirror-history/-/prosemirror-history-1.3.2.tgz",
+      "integrity": "sha512-/zm0XoU/N/+u7i5zepjmZAEnpvjDtzoPWW6VmKptcAnPadN/SStsBjMImdCEbb3seiNTpveziPTIrXQbHLtU1g==",
+      "dependencies": {
+        "prosemirror-state": "^1.2.2",
+        "prosemirror-transform": "^1.0.0",
+        "prosemirror-view": "^1.31.0",
+        "rope-sequence": "^1.3.0"
+      }
+    },
+    "node_modules/prosemirror-inputrules": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmmirror.com/prosemirror-inputrules/-/prosemirror-inputrules-1.4.0.tgz",
+      "integrity": "sha512-6ygpPRuTJ2lcOXs9JkefieMst63wVJBgHZGl5QOytN7oSZs3Co/BYbc3Yx9zm9H37Bxw8kVzCnDsihsVsL4yEg==",
+      "dependencies": {
+        "prosemirror-state": "^1.0.0",
+        "prosemirror-transform": "^1.0.0"
+      }
+    },
+    "node_modules/prosemirror-keymap": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmmirror.com/prosemirror-keymap/-/prosemirror-keymap-1.2.2.tgz",
+      "integrity": "sha512-EAlXoksqC6Vbocqc0GtzCruZEzYgrn+iiGnNjsJsH4mrnIGex4qbLdWWNza3AW5W36ZRrlBID0eM6bdKH4OStQ==",
+      "dependencies": {
+        "prosemirror-state": "^1.0.0",
+        "w3c-keyname": "^2.2.0"
+      }
+    },
+    "node_modules/prosemirror-model": {
+      "version": "1.22.2",
+      "resolved": "https://registry.npmmirror.com/prosemirror-model/-/prosemirror-model-1.22.2.tgz",
+      "integrity": "sha512-I4lS7HHIW47D0Xv/gWmi4iUWcQIDYaJKd8Hk4+lcSps+553FlQrhmxtItpEvTr75iAruhzVShVp6WUwsT6Boww==",
+      "dependencies": {
+        "orderedmap": "^2.0.0"
+      }
+    },
+    "node_modules/prosemirror-schema-basic": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmmirror.com/prosemirror-schema-basic/-/prosemirror-schema-basic-1.2.3.tgz",
+      "integrity": "sha512-h+H0OQwZVqMon1PNn0AG9cTfx513zgIG2DY00eJ00Yvgb3UD+GQ/VlWW5rcaxacpCGT1Yx8nuhwXk4+QbXUfJA==",
+      "dependencies": {
+        "prosemirror-model": "^1.19.0"
+      }
+    },
+    "node_modules/prosemirror-schema-list": {
+      "version": "1.4.1",
+      "resolved": "https://registry.npmmirror.com/prosemirror-schema-list/-/prosemirror-schema-list-1.4.1.tgz",
+      "integrity": "sha512-jbDyaP/6AFfDfu70VzySsD75Om2t3sXTOdl5+31Wlxlg62td1haUpty/ybajSfJ1pkGadlOfwQq9kgW5IMo1Rg==",
+      "dependencies": {
+        "prosemirror-model": "^1.0.0",
+        "prosemirror-state": "^1.0.0",
+        "prosemirror-transform": "^1.7.3"
+      }
+    },
+    "node_modules/prosemirror-state": {
+      "version": "1.4.3",
+      "resolved": "https://registry.npmmirror.com/prosemirror-state/-/prosemirror-state-1.4.3.tgz",
+      "integrity": "sha512-goFKORVbvPuAQaXhpbemJFRKJ2aixr+AZMGiquiqKxaucC6hlpHNZHWgz5R7dS4roHiwq9vDctE//CZ++o0W1Q==",
+      "dependencies": {
+        "prosemirror-model": "^1.0.0",
+        "prosemirror-transform": "^1.0.0",
+        "prosemirror-view": "^1.27.0"
+      }
+    },
+    "node_modules/prosemirror-transform": {
+      "version": "1.8.0",
+      "resolved": "https://registry.npmmirror.com/prosemirror-transform/-/prosemirror-transform-1.8.0.tgz",
+      "integrity": "sha512-BaSBsIMv52F1BVVMvOmp1yzD3u65uC3HTzCBQV1WDPqJRQ2LuHKcyfn0jwqodo8sR9vVzMzZyI+Dal5W9E6a9A==",
+      "dependencies": {
+        "prosemirror-model": "^1.0.0"
+      }
+    },
+    "node_modules/prosemirror-view": {
+      "version": "1.33.9",
+      "resolved": "https://registry.npmmirror.com/prosemirror-view/-/prosemirror-view-1.33.9.tgz",
+      "integrity": "sha512-xV1A0Vz9cIcEnwmMhKKFAOkfIp8XmJRnaZoPqNXrPS7EK5n11Ov8V76KhR0RsfQd/SIzmWY+bg+M44A2Lx/Nnw==",
+      "dependencies": {
+        "prosemirror-model": "^1.20.0",
+        "prosemirror-state": "^1.0.0",
+        "prosemirror-transform": "^1.1.0"
+      }
+    },
+    "node_modules/proxy-from-env": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
+      "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="
+    },
+    "node_modules/punycode": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmmirror.com/punycode/-/punycode-2.3.1.tgz",
+      "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+      "dev": true,
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/punycode.js": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
+      "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/qs": {
+      "version": "6.14.0",
+      "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
+      "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
+      "dependencies": {
+        "side-channel": "^1.1.0"
+      },
+      "engines": {
+        "node": ">=0.6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/queue": {
+      "version": "6.0.2",
+      "resolved": "https://registry.npmmirror.com/queue/-/queue-6.0.2.tgz",
+      "integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==",
+      "dependencies": {
+        "inherits": "~2.0.3"
+      }
+    },
+    "node_modules/queue-microtask": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmmirror.com/queue-microtask/-/queue-microtask-1.2.3.tgz",
+      "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
+      "dev": true
+    },
+    "node_modules/quick-lru": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmmirror.com/quick-lru/-/quick-lru-4.0.1.tgz",
+      "integrity": "sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/read-pkg": {
+      "version": "8.1.0",
+      "resolved": "https://registry.npmmirror.com/read-pkg/-/read-pkg-8.1.0.tgz",
+      "integrity": "sha512-PORM8AgzXeskHO/WEv312k9U03B8K9JSiWF/8N9sUuFjBa+9SF2u6K7VClzXwDXab51jCd8Nd36CNM+zR97ScQ==",
+      "dev": true,
+      "dependencies": {
+        "@types/normalize-package-data": "^2.4.1",
+        "normalize-package-data": "^6.0.0",
+        "parse-json": "^7.0.0",
+        "type-fest": "^4.2.0"
+      },
+      "engines": {
+        "node": ">=16"
+      }
+    },
+    "node_modules/read-pkg-up": {
+      "version": "7.0.1",
+      "resolved": "https://registry.npmmirror.com/read-pkg-up/-/read-pkg-up-7.0.1.tgz",
+      "integrity": "sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg==",
+      "dev": true,
+      "dependencies": {
+        "find-up": "^4.1.0",
+        "read-pkg": "^5.2.0",
+        "type-fest": "^0.8.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/read-pkg-up/node_modules/find-up": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmmirror.com/find-up/-/find-up-4.1.0.tgz",
+      "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
+      "dev": true,
+      "dependencies": {
+        "locate-path": "^5.0.0",
+        "path-exists": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/read-pkg-up/node_modules/hosted-git-info": {
+      "version": "2.8.9",
+      "resolved": "https://registry.npmmirror.com/hosted-git-info/-/hosted-git-info-2.8.9.tgz",
+      "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==",
+      "dev": true
+    },
+    "node_modules/read-pkg-up/node_modules/json-parse-even-better-errors": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmmirror.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz",
+      "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",
+      "dev": true
+    },
+    "node_modules/read-pkg-up/node_modules/lines-and-columns": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmmirror.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
+      "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
+      "dev": true
+    },
+    "node_modules/read-pkg-up/node_modules/locate-path": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmmirror.com/locate-path/-/locate-path-5.0.0.tgz",
+      "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
+      "dev": true,
+      "dependencies": {
+        "p-locate": "^4.1.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/read-pkg-up/node_modules/normalize-package-data": {
+      "version": "2.5.0",
+      "resolved": "https://registry.npmmirror.com/normalize-package-data/-/normalize-package-data-2.5.0.tgz",
+      "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==",
+      "dev": true,
+      "dependencies": {
+        "hosted-git-info": "^2.1.4",
+        "resolve": "^1.10.0",
+        "semver": "2 || 3 || 4 || 5",
+        "validate-npm-package-license": "^3.0.1"
+      }
+    },
+    "node_modules/read-pkg-up/node_modules/p-limit": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmmirror.com/p-limit/-/p-limit-2.3.0.tgz",
+      "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
+      "dev": true,
+      "dependencies": {
+        "p-try": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/read-pkg-up/node_modules/p-locate": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmmirror.com/p-locate/-/p-locate-4.1.0.tgz",
+      "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
+      "dev": true,
+      "dependencies": {
+        "p-limit": "^2.2.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/read-pkg-up/node_modules/parse-json": {
+      "version": "5.2.0",
+      "resolved": "https://registry.npmmirror.com/parse-json/-/parse-json-5.2.0.tgz",
+      "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==",
+      "dev": true,
+      "dependencies": {
+        "@babel/code-frame": "^7.0.0",
+        "error-ex": "^1.3.1",
+        "json-parse-even-better-errors": "^2.3.0",
+        "lines-and-columns": "^1.1.6"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/read-pkg-up/node_modules/read-pkg": {
+      "version": "5.2.0",
+      "resolved": "https://registry.npmmirror.com/read-pkg/-/read-pkg-5.2.0.tgz",
+      "integrity": "sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==",
+      "dev": true,
+      "dependencies": {
+        "@types/normalize-package-data": "^2.4.0",
+        "normalize-package-data": "^2.5.0",
+        "parse-json": "^5.0.0",
+        "type-fest": "^0.6.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/read-pkg-up/node_modules/read-pkg/node_modules/type-fest": {
+      "version": "0.6.0",
+      "resolved": "https://registry.npmmirror.com/type-fest/-/type-fest-0.6.0.tgz",
+      "integrity": "sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/read-pkg-up/node_modules/semver": {
+      "version": "5.7.2",
+      "resolved": "https://registry.npmmirror.com/semver/-/semver-5.7.2.tgz",
+      "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==",
+      "dev": true,
+      "bin": {
+        "semver": "bin/semver"
+      }
+    },
+    "node_modules/read-pkg-up/node_modules/type-fest": {
+      "version": "0.8.1",
+      "resolved": "https://registry.npmmirror.com/type-fest/-/type-fest-0.8.1.tgz",
+      "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/read-pkg/node_modules/type-fest": {
+      "version": "4.9.0",
+      "resolved": "https://registry.npmmirror.com/type-fest/-/type-fest-4.9.0.tgz",
+      "integrity": "sha512-KS/6lh/ynPGiHD/LnAobrEFq3Ad4pBzOlJ1wAnJx9N4EYoqFhMfLIBjUT2UEx4wg5ZE+cC1ob6DCSpppVo+rtg==",
+      "dev": true,
+      "engines": {
+        "node": ">=16"
+      }
+    },
+    "node_modules/readable-stream": {
+      "version": "2.3.8",
+      "resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-2.3.8.tgz",
+      "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
+      "dependencies": {
+        "core-util-is": "~1.0.0",
+        "inherits": "~2.0.3",
+        "isarray": "~1.0.0",
+        "process-nextick-args": "~2.0.0",
+        "safe-buffer": "~5.1.1",
+        "string_decoder": "~1.1.1",
+        "util-deprecate": "~1.0.1"
+      }
+    },
+    "node_modules/readdirp": {
+      "version": "3.6.0",
+      "resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-3.6.0.tgz",
+      "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
+      "dev": true,
+      "dependencies": {
+        "picomatch": "^2.2.1"
+      },
+      "engines": {
+        "node": ">=8.10.0"
+      }
+    },
+    "node_modules/redent": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmmirror.com/redent/-/redent-3.0.0.tgz",
+      "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==",
+      "dev": true,
+      "dependencies": {
+        "indent-string": "^4.0.0",
+        "strip-indent": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/require-directory": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmmirror.com/require-directory/-/require-directory-2.1.1.tgz",
+      "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
+      "dev": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/require-from-string": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmmirror.com/require-from-string/-/require-from-string-2.0.2.tgz",
+      "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+      "dev": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/resolve": {
+      "version": "1.22.8",
+      "resolved": "https://registry.npmmirror.com/resolve/-/resolve-1.22.8.tgz",
+      "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==",
+      "dev": true,
+      "dependencies": {
+        "is-core-module": "^2.13.0",
+        "path-parse": "^1.0.7",
+        "supports-preserve-symlinks-flag": "^1.0.0"
+      },
+      "bin": {
+        "resolve": "bin/resolve"
+      }
+    },
+    "node_modules/resolve-from": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmmirror.com/resolve-from/-/resolve-from-4.0.0.tgz",
+      "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
+      "dev": true,
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/resolve-global": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmmirror.com/resolve-global/-/resolve-global-1.0.0.tgz",
+      "integrity": "sha512-zFa12V4OLtT5XUX/Q4VLvTfBf+Ok0SPc1FNGM/z9ctUdiU618qwKpWnd0CHs3+RqROfyEg/DhuHbMWYqcgljEw==",
+      "dev": true,
+      "dependencies": {
+        "global-dirs": "^0.1.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/reusify": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmmirror.com/reusify/-/reusify-1.0.4.tgz",
+      "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==",
+      "dev": true,
+      "engines": {
+        "iojs": ">=1.0.0",
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/rfdc": {
+      "version": "1.4.1",
+      "resolved": "https://registry.npmmirror.com/rfdc/-/rfdc-1.4.1.tgz",
+      "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="
+    },
+    "node_modules/rimraf": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmmirror.com/rimraf/-/rimraf-3.0.2.tgz",
+      "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
+      "dev": true,
+      "dependencies": {
+        "glob": "^7.1.3"
+      },
+      "bin": {
+        "rimraf": "bin.js"
+      }
+    },
+    "node_modules/rollup": {
+      "version": "4.19.0",
+      "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.19.0.tgz",
+      "integrity": "sha512-5r7EYSQIowHsK4eTZ0Y81qpZuJz+MUuYeqmmYmRMl1nwhdmbiYqt5jwzf6u7wyOzJgYqtCRMtVRKOtHANBz7rA==",
+      "dev": true,
+      "dependencies": {
+        "@types/estree": "1.0.5"
+      },
+      "bin": {
+        "rollup": "dist/bin/rollup"
+      },
+      "engines": {
+        "node": ">=18.0.0",
+        "npm": ">=8.0.0"
+      },
+      "optionalDependencies": {
+        "@rollup/rollup-android-arm-eabi": "4.19.0",
+        "@rollup/rollup-android-arm64": "4.19.0",
+        "@rollup/rollup-darwin-arm64": "4.19.0",
+        "@rollup/rollup-darwin-x64": "4.19.0",
+        "@rollup/rollup-linux-arm-gnueabihf": "4.19.0",
+        "@rollup/rollup-linux-arm-musleabihf": "4.19.0",
+        "@rollup/rollup-linux-arm64-gnu": "4.19.0",
+        "@rollup/rollup-linux-arm64-musl": "4.19.0",
+        "@rollup/rollup-linux-powerpc64le-gnu": "4.19.0",
+        "@rollup/rollup-linux-riscv64-gnu": "4.19.0",
+        "@rollup/rollup-linux-s390x-gnu": "4.19.0",
+        "@rollup/rollup-linux-x64-gnu": "4.19.0",
+        "@rollup/rollup-linux-x64-musl": "4.19.0",
+        "@rollup/rollup-win32-arm64-msvc": "4.19.0",
+        "@rollup/rollup-win32-ia32-msvc": "4.19.0",
+        "@rollup/rollup-win32-x64-msvc": "4.19.0",
+        "fsevents": "~2.3.2"
+      }
+    },
+    "node_modules/rope-sequence": {
+      "version": "1.3.4",
+      "resolved": "https://registry.npmmirror.com/rope-sequence/-/rope-sequence-1.3.4.tgz",
+      "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ=="
+    },
+    "node_modules/rtf.js": {
+      "version": "3.0.9",
+      "resolved": "https://registry.npmjs.org/rtf.js/-/rtf.js-3.0.9.tgz",
+      "integrity": "sha512-I1GpDat4i548WzmeZXv27f/743984fvEeeBS8BC01/Sop17pMlUl3M7DYcdcB3PUvOZTrFIMxGZx8qw7cSMAKQ==",
+      "license": "MIT",
+      "dependencies": {
+        "codepage": "^1.15.0"
+      }
+    },
+    "node_modules/run-parallel": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmmirror.com/run-parallel/-/run-parallel-1.2.0.tgz",
+      "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
+      "dev": true,
+      "dependencies": {
+        "queue-microtask": "^1.2.2"
+      }
+    },
+    "node_modules/safe-buffer": {
+      "version": "5.1.2",
+      "resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.1.2.tgz",
+      "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
+    },
+    "node_modules/sass": {
+      "version": "1.69.6",
+      "resolved": "https://registry.npmmirror.com/sass/-/sass-1.69.6.tgz",
+      "integrity": "sha512-qbRr3k9JGHWXCvZU77SD2OTwUlC+gNT+61JOLcmLm+XqH4h/5D+p4IIsxvpkB89S9AwJOyb5+rWNpIucaFxSFQ==",
+      "dev": true,
+      "dependencies": {
+        "chokidar": ">=3.0.0 <4.0.0",
+        "immutable": "^4.0.0",
+        "source-map-js": ">=0.6.2 <2.0.0"
+      },
+      "bin": {
+        "sass": "sass.js"
+      },
+      "engines": {
+        "node": ">=14.0.0"
+      }
+    },
+    "node_modules/select": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmmirror.com/select/-/select-1.1.2.tgz",
+      "integrity": "sha512-OwpTSOfy6xSs1+pwcNrv0RBMOzI39Lp3qQKUTPVVPRjCdNa5JH/oPRiqsesIskK8TVgmRiHwO4KXlV2Li9dANA=="
+    },
+    "node_modules/semver": {
+      "version": "7.5.4",
+      "resolved": "https://registry.npmmirror.com/semver/-/semver-7.5.4.tgz",
+      "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==",
+      "dependencies": {
+        "lru-cache": "^6.0.0"
+      },
+      "bin": {
+        "semver": "bin/semver.js"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/semver/node_modules/lru-cache": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-6.0.0.tgz",
+      "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
+      "dependencies": {
+        "yallist": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/set-function-length": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmmirror.com/set-function-length/-/set-function-length-1.2.2.tgz",
+      "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
+      "license": "MIT",
+      "dependencies": {
+        "define-data-property": "^1.1.4",
+        "es-errors": "^1.3.0",
+        "function-bind": "^1.1.2",
+        "get-intrinsic": "^1.2.4",
+        "gopd": "^1.0.1",
+        "has-property-descriptors": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/setimmediate": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmmirror.com/setimmediate/-/setimmediate-1.0.5.tgz",
+      "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="
+    },
+    "node_modules/shebang-command": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmmirror.com/shebang-command/-/shebang-command-2.0.0.tgz",
+      "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+      "dependencies": {
+        "shebang-regex": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/shebang-regex": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmmirror.com/shebang-regex/-/shebang-regex-3.0.0.tgz",
+      "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/shell-quote": {
+      "version": "1.8.1",
+      "resolved": "https://registry.npmmirror.com/shell-quote/-/shell-quote-1.8.1.tgz",
+      "integrity": "sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA==",
+      "dev": true
+    },
+    "node_modules/side-channel": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
+      "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "object-inspect": "^1.13.3",
+        "side-channel-list": "^1.0.0",
+        "side-channel-map": "^1.0.1",
+        "side-channel-weakmap": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-list": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
+      "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "object-inspect": "^1.13.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-map": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+      "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.5",
+        "object-inspect": "^1.13.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-weakmap": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+      "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.5",
+        "object-inspect": "^1.13.3",
+        "side-channel-map": "^1.0.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/signal-exit": {
+      "version": "3.0.7",
+      "resolved": "https://registry.npmmirror.com/signal-exit/-/signal-exit-3.0.7.tgz",
+      "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
+      "dev": true
+    },
+    "node_modules/slash": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmmirror.com/slash/-/slash-3.0.0.tgz",
+      "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/sortablejs": {
+      "version": "1.14.0",
+      "resolved": "https://registry.npmmirror.com/sortablejs/-/sortablejs-1.14.0.tgz",
+      "integrity": "sha512-pBXvQCs5/33fdN1/39pPL0NZF20LeRbLQ5jtnheIPN9JQAaufGjKdWduZn4U7wCtVuzKhmRkI0DFYHYRbB2H1w=="
+    },
+    "node_modules/source-map-js": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz",
+      "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/spdx-correct": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmmirror.com/spdx-correct/-/spdx-correct-3.2.0.tgz",
+      "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==",
+      "dev": true,
+      "dependencies": {
+        "spdx-expression-parse": "^3.0.0",
+        "spdx-license-ids": "^3.0.0"
+      }
+    },
+    "node_modules/spdx-exceptions": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmmirror.com/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz",
+      "integrity": "sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A==",
+      "dev": true
+    },
+    "node_modules/spdx-expression-parse": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmmirror.com/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz",
+      "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==",
+      "dev": true,
+      "dependencies": {
+        "spdx-exceptions": "^2.1.0",
+        "spdx-license-ids": "^3.0.0"
+      }
+    },
+    "node_modules/spdx-license-ids": {
+      "version": "3.0.16",
+      "resolved": "https://registry.npmmirror.com/spdx-license-ids/-/spdx-license-ids-3.0.16.tgz",
+      "integrity": "sha512-eWN+LnM3GR6gPu35WxNgbGl8rmY1AEmoMDvL/QD6zYmPWgywxWqJWNdLGT+ke8dKNWrcYgYjPpG5gbTfghP8rw==",
+      "dev": true
+    },
+    "node_modules/speakingurl": {
+      "version": "14.0.1",
+      "resolved": "https://registry.npmmirror.com/speakingurl/-/speakingurl-14.0.1.tgz",
+      "integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/split2": {
+      "version": "4.2.0",
+      "resolved": "https://registry.npmmirror.com/split2/-/split2-4.2.0.tgz",
+      "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
+      "dev": true,
+      "engines": {
+        "node": ">= 10.x"
+      }
+    },
+    "node_modules/string_decoder": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.1.1.tgz",
+      "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+      "dependencies": {
+        "safe-buffer": "~5.1.0"
+      }
+    },
+    "node_modules/string-width": {
+      "version": "4.2.3",
+      "resolved": "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz",
+      "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+      "dev": true,
+      "dependencies": {
+        "emoji-regex": "^8.0.0",
+        "is-fullwidth-code-point": "^3.0.0",
+        "strip-ansi": "^6.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/strip-ansi": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz",
+      "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+      "dev": true,
+      "dependencies": {
+        "ansi-regex": "^5.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/strip-final-newline": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmmirror.com/strip-final-newline/-/strip-final-newline-2.0.0.tgz",
+      "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==",
+      "dev": true,
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/strip-indent": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmmirror.com/strip-indent/-/strip-indent-3.0.0.tgz",
+      "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==",
+      "dev": true,
+      "dependencies": {
+        "min-indent": "^1.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/strip-json-comments": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmmirror.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
+      "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/superjson": {
+      "version": "2.2.2",
+      "resolved": "https://registry.npmmirror.com/superjson/-/superjson-2.2.2.tgz",
+      "integrity": "sha512-5JRxVqC8I8NuOUjzBbvVJAKNM8qoVuH0O77h4WInc/qC2q5IreqKxYwgkga3PfA22OayK2ikceb/B26dztPl+Q==",
+      "dependencies": {
+        "copy-anything": "^3.0.2"
+      },
+      "engines": {
+        "node": ">=16"
+      }
+    },
+    "node_modules/supports-color": {
+      "version": "7.2.0",
+      "resolved": "https://registry.npmmirror.com/supports-color/-/supports-color-7.2.0.tgz",
+      "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+      "dependencies": {
+        "has-flag": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/supports-preserve-symlinks-flag": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmmirror.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
+      "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
+      "dev": true,
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/svg-arc-to-cubic-bezier": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmmirror.com/svg-arc-to-cubic-bezier/-/svg-arc-to-cubic-bezier-3.2.0.tgz",
+      "integrity": "sha512-djbJ/vZKZO+gPoSDThGNpKDO+o+bAeA4XQKovvkNCqnIS2t+S4qnLAGQhyyrulhCFRl1WWzAp0wUDV8PpTVU3g=="
+    },
+    "node_modules/svg-pathdata": {
+      "version": "7.2.0",
+      "resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-7.2.0.tgz",
+      "integrity": "sha512-qd+AxqMpfRrRQaWb2SrNFvn69cvl6piqY8TxhYl2Li1g4/LO5F9NJb5wI4vNwRryqgSgD43gYKLm/w3ag1bKvQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=20.11.1"
+      }
+    },
+    "node_modules/text-extensions": {
+      "version": "2.4.0",
+      "resolved": "https://registry.npmmirror.com/text-extensions/-/text-extensions-2.4.0.tgz",
+      "integrity": "sha512-te/NtwBwfiNRLf9Ijqx3T0nlqZiQ2XrrtBvu+cLL8ZRrGkO0NHTug8MYFKyoSrv/sHTaSKfilUkizV6XhxMJ3g==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/text-segmentation": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
+      "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
+      "dependencies": {
+        "utrie": "^1.0.2"
+      }
+    },
+    "node_modules/text-table": {
+      "version": "0.2.0",
+      "resolved": "https://registry.npmmirror.com/text-table/-/text-table-0.2.0.tgz",
+      "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==",
+      "dev": true
+    },
+    "node_modules/through": {
+      "version": "2.3.8",
+      "resolved": "https://registry.npmmirror.com/through/-/through-2.3.8.tgz",
+      "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==",
+      "dev": true
+    },
+    "node_modules/through2": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmmirror.com/through2/-/through2-3.0.2.tgz",
+      "integrity": "sha512-enaDQ4MUyP2W6ZyT6EsMzqBPZaM/avg8iuo+l2d3QCs0J+6RaqkHV/2/lOwDTueBHeJ/2LG9lrLW3d5rWPucuQ==",
+      "dependencies": {
+        "inherits": "^2.0.4",
+        "readable-stream": "2 || 3"
+      }
+    },
+    "node_modules/tiny-emitter": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmmirror.com/tiny-emitter/-/tiny-emitter-2.1.0.tgz",
+      "integrity": "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q=="
+    },
+    "node_modules/tinycolor2": {
+      "version": "1.6.0",
+      "resolved": "https://registry.npmmirror.com/tinycolor2/-/tinycolor2-1.6.0.tgz",
+      "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw=="
+    },
+    "node_modules/tippy.js": {
+      "version": "6.3.7",
+      "resolved": "https://registry.npmmirror.com/tippy.js/-/tippy.js-6.3.7.tgz",
+      "integrity": "sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==",
+      "dependencies": {
+        "@popperjs/core": "^2.9.0"
+      }
+    },
+    "node_modules/tmp": {
+      "version": "0.2.5",
+      "resolved": "https://registry.npmmirror.com/tmp/-/tmp-0.2.5.tgz",
+      "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=14.14"
+      }
+    },
+    "node_modules/to-regex-range": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmmirror.com/to-regex-range/-/to-regex-range-5.0.1.tgz",
+      "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+      "dependencies": {
+        "is-number": "^7.0.0"
+      },
+      "engines": {
+        "node": ">=8.0"
+      }
+    },
+    "node_modules/trim-newlines": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmmirror.com/trim-newlines/-/trim-newlines-3.0.1.tgz",
+      "integrity": "sha512-c1PTsA3tYrIsLGkJkzHF+w9F2EyxfXGo4UyJc4pFL++FMjnq0HJS69T3M7d//gKrFKwy429bouPescbjecU+Zw==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/ts-api-utils": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmmirror.com/ts-api-utils/-/ts-api-utils-1.0.3.tgz",
+      "integrity": "sha512-wNMeqtMz5NtwpT/UZGY5alT+VoKdSsOOP/kqHFcUW1P/VRhH2wJ48+DN2WwUliNbQ976ETwDL0Ifd2VVvgonvg==",
+      "dev": true,
+      "engines": {
+        "node": ">=16.13.0"
+      },
+      "peerDependencies": {
+        "typescript": ">=4.2.0"
+      }
+    },
+    "node_modules/tslib": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.3.0.tgz",
+      "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg=="
+    },
+    "node_modules/txml": {
+      "version": "5.1.1",
+      "resolved": "https://registry.npmmirror.com/txml/-/txml-5.1.1.tgz",
+      "integrity": "sha512-TwMDLnXQ09enNaxybLVvKZU7rqog8LgnuAs4ZYXM0nV0eu10iLsSFwlX3AEknAXXtH1wT3CYfoiXAjyBexcmuw==",
+      "dependencies": {
+        "through2": "^3.0.1"
+      }
+    },
+    "node_modules/type-check": {
+      "version": "0.4.0",
+      "resolved": "https://registry.npmmirror.com/type-check/-/type-check-0.4.0.tgz",
+      "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
+      "dev": true,
+      "dependencies": {
+        "prelude-ls": "^1.2.1"
+      },
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/type-fest": {
+      "version": "0.20.2",
+      "resolved": "https://registry.npmmirror.com/type-fest/-/type-fest-0.20.2.tgz",
+      "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
+      "dev": true,
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/typescript": {
+      "version": "5.3.3",
+      "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.3.3.tgz",
+      "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==",
+      "devOptional": true,
+      "bin": {
+        "tsc": "bin/tsc",
+        "tsserver": "bin/tsserver"
+      },
+      "engines": {
+        "node": ">=14.17"
+      }
+    },
+    "node_modules/uc.micro": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
+      "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A=="
+    },
+    "node_modules/undici-types": {
+      "version": "5.26.5",
+      "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-5.26.5.tgz",
+      "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="
+    },
+    "node_modules/universalify": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmmirror.com/universalify/-/universalify-2.0.1.tgz",
+      "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
+      "engines": {
+        "node": ">= 10.0.0"
+      }
+    },
+    "node_modules/update-browserslist-db": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
+      "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/browserslist"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/browserslist"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "escalade": "^3.2.0",
+        "picocolors": "^1.1.1"
+      },
+      "bin": {
+        "update-browserslist-db": "cli.js"
+      },
+      "peerDependencies": {
+        "browserslist": ">= 4.21.0"
+      }
+    },
+    "node_modules/uri-js": {
+      "version": "4.4.1",
+      "resolved": "https://registry.npmmirror.com/uri-js/-/uri-js-4.4.1.tgz",
+      "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
+      "dev": true,
+      "dependencies": {
+        "punycode": "^2.1.0"
+      }
+    },
+    "node_modules/utif": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/utif/-/utif-3.1.0.tgz",
+      "integrity": "sha512-WEo4D/xOvFW53K5f5QTaTbbiORcm2/pCL9P6qmJnup+17eYfKaEhDeX9PeQkuyEoIxlbGklDuGl8xwuXYMrrXQ==",
+      "license": "MIT",
+      "dependencies": {
+        "pako": "^1.0.5"
+      }
+    },
+    "node_modules/utif2": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/utif2/-/utif2-4.1.0.tgz",
+      "integrity": "sha512-+oknB9FHrJ7oW7A2WZYajOcv4FcDR4CfoGB0dPNfxbi4GO05RRnFmt5oa23+9w32EanrYcSJWspUiJkLMs+37w==",
+      "license": "MIT",
+      "dependencies": {
+        "pako": "^1.0.11"
+      }
+    },
+    "node_modules/util-deprecate": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz",
+      "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
+    },
+    "node_modules/utrie": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
+      "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
+      "dependencies": {
+        "base64-arraybuffer": "^1.0.2"
+      }
+    },
+    "node_modules/uuid": {
+      "version": "13.0.0",
+      "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
+      "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
+      "funding": [
+        "https://github.com/sponsors/broofa",
+        "https://github.com/sponsors/ctavan"
+      ],
+      "bin": {
+        "uuid": "dist-node/bin/uuid"
+      }
+    },
+    "node_modules/validate-npm-package-license": {
+      "version": "3.0.4",
+      "resolved": "https://registry.npmmirror.com/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz",
+      "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==",
+      "dev": true,
+      "dependencies": {
+        "spdx-correct": "^3.0.0",
+        "spdx-expression-parse": "^3.0.0"
+      }
+    },
+    "node_modules/vite": {
+      "version": "5.3.5",
+      "resolved": "https://registry.npmmirror.com/vite/-/vite-5.3.5.tgz",
+      "integrity": "sha512-MdjglKR6AQXQb9JGiS7Rc2wC6uMjcm7Go/NHNO63EwiJXfuk9PgqiP/n5IDJCziMkfw9n4Ubp7lttNwz+8ZVKA==",
+      "dev": true,
+      "dependencies": {
+        "esbuild": "^0.21.3",
+        "postcss": "^8.4.39",
+        "rollup": "^4.13.0"
+      },
+      "bin": {
+        "vite": "bin/vite.js"
+      },
+      "engines": {
+        "node": "^18.0.0 || >=20.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/vitejs/vite?sponsor=1"
+      },
+      "optionalDependencies": {
+        "fsevents": "~2.3.3"
+      },
+      "peerDependencies": {
+        "@types/node": "^18.0.0 || >=20.0.0",
+        "less": "*",
+        "lightningcss": "^1.21.0",
+        "sass": "*",
+        "stylus": "*",
+        "sugarss": "*",
+        "terser": "^5.4.0"
+      },
+      "peerDependenciesMeta": {
+        "@types/node": {
+          "optional": true
+        },
+        "less": {
+          "optional": true
+        },
+        "lightningcss": {
+          "optional": true
+        },
+        "sass": {
+          "optional": true
+        },
+        "stylus": {
+          "optional": true
+        },
+        "sugarss": {
+          "optional": true
+        },
+        "terser": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/vite-plugin-vue-inspector": {
+      "version": "5.4.0",
+      "resolved": "https://registry.npmjs.org/vite-plugin-vue-inspector/-/vite-plugin-vue-inspector-5.4.0.tgz",
+      "integrity": "sha512-Iq/024CydcE46FZqWPU4t4lw4uYOdLnFSO1RNxJVt2qY9zxIjmnkBqhHnYaReWM82kmNnaXs7OkfgRrV2GEjyw==",
+      "dev": true,
+      "dependencies": {
+        "@babel/core": "^7.23.0",
+        "@babel/plugin-proposal-decorators": "^7.23.0",
+        "@babel/plugin-syntax-import-attributes": "^7.22.5",
+        "@babel/plugin-syntax-import-meta": "^7.10.4",
+        "@babel/plugin-transform-typescript": "^7.22.15",
+        "@vue/babel-plugin-jsx": "^1.1.5",
+        "@vue/compiler-dom": "^3.3.4",
+        "kolorist": "^1.8.0",
+        "magic-string": "^0.30.4"
+      },
+      "peerDependencies": {
+        "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
+      }
+    },
+    "node_modules/vscode-uri": {
+      "version": "3.0.8",
+      "resolved": "https://registry.npmmirror.com/vscode-uri/-/vscode-uri-3.0.8.tgz",
+      "integrity": "sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==",
+      "dev": true
+    },
+    "node_modules/vue": {
+      "version": "3.5.17",
+      "resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.17.tgz",
+      "integrity": "sha512-LbHV3xPN9BeljML+Xctq4lbz2lVHCR6DtbpTf5XIO6gugpXUN49j2QQPcMj086r9+AkJ0FfUT8xjulKKBkkr9g==",
+      "dependencies": {
+        "@vue/compiler-dom": "3.5.17",
+        "@vue/compiler-sfc": "3.5.17",
+        "@vue/runtime-dom": "3.5.17",
+        "@vue/server-renderer": "3.5.17",
+        "@vue/shared": "3.5.17"
+      },
+      "peerDependencies": {
+        "typescript": "*"
+      },
+      "peerDependenciesMeta": {
+        "typescript": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/vue-eslint-parser": {
+      "version": "9.3.2",
+      "resolved": "https://registry.npmmirror.com/vue-eslint-parser/-/vue-eslint-parser-9.3.2.tgz",
+      "integrity": "sha512-q7tWyCVaV9f8iQyIA5Mkj/S6AoJ9KBN8IeUSf3XEmBrOtxOZnfTg5s4KClbZBCK3GtnT/+RyCLZyDHuZwTuBjg==",
+      "dev": true,
+      "dependencies": {
+        "debug": "^4.3.4",
+        "eslint-scope": "^7.1.1",
+        "eslint-visitor-keys": "^3.3.0",
+        "espree": "^9.3.1",
+        "esquery": "^1.4.0",
+        "lodash": "^4.17.21",
+        "semver": "^7.3.6"
+      },
+      "engines": {
+        "node": "^14.17.0 || >=16.0.0"
+      },
+      "peerDependencies": {
+        "eslint": ">=6.0.0"
+      }
+    },
+    "node_modules/vue-tsc": {
+      "version": "2.0.29",
+      "resolved": "https://registry.npmmirror.com/vue-tsc/-/vue-tsc-2.0.29.tgz",
+      "integrity": "sha512-MHhsfyxO3mYShZCGYNziSbc63x7cQ5g9kvijV7dRe1TTXBRLxXyL0FnXWpUF1xII2mJ86mwYpYsUmMwkmerq7Q==",
+      "dev": true,
+      "dependencies": {
+        "@volar/typescript": "~2.4.0-alpha.18",
+        "@vue/language-core": "2.0.29",
+        "semver": "^7.5.4"
+      },
+      "bin": {
+        "vue-tsc": "bin/vue-tsc.js"
+      },
+      "peerDependencies": {
+        "typescript": ">=5.0.0"
+      }
+    },
+    "node_modules/vuedraggable": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmmirror.com/vuedraggable/-/vuedraggable-4.1.0.tgz",
+      "integrity": "sha512-FU5HCWBmsf20GpP3eudURW3WdWTKIbEIQxh9/8GE806hydR9qZqRRxRE3RjqX7PkuLuMQG/A7n3cfj9rCEchww==",
+      "dependencies": {
+        "sortablejs": "1.14.0"
+      },
+      "peerDependencies": {
+        "vue": "^3.0.1"
+      }
+    },
+    "node_modules/w3c-keyname": {
+      "version": "2.2.8",
+      "resolved": "https://registry.npmmirror.com/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
+      "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="
+    },
+    "node_modules/wangeditor": {
+      "version": "4.7.15",
+      "resolved": "https://registry.npmjs.org/wangeditor/-/wangeditor-4.7.15.tgz",
+      "integrity": "sha512-aPTdREd8BxXVyJ5MI+LU83FQ7u1EPd341iXIorRNYSOvoimNoZ4nPg+yn3FGbB93/owEa6buLw8wdhYnMCJQLg==",
+      "dependencies": {
+        "@babel/runtime": "^7.11.2",
+        "@babel/runtime-corejs3": "^7.11.2",
+        "tslib": "^2.1.0"
+      }
+    },
+    "node_modules/webcodecs-encoder": {
+      "version": "0.3.2",
+      "resolved": "https://registry.npmmirror.com/webcodecs-encoder/-/webcodecs-encoder-0.3.2.tgz",
+      "integrity": "sha512-F4XKzafAxVNhtnaQ5F8Q6xVn+bRrzLiUQs3p4PpogN3svBqGPR1y5OK66bunXEULOJ0XVJ0HqlJ/TSGGZTZ0AQ==",
+      "hasInstallScript": true,
+      "license": "MIT",
+      "dependencies": {
+        "mp4-muxer": "^2.0.2",
+        "patch-package": "^8.0.0",
+        "webm-muxer": "^1.2.0"
+      }
+    },
+    "node_modules/webm-muxer": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmmirror.com/webm-muxer/-/webm-muxer-1.2.1.tgz",
+      "integrity": "sha512-FaH7GVeK9tOZoWEW0gT/ryR9EoZnDe/RplJlTkG6zPWfEdrIy24PaXwEiyUnKwwO3zsjJWzFq4DLHECqGdJXIw==",
+      "deprecated": "This library is superseded by Mediabunny. Please migrate to it.",
+      "license": "MIT",
+      "dependencies": {
+        "@types/dom-webcodecs": "^0.1.4",
+        "@types/wicg-file-system-access": "^2020.9.5"
+      }
+    },
+    "node_modules/which": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmmirror.com/which/-/which-2.0.2.tgz",
+      "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+      "dependencies": {
+        "isexe": "^2.0.0"
+      },
+      "bin": {
+        "node-which": "bin/node-which"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/wrap-ansi": {
+      "version": "7.0.0",
+      "resolved": "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+      "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
+      "dev": true,
+      "dependencies": {
+        "ansi-styles": "^4.0.0",
+        "string-width": "^4.1.0",
+        "strip-ansi": "^6.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/wrappy": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmmirror.com/wrappy/-/wrappy-1.0.2.tgz",
+      "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+      "dev": true
+    },
+    "node_modules/xml-name-validator": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmmirror.com/xml-name-validator/-/xml-name-validator-4.0.0.tgz",
+      "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==",
+      "dev": true,
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/y-protocols": {
+      "version": "1.0.6",
+      "resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.6.tgz",
+      "integrity": "sha512-vHRF2L6iT3rwj1jub/K5tYcTT/mEYDUppgNPXwp8fmLpui9f7Yeq3OEtTLVF012j39QnV+KEQpNqoN7CWU7Y9Q==",
+      "dependencies": {
+        "lib0": "^0.2.85"
+      },
+      "engines": {
+        "node": ">=16.0.0",
+        "npm": ">=8.0.0"
+      },
+      "funding": {
+        "type": "GitHub Sponsors ❤",
+        "url": "https://github.com/sponsors/dmonad"
+      },
+      "peerDependencies": {
+        "yjs": "^13.0.0"
+      }
+    },
+    "node_modules/y-websocket": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/y-websocket/-/y-websocket-3.0.0.tgz",
+      "integrity": "sha512-mUHy7AzkOZ834T/7piqtlA8Yk6AchqKqcrCXjKW8J1w2lPtRDjz8W5/CvXz9higKAHgKRKqpI3T33YkRFLkPtg==",
+      "dependencies": {
+        "lib0": "^0.2.102",
+        "y-protocols": "^1.0.5"
+      },
+      "engines": {
+        "node": ">=16.0.0",
+        "npm": ">=8.0.0"
+      },
+      "funding": {
+        "type": "GitHub Sponsors ❤",
+        "url": "https://github.com/sponsors/dmonad"
+      },
+      "peerDependencies": {
+        "yjs": "^13.5.6"
+      }
+    },
+    "node_modules/y18n": {
+      "version": "5.0.8",
+      "resolved": "https://registry.npmmirror.com/y18n/-/y18n-5.0.8.tgz",
+      "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
+      "dev": true,
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/yallist": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmmirror.com/yallist/-/yallist-4.0.0.tgz",
+      "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="
+    },
+    "node_modules/yaml": {
+      "version": "2.8.3",
+      "resolved": "https://registry.npmmirror.com/yaml/-/yaml-2.8.3.tgz",
+      "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
+      "license": "ISC",
+      "bin": {
+        "yaml": "bin.mjs"
+      },
+      "engines": {
+        "node": ">= 14.6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/eemeli"
+      }
+    },
+    "node_modules/yargs": {
+      "version": "17.7.2",
+      "resolved": "https://registry.npmmirror.com/yargs/-/yargs-17.7.2.tgz",
+      "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
+      "dev": true,
+      "dependencies": {
+        "cliui": "^8.0.1",
+        "escalade": "^3.1.1",
+        "get-caller-file": "^2.0.5",
+        "require-directory": "^2.1.1",
+        "string-width": "^4.2.3",
+        "y18n": "^5.0.5",
+        "yargs-parser": "^21.1.1"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/yargs-parser": {
+      "version": "21.1.1",
+      "resolved": "https://registry.npmmirror.com/yargs-parser/-/yargs-parser-21.1.1.tgz",
+      "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
+      "dev": true,
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/yjs": {
+      "version": "13.6.27",
+      "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.27.tgz",
+      "integrity": "sha512-OIDwaflOaq4wC6YlPBy2L6ceKeKuF7DeTxx+jPzv1FHn9tCZ0ZwSRnUBxD05E3yed46fv/FWJbvR+Ud7x0L7zw==",
+      "dependencies": {
+        "lib0": "^0.2.99"
+      },
+      "engines": {
+        "node": ">=16.0.0",
+        "npm": ">=8.0.0"
+      },
+      "funding": {
+        "type": "GitHub Sponsors ❤",
+        "url": "https://github.com/sponsors/dmonad"
+      }
+    },
+    "node_modules/yocto-queue": {
+      "version": "0.1.0",
+      "resolved": "https://registry.npmmirror.com/yocto-queue/-/yocto-queue-0.1.0.tgz",
+      "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
+      "dev": true,
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/zrender": {
+      "version": "5.6.1",
+      "resolved": "https://registry.npmjs.org/zrender/-/zrender-5.6.1.tgz",
+      "integrity": "sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==",
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "tslib": "2.3.0"
+      }
+    }
+  },
+  "dependencies": {
+    "@aashutoshrathi/word-wrap": {
+      "version": "1.2.6",
+      "resolved": "https://registry.npmmirror.com/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz",
+      "integrity": "sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==",
+      "dev": true
+    },
+    "@babel/code-frame": {
+      "version": "7.29.0",
+      "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
+      "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
+      "dev": true,
+      "requires": {
+        "@babel/helper-validator-identifier": "^7.28.5",
+        "js-tokens": "^4.0.0",
+        "picocolors": "^1.1.1"
+      }
+    },
+    "@babel/compat-data": {
+      "version": "7.29.0",
+      "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz",
+      "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==",
+      "dev": true
+    },
+    "@babel/core": {
+      "version": "7.29.0",
+      "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
+      "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
+      "dev": true,
+      "requires": {
+        "@babel/code-frame": "^7.29.0",
+        "@babel/generator": "^7.29.0",
+        "@babel/helper-compilation-targets": "^7.28.6",
+        "@babel/helper-module-transforms": "^7.28.6",
+        "@babel/helpers": "^7.28.6",
+        "@babel/parser": "^7.29.0",
+        "@babel/template": "^7.28.6",
+        "@babel/traverse": "^7.29.0",
+        "@babel/types": "^7.29.0",
+        "@jridgewell/remapping": "^2.3.5",
+        "convert-source-map": "^2.0.0",
+        "debug": "^4.1.0",
+        "gensync": "^1.0.0-beta.2",
+        "json5": "^2.2.3",
+        "semver": "^6.3.1"
+      },
+      "dependencies": {
+        "semver": {
+          "version": "6.3.1",
+          "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+          "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+          "dev": true
+        }
+      }
+    },
+    "@babel/generator": {
+      "version": "7.29.1",
+      "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
+      "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
+      "dev": true,
+      "requires": {
+        "@babel/parser": "^7.29.0",
+        "@babel/types": "^7.29.0",
+        "@jridgewell/gen-mapping": "^0.3.12",
+        "@jridgewell/trace-mapping": "^0.3.28",
+        "jsesc": "^3.0.2"
+      }
+    },
+    "@babel/helper-annotate-as-pure": {
+      "version": "7.27.3",
+      "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz",
+      "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==",
+      "dev": true,
+      "requires": {
+        "@babel/types": "^7.27.3"
+      }
+    },
+    "@babel/helper-compilation-targets": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
+      "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
+      "dev": true,
+      "requires": {
+        "@babel/compat-data": "^7.28.6",
+        "@babel/helper-validator-option": "^7.27.1",
+        "browserslist": "^4.24.0",
+        "lru-cache": "^5.1.1",
+        "semver": "^6.3.1"
+      },
+      "dependencies": {
+        "lru-cache": {
+          "version": "5.1.1",
+          "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+          "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+          "dev": true,
+          "requires": {
+            "yallist": "^3.0.2"
+          }
+        },
+        "semver": {
+          "version": "6.3.1",
+          "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+          "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+          "dev": true
+        },
+        "yallist": {
+          "version": "3.1.1",
+          "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+          "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+          "dev": true
+        }
+      }
+    },
+    "@babel/helper-create-class-features-plugin": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.6.tgz",
+      "integrity": "sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==",
+      "dev": true,
+      "requires": {
+        "@babel/helper-annotate-as-pure": "^7.27.3",
+        "@babel/helper-member-expression-to-functions": "^7.28.5",
+        "@babel/helper-optimise-call-expression": "^7.27.1",
+        "@babel/helper-replace-supers": "^7.28.6",
+        "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1",
+        "@babel/traverse": "^7.28.6",
+        "semver": "^6.3.1"
+      },
+      "dependencies": {
+        "semver": {
+          "version": "6.3.1",
+          "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+          "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+          "dev": true
+        }
+      }
+    },
+    "@babel/helper-globals": {
+      "version": "7.28.0",
+      "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
+      "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
+      "dev": true
+    },
+    "@babel/helper-member-expression-to-functions": {
+      "version": "7.28.5",
+      "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.28.5.tgz",
+      "integrity": "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==",
+      "dev": true,
+      "requires": {
+        "@babel/traverse": "^7.28.5",
+        "@babel/types": "^7.28.5"
+      }
+    },
+    "@babel/helper-module-imports": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
+      "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
+      "dev": true,
+      "requires": {
+        "@babel/traverse": "^7.28.6",
+        "@babel/types": "^7.28.6"
+      }
+    },
+    "@babel/helper-module-transforms": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
+      "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
+      "dev": true,
+      "requires": {
+        "@babel/helper-module-imports": "^7.28.6",
+        "@babel/helper-validator-identifier": "^7.28.5",
+        "@babel/traverse": "^7.28.6"
+      }
+    },
+    "@babel/helper-optimise-call-expression": {
+      "version": "7.27.1",
+      "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz",
+      "integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==",
+      "dev": true,
+      "requires": {
+        "@babel/types": "^7.27.1"
+      }
+    },
+    "@babel/helper-plugin-utils": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz",
+      "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==",
+      "dev": true
+    },
+    "@babel/helper-replace-supers": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.28.6.tgz",
+      "integrity": "sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==",
+      "dev": true,
+      "requires": {
+        "@babel/helper-member-expression-to-functions": "^7.28.5",
+        "@babel/helper-optimise-call-expression": "^7.27.1",
+        "@babel/traverse": "^7.28.6"
+      }
+    },
+    "@babel/helper-skip-transparent-expression-wrappers": {
+      "version": "7.27.1",
+      "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz",
+      "integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==",
+      "dev": true,
+      "requires": {
+        "@babel/traverse": "^7.27.1",
+        "@babel/types": "^7.27.1"
+      }
+    },
+    "@babel/helper-string-parser": {
+      "version": "7.27.1",
+      "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
+      "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="
+    },
+    "@babel/helper-validator-identifier": {
+      "version": "7.28.5",
+      "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
+      "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="
+    },
+    "@babel/helper-validator-option": {
+      "version": "7.27.1",
+      "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
+      "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
+      "dev": true
+    },
+    "@babel/helpers": {
+      "version": "7.29.2",
+      "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz",
+      "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==",
+      "dev": true,
+      "requires": {
+        "@babel/template": "^7.28.6",
+        "@babel/types": "^7.29.0"
+      }
+    },
+    "@babel/parser": {
+      "version": "7.29.2",
+      "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz",
+      "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==",
+      "requires": {
+        "@babel/types": "^7.29.0"
+      }
+    },
+    "@babel/plugin-proposal-decorators": {
+      "version": "7.29.0",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.29.0.tgz",
+      "integrity": "sha512-CVBVv3VY/XRMxRYq5dwr2DS7/MvqPm23cOCjbwNnVrfOqcWlnefua1uUs0sjdKOGjvPUG633o07uWzJq4oI6dA==",
+      "dev": true,
+      "requires": {
+        "@babel/helper-create-class-features-plugin": "^7.28.6",
+        "@babel/helper-plugin-utils": "^7.28.6",
+        "@babel/plugin-syntax-decorators": "^7.28.6"
+      }
+    },
+    "@babel/plugin-syntax-decorators": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.28.6.tgz",
+      "integrity": "sha512-71EYI0ONURHJBL4rSFXnITXqXrrY8q4P0q006DPfN+Rk+ASM+++IBXem/ruokgBZR8YNEWZ8R6B+rCb8VcUTqA==",
+      "dev": true,
+      "requires": {
+        "@babel/helper-plugin-utils": "^7.28.6"
+      }
+    },
+    "@babel/plugin-syntax-import-attributes": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.28.6.tgz",
+      "integrity": "sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==",
+      "dev": true,
+      "requires": {
+        "@babel/helper-plugin-utils": "^7.28.6"
+      }
+    },
+    "@babel/plugin-syntax-import-meta": {
+      "version": "7.10.4",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz",
+      "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==",
+      "dev": true,
+      "requires": {
+        "@babel/helper-plugin-utils": "^7.10.4"
+      }
+    },
+    "@babel/plugin-syntax-jsx": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz",
+      "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==",
+      "dev": true,
+      "requires": {
+        "@babel/helper-plugin-utils": "^7.28.6"
+      }
+    },
+    "@babel/plugin-syntax-typescript": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.28.6.tgz",
+      "integrity": "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==",
+      "dev": true,
+      "requires": {
+        "@babel/helper-plugin-utils": "^7.28.6"
+      }
+    },
+    "@babel/plugin-transform-typescript": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.28.6.tgz",
+      "integrity": "sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw==",
+      "dev": true,
+      "requires": {
+        "@babel/helper-annotate-as-pure": "^7.27.3",
+        "@babel/helper-create-class-features-plugin": "^7.28.6",
+        "@babel/helper-plugin-utils": "^7.28.6",
+        "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1",
+        "@babel/plugin-syntax-typescript": "^7.28.6"
+      }
+    },
+    "@babel/runtime": {
+      "version": "7.28.4",
+      "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
+      "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ=="
+    },
+    "@babel/runtime-corejs3": {
+      "version": "7.28.4",
+      "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.28.4.tgz",
+      "integrity": "sha512-h7iEYiW4HebClDEhtvFObtPmIvrd1SSfpI9EhOeKk4CtIK/ngBWFpuhCzhdmRKtg71ylcue+9I6dv54XYO1epQ==",
+      "requires": {
+        "core-js-pure": "^3.43.0"
+      }
+    },
+    "@babel/template": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
+      "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
+      "dev": true,
+      "requires": {
+        "@babel/code-frame": "^7.28.6",
+        "@babel/parser": "^7.28.6",
+        "@babel/types": "^7.28.6"
+      }
+    },
+    "@babel/traverse": {
+      "version": "7.29.0",
+      "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz",
+      "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
+      "dev": true,
+      "requires": {
+        "@babel/code-frame": "^7.29.0",
+        "@babel/generator": "^7.29.0",
+        "@babel/helper-globals": "^7.28.0",
+        "@babel/parser": "^7.29.0",
+        "@babel/template": "^7.28.6",
+        "@babel/types": "^7.29.0",
+        "debug": "^4.3.1"
+      }
+    },
+    "@babel/types": {
+      "version": "7.29.0",
+      "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
+      "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
+      "requires": {
+        "@babel/helper-string-parser": "^7.27.1",
+        "@babel/helper-validator-identifier": "^7.28.5"
+      }
+    },
+    "@commitlint/cli": {
+      "version": "18.4.3",
+      "resolved": "https://registry.npmmirror.com/@commitlint/cli/-/cli-18.4.3.tgz",
+      "integrity": "sha512-zop98yfB3A6NveYAZ3P1Mb6bIXuCeWgnUfVNkH4yhIMQpQfzFwseadazOuSn0OOfTt0lWuFauehpm9GcqM5lww==",
+      "dev": true,
+      "requires": {
+        "@commitlint/format": "^18.4.3",
+        "@commitlint/lint": "^18.4.3",
+        "@commitlint/load": "^18.4.3",
+        "@commitlint/read": "^18.4.3",
+        "@commitlint/types": "^18.4.3",
+        "execa": "^5.0.0",
+        "lodash.isfunction": "^3.0.9",
+        "resolve-from": "5.0.0",
+        "resolve-global": "1.0.0",
+        "yargs": "^17.0.0"
+      },
+      "dependencies": {
+        "resolve-from": {
+          "version": "5.0.0",
+          "resolved": "https://registry.npmmirror.com/resolve-from/-/resolve-from-5.0.0.tgz",
+          "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==",
+          "dev": true
+        }
+      }
+    },
+    "@commitlint/config-conventional": {
+      "version": "18.4.3",
+      "resolved": "https://registry.npmmirror.com/@commitlint/config-conventional/-/config-conventional-18.4.3.tgz",
+      "integrity": "sha512-729eRRaNta7JZF07qf6SAGSghoDEp9mH7yHU0m7ff0q89W97wDrWCyZ3yoV3mcQJwbhlmVmZPTkPcm7qiAu8WA==",
+      "dev": true,
+      "requires": {
+        "conventional-changelog-conventionalcommits": "^7.0.2"
+      }
+    },
+    "@commitlint/config-validator": {
+      "version": "18.4.3",
+      "resolved": "https://registry.npmmirror.com/@commitlint/config-validator/-/config-validator-18.4.3.tgz",
+      "integrity": "sha512-FPZZmTJBARPCyef9ohRC9EANiQEKSWIdatx5OlgeHKu878dWwpyeFauVkhzuBRJFcCA4Uvz/FDtlDKs008IHcA==",
+      "dev": true,
+      "requires": {
+        "@commitlint/types": "^18.4.3",
+        "ajv": "^8.11.0"
+      },
+      "dependencies": {
+        "ajv": {
+          "version": "8.12.0",
+          "resolved": "https://registry.npmmirror.com/ajv/-/ajv-8.12.0.tgz",
+          "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==",
+          "dev": true,
+          "requires": {
+            "fast-deep-equal": "^3.1.1",
+            "json-schema-traverse": "^1.0.0",
+            "require-from-string": "^2.0.2",
+            "uri-js": "^4.2.2"
+          }
+        },
+        "json-schema-traverse": {
+          "version": "1.0.0",
+          "resolved": "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+          "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+          "dev": true
+        }
+      }
+    },
+    "@commitlint/ensure": {
+      "version": "18.4.3",
+      "resolved": "https://registry.npmmirror.com/@commitlint/ensure/-/ensure-18.4.3.tgz",
+      "integrity": "sha512-MI4fwD9TWDVn4plF5+7JUyLLbkOdzIRBmVeNlk4dcGlkrVA+/l5GLcpN66q9LkFsFv6G2X31y89ApA3hqnqIFg==",
+      "dev": true,
+      "requires": {
+        "@commitlint/types": "^18.4.3",
+        "lodash.camelcase": "^4.3.0",
+        "lodash.kebabcase": "^4.1.1",
+        "lodash.snakecase": "^4.1.1",
+        "lodash.startcase": "^4.4.0",
+        "lodash.upperfirst": "^4.3.1"
+      }
+    },
+    "@commitlint/execute-rule": {
+      "version": "18.4.3",
+      "resolved": "https://registry.npmmirror.com/@commitlint/execute-rule/-/execute-rule-18.4.3.tgz",
+      "integrity": "sha512-t7FM4c+BdX9WWZCPrrbV5+0SWLgT3kCq7e7/GhHCreYifg3V8qyvO127HF796vyFql75n4TFF+5v1asOOWkV1Q==",
+      "dev": true
+    },
+    "@commitlint/format": {
+      "version": "18.4.3",
+      "resolved": "https://registry.npmmirror.com/@commitlint/format/-/format-18.4.3.tgz",
+      "integrity": "sha512-8b+ItXYHxAhRAXFfYki5PpbuMMOmXYuzLxib65z2XTqki59YDQJGpJ/wB1kEE5MQDgSTQWtKUrA8n9zS/1uIDQ==",
+      "dev": true,
+      "requires": {
+        "@commitlint/types": "^18.4.3",
+        "chalk": "^4.1.0"
+      }
+    },
+    "@commitlint/is-ignored": {
+      "version": "18.4.3",
+      "resolved": "https://registry.npmmirror.com/@commitlint/is-ignored/-/is-ignored-18.4.3.tgz",
+      "integrity": "sha512-ZseOY9UfuAI32h9w342Km4AIaTieeFskm2ZKdrG7r31+c6zGBzuny9KQhwI9puc0J3GkUquEgKJblCl7pMnjwg==",
+      "dev": true,
+      "requires": {
+        "@commitlint/types": "^18.4.3",
+        "semver": "7.5.4"
+      }
+    },
+    "@commitlint/lint": {
+      "version": "18.4.3",
+      "resolved": "https://registry.npmmirror.com/@commitlint/lint/-/lint-18.4.3.tgz",
+      "integrity": "sha512-18u3MRgEXNbnYkMOWoncvq6QB8/90m9TbERKgdPqVvS+zQ/MsuRhdvHYCIXGXZxUb0YI4DV2PC4bPneBV/fYuA==",
+      "dev": true,
+      "requires": {
+        "@commitlint/is-ignored": "^18.4.3",
+        "@commitlint/parse": "^18.4.3",
+        "@commitlint/rules": "^18.4.3",
+        "@commitlint/types": "^18.4.3"
+      }
+    },
+    "@commitlint/load": {
+      "version": "18.4.3",
+      "resolved": "https://registry.npmmirror.com/@commitlint/load/-/load-18.4.3.tgz",
+      "integrity": "sha512-v6j2WhvRQJrcJaj5D+EyES2WKTxPpxENmNpNG3Ww8MZGik3jWRXtph0QTzia5ZJyPh2ib5aC/6BIDymkUUM58Q==",
+      "dev": true,
+      "requires": {
+        "@commitlint/config-validator": "^18.4.3",
+        "@commitlint/execute-rule": "^18.4.3",
+        "@commitlint/resolve-extends": "^18.4.3",
+        "@commitlint/types": "^18.4.3",
+        "@types/node": "^18.11.9",
+        "chalk": "^4.1.0",
+        "cosmiconfig": "^8.3.6",
+        "cosmiconfig-typescript-loader": "^5.0.0",
+        "lodash.isplainobject": "^4.0.6",
+        "lodash.merge": "^4.6.2",
+        "lodash.uniq": "^4.5.0",
+        "resolve-from": "^5.0.0"
+      },
+      "dependencies": {
+        "resolve-from": {
+          "version": "5.0.0",
+          "resolved": "https://registry.npmmirror.com/resolve-from/-/resolve-from-5.0.0.tgz",
+          "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==",
+          "dev": true
+        }
+      }
+    },
+    "@commitlint/message": {
+      "version": "18.4.3",
+      "resolved": "https://registry.npmmirror.com/@commitlint/message/-/message-18.4.3.tgz",
+      "integrity": "sha512-ddJ7AztWUIoEMAXoewx45lKEYEOeOlBVWjk8hDMUGpprkuvWULpaXczqdjwVtjrKT3JhhN+gMs8pm5G3vB2how==",
+      "dev": true
+    },
+    "@commitlint/parse": {
+      "version": "18.4.3",
+      "resolved": "https://registry.npmmirror.com/@commitlint/parse/-/parse-18.4.3.tgz",
+      "integrity": "sha512-eoH7CXM9L+/Me96KVcfJ27EIIbA5P9sqw3DqjJhRYuhaULIsPHFs5S5GBDCqT0vKZQDx0DgxhMpW6AQbnKrFtA==",
+      "dev": true,
+      "requires": {
+        "@commitlint/types": "^18.4.3",
+        "conventional-changelog-angular": "^7.0.0",
+        "conventional-commits-parser": "^5.0.0"
+      }
+    },
+    "@commitlint/read": {
+      "version": "18.4.3",
+      "resolved": "https://registry.npmmirror.com/@commitlint/read/-/read-18.4.3.tgz",
+      "integrity": "sha512-H4HGxaYA6OBCimZAtghL+B+SWu8ep4X7BwgmedmqWZRHxRLcX2q0bWBtUm5FsMbluxbOfrJwOs/Z0ah4roP/GQ==",
+      "dev": true,
+      "requires": {
+        "@commitlint/top-level": "^18.4.3",
+        "@commitlint/types": "^18.4.3",
+        "fs-extra": "^11.0.0",
+        "git-raw-commits": "^2.0.11",
+        "minimist": "^1.2.6"
+      }
+    },
+    "@commitlint/resolve-extends": {
+      "version": "18.4.3",
+      "resolved": "https://registry.npmmirror.com/@commitlint/resolve-extends/-/resolve-extends-18.4.3.tgz",
+      "integrity": "sha512-30sk04LZWf8+SDgJrbJCjM90gTg2LxsD9cykCFeFu+JFHvBFq5ugzp2eO/DJGylAdVaqxej3c7eTSE64hR/lnw==",
+      "dev": true,
+      "requires": {
+        "@commitlint/config-validator": "^18.4.3",
+        "@commitlint/types": "^18.4.3",
+        "import-fresh": "^3.0.0",
+        "lodash.mergewith": "^4.6.2",
+        "resolve-from": "^5.0.0",
+        "resolve-global": "^1.0.0"
+      },
+      "dependencies": {
+        "resolve-from": {
+          "version": "5.0.0",
+          "resolved": "https://registry.npmmirror.com/resolve-from/-/resolve-from-5.0.0.tgz",
+          "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==",
+          "dev": true
+        }
+      }
+    },
+    "@commitlint/rules": {
+      "version": "18.4.3",
+      "resolved": "https://registry.npmmirror.com/@commitlint/rules/-/rules-18.4.3.tgz",
+      "integrity": "sha512-8KIeukDf45BiY+Lul1T0imSNXF0sMrlLG6JpLLKolkmYVQ6PxxoNOriwyZ3UTFFpaVbPy0rcITaV7U9JCAfDTA==",
+      "dev": true,
+      "requires": {
+        "@commitlint/ensure": "^18.4.3",
+        "@commitlint/message": "^18.4.3",
+        "@commitlint/to-lines": "^18.4.3",
+        "@commitlint/types": "^18.4.3",
+        "execa": "^5.0.0"
+      }
+    },
+    "@commitlint/to-lines": {
+      "version": "18.4.3",
+      "resolved": "https://registry.npmmirror.com/@commitlint/to-lines/-/to-lines-18.4.3.tgz",
+      "integrity": "sha512-fy1TAleik4Zfru1RJ8ZU6cOSvgSVhUellxd3WZV1D5RwHZETt1sZdcA4mQN2y3VcIZsUNKkW0Mq8CM9/L9harQ==",
+      "dev": true
+    },
+    "@commitlint/top-level": {
+      "version": "18.4.3",
+      "resolved": "https://registry.npmmirror.com/@commitlint/top-level/-/top-level-18.4.3.tgz",
+      "integrity": "sha512-E6fJPBLPFL5R8+XUNSYkj4HekIOuGMyJo3mIx2PkYc3clel+pcWQ7TConqXxNWW4x1ugigiIY2RGot55qUq1hw==",
+      "dev": true,
+      "requires": {
+        "find-up": "^5.0.0"
+      }
+    },
+    "@commitlint/types": {
+      "version": "18.4.3",
+      "resolved": "https://registry.npmmirror.com/@commitlint/types/-/types-18.4.3.tgz",
+      "integrity": "sha512-cvzx+vtY/I2hVBZHCLrpoh+sA0hfuzHwDc+BAFPimYLjJkpHnghQM+z8W/KyLGkygJh3BtI3xXXq+dKjnSWEmA==",
+      "dev": true,
+      "requires": {
+        "chalk": "^4.1.0"
+      }
+    },
+    "@esbuild/aix-ppc64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
+      "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
+      "dev": true,
+      "optional": true
+    },
+    "@esbuild/android-arm": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
+      "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
+      "dev": true,
+      "optional": true
+    },
+    "@esbuild/android-arm64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
+      "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
+      "dev": true,
+      "optional": true
+    },
+    "@esbuild/android-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
+      "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
+      "dev": true,
+      "optional": true
+    },
+    "@esbuild/darwin-arm64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
+      "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
+      "dev": true,
+      "optional": true
+    },
+    "@esbuild/darwin-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
+      "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
+      "dev": true,
+      "optional": true
+    },
+    "@esbuild/freebsd-arm64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
+      "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
+      "dev": true,
+      "optional": true
+    },
+    "@esbuild/freebsd-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
+      "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
+      "dev": true,
+      "optional": true
+    },
+    "@esbuild/linux-arm": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
+      "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
+      "dev": true,
+      "optional": true
+    },
+    "@esbuild/linux-arm64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
+      "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
+      "dev": true,
+      "optional": true
+    },
+    "@esbuild/linux-ia32": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
+      "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
+      "dev": true,
+      "optional": true
+    },
+    "@esbuild/linux-loong64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
+      "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
+      "dev": true,
+      "optional": true
+    },
+    "@esbuild/linux-mips64el": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
+      "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
+      "dev": true,
+      "optional": true
+    },
+    "@esbuild/linux-ppc64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
+      "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
+      "dev": true,
+      "optional": true
+    },
+    "@esbuild/linux-riscv64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
+      "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
+      "dev": true,
+      "optional": true
+    },
+    "@esbuild/linux-s390x": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
+      "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
+      "dev": true,
+      "optional": true
+    },
+    "@esbuild/linux-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
+      "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
+      "dev": true,
+      "optional": true
+    },
+    "@esbuild/netbsd-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
+      "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
+      "dev": true,
+      "optional": true
+    },
+    "@esbuild/openbsd-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
+      "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
+      "dev": true,
+      "optional": true
+    },
+    "@esbuild/sunos-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
+      "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
+      "dev": true,
+      "optional": true
+    },
+    "@esbuild/win32-arm64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
+      "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
+      "dev": true,
+      "optional": true
+    },
+    "@esbuild/win32-ia32": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
+      "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
+      "dev": true,
+      "optional": true
+    },
+    "@esbuild/win32-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
+      "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
+      "dev": true,
+      "optional": true
+    },
+    "@eslint-community/eslint-utils": {
+      "version": "4.4.0",
+      "resolved": "https://registry.npmmirror.com/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz",
+      "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==",
+      "dev": true,
+      "requires": {
+        "eslint-visitor-keys": "^3.3.0"
+      }
+    },
+    "@eslint-community/regexpp": {
+      "version": "4.10.0",
+      "resolved": "https://registry.npmmirror.com/@eslint-community/regexpp/-/regexpp-4.10.0.tgz",
+      "integrity": "sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA==",
+      "dev": true
+    },
+    "@eslint/eslintrc": {
+      "version": "2.1.4",
+      "resolved": "https://registry.npmmirror.com/@eslint/eslintrc/-/eslintrc-2.1.4.tgz",
+      "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==",
+      "dev": true,
+      "requires": {
+        "ajv": "^6.12.4",
+        "debug": "^4.3.2",
+        "espree": "^9.6.0",
+        "globals": "^13.19.0",
+        "ignore": "^5.2.0",
+        "import-fresh": "^3.2.1",
+        "js-yaml": "^4.1.0",
+        "minimatch": "^3.1.2",
+        "strip-json-comments": "^3.1.1"
+      },
+      "dependencies": {
+        "brace-expansion": {
+          "version": "1.1.11",
+          "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.11.tgz",
+          "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+          "dev": true,
+          "requires": {
+            "balanced-match": "^1.0.0",
+            "concat-map": "0.0.1"
+          }
+        },
+        "minimatch": {
+          "version": "3.1.2",
+          "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.2.tgz",
+          "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+          "dev": true,
+          "requires": {
+            "brace-expansion": "^1.1.7"
+          }
+        }
+      }
+    },
+    "@eslint/js": {
+      "version": "8.56.0",
+      "resolved": "https://registry.npmmirror.com/@eslint/js/-/js-8.56.0.tgz",
+      "integrity": "sha512-gMsVel9D7f2HLkBma9VbtzZRehRogVRfbr++f06nL2vnCGCNlzOD+/MUov/F4p8myyAHspEhVobgjpX64q5m6A==",
+      "dev": true
+    },
+    "@humanwhocodes/config-array": {
+      "version": "0.11.13",
+      "resolved": "https://registry.npmmirror.com/@humanwhocodes/config-array/-/config-array-0.11.13.tgz",
+      "integrity": "sha512-JSBDMiDKSzQVngfRjOdFXgFfklaXI4K9nLF49Auh21lmBWRLIK3+xTErTWD4KU54pb6coM6ESE7Awz/FNU3zgQ==",
+      "dev": true,
+      "requires": {
+        "@humanwhocodes/object-schema": "^2.0.1",
+        "debug": "^4.1.1",
+        "minimatch": "^3.0.5"
+      },
+      "dependencies": {
+        "brace-expansion": {
+          "version": "1.1.11",
+          "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.11.tgz",
+          "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+          "dev": true,
+          "requires": {
+            "balanced-match": "^1.0.0",
+            "concat-map": "0.0.1"
+          }
+        },
+        "minimatch": {
+          "version": "3.1.2",
+          "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.2.tgz",
+          "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+          "dev": true,
+          "requires": {
+            "brace-expansion": "^1.1.7"
+          }
+        }
+      }
+    },
+    "@humanwhocodes/module-importer": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmmirror.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
+      "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
+      "dev": true
+    },
+    "@humanwhocodes/object-schema": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmmirror.com/@humanwhocodes/object-schema/-/object-schema-2.0.1.tgz",
+      "integrity": "sha512-dvuCeX5fC9dXgJn9t+X5atfmgQAzUOWqS1254Gh0m6i8wKd10ebXkfNKiRK+1GWi/yTvvLDHpoxLr0xxxeslWw==",
+      "dev": true
+    },
+    "@icon-park/vue-next": {
+      "version": "1.4.2",
+      "resolved": "https://registry.npmmirror.com/@icon-park/vue-next/-/vue-next-1.4.2.tgz",
+      "integrity": "sha512-+QklF255wkfBOabY+xw6FAI0Bwln/RhdwCunNy/9sKdKuChtaU67QZqU67KGAvZUTeeBgsL+yaHHxqfQeGZXEQ==",
+      "requires": {}
+    },
+    "@jridgewell/gen-mapping": {
+      "version": "0.3.13",
+      "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+      "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+      "dev": true,
+      "requires": {
+        "@jridgewell/sourcemap-codec": "^1.5.0",
+        "@jridgewell/trace-mapping": "^0.3.24"
+      }
+    },
+    "@jridgewell/remapping": {
+      "version": "2.3.5",
+      "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+      "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+      "dev": true,
+      "requires": {
+        "@jridgewell/gen-mapping": "^0.3.5",
+        "@jridgewell/trace-mapping": "^0.3.24"
+      }
+    },
+    "@jridgewell/resolve-uri": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+      "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+      "dev": true
+    },
+    "@jridgewell/sourcemap-codec": {
+      "version": "1.5.5",
+      "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+      "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="
+    },
+    "@jridgewell/trace-mapping": {
+      "version": "0.3.31",
+      "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+      "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+      "dev": true,
+      "requires": {
+        "@jridgewell/resolve-uri": "^3.1.0",
+        "@jridgewell/sourcemap-codec": "^1.4.14"
+      }
+    },
+    "@microsoft/fetch-event-source": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/@microsoft/fetch-event-source/-/fetch-event-source-2.0.1.tgz",
+      "integrity": "sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA=="
+    },
+    "@nodelib/fs.scandir": {
+      "version": "2.1.5",
+      "resolved": "https://registry.npmmirror.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
+      "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
+      "dev": true,
+      "requires": {
+        "@nodelib/fs.stat": "2.0.5",
+        "run-parallel": "^1.1.9"
+      }
+    },
+    "@nodelib/fs.stat": {
+      "version": "2.0.5",
+      "resolved": "https://registry.npmmirror.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
+      "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
+      "dev": true
+    },
+    "@nodelib/fs.walk": {
+      "version": "1.2.8",
+      "resolved": "https://registry.npmmirror.com/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
+      "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
+      "dev": true,
+      "requires": {
+        "@nodelib/fs.scandir": "2.1.5",
+        "fastq": "^1.6.0"
+      }
+    },
+    "@popperjs/core": {
+      "version": "2.11.8",
+      "resolved": "https://registry.npmmirror.com/@popperjs/core/-/core-2.11.8.tgz",
+      "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A=="
+    },
+    "@rollup/rollup-android-arm-eabi": {
+      "version": "4.19.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.19.0.tgz",
+      "integrity": "sha512-JlPfZ/C7yn5S5p0yKk7uhHTTnFlvTgLetl2VxqE518QgyM7C9bSfFTYvB/Q/ftkq0RIPY4ySxTz+/wKJ/dXC0w==",
+      "dev": true,
+      "optional": true
+    },
+    "@rollup/rollup-android-arm64": {
+      "version": "4.19.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.19.0.tgz",
+      "integrity": "sha512-RDxUSY8D1tWYfn00DDi5myxKgOk6RvWPxhmWexcICt/MEC6yEMr4HNCu1sXXYLw8iAsg0D44NuU+qNq7zVWCrw==",
+      "dev": true,
+      "optional": true
+    },
+    "@rollup/rollup-darwin-arm64": {
+      "version": "4.19.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.19.0.tgz",
+      "integrity": "sha512-emvKHL4B15x6nlNTBMtIaC9tLPRpeA5jMvRLXVbl/W9Ie7HhkrE7KQjvgS9uxgatL1HmHWDXk5TTS4IaNJxbAA==",
+      "dev": true,
+      "optional": true
+    },
+    "@rollup/rollup-darwin-x64": {
+      "version": "4.19.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.19.0.tgz",
+      "integrity": "sha512-fO28cWA1dC57qCd+D0rfLC4VPbh6EOJXrreBmFLWPGI9dpMlER2YwSPZzSGfq11XgcEpPukPTfEVFtw2q2nYJg==",
+      "dev": true,
+      "optional": true
+    },
+    "@rollup/rollup-linux-arm-gnueabihf": {
+      "version": "4.19.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.19.0.tgz",
+      "integrity": "sha512-2Rn36Ubxdv32NUcfm0wB1tgKqkQuft00PtM23VqLuCUR4N5jcNWDoV5iBC9jeGdgS38WK66ElncprqgMUOyomw==",
+      "dev": true,
+      "optional": true
+    },
+    "@rollup/rollup-linux-arm-musleabihf": {
+      "version": "4.19.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.19.0.tgz",
+      "integrity": "sha512-gJuzIVdq/X1ZA2bHeCGCISe0VWqCoNT8BvkQ+BfsixXwTOndhtLUpOg0A1Fcx/+eA6ei6rMBzlOz4JzmiDw7JQ==",
+      "dev": true,
+      "optional": true
+    },
+    "@rollup/rollup-linux-arm64-gnu": {
+      "version": "4.19.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.19.0.tgz",
+      "integrity": "sha512-0EkX2HYPkSADo9cfeGFoQ7R0/wTKb7q6DdwI4Yn/ULFE1wuRRCHybxpl2goQrx4c/yzK3I8OlgtBu4xvted0ug==",
+      "dev": true,
+      "optional": true
+    },
+    "@rollup/rollup-linux-arm64-musl": {
+      "version": "4.19.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.19.0.tgz",
+      "integrity": "sha512-GlIQRj9px52ISomIOEUq/IojLZqzkvRpdP3cLgIE1wUWaiU5Takwlzpz002q0Nxxr1y2ZgxC2obWxjr13lvxNQ==",
+      "dev": true,
+      "optional": true
+    },
+    "@rollup/rollup-linux-powerpc64le-gnu": {
+      "version": "4.19.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.19.0.tgz",
+      "integrity": "sha512-N6cFJzssruDLUOKfEKeovCKiHcdwVYOT1Hs6dovDQ61+Y9n3Ek4zXvtghPPelt6U0AH4aDGnDLb83uiJMkWYzQ==",
+      "dev": true,
+      "optional": true
+    },
+    "@rollup/rollup-linux-riscv64-gnu": {
+      "version": "4.19.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.19.0.tgz",
+      "integrity": "sha512-2DnD3mkS2uuam/alF+I7M84koGwvn3ZVD7uG+LEWpyzo/bq8+kKnus2EVCkcvh6PlNB8QPNFOz6fWd5N8o1CYg==",
+      "dev": true,
+      "optional": true
+    },
+    "@rollup/rollup-linux-s390x-gnu": {
+      "version": "4.19.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.19.0.tgz",
+      "integrity": "sha512-D6pkaF7OpE7lzlTOFCB2m3Ngzu2ykw40Nka9WmKGUOTS3xcIieHe82slQlNq69sVB04ch73thKYIWz/Ian8DUA==",
+      "dev": true,
+      "optional": true
+    },
+    "@rollup/rollup-linux-x64-gnu": {
+      "version": "4.19.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.19.0.tgz",
+      "integrity": "sha512-HBndjQLP8OsdJNSxpNIN0einbDmRFg9+UQeZV1eiYupIRuZsDEoeGU43NQsS34Pp166DtwQOnpcbV/zQxM+rWA==",
+      "dev": true,
+      "optional": true
+    },
+    "@rollup/rollup-linux-x64-musl": {
+      "version": "4.19.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.19.0.tgz",
+      "integrity": "sha512-HxfbvfCKJe/RMYJJn0a12eiOI9OOtAUF4G6ozrFUK95BNyoJaSiBjIOHjZskTUffUrB84IPKkFG9H9nEvJGW6A==",
+      "dev": true,
+      "optional": true
+    },
+    "@rollup/rollup-win32-arm64-msvc": {
+      "version": "4.19.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.19.0.tgz",
+      "integrity": "sha512-HxDMKIhmcguGTiP5TsLNolwBUK3nGGUEoV/BO9ldUBoMLBssvh4J0X8pf11i1fTV7WShWItB1bKAKjX4RQeYmg==",
+      "dev": true,
+      "optional": true
+    },
+    "@rollup/rollup-win32-ia32-msvc": {
+      "version": "4.19.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.19.0.tgz",
+      "integrity": "sha512-xItlIAZZaiG/u0wooGzRsx11rokP4qyc/79LkAOdznGRAbOFc+SfEdfUOszG1odsHNgwippUJavag/+W/Etc6Q==",
+      "dev": true,
+      "optional": true
+    },
+    "@rollup/rollup-win32-x64-msvc": {
+      "version": "4.19.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.19.0.tgz",
+      "integrity": "sha512-xNo5fV5ycvCCKqiZcpB65VMR11NJB+StnxHz20jdqRAktfdfzhgjTiJ2doTDQE/7dqGaV5I7ZGqKpgph6lCIag==",
+      "dev": true,
+      "optional": true
+    },
+    "@rushstack/eslint-patch": {
+      "version": "1.6.1",
+      "resolved": "https://registry.npmmirror.com/@rushstack/eslint-patch/-/eslint-patch-1.6.1.tgz",
+      "integrity": "sha512-UY+FGM/2jjMkzQLn8pxcHGMaVLh9aEitG3zY2CiY7XHdLiz3bZOwa6oDxNqEMv7zZkV+cj5DOdz0cQ1BP5Hjgw==",
+      "dev": true
+    },
+    "@tsconfig/node18": {
+      "version": "18.2.2",
+      "resolved": "https://registry.npmmirror.com/@tsconfig/node18/-/node18-18.2.2.tgz",
+      "integrity": "sha512-d6McJeGsuoRlwWZmVIeE8CUA27lu6jLjvv1JzqmpsytOYYbVi1tHZEnwCNVOXnj4pyLvneZlFlpXUK+X9wBWyw==",
+      "dev": true
+    },
+    "@types/crypto-js": {
+      "version": "4.2.1",
+      "resolved": "https://registry.npmmirror.com/@types/crypto-js/-/crypto-js-4.2.1.tgz",
+      "integrity": "sha512-FSPGd9+OcSok3RsM0UZ/9fcvMOXJ1ENE/ZbLfOPlBWj7BgXtEAM8VYfTtT760GiLbQIMoVozwVuisjvsVwqYWw==",
+      "dev": true
+    },
+    "@types/dom-webcodecs": {
+      "version": "0.1.18",
+      "resolved": "https://registry.npmmirror.com/@types/dom-webcodecs/-/dom-webcodecs-0.1.18.tgz",
+      "integrity": "sha512-vAvE8C9DGWR+tkb19xyjk1TSUlJ7RUzzp4a9Anu7mwBT+fpyePWK1UxmH14tMO5zHmrnrRIMg5NutnnDztLxgg=="
+    },
+    "@types/estree": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.5.tgz",
+      "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==",
+      "dev": true
+    },
+    "@types/file-saver": {
+      "version": "2.0.7",
+      "resolved": "https://registry.npmmirror.com/@types/file-saver/-/file-saver-2.0.7.tgz",
+      "integrity": "sha512-dNKVfHd/jk0SkR/exKGj2ggkB45MAkzvWCaqLUUgkyjITkGNzH8H+yUwr+BLJUBjZOe9w8X3wgmXhZDRg1ED6A==",
+      "dev": true
+    },
+    "@types/html2canvas": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/@types/html2canvas/-/html2canvas-1.0.0.tgz",
+      "integrity": "sha512-BJpVf+FIN9UERmzhbtUgpXj6XBZpG67FMgBLLoj9HZKd9XifcCpSV+UnFcwTZfEyun4U/KmCrrVOG7829L589w==",
+      "dev": true,
+      "requires": {
+        "html2canvas": "*"
+      }
+    },
+    "@types/json-schema": {
+      "version": "7.0.15",
+      "resolved": "https://registry.npmmirror.com/@types/json-schema/-/json-schema-7.0.15.tgz",
+      "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
+      "dev": true
+    },
+    "@types/linkify-it": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz",
+      "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==",
+      "dev": true
+    },
+    "@types/lodash": {
+      "version": "4.14.202",
+      "resolved": "https://registry.npmmirror.com/@types/lodash/-/lodash-4.14.202.tgz",
+      "integrity": "sha512-OvlIYQK9tNneDlS0VN54LLd5uiPCBOp7gS5Z0f1mjoJYBrtStzgmJBxONW3U6OZqdtNzZPmn9BS/7WI7BFFcFQ==",
+      "dev": true
+    },
+    "@types/markdown-it": {
+      "version": "14.1.2",
+      "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz",
+      "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==",
+      "dev": true,
+      "requires": {
+        "@types/linkify-it": "^5",
+        "@types/mdurl": "^2"
+      }
+    },
+    "@types/mdurl": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz",
+      "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==",
+      "dev": true
+    },
+    "@types/minimist": {
+      "version": "1.2.5",
+      "resolved": "https://registry.npmmirror.com/@types/minimist/-/minimist-1.2.5.tgz",
+      "integrity": "sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==",
+      "dev": true
+    },
+    "@types/node": {
+      "version": "18.19.4",
+      "resolved": "https://registry.npmmirror.com/@types/node/-/node-18.19.4.tgz",
+      "integrity": "sha512-xNzlUhzoHotIsnFoXmJB+yWmBvFZgKCI9TtPIEdYIMM1KWfwuY8zh7wvc1u1OAXlC7dlf6mZVx/s+Y5KfFz19A==",
+      "requires": {
+        "undici-types": "~5.26.4"
+      }
+    },
+    "@types/normalize-package-data": {
+      "version": "2.4.4",
+      "resolved": "https://registry.npmmirror.com/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz",
+      "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==",
+      "dev": true
+    },
+    "@types/qs": {
+      "version": "6.14.0",
+      "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
+      "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==",
+      "dev": true
+    },
+    "@types/semver": {
+      "version": "7.5.6",
+      "resolved": "https://registry.npmmirror.com/@types/semver/-/semver-7.5.6.tgz",
+      "integrity": "sha512-dn1l8LaMea/IjDoHNd9J52uBbInB796CDffS6VdIxvqYCPSG0V0DzHp76GpaWnlhg88uYyPbXCDIowa86ybd5A==",
+      "dev": true
+    },
+    "@types/svg-arc-to-cubic-bezier": {
+      "version": "3.2.2",
+      "resolved": "https://registry.npmmirror.com/@types/svg-arc-to-cubic-bezier/-/svg-arc-to-cubic-bezier-3.2.2.tgz",
+      "integrity": "sha512-XQtKy9lmkKlV+c3Jelo7kxNPw7qOqIq3GcnOhywGZHF7zw5D5m+Ssigbmf3Turbe/A8Ur+lRh8TYjuxXKvyivw==",
+      "dev": true
+    },
+    "@types/tinycolor2": {
+      "version": "1.4.6",
+      "resolved": "https://registry.npmmirror.com/@types/tinycolor2/-/tinycolor2-1.4.6.tgz",
+      "integrity": "sha512-iEN8J0BoMnsWBqjVbWH/c0G0Hh7O21lpR2/+PrvAVgWdzL7eexIFm4JN/Wn10PTcmNdtS6U67r499mlWMXOxNw==",
+      "dev": true
+    },
+    "@types/wicg-file-system-access": {
+      "version": "2020.9.8",
+      "resolved": "https://registry.npmmirror.com/@types/wicg-file-system-access/-/wicg-file-system-access-2020.9.8.tgz",
+      "integrity": "sha512-ggMz8nOygG7d/stpH40WVaNvBwuyYLnrg5Mbyf6bmsj/8+gb6Ei4ZZ9/4PNpcPNTT8th9Q8sM8wYmWGjMWLX/A=="
+    },
+    "@typescript-eslint/eslint-plugin": {
+      "version": "6.16.0",
+      "resolved": "https://registry.npmmirror.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.16.0.tgz",
+      "integrity": "sha512-O5f7Kv5o4dLWQtPX4ywPPa+v9G+1q1x8mz0Kr0pXUtKsevo+gIJHLkGc8RxaZWtP8RrhwhSNIWThnW42K9/0rQ==",
+      "dev": true,
+      "requires": {
+        "@eslint-community/regexpp": "^4.5.1",
+        "@typescript-eslint/scope-manager": "6.16.0",
+        "@typescript-eslint/type-utils": "6.16.0",
+        "@typescript-eslint/utils": "6.16.0",
+        "@typescript-eslint/visitor-keys": "6.16.0",
+        "debug": "^4.3.4",
+        "graphemer": "^1.4.0",
+        "ignore": "^5.2.4",
+        "natural-compare": "^1.4.0",
+        "semver": "^7.5.4",
+        "ts-api-utils": "^1.0.1"
+      }
+    },
+    "@typescript-eslint/parser": {
+      "version": "6.16.0",
+      "resolved": "https://registry.npmmirror.com/@typescript-eslint/parser/-/parser-6.16.0.tgz",
+      "integrity": "sha512-H2GM3eUo12HpKZU9njig3DF5zJ58ja6ahj1GoHEHOgQvYxzoFJJEvC1MQ7T2l9Ha+69ZSOn7RTxOdpC/y3ikMw==",
+      "dev": true,
+      "requires": {
+        "@typescript-eslint/scope-manager": "6.16.0",
+        "@typescript-eslint/types": "6.16.0",
+        "@typescript-eslint/typescript-estree": "6.16.0",
+        "@typescript-eslint/visitor-keys": "6.16.0",
+        "debug": "^4.3.4"
+      }
+    },
+    "@typescript-eslint/scope-manager": {
+      "version": "6.16.0",
+      "resolved": "https://registry.npmmirror.com/@typescript-eslint/scope-manager/-/scope-manager-6.16.0.tgz",
+      "integrity": "sha512-0N7Y9DSPdaBQ3sqSCwlrm9zJwkpOuc6HYm7LpzLAPqBL7dmzAUimr4M29dMkOP/tEwvOCC/Cxo//yOfJD3HUiw==",
+      "dev": true,
+      "requires": {
+        "@typescript-eslint/types": "6.16.0",
+        "@typescript-eslint/visitor-keys": "6.16.0"
+      }
+    },
+    "@typescript-eslint/type-utils": {
+      "version": "6.16.0",
+      "resolved": "https://registry.npmmirror.com/@typescript-eslint/type-utils/-/type-utils-6.16.0.tgz",
+      "integrity": "sha512-ThmrEOcARmOnoyQfYkHw/DX2SEYBalVECmoldVuH6qagKROp/jMnfXpAU/pAIWub9c4YTxga+XwgAkoA0pxfmg==",
+      "dev": true,
+      "requires": {
+        "@typescript-eslint/typescript-estree": "6.16.0",
+        "@typescript-eslint/utils": "6.16.0",
+        "debug": "^4.3.4",
+        "ts-api-utils": "^1.0.1"
+      }
+    },
+    "@typescript-eslint/types": {
+      "version": "6.16.0",
+      "resolved": "https://registry.npmmirror.com/@typescript-eslint/types/-/types-6.16.0.tgz",
+      "integrity": "sha512-hvDFpLEvTJoHutVl87+MG/c5C8I6LOgEx05zExTSJDEVU7hhR3jhV8M5zuggbdFCw98+HhZWPHZeKS97kS3JoQ==",
+      "dev": true
+    },
+    "@typescript-eslint/typescript-estree": {
+      "version": "6.16.0",
+      "resolved": "https://registry.npmmirror.com/@typescript-eslint/typescript-estree/-/typescript-estree-6.16.0.tgz",
+      "integrity": "sha512-VTWZuixh/vr7nih6CfrdpmFNLEnoVBF1skfjdyGnNwXOH1SLeHItGdZDHhhAIzd3ACazyY2Fg76zuzOVTaknGA==",
+      "dev": true,
+      "requires": {
+        "@typescript-eslint/types": "6.16.0",
+        "@typescript-eslint/visitor-keys": "6.16.0",
+        "debug": "^4.3.4",
+        "globby": "^11.1.0",
+        "is-glob": "^4.0.3",
+        "minimatch": "9.0.3",
+        "semver": "^7.5.4",
+        "ts-api-utils": "^1.0.1"
+      }
+    },
+    "@typescript-eslint/utils": {
+      "version": "6.16.0",
+      "resolved": "https://registry.npmmirror.com/@typescript-eslint/utils/-/utils-6.16.0.tgz",
+      "integrity": "sha512-T83QPKrBm6n//q9mv7oiSvy/Xq/7Hyw9SzSEhMHJwznEmQayfBM87+oAlkNAMEO7/MjIwKyOHgBJbxB0s7gx2A==",
+      "dev": true,
+      "requires": {
+        "@eslint-community/eslint-utils": "^4.4.0",
+        "@types/json-schema": "^7.0.12",
+        "@types/semver": "^7.5.0",
+        "@typescript-eslint/scope-manager": "6.16.0",
+        "@typescript-eslint/types": "6.16.0",
+        "@typescript-eslint/typescript-estree": "6.16.0",
+        "semver": "^7.5.4"
+      }
+    },
+    "@typescript-eslint/visitor-keys": {
+      "version": "6.16.0",
+      "resolved": "https://registry.npmmirror.com/@typescript-eslint/visitor-keys/-/visitor-keys-6.16.0.tgz",
+      "integrity": "sha512-QSFQLruk7fhs91a/Ep/LqRdbJCZ1Rq03rqBdKT5Ky17Sz8zRLUksqIe9DW0pKtg/Z35/ztbLQ6qpOCN6rOC11A==",
+      "dev": true,
+      "requires": {
+        "@typescript-eslint/types": "6.16.0",
+        "eslint-visitor-keys": "^3.4.1"
+      }
+    },
+    "@ungap/structured-clone": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmmirror.com/@ungap/structured-clone/-/structured-clone-1.2.0.tgz",
+      "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==",
+      "dev": true
+    },
+    "@vitejs/plugin-vue": {
+      "version": "5.1.0",
+      "resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-5.1.0.tgz",
+      "integrity": "sha512-QMRxARyrdiwi1mj3AW4fLByoHTavreXq0itdEW696EihXglf1MB3D4C2gBvE0jMPH29ZjC3iK8aIaUMLf4EOGA==",
+      "dev": true,
+      "requires": {}
+    },
+    "@volar/language-core": {
+      "version": "2.4.0-alpha.18",
+      "resolved": "https://registry.npmmirror.com/@volar/language-core/-/language-core-2.4.0-alpha.18.tgz",
+      "integrity": "sha512-JAYeJvYQQROmVRtSBIczaPjP3DX4QW1fOqW1Ebs0d3Y3EwSNRglz03dSv0Dm61dzd0Yx3WgTW3hndDnTQqgmyg==",
+      "dev": true,
+      "requires": {
+        "@volar/source-map": "2.4.0-alpha.18"
+      }
+    },
+    "@volar/source-map": {
+      "version": "2.4.0-alpha.18",
+      "resolved": "https://registry.npmmirror.com/@volar/source-map/-/source-map-2.4.0-alpha.18.tgz",
+      "integrity": "sha512-MTeCV9MUwwsH0sNFiZwKtFrrVZUK6p8ioZs3xFzHc2cvDXHWlYN3bChdQtwKX+FY2HG6H3CfAu1pKijolzIQ8g==",
+      "dev": true
+    },
+    "@volar/typescript": {
+      "version": "2.4.0-alpha.18",
+      "resolved": "https://registry.npmmirror.com/@volar/typescript/-/typescript-2.4.0-alpha.18.tgz",
+      "integrity": "sha512-sXh5Y8sqGUkgxpMWUGvRXggxYHAVxg0Pa1C42lQZuPDrW6vHJPR0VCK8Sr7WJsAW530HuNQT/ZIskmXtxjybMQ==",
+      "dev": true,
+      "requires": {
+        "@volar/language-core": "2.4.0-alpha.18",
+        "path-browserify": "^1.0.1",
+        "vscode-uri": "^3.0.8"
+      }
+    },
+    "@vue/babel-helper-vue-transform-on": {
+      "version": "1.5.0",
+      "resolved": "https://registry.npmjs.org/@vue/babel-helper-vue-transform-on/-/babel-helper-vue-transform-on-1.5.0.tgz",
+      "integrity": "sha512-0dAYkerNhhHutHZ34JtTl2czVQHUNWv6xEbkdF5W+Yrv5pCWsqjeORdOgbtW2I9gWlt+wBmVn+ttqN9ZxR5tzA==",
+      "dev": true
+    },
+    "@vue/babel-plugin-jsx": {
+      "version": "1.5.0",
+      "resolved": "https://registry.npmjs.org/@vue/babel-plugin-jsx/-/babel-plugin-jsx-1.5.0.tgz",
+      "integrity": "sha512-mneBhw1oOqCd2247O0Yw/mRwC9jIGACAJUlawkmMBiNmL4dGA2eMzuNZVNqOUfYTa6vqmND4CtOPzmEEEqLKFw==",
+      "dev": true,
+      "requires": {
+        "@babel/helper-module-imports": "^7.27.1",
+        "@babel/helper-plugin-utils": "^7.27.1",
+        "@babel/plugin-syntax-jsx": "^7.27.1",
+        "@babel/template": "^7.27.2",
+        "@babel/traverse": "^7.28.0",
+        "@babel/types": "^7.28.2",
+        "@vue/babel-helper-vue-transform-on": "1.5.0",
+        "@vue/babel-plugin-resolve-type": "1.5.0",
+        "@vue/shared": "^3.5.18"
+      },
+      "dependencies": {
+        "@vue/shared": {
+          "version": "3.5.31",
+          "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.31.tgz",
+          "integrity": "sha512-nBxuiuS9Lj5bPkPbWogPUnjxxWpkRniX7e5UBQDWl6Fsf4roq9wwV+cR7ezQ4zXswNvPIlsdj1slcLB7XCsRAw==",
+          "dev": true
+        }
+      }
+    },
+    "@vue/babel-plugin-resolve-type": {
+      "version": "1.5.0",
+      "resolved": "https://registry.npmjs.org/@vue/babel-plugin-resolve-type/-/babel-plugin-resolve-type-1.5.0.tgz",
+      "integrity": "sha512-Wm/60o+53JwJODm4Knz47dxJnLDJ9FnKnGZJbUUf8nQRAtt6P+undLUAVU3Ha33LxOJe6IPoifRQ6F/0RrU31w==",
+      "dev": true,
+      "requires": {
+        "@babel/code-frame": "^7.27.1",
+        "@babel/helper-module-imports": "^7.27.1",
+        "@babel/helper-plugin-utils": "^7.27.1",
+        "@babel/parser": "^7.28.0",
+        "@vue/compiler-sfc": "^3.5.18"
+      },
+      "dependencies": {
+        "@vue/compiler-core": {
+          "version": "3.5.31",
+          "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.31.tgz",
+          "integrity": "sha512-k/ueL14aNIEy5Onf0OVzR8kiqF/WThgLdFhxwa4e/KF/0qe38IwIdofoSWBTvvxQOesaz6riAFAUaYjoF9fLLQ==",
+          "dev": true,
+          "requires": {
+            "@babel/parser": "^7.29.2",
+            "@vue/shared": "3.5.31",
+            "entities": "^7.0.1",
+            "estree-walker": "^2.0.2",
+            "source-map-js": "^1.2.1"
+          }
+        },
+        "@vue/compiler-dom": {
+          "version": "3.5.31",
+          "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.31.tgz",
+          "integrity": "sha512-BMY/ozS/xxjYqRFL+tKdRpATJYDTTgWSo0+AJvJNg4ig+Hgb0dOsHPXvloHQ5hmlivUqw1Yt2pPIqp4e0v1GUw==",
+          "dev": true,
+          "requires": {
+            "@vue/compiler-core": "3.5.31",
+            "@vue/shared": "3.5.31"
+          }
+        },
+        "@vue/compiler-sfc": {
+          "version": "3.5.31",
+          "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.31.tgz",
+          "integrity": "sha512-M8wpPgR9UJ8MiRGjppvx9uWJfLV7A/T+/rL8s/y3QG3u0c2/YZgff3d6SuimKRIhcYnWg5fTfDMlz2E6seUW8Q==",
+          "dev": true,
+          "requires": {
+            "@babel/parser": "^7.29.2",
+            "@vue/compiler-core": "3.5.31",
+            "@vue/compiler-dom": "3.5.31",
+            "@vue/compiler-ssr": "3.5.31",
+            "@vue/shared": "3.5.31",
+            "estree-walker": "^2.0.2",
+            "magic-string": "^0.30.21",
+            "postcss": "^8.5.8",
+            "source-map-js": "^1.2.1"
+          }
+        },
+        "@vue/compiler-ssr": {
+          "version": "3.5.31",
+          "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.31.tgz",
+          "integrity": "sha512-h0xIMxrt/LHOvJKMri+vdYT92BrK3HFLtDqq9Pr/lVVfE4IyKZKvWf0vJFW10Yr6nX02OR4MkJwI0c1HDa1hog==",
+          "dev": true,
+          "requires": {
+            "@vue/compiler-dom": "3.5.31",
+            "@vue/shared": "3.5.31"
+          }
+        },
+        "@vue/shared": {
+          "version": "3.5.31",
+          "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.31.tgz",
+          "integrity": "sha512-nBxuiuS9Lj5bPkPbWogPUnjxxWpkRniX7e5UBQDWl6Fsf4roq9wwV+cR7ezQ4zXswNvPIlsdj1slcLB7XCsRAw==",
+          "dev": true
+        },
+        "entities": {
+          "version": "7.0.1",
+          "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
+          "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
+          "dev": true
+        }
+      }
+    },
+    "@vue/compiler-core": {
+      "version": "3.5.17",
+      "resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.17.tgz",
+      "integrity": "sha512-Xe+AittLbAyV0pabcN7cP7/BenRBNcteM4aSDCtRvGw0d9OL+HG1u/XHLY/kt1q4fyMeZYXyIYrsHuPSiDPosA==",
+      "requires": {
+        "@babel/parser": "^7.27.5",
+        "@vue/shared": "3.5.17",
+        "entities": "^4.5.0",
+        "estree-walker": "^2.0.2",
+        "source-map-js": "^1.2.1"
+      }
+    },
+    "@vue/compiler-dom": {
+      "version": "3.5.17",
+      "resolved": "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.17.tgz",
+      "integrity": "sha512-+2UgfLKoaNLhgfhV5Ihnk6wB4ljyW1/7wUIog2puUqajiC29Lp5R/IKDdkebh9jTbTogTbsgB+OY9cEWzG95JQ==",
+      "requires": {
+        "@vue/compiler-core": "3.5.17",
+        "@vue/shared": "3.5.17"
+      }
+    },
+    "@vue/compiler-sfc": {
+      "version": "3.5.17",
+      "resolved": "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.17.tgz",
+      "integrity": "sha512-rQQxbRJMgTqwRugtjw0cnyQv9cP4/4BxWfTdRBkqsTfLOHWykLzbOc3C4GGzAmdMDxhzU/1Ija5bTjMVrddqww==",
+      "requires": {
+        "@babel/parser": "^7.27.5",
+        "@vue/compiler-core": "3.5.17",
+        "@vue/compiler-dom": "3.5.17",
+        "@vue/compiler-ssr": "3.5.17",
+        "@vue/shared": "3.5.17",
+        "estree-walker": "^2.0.2",
+        "magic-string": "^0.30.17",
+        "postcss": "^8.5.6",
+        "source-map-js": "^1.2.1"
+      }
+    },
+    "@vue/compiler-ssr": {
+      "version": "3.5.17",
+      "resolved": "https://registry.npmmirror.com/@vue/compiler-ssr/-/compiler-ssr-3.5.17.tgz",
+      "integrity": "sha512-hkDbA0Q20ZzGgpj5uZjb9rBzQtIHLS78mMilwrlpWk2Ep37DYntUz0PonQ6kr113vfOEdM+zTBuJDaceNIW0tQ==",
+      "requires": {
+        "@vue/compiler-dom": "3.5.17",
+        "@vue/shared": "3.5.17"
+      }
+    },
+    "@vue/compiler-vue2": {
+      "version": "2.7.16",
+      "resolved": "https://registry.npmmirror.com/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz",
+      "integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==",
+      "dev": true,
+      "requires": {
+        "de-indent": "^1.0.2",
+        "he": "^1.2.0"
+      }
+    },
+    "@vue/devtools-api": {
+      "version": "7.7.2",
+      "resolved": "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-7.7.2.tgz",
+      "integrity": "sha512-1syn558KhyN+chO5SjlZIwJ8bV/bQ1nOVTG66t2RbG66ZGekyiYNmRO7X9BJCXQqPsFHlnksqvPhce2qpzxFnA==",
+      "requires": {
+        "@vue/devtools-kit": "^7.7.2"
+      }
+    },
+    "@vue/devtools-kit": {
+      "version": "7.7.2",
+      "resolved": "https://registry.npmmirror.com/@vue/devtools-kit/-/devtools-kit-7.7.2.tgz",
+      "integrity": "sha512-CY0I1JH3Z8PECbn6k3TqM1Bk9ASWxeMtTCvZr7vb+CHi+X/QwQm5F1/fPagraamKMAHVfuuCbdcnNg1A4CYVWQ==",
+      "requires": {
+        "@vue/devtools-shared": "^7.7.2",
+        "birpc": "^0.2.19",
+        "hookable": "^5.5.3",
+        "mitt": "^3.0.1",
+        "perfect-debounce": "^1.0.0",
+        "speakingurl": "^14.0.1",
+        "superjson": "^2.2.1"
+      }
+    },
+    "@vue/devtools-shared": {
+      "version": "7.7.2",
+      "resolved": "https://registry.npmmirror.com/@vue/devtools-shared/-/devtools-shared-7.7.2.tgz",
+      "integrity": "sha512-uBFxnp8gwW2vD6FrJB8JZLUzVb6PNRG0B0jBnHsOH8uKyva2qINY8PTF5Te4QlTbMDqU5K6qtJDr6cNsKWhbOA==",
+      "requires": {
+        "rfdc": "^1.4.1"
+      }
+    },
+    "@vue/eslint-config-typescript": {
+      "version": "12.0.0",
+      "resolved": "https://registry.npmmirror.com/@vue/eslint-config-typescript/-/eslint-config-typescript-12.0.0.tgz",
+      "integrity": "sha512-StxLFet2Qe97T8+7L8pGlhYBBr8Eg05LPuTDVopQV6il+SK6qqom59BA/rcFipUef2jD8P2X44Vd8tMFytfvlg==",
+      "dev": true,
+      "requires": {
+        "@typescript-eslint/eslint-plugin": "^6.7.0",
+        "@typescript-eslint/parser": "^6.7.0",
+        "vue-eslint-parser": "^9.3.1"
+      }
+    },
+    "@vue/language-core": {
+      "version": "2.0.29",
+      "resolved": "https://registry.npmmirror.com/@vue/language-core/-/language-core-2.0.29.tgz",
+      "integrity": "sha512-o2qz9JPjhdoVj8D2+9bDXbaI4q2uZTHQA/dbyZT4Bj1FR9viZxDJnLcKVHfxdn6wsOzRgpqIzJEEmSSvgMvDTQ==",
+      "dev": true,
+      "requires": {
+        "@volar/language-core": "~2.4.0-alpha.18",
+        "@vue/compiler-dom": "^3.4.0",
+        "@vue/compiler-vue2": "^2.7.16",
+        "@vue/shared": "^3.4.0",
+        "computeds": "^0.0.1",
+        "minimatch": "^9.0.3",
+        "muggle-string": "^0.4.1",
+        "path-browserify": "^1.0.1"
+      }
+    },
+    "@vue/reactivity": {
+      "version": "3.5.17",
+      "resolved": "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.17.tgz",
+      "integrity": "sha512-l/rmw2STIscWi7SNJp708FK4Kofs97zc/5aEPQh4bOsReD/8ICuBcEmS7KGwDj5ODQLYWVN2lNibKJL1z5b+Lw==",
+      "requires": {
+        "@vue/shared": "3.5.17"
+      }
+    },
+    "@vue/runtime-core": {
+      "version": "3.5.17",
+      "resolved": "https://registry.npmmirror.com/@vue/runtime-core/-/runtime-core-3.5.17.tgz",
+      "integrity": "sha512-QQLXa20dHg1R0ri4bjKeGFKEkJA7MMBxrKo2G+gJikmumRS7PTD4BOU9FKrDQWMKowz7frJJGqBffYMgQYS96Q==",
+      "requires": {
+        "@vue/reactivity": "3.5.17",
+        "@vue/shared": "3.5.17"
+      }
+    },
+    "@vue/runtime-dom": {
+      "version": "3.5.17",
+      "resolved": "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.5.17.tgz",
+      "integrity": "sha512-8El0M60TcwZ1QMz4/os2MdlQECgGoVHPuLnQBU3m9h3gdNRW9xRmI8iLS4t/22OQlOE6aJvNNlBiCzPHur4H9g==",
+      "requires": {
+        "@vue/reactivity": "3.5.17",
+        "@vue/runtime-core": "3.5.17",
+        "@vue/shared": "3.5.17",
+        "csstype": "^3.1.3"
+      }
+    },
+    "@vue/server-renderer": {
+      "version": "3.5.17",
+      "resolved": "https://registry.npmmirror.com/@vue/server-renderer/-/server-renderer-3.5.17.tgz",
+      "integrity": "sha512-BOHhm8HalujY6lmC3DbqF6uXN/K00uWiEeF22LfEsm9Q93XeJ/plHTepGwf6tqFcF7GA5oGSSAAUock3VvzaCA==",
+      "requires": {
+        "@vue/compiler-ssr": "3.5.17",
+        "@vue/shared": "3.5.17"
+      }
+    },
+    "@vue/shared": {
+      "version": "3.5.17",
+      "resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.17.tgz",
+      "integrity": "sha512-CabR+UN630VnsJO/jHWYBC1YVXyMq94KKp6iF5MQgZJs5I8cmjw6oVMO1oDbtBkENSHSSn/UadWlW/OAgdmKrg=="
+    },
+    "@vue/tsconfig": {
+      "version": "0.5.1",
+      "resolved": "https://registry.npmmirror.com/@vue/tsconfig/-/tsconfig-0.5.1.tgz",
+      "integrity": "sha512-VcZK7MvpjuTPx2w6blwnwZAu5/LgBUtejFOi3pPGQFXQN5Ela03FUtd2Qtg4yWGGissVL0dr6Ro1LfOFh+PCuQ==",
+      "dev": true
+    },
+    "@yarnpkg/lockfile": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmmirror.com/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz",
+      "integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ=="
+    },
+    "acorn": {
+      "version": "8.11.3",
+      "resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.11.3.tgz",
+      "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==",
+      "dev": true
+    },
+    "acorn-jsx": {
+      "version": "5.3.2",
+      "resolved": "https://registry.npmmirror.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
+      "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
+      "dev": true,
+      "requires": {}
+    },
+    "ajv": {
+      "version": "6.12.6",
+      "resolved": "https://registry.npmmirror.com/ajv/-/ajv-6.12.6.tgz",
+      "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
+      "dev": true,
+      "requires": {
+        "fast-deep-equal": "^3.1.1",
+        "fast-json-stable-stringify": "^2.0.0",
+        "json-schema-traverse": "^0.4.1",
+        "uri-js": "^4.2.2"
+      }
+    },
+    "animate.css": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmmirror.com/animate.css/-/animate.css-4.1.1.tgz",
+      "integrity": "sha512-+mRmCTv6SbCmtYJCN4faJMNFVNN5EuCTTprDTAo7YzIGji2KADmakjVA3+8mVDkZ2Bf09vayB35lSQIex2+QaQ=="
+    },
+    "ansi-regex": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz",
+      "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+      "dev": true
+    },
+    "ansi-styles": {
+      "version": "4.3.0",
+      "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz",
+      "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+      "requires": {
+        "color-convert": "^2.0.1"
+      }
+    },
+    "anymatch": {
+      "version": "3.1.3",
+      "resolved": "https://registry.npmmirror.com/anymatch/-/anymatch-3.1.3.tgz",
+      "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
+      "dev": true,
+      "requires": {
+        "normalize-path": "^3.0.0",
+        "picomatch": "^2.0.4"
+      }
+    },
+    "argparse": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz",
+      "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="
+    },
+    "array-ify": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmmirror.com/array-ify/-/array-ify-1.0.0.tgz",
+      "integrity": "sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==",
+      "dev": true
+    },
+    "array-union": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmmirror.com/array-union/-/array-union-2.1.0.tgz",
+      "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==",
+      "dev": true
+    },
+    "arrify": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmmirror.com/arrify/-/arrify-1.0.1.tgz",
+      "integrity": "sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA==",
+      "dev": true
+    },
+    "asynckit": {
+      "version": "0.4.0",
+      "resolved": "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz",
+      "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
+    },
+    "axios": {
+      "version": "1.7.9",
+      "resolved": "https://registry.npmmirror.com/axios/-/axios-1.7.9.tgz",
+      "integrity": "sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw==",
+      "requires": {
+        "follow-redirects": "^1.15.6",
+        "form-data": "^4.0.0",
+        "proxy-from-env": "^1.1.0"
+      }
+    },
+    "balanced-match": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz",
+      "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+      "dev": true
+    },
+    "base64-arraybuffer": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
+      "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ=="
+    },
+    "baseline-browser-mapping": {
+      "version": "2.10.13",
+      "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.13.tgz",
+      "integrity": "sha512-BL2sTuHOdy0YT1lYieUxTw/QMtPBC3pmlJC6xk8BBYVv6vcw3SGdKemQ+Xsx9ik2F/lYDO9tqsFQH1r9PFuHKw==",
+      "dev": true
+    },
+    "binary-extensions": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmmirror.com/binary-extensions/-/binary-extensions-2.3.0.tgz",
+      "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
+      "dev": true
+    },
+    "birpc": {
+      "version": "0.2.19",
+      "resolved": "https://registry.npmmirror.com/birpc/-/birpc-0.2.19.tgz",
+      "integrity": "sha512-5WeXXAvTmitV1RqJFppT5QtUiz2p1mRSYU000Jkft5ZUCLJIk4uQriYNO50HknxKwM6jd8utNc66K1qGIwwWBQ=="
+    },
+    "boolbase": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmmirror.com/boolbase/-/boolbase-1.0.0.tgz",
+      "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
+      "dev": true
+    },
+    "brace-expansion": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.0.1.tgz",
+      "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
+      "dev": true,
+      "requires": {
+        "balanced-match": "^1.0.0"
+      }
+    },
+    "braces": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmmirror.com/braces/-/braces-3.0.2.tgz",
+      "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==",
+      "requires": {
+        "fill-range": "^7.0.1"
+      }
+    },
+    "browserslist": {
+      "version": "4.28.2",
+      "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
+      "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==",
+      "dev": true,
+      "requires": {
+        "baseline-browser-mapping": "^2.10.12",
+        "caniuse-lite": "^1.0.30001782",
+        "electron-to-chromium": "^1.5.328",
+        "node-releases": "^2.0.36",
+        "update-browserslist-db": "^1.2.3"
+      }
+    },
+    "call-bind": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmmirror.com/call-bind/-/call-bind-1.0.8.tgz",
+      "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==",
+      "requires": {
+        "call-bind-apply-helpers": "^1.0.0",
+        "es-define-property": "^1.0.0",
+        "get-intrinsic": "^1.2.4",
+        "set-function-length": "^1.2.2"
+      }
+    },
+    "call-bind-apply-helpers": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+      "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+      "requires": {
+        "es-errors": "^1.3.0",
+        "function-bind": "^1.1.2"
+      }
+    },
+    "call-bound": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+      "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+      "requires": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "get-intrinsic": "^1.3.0"
+      }
+    },
+    "callsites": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmmirror.com/callsites/-/callsites-3.1.0.tgz",
+      "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
+      "dev": true
+    },
+    "camelcase": {
+      "version": "5.3.1",
+      "resolved": "https://registry.npmmirror.com/camelcase/-/camelcase-5.3.1.tgz",
+      "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
+      "dev": true
+    },
+    "camelcase-keys": {
+      "version": "6.2.2",
+      "resolved": "https://registry.npmmirror.com/camelcase-keys/-/camelcase-keys-6.2.2.tgz",
+      "integrity": "sha512-YrwaA0vEKazPBkn0ipTiMpSajYDSe+KjQfrjhcBMxJt/znbvlHd8Pw/Vamaz5EB4Wfhs3SUR3Z9mwRu/P3s3Yg==",
+      "dev": true,
+      "requires": {
+        "camelcase": "^5.3.1",
+        "map-obj": "^4.0.0",
+        "quick-lru": "^4.0.1"
+      }
+    },
+    "caniuse-lite": {
+      "version": "1.0.30001782",
+      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001782.tgz",
+      "integrity": "sha512-dZcaJLJeDMh4rELYFw1tvSn1bhZWYFOt468FcbHHxx/Z/dFidd1I6ciyFdi3iwfQCyOjqo9upF6lGQYtMiJWxw==",
+      "dev": true
+    },
+    "chalk": {
+      "version": "4.1.2",
+      "resolved": "https://registry.npmmirror.com/chalk/-/chalk-4.1.2.tgz",
+      "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+      "requires": {
+        "ansi-styles": "^4.1.0",
+        "supports-color": "^7.1.0"
+      }
+    },
+    "chokidar": {
+      "version": "3.6.0",
+      "resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-3.6.0.tgz",
+      "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
+      "dev": true,
+      "requires": {
+        "anymatch": "~3.1.2",
+        "braces": "~3.0.2",
+        "fsevents": "~2.3.2",
+        "glob-parent": "~5.1.2",
+        "is-binary-path": "~2.1.0",
+        "is-glob": "~4.0.1",
+        "normalize-path": "~3.0.0",
+        "readdirp": "~3.6.0"
+      },
+      "dependencies": {
+        "glob-parent": {
+          "version": "5.1.2",
+          "resolved": "https://registry.npmmirror.com/glob-parent/-/glob-parent-5.1.2.tgz",
+          "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+          "dev": true,
+          "requires": {
+            "is-glob": "^4.0.1"
+          }
+        }
+      }
+    },
+    "ci-info": {
+      "version": "3.9.0",
+      "resolved": "https://registry.npmmirror.com/ci-info/-/ci-info-3.9.0.tgz",
+      "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ=="
+    },
+    "clipboard": {
+      "version": "2.0.11",
+      "resolved": "https://registry.npmmirror.com/clipboard/-/clipboard-2.0.11.tgz",
+      "integrity": "sha512-C+0bbOqkezLIsmWSvlsXS0Q0bmkugu7jcfMIACB+RDEntIzQIkdr148we28AfSloQLRdZlYL/QYyrq05j/3Faw==",
+      "requires": {
+        "good-listener": "^1.2.2",
+        "select": "^1.1.2",
+        "tiny-emitter": "^2.0.0"
+      }
+    },
+    "cliui": {
+      "version": "8.0.1",
+      "resolved": "https://registry.npmmirror.com/cliui/-/cliui-8.0.1.tgz",
+      "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
+      "dev": true,
+      "requires": {
+        "string-width": "^4.2.0",
+        "strip-ansi": "^6.0.1",
+        "wrap-ansi": "^7.0.0"
+      }
+    },
+    "codepage": {
+      "version": "1.15.0",
+      "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
+      "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA=="
+    },
+    "color-convert": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz",
+      "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+      "requires": {
+        "color-name": "~1.1.4"
+      }
+    },
+    "color-name": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmmirror.com/color-name/-/color-name-1.1.4.tgz",
+      "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
+    },
+    "combined-stream": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz",
+      "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+      "requires": {
+        "delayed-stream": "~1.0.0"
+      }
+    },
+    "commander": {
+      "version": "8.3.0",
+      "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
+      "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="
+    },
+    "compare-func": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmmirror.com/compare-func/-/compare-func-2.0.0.tgz",
+      "integrity": "sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==",
+      "dev": true,
+      "requires": {
+        "array-ify": "^1.0.0",
+        "dot-prop": "^5.1.0"
+      }
+    },
+    "computeds": {
+      "version": "0.0.1",
+      "resolved": "https://registry.npmmirror.com/computeds/-/computeds-0.0.1.tgz",
+      "integrity": "sha512-7CEBgcMjVmitjYo5q8JTJVra6X5mQ20uTThdK+0kR7UEaDrAWEQcRiBtWJzga4eRpP6afNwwLsX2SET2JhVB1Q==",
+      "dev": true
+    },
+    "concat-map": {
+      "version": "0.0.1",
+      "resolved": "https://registry.npmmirror.com/concat-map/-/concat-map-0.0.1.tgz",
+      "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
+      "dev": true
+    },
+    "conventional-changelog-angular": {
+      "version": "7.0.0",
+      "resolved": "https://registry.npmmirror.com/conventional-changelog-angular/-/conventional-changelog-angular-7.0.0.tgz",
+      "integrity": "sha512-ROjNchA9LgfNMTTFSIWPzebCwOGFdgkEq45EnvvrmSLvCtAw0HSmrCs7/ty+wAeYUZyNay0YMUNYFTRL72PkBQ==",
+      "dev": true,
+      "requires": {
+        "compare-func": "^2.0.0"
+      }
+    },
+    "conventional-changelog-conventionalcommits": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmmirror.com/conventional-changelog-conventionalcommits/-/conventional-changelog-conventionalcommits-7.0.2.tgz",
+      "integrity": "sha512-NKXYmMR/Hr1DevQegFB4MwfM5Vv0m4UIxKZTTYuD98lpTknaZlSRrDOG4X7wIXpGkfsYxZTghUN+Qq+T0YQI7w==",
+      "dev": true,
+      "requires": {
+        "compare-func": "^2.0.0"
+      }
+    },
+    "conventional-commits-parser": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmmirror.com/conventional-commits-parser/-/conventional-commits-parser-5.0.0.tgz",
+      "integrity": "sha512-ZPMl0ZJbw74iS9LuX9YIAiW8pfM5p3yh2o/NbXHbkFuZzY5jvdi5jFycEOkmBW5H5I7nA+D6f3UcsCLP2vvSEA==",
+      "dev": true,
+      "requires": {
+        "is-text-path": "^2.0.0",
+        "JSONStream": "^1.3.5",
+        "meow": "^12.0.1",
+        "split2": "^4.0.0"
+      }
+    },
+    "convert-source-map": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+      "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+      "dev": true
+    },
+    "copy-anything": {
+      "version": "3.0.5",
+      "resolved": "https://registry.npmmirror.com/copy-anything/-/copy-anything-3.0.5.tgz",
+      "integrity": "sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==",
+      "requires": {
+        "is-what": "^4.1.8"
+      }
+    },
+    "core-js-pure": {
+      "version": "3.47.0",
+      "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.47.0.tgz",
+      "integrity": "sha512-BcxeDbzUrRnXGYIVAGFtcGQVNpFcUhVjr6W7F8XktvQW2iJP9e66GP6xdKotCRFlrxBvNIBrhwKteRXqMV86Nw=="
+    },
+    "core-util-is": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmmirror.com/core-util-is/-/core-util-is-1.0.3.tgz",
+      "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="
+    },
+    "cosmiconfig": {
+      "version": "8.3.6",
+      "resolved": "https://registry.npmmirror.com/cosmiconfig/-/cosmiconfig-8.3.6.tgz",
+      "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==",
+      "dev": true,
+      "requires": {
+        "import-fresh": "^3.3.0",
+        "js-yaml": "^4.1.0",
+        "parse-json": "^5.2.0",
+        "path-type": "^4.0.0"
+      },
+      "dependencies": {
+        "json-parse-even-better-errors": {
+          "version": "2.3.1",
+          "resolved": "https://registry.npmmirror.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz",
+          "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",
+          "dev": true
+        },
+        "lines-and-columns": {
+          "version": "1.2.4",
+          "resolved": "https://registry.npmmirror.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
+          "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
+          "dev": true
+        },
+        "parse-json": {
+          "version": "5.2.0",
+          "resolved": "https://registry.npmmirror.com/parse-json/-/parse-json-5.2.0.tgz",
+          "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==",
+          "dev": true,
+          "requires": {
+            "@babel/code-frame": "^7.0.0",
+            "error-ex": "^1.3.1",
+            "json-parse-even-better-errors": "^2.3.0",
+            "lines-and-columns": "^1.1.6"
+          }
+        }
+      }
+    },
+    "cosmiconfig-typescript-loader": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmmirror.com/cosmiconfig-typescript-loader/-/cosmiconfig-typescript-loader-5.0.0.tgz",
+      "integrity": "sha512-+8cK7jRAReYkMwMiG+bxhcNKiHJDM6bR9FD/nGBXOWdMLuYawjF5cGrtLilJ+LGd3ZjCXnJjR5DkfWPoIVlqJA==",
+      "dev": true,
+      "requires": {
+        "jiti": "^1.19.1"
+      }
+    },
+    "cross-spawn": {
+      "version": "7.0.3",
+      "resolved": "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.3.tgz",
+      "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
+      "requires": {
+        "path-key": "^3.1.0",
+        "shebang-command": "^2.0.0",
+        "which": "^2.0.1"
+      }
+    },
+    "crypto-js": {
+      "version": "4.2.0",
+      "resolved": "https://registry.npmmirror.com/crypto-js/-/crypto-js-4.2.0.tgz",
+      "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q=="
+    },
+    "css-line-break": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
+      "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
+      "requires": {
+        "utrie": "^1.0.2"
+      }
+    },
+    "cssesc": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmmirror.com/cssesc/-/cssesc-3.0.0.tgz",
+      "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
+      "dev": true
+    },
+    "csstype": {
+      "version": "3.1.3",
+      "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.1.3.tgz",
+      "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="
+    },
+    "dargs": {
+      "version": "7.0.0",
+      "resolved": "https://registry.npmmirror.com/dargs/-/dargs-7.0.0.tgz",
+      "integrity": "sha512-2iy1EkLdlBzQGvbweYRFxmFath8+K7+AKB0TlhHWkNuH+TmovaMH/Wp7V7R4u7f4SnX3OgLsU9t1NI9ioDnUpg==",
+      "dev": true
+    },
+    "de-indent": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmmirror.com/de-indent/-/de-indent-1.0.2.tgz",
+      "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==",
+      "dev": true
+    },
+    "debug": {
+      "version": "4.3.4",
+      "resolved": "https://registry.npmmirror.com/debug/-/debug-4.3.4.tgz",
+      "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
+      "dev": true,
+      "requires": {
+        "ms": "2.1.2"
+      }
+    },
+    "decamelize": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmmirror.com/decamelize/-/decamelize-1.2.0.tgz",
+      "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
+      "dev": true
+    },
+    "decamelize-keys": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmmirror.com/decamelize-keys/-/decamelize-keys-1.1.1.tgz",
+      "integrity": "sha512-WiPxgEirIV0/eIOMcnFBA3/IJZAZqKnwAwWyvvdi4lsr1WCN22nhdf/3db3DoZcUjTV2SqfzIwNyp6y2xs3nmg==",
+      "dev": true,
+      "requires": {
+        "decamelize": "^1.1.0",
+        "map-obj": "^1.0.0"
+      },
+      "dependencies": {
+        "map-obj": {
+          "version": "1.0.1",
+          "resolved": "https://registry.npmmirror.com/map-obj/-/map-obj-1.0.1.tgz",
+          "integrity": "sha512-7N/q3lyZ+LVCp7PzuxrJr4KMbBE2hW7BT7YNia330OFxIf4d3r5zVpicP2650l7CPN6RM9zOJRl3NGpqSiw3Eg==",
+          "dev": true
+        }
+      }
+    },
+    "deep-is": {
+      "version": "0.1.4",
+      "resolved": "https://registry.npmmirror.com/deep-is/-/deep-is-0.1.4.tgz",
+      "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
+      "dev": true
+    },
+    "define-data-property": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmmirror.com/define-data-property/-/define-data-property-1.1.4.tgz",
+      "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
+      "requires": {
+        "es-define-property": "^1.0.0",
+        "es-errors": "^1.3.0",
+        "gopd": "^1.0.1"
+      }
+    },
+    "delayed-stream": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz",
+      "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="
+    },
+    "delegate": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmmirror.com/delegate/-/delegate-3.2.0.tgz",
+      "integrity": "sha512-IofjkYBZaZivn0V8nnsMJGBr4jVLxHDheKSW88PyxS5QC4Vo9ZbZVvhzlSxY87fVq3STR6r+4cGepyHkcWOQSw=="
+    },
+    "dexie": {
+      "version": "4.0.11",
+      "resolved": "https://registry.npmmirror.com/dexie/-/dexie-4.0.11.tgz",
+      "integrity": "sha512-SOKO002EqlvBYYKQSew3iymBoN2EQ4BDw/3yprjh7kAfFzjBYkaMNa/pZvcA7HSWlcKSQb9XhPe3wKyQ0x4A8A=="
+    },
+    "dir-glob": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmmirror.com/dir-glob/-/dir-glob-3.0.1.tgz",
+      "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==",
+      "dev": true,
+      "requires": {
+        "path-type": "^4.0.0"
+      }
+    },
+    "doctrine": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmmirror.com/doctrine/-/doctrine-3.0.0.tgz",
+      "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==",
+      "dev": true,
+      "requires": {
+        "esutils": "^2.0.2"
+      }
+    },
+    "dot-prop": {
+      "version": "5.3.0",
+      "resolved": "https://registry.npmmirror.com/dot-prop/-/dot-prop-5.3.0.tgz",
+      "integrity": "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==",
+      "dev": true,
+      "requires": {
+        "is-obj": "^2.0.0"
+      }
+    },
+    "dunder-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+      "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+      "requires": {
+        "call-bind-apply-helpers": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "gopd": "^1.2.0"
+      }
+    },
+    "echarts": {
+      "version": "5.6.0",
+      "resolved": "https://registry.npmjs.org/echarts/-/echarts-5.6.0.tgz",
+      "integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==",
+      "requires": {
+        "tslib": "2.3.0",
+        "zrender": "5.6.1"
+      }
+    },
+    "echarts-wordcloud": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/echarts-wordcloud/-/echarts-wordcloud-2.1.0.tgz",
+      "integrity": "sha512-Kt1JmbcROgb+3IMI48KZECK2AP5lG6bSsOEs+AsuwaWJxQom31RTNd6NFYI01E/YaI1PFZeueaupjlmzSQasjQ==",
+      "requires": {}
+    },
+    "electron-to-chromium": {
+      "version": "1.5.330",
+      "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.330.tgz",
+      "integrity": "sha512-jFNydB5kFtYUobh4IkWUnXeyDbjf/r9gcUEXe1xcrcUxIGfTdzPXA+ld6zBRbwvgIGVzDll/LTIiDztEtckSnA==",
+      "dev": true
+    },
+    "emoji-regex": {
+      "version": "8.0.0",
+      "resolved": "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-8.0.0.tgz",
+      "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+      "dev": true
+    },
+    "entities": {
+      "version": "4.5.0",
+      "resolved": "https://registry.npmmirror.com/entities/-/entities-4.5.0.tgz",
+      "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="
+    },
+    "error-ex": {
+      "version": "1.3.2",
+      "resolved": "https://registry.npmmirror.com/error-ex/-/error-ex-1.3.2.tgz",
+      "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==",
+      "dev": true,
+      "requires": {
+        "is-arrayish": "^0.2.1"
+      }
+    },
+    "es-define-property": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+      "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="
+    },
+    "es-errors": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+      "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="
+    },
+    "es-object-atoms": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+      "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+      "requires": {
+        "es-errors": "^1.3.0"
+      }
+    },
+    "esbuild": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.21.5.tgz",
+      "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
+      "dev": true,
+      "requires": {
+        "@esbuild/aix-ppc64": "0.21.5",
+        "@esbuild/android-arm": "0.21.5",
+        "@esbuild/android-arm64": "0.21.5",
+        "@esbuild/android-x64": "0.21.5",
+        "@esbuild/darwin-arm64": "0.21.5",
+        "@esbuild/darwin-x64": "0.21.5",
+        "@esbuild/freebsd-arm64": "0.21.5",
+        "@esbuild/freebsd-x64": "0.21.5",
+        "@esbuild/linux-arm": "0.21.5",
+        "@esbuild/linux-arm64": "0.21.5",
+        "@esbuild/linux-ia32": "0.21.5",
+        "@esbuild/linux-loong64": "0.21.5",
+        "@esbuild/linux-mips64el": "0.21.5",
+        "@esbuild/linux-ppc64": "0.21.5",
+        "@esbuild/linux-riscv64": "0.21.5",
+        "@esbuild/linux-s390x": "0.21.5",
+        "@esbuild/linux-x64": "0.21.5",
+        "@esbuild/netbsd-x64": "0.21.5",
+        "@esbuild/openbsd-x64": "0.21.5",
+        "@esbuild/sunos-x64": "0.21.5",
+        "@esbuild/win32-arm64": "0.21.5",
+        "@esbuild/win32-ia32": "0.21.5",
+        "@esbuild/win32-x64": "0.21.5"
+      }
+    },
+    "escalade": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+      "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+      "dev": true
+    },
+    "escape-string-regexp": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+      "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+      "dev": true
+    },
+    "eslint": {
+      "version": "8.56.0",
+      "resolved": "https://registry.npmmirror.com/eslint/-/eslint-8.56.0.tgz",
+      "integrity": "sha512-Go19xM6T9puCOWntie1/P997aXxFsOi37JIHRWI514Hc6ZnaHGKY9xFhrU65RT6CcBEzZoGG1e6Nq+DT04ZtZQ==",
+      "dev": true,
+      "requires": {
+        "@eslint-community/eslint-utils": "^4.2.0",
+        "@eslint-community/regexpp": "^4.6.1",
+        "@eslint/eslintrc": "^2.1.4",
+        "@eslint/js": "8.56.0",
+        "@humanwhocodes/config-array": "^0.11.13",
+        "@humanwhocodes/module-importer": "^1.0.1",
+        "@nodelib/fs.walk": "^1.2.8",
+        "@ungap/structured-clone": "^1.2.0",
+        "ajv": "^6.12.4",
+        "chalk": "^4.0.0",
+        "cross-spawn": "^7.0.2",
+        "debug": "^4.3.2",
+        "doctrine": "^3.0.0",
+        "escape-string-regexp": "^4.0.0",
+        "eslint-scope": "^7.2.2",
+        "eslint-visitor-keys": "^3.4.3",
+        "espree": "^9.6.1",
+        "esquery": "^1.4.2",
+        "esutils": "^2.0.2",
+        "fast-deep-equal": "^3.1.3",
+        "file-entry-cache": "^6.0.1",
+        "find-up": "^5.0.0",
+        "glob-parent": "^6.0.2",
+        "globals": "^13.19.0",
+        "graphemer": "^1.4.0",
+        "ignore": "^5.2.0",
+        "imurmurhash": "^0.1.4",
+        "is-glob": "^4.0.0",
+        "is-path-inside": "^3.0.3",
+        "js-yaml": "^4.1.0",
+        "json-stable-stringify-without-jsonify": "^1.0.1",
+        "levn": "^0.4.1",
+        "lodash.merge": "^4.6.2",
+        "minimatch": "^3.1.2",
+        "natural-compare": "^1.4.0",
+        "optionator": "^0.9.3",
+        "strip-ansi": "^6.0.1",
+        "text-table": "^0.2.0"
+      },
+      "dependencies": {
+        "brace-expansion": {
+          "version": "1.1.11",
+          "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.11.tgz",
+          "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+          "dev": true,
+          "requires": {
+            "balanced-match": "^1.0.0",
+            "concat-map": "0.0.1"
+          }
+        },
+        "minimatch": {
+          "version": "3.1.2",
+          "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.2.tgz",
+          "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+          "dev": true,
+          "requires": {
+            "brace-expansion": "^1.1.7"
+          }
+        }
+      }
+    },
+    "eslint-plugin-vue": {
+      "version": "9.19.2",
+      "resolved": "https://registry.npmmirror.com/eslint-plugin-vue/-/eslint-plugin-vue-9.19.2.tgz",
+      "integrity": "sha512-CPDqTOG2K4Ni2o4J5wixkLVNwgctKXFu6oBpVJlpNq7f38lh9I80pRTouZSJ2MAebPJlINU/KTFSXyQfBUlymA==",
+      "dev": true,
+      "requires": {
+        "@eslint-community/eslint-utils": "^4.4.0",
+        "natural-compare": "^1.4.0",
+        "nth-check": "^2.1.1",
+        "postcss-selector-parser": "^6.0.13",
+        "semver": "^7.5.4",
+        "vue-eslint-parser": "^9.3.1",
+        "xml-name-validator": "^4.0.0"
+      }
+    },
+    "eslint-scope": {
+      "version": "7.2.2",
+      "resolved": "https://registry.npmmirror.com/eslint-scope/-/eslint-scope-7.2.2.tgz",
+      "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==",
+      "dev": true,
+      "requires": {
+        "esrecurse": "^4.3.0",
+        "estraverse": "^5.2.0"
+      }
+    },
+    "eslint-visitor-keys": {
+      "version": "3.4.3",
+      "resolved": "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
+      "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
+      "dev": true
+    },
+    "espree": {
+      "version": "9.6.1",
+      "resolved": "https://registry.npmmirror.com/espree/-/espree-9.6.1.tgz",
+      "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==",
+      "dev": true,
+      "requires": {
+        "acorn": "^8.9.0",
+        "acorn-jsx": "^5.3.2",
+        "eslint-visitor-keys": "^3.4.1"
+      }
+    },
+    "esquery": {
+      "version": "1.5.0",
+      "resolved": "https://registry.npmmirror.com/esquery/-/esquery-1.5.0.tgz",
+      "integrity": "sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==",
+      "dev": true,
+      "requires": {
+        "estraverse": "^5.1.0"
+      }
+    },
+    "esrecurse": {
+      "version": "4.3.0",
+      "resolved": "https://registry.npmmirror.com/esrecurse/-/esrecurse-4.3.0.tgz",
+      "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
+      "dev": true,
+      "requires": {
+        "estraverse": "^5.2.0"
+      }
+    },
+    "estraverse": {
+      "version": "5.3.0",
+      "resolved": "https://registry.npmmirror.com/estraverse/-/estraverse-5.3.0.tgz",
+      "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
+      "dev": true
+    },
+    "estree-walker": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz",
+      "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="
+    },
+    "esutils": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmmirror.com/esutils/-/esutils-2.0.3.tgz",
+      "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
+      "dev": true
+    },
+    "execa": {
+      "version": "5.1.1",
+      "resolved": "https://registry.npmmirror.com/execa/-/execa-5.1.1.tgz",
+      "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==",
+      "dev": true,
+      "requires": {
+        "cross-spawn": "^7.0.3",
+        "get-stream": "^6.0.0",
+        "human-signals": "^2.1.0",
+        "is-stream": "^2.0.0",
+        "merge-stream": "^2.0.0",
+        "npm-run-path": "^4.0.1",
+        "onetime": "^5.1.2",
+        "signal-exit": "^3.0.3",
+        "strip-final-newline": "^2.0.0"
+      }
+    },
+    "fast-deep-equal": {
+      "version": "3.1.3",
+      "resolved": "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+      "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+      "dev": true
+    },
+    "fast-glob": {
+      "version": "3.3.2",
+      "resolved": "https://registry.npmmirror.com/fast-glob/-/fast-glob-3.3.2.tgz",
+      "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==",
+      "dev": true,
+      "requires": {
+        "@nodelib/fs.stat": "^2.0.2",
+        "@nodelib/fs.walk": "^1.2.3",
+        "glob-parent": "^5.1.2",
+        "merge2": "^1.3.0",
+        "micromatch": "^4.0.4"
+      },
+      "dependencies": {
+        "glob-parent": {
+          "version": "5.1.2",
+          "resolved": "https://registry.npmmirror.com/glob-parent/-/glob-parent-5.1.2.tgz",
+          "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+          "dev": true,
+          "requires": {
+            "is-glob": "^4.0.1"
+          }
+        }
+      }
+    },
+    "fast-json-stable-stringify": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmmirror.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
+      "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
+      "dev": true
+    },
+    "fast-levenshtein": {
+      "version": "2.0.6",
+      "resolved": "https://registry.npmmirror.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
+      "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
+      "dev": true
+    },
+    "fastq": {
+      "version": "1.16.0",
+      "resolved": "https://registry.npmmirror.com/fastq/-/fastq-1.16.0.tgz",
+      "integrity": "sha512-ifCoaXsDrsdkWTtiNJX5uzHDsrck5TzfKKDcuFFTIrrc/BS076qgEIfoIy1VeZqViznfKiysPYTh/QeHtnIsYA==",
+      "dev": true,
+      "requires": {
+        "reusify": "^1.0.4"
+      }
+    },
+    "file-entry-cache": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmmirror.com/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
+      "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==",
+      "dev": true,
+      "requires": {
+        "flat-cache": "^3.0.4"
+      }
+    },
+    "file-saver": {
+      "version": "2.0.5",
+      "resolved": "https://registry.npmmirror.com/file-saver/-/file-saver-2.0.5.tgz",
+      "integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA=="
+    },
+    "fill-range": {
+      "version": "7.0.1",
+      "resolved": "https://registry.npmmirror.com/fill-range/-/fill-range-7.0.1.tgz",
+      "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==",
+      "requires": {
+        "to-regex-range": "^5.0.1"
+      }
+    },
+    "find-up": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmmirror.com/find-up/-/find-up-5.0.0.tgz",
+      "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
+      "dev": true,
+      "requires": {
+        "locate-path": "^6.0.0",
+        "path-exists": "^4.0.0"
+      }
+    },
+    "find-yarn-workspace-root": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmmirror.com/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz",
+      "integrity": "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ==",
+      "requires": {
+        "micromatch": "^4.0.2"
+      }
+    },
+    "flat-cache": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmmirror.com/flat-cache/-/flat-cache-3.2.0.tgz",
+      "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==",
+      "dev": true,
+      "requires": {
+        "flatted": "^3.2.9",
+        "keyv": "^4.5.3",
+        "rimraf": "^3.0.2"
+      }
+    },
+    "flatted": {
+      "version": "3.2.9",
+      "resolved": "https://registry.npmmirror.com/flatted/-/flatted-3.2.9.tgz",
+      "integrity": "sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ==",
+      "dev": true
+    },
+    "follow-redirects": {
+      "version": "1.15.9",
+      "resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.15.9.tgz",
+      "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ=="
+    },
+    "form-data": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmmirror.com/form-data/-/form-data-4.0.1.tgz",
+      "integrity": "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==",
+      "requires": {
+        "asynckit": "^0.4.0",
+        "combined-stream": "^1.0.8",
+        "mime-types": "^2.1.12"
+      }
+    },
+    "fs-extra": {
+      "version": "11.2.0",
+      "resolved": "https://registry.npmmirror.com/fs-extra/-/fs-extra-11.2.0.tgz",
+      "integrity": "sha512-PmDi3uwK5nFuXh7XDTlVnS17xJS7vW36is2+w3xcv8SVxiB4NyATf4ctkVY5bkSjX0Y4nbvZCq1/EjtEyr9ktw==",
+      "dev": true,
+      "requires": {
+        "graceful-fs": "^4.2.0",
+        "jsonfile": "^6.0.1",
+        "universalify": "^2.0.0"
+      }
+    },
+    "fs.realpath": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmmirror.com/fs.realpath/-/fs.realpath-1.0.0.tgz",
+      "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
+      "dev": true
+    },
+    "fsevents": {
+      "version": "2.3.3",
+      "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz",
+      "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+      "dev": true,
+      "optional": true
+    },
+    "function-bind": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz",
+      "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="
+    },
+    "gensync": {
+      "version": "1.0.0-beta.2",
+      "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
+      "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
+      "dev": true
+    },
+    "get-caller-file": {
+      "version": "2.0.5",
+      "resolved": "https://registry.npmmirror.com/get-caller-file/-/get-caller-file-2.0.5.tgz",
+      "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
+      "dev": true
+    },
+    "get-intrinsic": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+      "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+      "requires": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "es-define-property": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "es-object-atoms": "^1.1.1",
+        "function-bind": "^1.1.2",
+        "get-proto": "^1.0.1",
+        "gopd": "^1.2.0",
+        "has-symbols": "^1.1.0",
+        "hasown": "^2.0.2",
+        "math-intrinsics": "^1.1.0"
+      }
+    },
+    "get-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+      "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+      "requires": {
+        "dunder-proto": "^1.0.1",
+        "es-object-atoms": "^1.0.0"
+      }
+    },
+    "get-stream": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmmirror.com/get-stream/-/get-stream-6.0.1.tgz",
+      "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==",
+      "dev": true
+    },
+    "git-raw-commits": {
+      "version": "2.0.11",
+      "resolved": "https://registry.npmmirror.com/git-raw-commits/-/git-raw-commits-2.0.11.tgz",
+      "integrity": "sha512-VnctFhw+xfj8Va1xtfEqCUD2XDrbAPSJx+hSrE5K7fGdjZruW7XV+QOrN7LF/RJyvspRiD2I0asWsxFp0ya26A==",
+      "dev": true,
+      "requires": {
+        "dargs": "^7.0.0",
+        "lodash": "^4.17.15",
+        "meow": "^8.0.0",
+        "split2": "^3.0.0",
+        "through2": "^4.0.0"
+      },
+      "dependencies": {
+        "hosted-git-info": {
+          "version": "4.1.0",
+          "resolved": "https://registry.npmmirror.com/hosted-git-info/-/hosted-git-info-4.1.0.tgz",
+          "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==",
+          "dev": true,
+          "requires": {
+            "lru-cache": "^6.0.0"
+          }
+        },
+        "lru-cache": {
+          "version": "6.0.0",
+          "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-6.0.0.tgz",
+          "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
+          "dev": true,
+          "requires": {
+            "yallist": "^4.0.0"
+          }
+        },
+        "meow": {
+          "version": "8.1.2",
+          "resolved": "https://registry.npmmirror.com/meow/-/meow-8.1.2.tgz",
+          "integrity": "sha512-r85E3NdZ+mpYk1C6RjPFEMSE+s1iZMuHtsHAqY0DT3jZczl0diWUZ8g6oU7h0M9cD2EL+PzaYghhCLzR0ZNn5Q==",
+          "dev": true,
+          "requires": {
+            "@types/minimist": "^1.2.0",
+            "camelcase-keys": "^6.2.2",
+            "decamelize-keys": "^1.1.0",
+            "hard-rejection": "^2.1.0",
+            "minimist-options": "4.1.0",
+            "normalize-package-data": "^3.0.0",
+            "read-pkg-up": "^7.0.1",
+            "redent": "^3.0.0",
+            "trim-newlines": "^3.0.0",
+            "type-fest": "^0.18.0",
+            "yargs-parser": "^20.2.3"
+          }
+        },
+        "normalize-package-data": {
+          "version": "3.0.3",
+          "resolved": "https://registry.npmmirror.com/normalize-package-data/-/normalize-package-data-3.0.3.tgz",
+          "integrity": "sha512-p2W1sgqij3zMMyRC067Dg16bfzVH+w7hyegmpIvZ4JNjqtGOVAIvLmjBx3yP7YTe9vKJgkoNOPjwQGogDoMXFA==",
+          "dev": true,
+          "requires": {
+            "hosted-git-info": "^4.0.1",
+            "is-core-module": "^2.5.0",
+            "semver": "^7.3.4",
+            "validate-npm-package-license": "^3.0.1"
+          }
+        },
+        "readable-stream": {
+          "version": "3.6.2",
+          "resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-3.6.2.tgz",
+          "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
+          "dev": true,
+          "requires": {
+            "inherits": "^2.0.3",
+            "string_decoder": "^1.1.1",
+            "util-deprecate": "^1.0.1"
+          }
+        },
+        "split2": {
+          "version": "3.2.2",
+          "resolved": "https://registry.npmmirror.com/split2/-/split2-3.2.2.tgz",
+          "integrity": "sha512-9NThjpgZnifTkJpzTZ7Eue85S49QwpNhZTq6GRJwObb6jnLFNGB7Qm73V5HewTROPyxD0C29xqmaI68bQtV+hg==",
+          "dev": true,
+          "requires": {
+            "readable-stream": "^3.0.0"
+          }
+        },
+        "through2": {
+          "version": "4.0.2",
+          "resolved": "https://registry.npmmirror.com/through2/-/through2-4.0.2.tgz",
+          "integrity": "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==",
+          "dev": true,
+          "requires": {
+            "readable-stream": "3"
+          }
+        },
+        "type-fest": {
+          "version": "0.18.1",
+          "resolved": "https://registry.npmmirror.com/type-fest/-/type-fest-0.18.1.tgz",
+          "integrity": "sha512-OIAYXk8+ISY+qTOwkHtKqzAuxchoMiD9Udx+FSGQDuiRR+PJKJHc2NJAXlbhkGwTt/4/nKZxELY1w3ReWOL8mw==",
+          "dev": true
+        },
+        "yargs-parser": {
+          "version": "20.2.9",
+          "resolved": "https://registry.npmmirror.com/yargs-parser/-/yargs-parser-20.2.9.tgz",
+          "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==",
+          "dev": true
+        }
+      }
+    },
+    "glob": {
+      "version": "7.2.3",
+      "resolved": "https://registry.npmmirror.com/glob/-/glob-7.2.3.tgz",
+      "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
+      "dev": true,
+      "requires": {
+        "fs.realpath": "^1.0.0",
+        "inflight": "^1.0.4",
+        "inherits": "2",
+        "minimatch": "^3.1.1",
+        "once": "^1.3.0",
+        "path-is-absolute": "^1.0.0"
+      },
+      "dependencies": {
+        "brace-expansion": {
+          "version": "1.1.11",
+          "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.11.tgz",
+          "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+          "dev": true,
+          "requires": {
+            "balanced-match": "^1.0.0",
+            "concat-map": "0.0.1"
+          }
+        },
+        "minimatch": {
+          "version": "3.1.2",
+          "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.2.tgz",
+          "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+          "dev": true,
+          "requires": {
+            "brace-expansion": "^1.1.7"
+          }
+        }
+      }
+    },
+    "glob-parent": {
+      "version": "6.0.2",
+      "resolved": "https://registry.npmmirror.com/glob-parent/-/glob-parent-6.0.2.tgz",
+      "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
+      "dev": true,
+      "requires": {
+        "is-glob": "^4.0.3"
+      }
+    },
+    "global-dirs": {
+      "version": "0.1.1",
+      "resolved": "https://registry.npmmirror.com/global-dirs/-/global-dirs-0.1.1.tgz",
+      "integrity": "sha512-NknMLn7F2J7aflwFOlGdNIuCDpN3VGoSoB+aap3KABFWbHVn1TCgFC+np23J8W2BiZbjfEw3BFBycSMv1AFblg==",
+      "dev": true,
+      "requires": {
+        "ini": "^1.3.4"
+      }
+    },
+    "globals": {
+      "version": "13.24.0",
+      "resolved": "https://registry.npmmirror.com/globals/-/globals-13.24.0.tgz",
+      "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==",
+      "dev": true,
+      "requires": {
+        "type-fest": "^0.20.2"
+      }
+    },
+    "globby": {
+      "version": "11.1.0",
+      "resolved": "https://registry.npmmirror.com/globby/-/globby-11.1.0.tgz",
+      "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==",
+      "dev": true,
+      "requires": {
+        "array-union": "^2.1.0",
+        "dir-glob": "^3.0.1",
+        "fast-glob": "^3.2.9",
+        "ignore": "^5.2.0",
+        "merge2": "^1.4.1",
+        "slash": "^3.0.0"
+      }
+    },
+    "good-listener": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmmirror.com/good-listener/-/good-listener-1.2.2.tgz",
+      "integrity": "sha512-goW1b+d9q/HIwbVYZzZ6SsTr4IgE+WA44A0GmPIQstuOrgsFcT7VEJ48nmr9GaRtNu0XTKacFLGnBPAM6Afouw==",
+      "requires": {
+        "delegate": "^3.1.2"
+      }
+    },
+    "gopd": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+      "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="
+    },
+    "graceful-fs": {
+      "version": "4.2.11",
+      "resolved": "https://registry.npmmirror.com/graceful-fs/-/graceful-fs-4.2.11.tgz",
+      "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="
+    },
+    "graphemer": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmmirror.com/graphemer/-/graphemer-1.4.0.tgz",
+      "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==",
+      "dev": true
+    },
+    "hard-rejection": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmmirror.com/hard-rejection/-/hard-rejection-2.1.0.tgz",
+      "integrity": "sha512-VIZB+ibDhx7ObhAe7OVtoEbuP4h/MuOTHJ+J8h/eBXotJYl0fBgR72xDFCKgIh22OJZIOVNxBMWuhAr10r8HdA==",
+      "dev": true
+    },
+    "has-flag": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmmirror.com/has-flag/-/has-flag-4.0.0.tgz",
+      "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="
+    },
+    "has-property-descriptors": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmmirror.com/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
+      "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
+      "requires": {
+        "es-define-property": "^1.0.0"
+      }
+    },
+    "has-symbols": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+      "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="
+    },
+    "hasown": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
+      "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
+      "requires": {
+        "function-bind": "^1.1.2"
+      }
+    },
+    "he": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmmirror.com/he/-/he-1.2.0.tgz",
+      "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
+      "dev": true
+    },
+    "hfmath": {
+      "version": "0.0.2",
+      "resolved": "https://registry.npmmirror.com/hfmath/-/hfmath-0.0.2.tgz",
+      "integrity": "sha512-cKUi0yiQLGfLgs8+3Iw5nAiqSH13Knp7vCf0G1vlF5nfiKKO1XmxNagMvyp0F4ZvUNaHpRGTkmc7nowCy1S58g=="
+    },
+    "hookable": {
+      "version": "5.5.3",
+      "resolved": "https://registry.npmmirror.com/hookable/-/hookable-5.5.3.tgz",
+      "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="
+    },
+    "hosted-git-info": {
+      "version": "7.0.1",
+      "resolved": "https://registry.npmmirror.com/hosted-git-info/-/hosted-git-info-7.0.1.tgz",
+      "integrity": "sha512-+K84LB1DYwMHoHSgaOY/Jfhw3ucPmSET5v98Ke/HdNSw4a0UktWzyW1mjhjpuxxTqOOsfWT/7iVshHmVZ4IpOA==",
+      "dev": true,
+      "requires": {
+        "lru-cache": "^10.0.1"
+      }
+    },
+    "html-to-image": {
+      "version": "1.11.13",
+      "resolved": "https://registry.npmmirror.com/html-to-image/-/html-to-image-1.11.13.tgz",
+      "integrity": "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg=="
+    },
+    "html2canvas": {
+      "version": "1.4.1",
+      "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
+      "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
+      "requires": {
+        "css-line-break": "^2.1.0",
+        "text-segmentation": "^1.0.3"
+      }
+    },
+    "https": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmmirror.com/https/-/https-1.0.0.tgz",
+      "integrity": "sha512-4EC57ddXrkaF0x83Oj8sM6SLQHAWXw90Skqu2M4AEWENZ3F02dFJE/GARA8igO79tcgYqGrD7ae4f5L3um2lgg=="
+    },
+    "human-signals": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmmirror.com/human-signals/-/human-signals-2.1.0.tgz",
+      "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==",
+      "dev": true
+    },
+    "husky": {
+      "version": "8.0.3",
+      "resolved": "https://registry.npmmirror.com/husky/-/husky-8.0.3.tgz",
+      "integrity": "sha512-+dQSyqPh4x1hlO1swXBiNb2HzTDN1I2IGLQx1GrBuiqFJfoMrnZWwVmatvSiO+Iz8fBUnf+lekwNo4c2LlXItg==",
+      "dev": true
+    },
+    "ignore": {
+      "version": "5.3.0",
+      "resolved": "https://registry.npmmirror.com/ignore/-/ignore-5.3.0.tgz",
+      "integrity": "sha512-g7dmpshy+gD7mh88OC9NwSGTKoc3kyLAZQRU1mt53Aw/vnvfXnbC+F/7F7QoYVKbV+KNvJx8wArewKy1vXMtlg==",
+      "dev": true
+    },
+    "image-size": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmmirror.com/image-size/-/image-size-1.1.0.tgz",
+      "integrity": "sha512-asnTHw2K8OlqT5kVnQwX+AGKQqpvLo95LbNzQ/C0ln3yzentZmAdd0ygoD004VC4Kkd4PV7J2iaPQkqwp9yuTw==",
+      "requires": {
+        "queue": "6.0.2"
+      }
+    },
+    "immediate": {
+      "version": "3.0.6",
+      "resolved": "https://registry.npmmirror.com/immediate/-/immediate-3.0.6.tgz",
+      "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="
+    },
+    "immutable": {
+      "version": "4.3.7",
+      "resolved": "https://registry.npmmirror.com/immutable/-/immutable-4.3.7.tgz",
+      "integrity": "sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw==",
+      "dev": true
+    },
+    "import-fresh": {
+      "version": "3.3.0",
+      "resolved": "https://registry.npmmirror.com/import-fresh/-/import-fresh-3.3.0.tgz",
+      "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==",
+      "dev": true,
+      "requires": {
+        "parent-module": "^1.0.0",
+        "resolve-from": "^4.0.0"
+      }
+    },
+    "imurmurhash": {
+      "version": "0.1.4",
+      "resolved": "https://registry.npmmirror.com/imurmurhash/-/imurmurhash-0.1.4.tgz",
+      "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
+      "dev": true
+    },
+    "indent-string": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmmirror.com/indent-string/-/indent-string-4.0.0.tgz",
+      "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
+      "dev": true
+    },
+    "inflight": {
+      "version": "1.0.6",
+      "resolved": "https://registry.npmmirror.com/inflight/-/inflight-1.0.6.tgz",
+      "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
+      "dev": true,
+      "requires": {
+        "once": "^1.3.0",
+        "wrappy": "1"
+      }
+    },
+    "inherits": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz",
+      "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
+    },
+    "ini": {
+      "version": "1.3.8",
+      "resolved": "https://registry.npmmirror.com/ini/-/ini-1.3.8.tgz",
+      "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
+      "dev": true
+    },
+    "is-arrayish": {
+      "version": "0.2.1",
+      "resolved": "https://registry.npmmirror.com/is-arrayish/-/is-arrayish-0.2.1.tgz",
+      "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==",
+      "dev": true
+    },
+    "is-binary-path": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmmirror.com/is-binary-path/-/is-binary-path-2.1.0.tgz",
+      "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
+      "dev": true,
+      "requires": {
+        "binary-extensions": "^2.0.0"
+      }
+    },
+    "is-core-module": {
+      "version": "2.13.1",
+      "resolved": "https://registry.npmmirror.com/is-core-module/-/is-core-module-2.13.1.tgz",
+      "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==",
+      "dev": true,
+      "requires": {
+        "hasown": "^2.0.0"
+      }
+    },
+    "is-docker": {
+      "version": "2.2.1",
+      "resolved": "https://registry.npmmirror.com/is-docker/-/is-docker-2.2.1.tgz",
+      "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="
+    },
+    "is-extglob": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz",
+      "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+      "dev": true
+    },
+    "is-fullwidth-code-point": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+      "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+      "dev": true
+    },
+    "is-glob": {
+      "version": "4.0.3",
+      "resolved": "https://registry.npmmirror.com/is-glob/-/is-glob-4.0.3.tgz",
+      "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+      "dev": true,
+      "requires": {
+        "is-extglob": "^2.1.1"
+      }
+    },
+    "is-number": {
+      "version": "7.0.0",
+      "resolved": "https://registry.npmmirror.com/is-number/-/is-number-7.0.0.tgz",
+      "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="
+    },
+    "is-obj": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmmirror.com/is-obj/-/is-obj-2.0.0.tgz",
+      "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==",
+      "dev": true
+    },
+    "is-path-inside": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmmirror.com/is-path-inside/-/is-path-inside-3.0.3.tgz",
+      "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==",
+      "dev": true
+    },
+    "is-plain-obj": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmmirror.com/is-plain-obj/-/is-plain-obj-1.1.0.tgz",
+      "integrity": "sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg==",
+      "dev": true
+    },
+    "is-stream": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmmirror.com/is-stream/-/is-stream-2.0.1.tgz",
+      "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
+      "dev": true
+    },
+    "is-text-path": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmmirror.com/is-text-path/-/is-text-path-2.0.0.tgz",
+      "integrity": "sha512-+oDTluR6WEjdXEJMnC2z6A4FRwFoYuvShVVEGsS7ewc0UTi2QtAKMDJuL4BDEVt+5T7MjFo12RP8ghOM75oKJw==",
+      "dev": true,
+      "requires": {
+        "text-extensions": "^2.0.0"
+      }
+    },
+    "is-what": {
+      "version": "4.1.16",
+      "resolved": "https://registry.npmmirror.com/is-what/-/is-what-4.1.16.tgz",
+      "integrity": "sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A=="
+    },
+    "is-wsl": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmmirror.com/is-wsl/-/is-wsl-2.2.0.tgz",
+      "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==",
+      "requires": {
+        "is-docker": "^2.0.0"
+      }
+    },
+    "isarray": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmmirror.com/isarray/-/isarray-1.0.0.tgz",
+      "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="
+    },
+    "isexe": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz",
+      "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="
+    },
+    "isomorphic.js": {
+      "version": "0.2.5",
+      "resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz",
+      "integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw=="
+    },
+    "jiti": {
+      "version": "1.21.0",
+      "resolved": "https://registry.npmmirror.com/jiti/-/jiti-1.21.0.tgz",
+      "integrity": "sha512-gFqAIbuKyyso/3G2qhiO2OM6shY6EPP/R0+mkDbyspxKazh8BXDC5FiFsUjlczgdNz/vfra0da2y+aHrusLG/Q==",
+      "dev": true
+    },
+    "js-tokens": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+      "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+      "dev": true
+    },
+    "js-yaml": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmmirror.com/js-yaml/-/js-yaml-4.1.0.tgz",
+      "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
+      "dev": true,
+      "requires": {
+        "argparse": "^2.0.1"
+      }
+    },
+    "jsesc": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
+      "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
+      "dev": true
+    },
+    "json-buffer": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmmirror.com/json-buffer/-/json-buffer-3.0.1.tgz",
+      "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
+      "dev": true
+    },
+    "json-parse-even-better-errors": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmmirror.com/json-parse-even-better-errors/-/json-parse-even-better-errors-3.0.1.tgz",
+      "integrity": "sha512-aatBvbL26wVUCLmbWdCpeu9iF5wOyWpagiKkInA+kfws3sWdBrTnsvN2CKcyCYyUrc7rebNBlK6+kteg7ksecg==",
+      "dev": true
+    },
+    "json-schema-traverse": {
+      "version": "0.4.1",
+      "resolved": "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
+      "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
+      "dev": true
+    },
+    "json-stable-stringify": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmmirror.com/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz",
+      "integrity": "sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==",
+      "requires": {
+        "call-bind": "^1.0.8",
+        "call-bound": "^1.0.4",
+        "isarray": "^2.0.5",
+        "jsonify": "^0.0.1",
+        "object-keys": "^1.1.1"
+      },
+      "dependencies": {
+        "isarray": {
+          "version": "2.0.5",
+          "resolved": "https://registry.npmmirror.com/isarray/-/isarray-2.0.5.tgz",
+          "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="
+        }
+      }
+    },
+    "json-stable-stringify-without-jsonify": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmmirror.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
+      "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
+      "dev": true
+    },
+    "json5": {
+      "version": "2.2.3",
+      "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
+      "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
+      "dev": true
+    },
+    "jsonfile": {
+      "version": "6.1.0",
+      "resolved": "https://registry.npmmirror.com/jsonfile/-/jsonfile-6.1.0.tgz",
+      "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==",
+      "requires": {
+        "graceful-fs": "^4.1.6",
+        "universalify": "^2.0.0"
+      }
+    },
+    "jsonify": {
+      "version": "0.0.1",
+      "resolved": "https://registry.npmmirror.com/jsonify/-/jsonify-0.0.1.tgz",
+      "integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg=="
+    },
+    "jsonparse": {
+      "version": "1.3.1",
+      "resolved": "https://registry.npmmirror.com/jsonparse/-/jsonparse-1.3.1.tgz",
+      "integrity": "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==",
+      "dev": true
+    },
+    "JSONStream": {
+      "version": "1.3.5",
+      "resolved": "https://registry.npmmirror.com/JSONStream/-/JSONStream-1.3.5.tgz",
+      "integrity": "sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==",
+      "dev": true,
+      "requires": {
+        "jsonparse": "^1.2.0",
+        "through": ">=2.2.7 <3"
+      }
+    },
+    "jszip": {
+      "version": "3.10.1",
+      "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
+      "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
+      "requires": {
+        "lie": "~3.3.0",
+        "pako": "~1.0.2",
+        "readable-stream": "~2.3.6",
+        "setimmediate": "^1.0.5"
+      }
+    },
+    "katex": {
+      "version": "0.16.22",
+      "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.22.tgz",
+      "integrity": "sha512-XCHRdUw4lf3SKBaJe4EvgqIuWwkPSo9XoeO8GjQW94Bp7TWv9hNhzZjZ+OH9yf1UmLygb7DIT5GSFQiyt16zYg==",
+      "requires": {
+        "commander": "^8.3.0"
+      }
+    },
+    "keyv": {
+      "version": "4.5.4",
+      "resolved": "https://registry.npmmirror.com/keyv/-/keyv-4.5.4.tgz",
+      "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
+      "dev": true,
+      "requires": {
+        "json-buffer": "3.0.1"
+      }
+    },
+    "kind-of": {
+      "version": "6.0.3",
+      "resolved": "https://registry.npmmirror.com/kind-of/-/kind-of-6.0.3.tgz",
+      "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==",
+      "dev": true
+    },
+    "klaw-sync": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmmirror.com/klaw-sync/-/klaw-sync-6.0.0.tgz",
+      "integrity": "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ==",
+      "requires": {
+        "graceful-fs": "^4.1.11"
+      }
+    },
+    "kolorist": {
+      "version": "1.8.0",
+      "resolved": "https://registry.npmjs.org/kolorist/-/kolorist-1.8.0.tgz",
+      "integrity": "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==",
+      "dev": true
+    },
+    "levn": {
+      "version": "0.4.1",
+      "resolved": "https://registry.npmmirror.com/levn/-/levn-0.4.1.tgz",
+      "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
+      "dev": true,
+      "requires": {
+        "prelude-ls": "^1.2.1",
+        "type-check": "~0.4.0"
+      }
+    },
+    "lib0": {
+      "version": "0.2.114",
+      "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.114.tgz",
+      "integrity": "sha512-gcxmNFzA4hv8UYi8j43uPlQ7CGcyMJ2KQb5kZASw6SnAKAf10hK12i2fjrS3Cl/ugZa5Ui6WwIu1/6MIXiHttQ==",
+      "requires": {
+        "isomorphic.js": "^0.2.4"
+      }
+    },
+    "lie": {
+      "version": "3.3.0",
+      "resolved": "https://registry.npmmirror.com/lie/-/lie-3.3.0.tgz",
+      "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
+      "requires": {
+        "immediate": "~3.0.5"
+      }
+    },
+    "lines-and-columns": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmmirror.com/lines-and-columns/-/lines-and-columns-2.0.4.tgz",
+      "integrity": "sha512-wM1+Z03eypVAVUCE7QdSqpVIvelbOakn1M0bPDoA4SGWPx3sNDVUiMo3L6To6WWGClB7VyXnhQ4Sn7gxiJbE6A==",
+      "dev": true
+    },
+    "linkify-it": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz",
+      "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==",
+      "requires": {
+        "uc.micro": "^2.0.0"
+      }
+    },
+    "locate-path": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmmirror.com/locate-path/-/locate-path-6.0.0.tgz",
+      "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
+      "dev": true,
+      "requires": {
+        "p-locate": "^5.0.0"
+      }
+    },
+    "lodash": {
+      "version": "4.17.21",
+      "resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.17.21.tgz",
+      "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
+    },
+    "lodash.camelcase": {
+      "version": "4.3.0",
+      "resolved": "https://registry.npmmirror.com/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz",
+      "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==",
+      "dev": true
+    },
+    "lodash.isfunction": {
+      "version": "3.0.9",
+      "resolved": "https://registry.npmmirror.com/lodash.isfunction/-/lodash.isfunction-3.0.9.tgz",
+      "integrity": "sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw==",
+      "dev": true
+    },
+    "lodash.isplainobject": {
+      "version": "4.0.6",
+      "resolved": "https://registry.npmmirror.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
+      "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
+      "dev": true
+    },
+    "lodash.kebabcase": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmmirror.com/lodash.kebabcase/-/lodash.kebabcase-4.1.1.tgz",
+      "integrity": "sha512-N8XRTIMMqqDgSy4VLKPnJ/+hpGZN+PHQiJnSenYqPaVV/NCqEogTnAdZLQiGKhxX+JCs8waWq2t1XHWKOmlY8g==",
+      "dev": true
+    },
+    "lodash.merge": {
+      "version": "4.6.2",
+      "resolved": "https://registry.npmmirror.com/lodash.merge/-/lodash.merge-4.6.2.tgz",
+      "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
+      "dev": true
+    },
+    "lodash.mergewith": {
+      "version": "4.6.2",
+      "resolved": "https://registry.npmmirror.com/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz",
+      "integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==",
+      "dev": true
+    },
+    "lodash.snakecase": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmmirror.com/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz",
+      "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==",
+      "dev": true
+    },
+    "lodash.startcase": {
+      "version": "4.4.0",
+      "resolved": "https://registry.npmmirror.com/lodash.startcase/-/lodash.startcase-4.4.0.tgz",
+      "integrity": "sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==",
+      "dev": true
+    },
+    "lodash.uniq": {
+      "version": "4.5.0",
+      "resolved": "https://registry.npmmirror.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz",
+      "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==",
+      "dev": true
+    },
+    "lodash.upperfirst": {
+      "version": "4.3.1",
+      "resolved": "https://registry.npmmirror.com/lodash.upperfirst/-/lodash.upperfirst-4.3.1.tgz",
+      "integrity": "sha512-sReKOYJIJf74dhJONhU4e0/shzi1trVbSWDOhKYE5XV2O+H7Sb2Dihwuc7xWxVl+DgFPyTqIN3zMfT9cq5iWDg==",
+      "dev": true
+    },
+    "lru-cache": {
+      "version": "10.1.0",
+      "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-10.1.0.tgz",
+      "integrity": "sha512-/1clY/ui8CzjKFyjdvwPWJUYKiFVXG2I2cY0ssG7h4+hwk+XOIX7ZSG9Q7TW8TW3Kp3BUSqgFWBLgL4PJ+Blag==",
+      "dev": true
+    },
+    "magic-string": {
+      "version": "0.30.21",
+      "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+      "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+      "requires": {
+        "@jridgewell/sourcemap-codec": "^1.5.5"
+      }
+    },
+    "map-obj": {
+      "version": "4.3.0",
+      "resolved": "https://registry.npmmirror.com/map-obj/-/map-obj-4.3.0.tgz",
+      "integrity": "sha512-hdN1wVrZbb29eBGiGjJbeP8JbKjq1urkHJ/LIP/NY48MZ1QVXUsQBV1G1zvYFHn1XE06cwjBsOI2K3Ulnj1YXQ==",
+      "dev": true
+    },
+    "markdown-it": {
+      "version": "14.1.1",
+      "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz",
+      "integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==",
+      "requires": {
+        "argparse": "^2.0.1",
+        "entities": "^4.4.0",
+        "linkify-it": "^5.0.0",
+        "mdurl": "^2.0.0",
+        "punycode.js": "^2.3.1",
+        "uc.micro": "^2.1.0"
+      }
+    },
+    "math-intrinsics": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+      "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="
+    },
+    "mdurl": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz",
+      "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w=="
+    },
+    "memorystream": {
+      "version": "0.3.1",
+      "resolved": "https://registry.npmmirror.com/memorystream/-/memorystream-0.3.1.tgz",
+      "integrity": "sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==",
+      "dev": true
+    },
+    "meow": {
+      "version": "12.1.1",
+      "resolved": "https://registry.npmmirror.com/meow/-/meow-12.1.1.tgz",
+      "integrity": "sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw==",
+      "dev": true
+    },
+    "merge-stream": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmmirror.com/merge-stream/-/merge-stream-2.0.0.tgz",
+      "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==",
+      "dev": true
+    },
+    "merge2": {
+      "version": "1.4.1",
+      "resolved": "https://registry.npmmirror.com/merge2/-/merge2-1.4.1.tgz",
+      "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
+      "dev": true
+    },
+    "micromatch": {
+      "version": "4.0.5",
+      "resolved": "https://registry.npmmirror.com/micromatch/-/micromatch-4.0.5.tgz",
+      "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==",
+      "requires": {
+        "braces": "^3.0.2",
+        "picomatch": "^2.3.1"
+      }
+    },
+    "mime-db": {
+      "version": "1.52.0",
+      "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz",
+      "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="
+    },
+    "mime-types": {
+      "version": "2.1.35",
+      "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz",
+      "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+      "requires": {
+        "mime-db": "1.52.0"
+      }
+    },
+    "mimic-fn": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmmirror.com/mimic-fn/-/mimic-fn-2.1.0.tgz",
+      "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==",
+      "dev": true
+    },
+    "min-indent": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmmirror.com/min-indent/-/min-indent-1.0.1.tgz",
+      "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==",
+      "dev": true
+    },
+    "minimatch": {
+      "version": "9.0.3",
+      "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.3.tgz",
+      "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==",
+      "dev": true,
+      "requires": {
+        "brace-expansion": "^2.0.1"
+      }
+    },
+    "minimist": {
+      "version": "1.2.8",
+      "resolved": "https://registry.npmmirror.com/minimist/-/minimist-1.2.8.tgz",
+      "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="
+    },
+    "minimist-options": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmmirror.com/minimist-options/-/minimist-options-4.1.0.tgz",
+      "integrity": "sha512-Q4r8ghd80yhO/0j1O3B2BjweX3fiHg9cdOwjJd2J76Q135c+NDxGCqdYKQ1SKBuFfgWbAUzBfvYjPUEeNgqN1A==",
+      "dev": true,
+      "requires": {
+        "arrify": "^1.0.1",
+        "is-plain-obj": "^1.1.0",
+        "kind-of": "^6.0.3"
+      }
+    },
+    "mitt": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmmirror.com/mitt/-/mitt-3.0.1.tgz",
+      "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="
+    },
+    "mp4-muxer": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmmirror.com/mp4-muxer/-/mp4-muxer-2.3.0.tgz",
+      "integrity": "sha512-eupg1QMfcVJy/nZ2smUbC4OcEKdKBaLxKe+oeoDlklB5Dkb+4mv4CdPVYtzo1ZnzBKuVCclDk/znQwFQJIC32g==",
+      "requires": {
+        "@types/dom-webcodecs": "^0.1.6",
+        "@types/wicg-file-system-access": "^2020.9.5"
+      }
+    },
+    "ms": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.2.tgz",
+      "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
+      "dev": true
+    },
+    "muggle-string": {
+      "version": "0.4.1",
+      "resolved": "https://registry.npmmirror.com/muggle-string/-/muggle-string-0.4.1.tgz",
+      "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==",
+      "dev": true
+    },
+    "nanoid": {
+      "version": "5.0.7",
+      "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-5.0.7.tgz",
+      "integrity": "sha512-oLxFY2gd2IqnjcYyOXD8XGCftpGtZP2AbHbOkthDkvRywH5ayNtPVy9YlOPcHckXzbLTCHpkb7FB+yuxKV13pQ=="
+    },
+    "natural-compare": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmmirror.com/natural-compare/-/natural-compare-1.4.0.tgz",
+      "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
+      "dev": true
+    },
+    "node-releases": {
+      "version": "2.0.36",
+      "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz",
+      "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==",
+      "dev": true
+    },
+    "normalize-package-data": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmmirror.com/normalize-package-data/-/normalize-package-data-6.0.0.tgz",
+      "integrity": "sha512-UL7ELRVxYBHBgYEtZCXjxuD5vPxnmvMGq0jp/dGPKKrN7tfsBh2IY7TlJ15WWwdjRWD3RJbnsygUurTK3xkPkg==",
+      "dev": true,
+      "requires": {
+        "hosted-git-info": "^7.0.0",
+        "is-core-module": "^2.8.1",
+        "semver": "^7.3.5",
+        "validate-npm-package-license": "^3.0.4"
+      }
+    },
+    "normalize-path": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmmirror.com/normalize-path/-/normalize-path-3.0.0.tgz",
+      "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
+      "dev": true
+    },
+    "npm-run-all2": {
+      "version": "6.1.1",
+      "resolved": "https://registry.npmmirror.com/npm-run-all2/-/npm-run-all2-6.1.1.tgz",
+      "integrity": "sha512-lWLbkPZ5BSdXtN8lR+0rc8caKoPdymycpZksyDEC9MOBvfdwTXZ0uVhb7bMcGeXv2/BKtfQuo6Zn3zfc8rxNXA==",
+      "dev": true,
+      "requires": {
+        "ansi-styles": "^6.2.1",
+        "cross-spawn": "^7.0.3",
+        "memorystream": "^0.3.1",
+        "minimatch": "^9.0.0",
+        "pidtree": "^0.6.0",
+        "read-pkg": "^8.0.0",
+        "shell-quote": "^1.7.3"
+      },
+      "dependencies": {
+        "ansi-styles": {
+          "version": "6.2.1",
+          "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-6.2.1.tgz",
+          "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==",
+          "dev": true
+        }
+      }
+    },
+    "npm-run-path": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmmirror.com/npm-run-path/-/npm-run-path-4.0.1.tgz",
+      "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==",
+      "dev": true,
+      "requires": {
+        "path-key": "^3.0.0"
+      }
+    },
+    "nth-check": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmmirror.com/nth-check/-/nth-check-2.1.1.tgz",
+      "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
+      "dev": true,
+      "requires": {
+        "boolbase": "^1.0.0"
+      }
+    },
+    "number-precision": {
+      "version": "1.6.0",
+      "resolved": "https://registry.npmmirror.com/number-precision/-/number-precision-1.6.0.tgz",
+      "integrity": "sha512-05OLPgbgmnixJw+VvEh18yNPUo3iyp4BEWJcrLu4X9W05KmMifN7Mu5exYvQXqxxeNWhvIF+j3Rij+HmddM/hQ=="
+    },
+    "object-inspect": {
+      "version": "1.13.4",
+      "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+      "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="
+    },
+    "object-keys": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmmirror.com/object-keys/-/object-keys-1.1.1.tgz",
+      "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="
+    },
+    "once": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmmirror.com/once/-/once-1.4.0.tgz",
+      "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+      "dev": true,
+      "requires": {
+        "wrappy": "1"
+      }
+    },
+    "onetime": {
+      "version": "5.1.2",
+      "resolved": "https://registry.npmmirror.com/onetime/-/onetime-5.1.2.tgz",
+      "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==",
+      "dev": true,
+      "requires": {
+        "mimic-fn": "^2.1.0"
+      }
+    },
+    "open": {
+      "version": "7.4.2",
+      "resolved": "https://registry.npmmirror.com/open/-/open-7.4.2.tgz",
+      "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==",
+      "requires": {
+        "is-docker": "^2.0.0",
+        "is-wsl": "^2.1.1"
+      }
+    },
+    "optionator": {
+      "version": "0.9.3",
+      "resolved": "https://registry.npmmirror.com/optionator/-/optionator-0.9.3.tgz",
+      "integrity": "sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==",
+      "dev": true,
+      "requires": {
+        "@aashutoshrathi/word-wrap": "^1.2.3",
+        "deep-is": "^0.1.3",
+        "fast-levenshtein": "^2.0.6",
+        "levn": "^0.4.1",
+        "prelude-ls": "^1.2.1",
+        "type-check": "^0.4.0"
+      }
+    },
+    "orderedmap": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmmirror.com/orderedmap/-/orderedmap-2.1.1.tgz",
+      "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g=="
+    },
+    "p-limit": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmmirror.com/p-limit/-/p-limit-3.1.0.tgz",
+      "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
+      "dev": true,
+      "requires": {
+        "yocto-queue": "^0.1.0"
+      }
+    },
+    "p-locate": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmmirror.com/p-locate/-/p-locate-5.0.0.tgz",
+      "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
+      "dev": true,
+      "requires": {
+        "p-limit": "^3.0.2"
+      }
+    },
+    "p-try": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmmirror.com/p-try/-/p-try-2.2.0.tgz",
+      "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
+      "dev": true
+    },
+    "pako": {
+      "version": "1.0.11",
+      "resolved": "https://registry.npmmirror.com/pako/-/pako-1.0.11.tgz",
+      "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="
+    },
+    "parent-module": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmmirror.com/parent-module/-/parent-module-1.0.1.tgz",
+      "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
+      "dev": true,
+      "requires": {
+        "callsites": "^3.0.0"
+      }
+    },
+    "parse-json": {
+      "version": "7.1.1",
+      "resolved": "https://registry.npmmirror.com/parse-json/-/parse-json-7.1.1.tgz",
+      "integrity": "sha512-SgOTCX/EZXtZxBE5eJ97P4yGM5n37BwRU+YMsH4vNzFqJV/oWFXXCmwFlgWUM4PrakybVOueJJ6pwHqSVhTFDw==",
+      "dev": true,
+      "requires": {
+        "@babel/code-frame": "^7.21.4",
+        "error-ex": "^1.3.2",
+        "json-parse-even-better-errors": "^3.0.0",
+        "lines-and-columns": "^2.0.3",
+        "type-fest": "^3.8.0"
+      },
+      "dependencies": {
+        "type-fest": {
+          "version": "3.13.1",
+          "resolved": "https://registry.npmmirror.com/type-fest/-/type-fest-3.13.1.tgz",
+          "integrity": "sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g==",
+          "dev": true
+        }
+      }
+    },
+    "patch-package": {
+      "version": "8.0.1",
+      "resolved": "https://registry.npmmirror.com/patch-package/-/patch-package-8.0.1.tgz",
+      "integrity": "sha512-VsKRIA8f5uqHQ7NGhwIna6Bx6D9s/1iXlA1hthBVBEbkq+t4kXD0HHt+rJhf/Z+Ci0F/HCB2hvn0qLdLG+Qxlw==",
+      "requires": {
+        "@yarnpkg/lockfile": "^1.1.0",
+        "chalk": "^4.1.2",
+        "ci-info": "^3.7.0",
+        "cross-spawn": "^7.0.3",
+        "find-yarn-workspace-root": "^2.0.0",
+        "fs-extra": "^10.0.0",
+        "json-stable-stringify": "^1.0.2",
+        "klaw-sync": "^6.0.0",
+        "minimist": "^1.2.6",
+        "open": "^7.4.2",
+        "semver": "^7.5.3",
+        "slash": "^2.0.0",
+        "tmp": "^0.2.4",
+        "yaml": "^2.2.2"
+      },
+      "dependencies": {
+        "fs-extra": {
+          "version": "10.1.0",
+          "resolved": "https://registry.npmmirror.com/fs-extra/-/fs-extra-10.1.0.tgz",
+          "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
+          "requires": {
+            "graceful-fs": "^4.2.0",
+            "jsonfile": "^6.0.1",
+            "universalify": "^2.0.0"
+          }
+        },
+        "slash": {
+          "version": "2.0.0",
+          "resolved": "https://registry.npmmirror.com/slash/-/slash-2.0.0.tgz",
+          "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A=="
+        }
+      }
+    },
+    "path-browserify": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz",
+      "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==",
+      "dev": true
+    },
+    "path-exists": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmmirror.com/path-exists/-/path-exists-4.0.0.tgz",
+      "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
+      "dev": true
+    },
+    "path-is-absolute": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmmirror.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
+      "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
+      "dev": true
+    },
+    "path-key": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmmirror.com/path-key/-/path-key-3.1.1.tgz",
+      "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="
+    },
+    "path-parse": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmmirror.com/path-parse/-/path-parse-1.0.7.tgz",
+      "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
+      "dev": true
+    },
+    "path-type": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmmirror.com/path-type/-/path-type-4.0.0.tgz",
+      "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==",
+      "dev": true
+    },
+    "perfect-debounce": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmmirror.com/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
+      "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA=="
+    },
+    "picocolors": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz",
+      "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
+    },
+    "picomatch": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.1.tgz",
+      "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="
+    },
+    "pidtree": {
+      "version": "0.6.0",
+      "resolved": "https://registry.npmmirror.com/pidtree/-/pidtree-0.6.0.tgz",
+      "integrity": "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==",
+      "dev": true
+    },
+    "pinia": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmmirror.com/pinia/-/pinia-3.0.2.tgz",
+      "integrity": "sha512-sH2JK3wNY809JOeiiURUR0wehJ9/gd9qFN2Y828jCbxEzKEmEt0pzCXwqiSTfuRsK9vQsOflSdnbdBOGrhtn+g==",
+      "requires": {
+        "@vue/devtools-api": "^7.7.2"
+      }
+    },
+    "postcss": {
+      "version": "8.5.8",
+      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
+      "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
+      "requires": {
+        "nanoid": "^3.3.11",
+        "picocolors": "^1.1.1",
+        "source-map-js": "^1.2.1"
+      },
+      "dependencies": {
+        "nanoid": {
+          "version": "3.3.11",
+          "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz",
+          "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="
+        }
+      }
+    },
+    "postcss-selector-parser": {
+      "version": "6.0.15",
+      "resolved": "https://registry.npmmirror.com/postcss-selector-parser/-/postcss-selector-parser-6.0.15.tgz",
+      "integrity": "sha512-rEYkQOMUCEMhsKbK66tbEU9QVIxbhN18YiniAwA7XQYTVBqrBy+P2p5JcdqsHgKM2zWylp8d7J6eszocfds5Sw==",
+      "dev": true,
+      "requires": {
+        "cssesc": "^3.0.0",
+        "util-deprecate": "^1.0.2"
+      }
+    },
+    "pptxgenjs": {
+      "version": "3.12.0",
+      "resolved": "https://registry.npmmirror.com/pptxgenjs/-/pptxgenjs-3.12.0.tgz",
+      "integrity": "sha512-ZozkYKWb1MoPR4ucw3/aFYlHkVIJxo9czikEclcUVnS4Iw/M+r+TEwdlB3fyAWO9JY1USxJDt0Y0/r15IR/RUA==",
+      "requires": {
+        "@types/node": "^18.7.3",
+        "https": "^1.0.0",
+        "image-size": "^1.0.0",
+        "jszip": "^3.7.1"
+      }
+    },
+    "pptxtojson": {
+      "version": "1.12.0",
+      "resolved": "https://registry.npmmirror.com/pptxtojson/-/pptxtojson-1.12.0.tgz",
+      "integrity": "sha512-kXdCqXlCLdiGpM3womaQIpGYrQEzl8/jdxSpWWtYNj3Pxvr9n39tGpuBxeZSdenttwzM7Fb/yBKUPzxMFXLIJQ==",
+      "requires": {
+        "jszip": "^3.10.1",
+        "tinycolor2": "1.6.0",
+        "txml": "^5.1.1"
+      }
+    },
+    "prelude-ls": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmmirror.com/prelude-ls/-/prelude-ls-1.2.1.tgz",
+      "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
+      "dev": true
+    },
+    "process-nextick-args": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmmirror.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
+      "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="
+    },
+    "prosemirror-commands": {
+      "version": "1.6.0",
+      "resolved": "https://registry.npmmirror.com/prosemirror-commands/-/prosemirror-commands-1.6.0.tgz",
+      "integrity": "sha512-xn1U/g36OqXn2tn5nGmvnnimAj/g1pUx2ypJJIe8WkVX83WyJVC5LTARaxZa2AtQRwntu9Jc5zXs9gL9svp/mg==",
+      "requires": {
+        "prosemirror-model": "^1.0.0",
+        "prosemirror-state": "^1.0.0",
+        "prosemirror-transform": "^1.0.0"
+      }
+    },
+    "prosemirror-dropcursor": {
+      "version": "1.8.1",
+      "resolved": "https://registry.npmmirror.com/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.1.tgz",
+      "integrity": "sha512-M30WJdJZLyXHi3N8vxN6Zh5O8ZBbQCz0gURTfPmTIBNQ5pxrdU7A58QkNqfa98YEjSAL1HUyyU34f6Pm5xBSGw==",
+      "requires": {
+        "prosemirror-state": "^1.0.0",
+        "prosemirror-transform": "^1.1.0",
+        "prosemirror-view": "^1.1.0"
+      }
+    },
+    "prosemirror-gapcursor": {
+      "version": "1.3.2",
+      "resolved": "https://registry.npmmirror.com/prosemirror-gapcursor/-/prosemirror-gapcursor-1.3.2.tgz",
+      "integrity": "sha512-wtjswVBd2vaQRrnYZaBCbyDqr232Ed4p2QPtRIUK5FuqHYKGWkEwl08oQM4Tw7DOR0FsasARV5uJFvMZWxdNxQ==",
+      "requires": {
+        "prosemirror-keymap": "^1.0.0",
+        "prosemirror-model": "^1.0.0",
+        "prosemirror-state": "^1.0.0",
+        "prosemirror-view": "^1.0.0"
+      }
+    },
+    "prosemirror-history": {
+      "version": "1.3.2",
+      "resolved": "https://registry.npmmirror.com/prosemirror-history/-/prosemirror-history-1.3.2.tgz",
+      "integrity": "sha512-/zm0XoU/N/+u7i5zepjmZAEnpvjDtzoPWW6VmKptcAnPadN/SStsBjMImdCEbb3seiNTpveziPTIrXQbHLtU1g==",
+      "requires": {
+        "prosemirror-state": "^1.2.2",
+        "prosemirror-transform": "^1.0.0",
+        "prosemirror-view": "^1.31.0",
+        "rope-sequence": "^1.3.0"
+      }
+    },
+    "prosemirror-inputrules": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmmirror.com/prosemirror-inputrules/-/prosemirror-inputrules-1.4.0.tgz",
+      "integrity": "sha512-6ygpPRuTJ2lcOXs9JkefieMst63wVJBgHZGl5QOytN7oSZs3Co/BYbc3Yx9zm9H37Bxw8kVzCnDsihsVsL4yEg==",
+      "requires": {
+        "prosemirror-state": "^1.0.0",
+        "prosemirror-transform": "^1.0.0"
+      }
+    },
+    "prosemirror-keymap": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmmirror.com/prosemirror-keymap/-/prosemirror-keymap-1.2.2.tgz",
+      "integrity": "sha512-EAlXoksqC6Vbocqc0GtzCruZEzYgrn+iiGnNjsJsH4mrnIGex4qbLdWWNza3AW5W36ZRrlBID0eM6bdKH4OStQ==",
+      "requires": {
+        "prosemirror-state": "^1.0.0",
+        "w3c-keyname": "^2.2.0"
+      }
+    },
+    "prosemirror-model": {
+      "version": "1.22.2",
+      "resolved": "https://registry.npmmirror.com/prosemirror-model/-/prosemirror-model-1.22.2.tgz",
+      "integrity": "sha512-I4lS7HHIW47D0Xv/gWmi4iUWcQIDYaJKd8Hk4+lcSps+553FlQrhmxtItpEvTr75iAruhzVShVp6WUwsT6Boww==",
+      "requires": {
+        "orderedmap": "^2.0.0"
+      }
+    },
+    "prosemirror-schema-basic": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmmirror.com/prosemirror-schema-basic/-/prosemirror-schema-basic-1.2.3.tgz",
+      "integrity": "sha512-h+H0OQwZVqMon1PNn0AG9cTfx513zgIG2DY00eJ00Yvgb3UD+GQ/VlWW5rcaxacpCGT1Yx8nuhwXk4+QbXUfJA==",
+      "requires": {
+        "prosemirror-model": "^1.19.0"
+      }
+    },
+    "prosemirror-schema-list": {
+      "version": "1.4.1",
+      "resolved": "https://registry.npmmirror.com/prosemirror-schema-list/-/prosemirror-schema-list-1.4.1.tgz",
+      "integrity": "sha512-jbDyaP/6AFfDfu70VzySsD75Om2t3sXTOdl5+31Wlxlg62td1haUpty/ybajSfJ1pkGadlOfwQq9kgW5IMo1Rg==",
+      "requires": {
+        "prosemirror-model": "^1.0.0",
+        "prosemirror-state": "^1.0.0",
+        "prosemirror-transform": "^1.7.3"
+      }
+    },
+    "prosemirror-state": {
+      "version": "1.4.3",
+      "resolved": "https://registry.npmmirror.com/prosemirror-state/-/prosemirror-state-1.4.3.tgz",
+      "integrity": "sha512-goFKORVbvPuAQaXhpbemJFRKJ2aixr+AZMGiquiqKxaucC6hlpHNZHWgz5R7dS4roHiwq9vDctE//CZ++o0W1Q==",
+      "requires": {
+        "prosemirror-model": "^1.0.0",
+        "prosemirror-transform": "^1.0.0",
+        "prosemirror-view": "^1.27.0"
+      }
+    },
+    "prosemirror-transform": {
+      "version": "1.8.0",
+      "resolved": "https://registry.npmmirror.com/prosemirror-transform/-/prosemirror-transform-1.8.0.tgz",
+      "integrity": "sha512-BaSBsIMv52F1BVVMvOmp1yzD3u65uC3HTzCBQV1WDPqJRQ2LuHKcyfn0jwqodo8sR9vVzMzZyI+Dal5W9E6a9A==",
+      "requires": {
+        "prosemirror-model": "^1.0.0"
+      }
+    },
+    "prosemirror-view": {
+      "version": "1.33.9",
+      "resolved": "https://registry.npmmirror.com/prosemirror-view/-/prosemirror-view-1.33.9.tgz",
+      "integrity": "sha512-xV1A0Vz9cIcEnwmMhKKFAOkfIp8XmJRnaZoPqNXrPS7EK5n11Ov8V76KhR0RsfQd/SIzmWY+bg+M44A2Lx/Nnw==",
+      "requires": {
+        "prosemirror-model": "^1.20.0",
+        "prosemirror-state": "^1.0.0",
+        "prosemirror-transform": "^1.1.0"
+      }
+    },
+    "proxy-from-env": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
+      "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="
+    },
+    "punycode": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmmirror.com/punycode/-/punycode-2.3.1.tgz",
+      "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+      "dev": true
+    },
+    "punycode.js": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
+      "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA=="
+    },
+    "qs": {
+      "version": "6.14.0",
+      "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
+      "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
+      "requires": {
+        "side-channel": "^1.1.0"
+      }
+    },
+    "queue": {
+      "version": "6.0.2",
+      "resolved": "https://registry.npmmirror.com/queue/-/queue-6.0.2.tgz",
+      "integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==",
+      "requires": {
+        "inherits": "~2.0.3"
+      }
+    },
+    "queue-microtask": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmmirror.com/queue-microtask/-/queue-microtask-1.2.3.tgz",
+      "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
+      "dev": true
+    },
+    "quick-lru": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmmirror.com/quick-lru/-/quick-lru-4.0.1.tgz",
+      "integrity": "sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g==",
+      "dev": true
+    },
+    "read-pkg": {
+      "version": "8.1.0",
+      "resolved": "https://registry.npmmirror.com/read-pkg/-/read-pkg-8.1.0.tgz",
+      "integrity": "sha512-PORM8AgzXeskHO/WEv312k9U03B8K9JSiWF/8N9sUuFjBa+9SF2u6K7VClzXwDXab51jCd8Nd36CNM+zR97ScQ==",
+      "dev": true,
+      "requires": {
+        "@types/normalize-package-data": "^2.4.1",
+        "normalize-package-data": "^6.0.0",
+        "parse-json": "^7.0.0",
+        "type-fest": "^4.2.0"
+      },
+      "dependencies": {
+        "type-fest": {
+          "version": "4.9.0",
+          "resolved": "https://registry.npmmirror.com/type-fest/-/type-fest-4.9.0.tgz",
+          "integrity": "sha512-KS/6lh/ynPGiHD/LnAobrEFq3Ad4pBzOlJ1wAnJx9N4EYoqFhMfLIBjUT2UEx4wg5ZE+cC1ob6DCSpppVo+rtg==",
+          "dev": true
+        }
+      }
+    },
+    "read-pkg-up": {
+      "version": "7.0.1",
+      "resolved": "https://registry.npmmirror.com/read-pkg-up/-/read-pkg-up-7.0.1.tgz",
+      "integrity": "sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg==",
+      "dev": true,
+      "requires": {
+        "find-up": "^4.1.0",
+        "read-pkg": "^5.2.0",
+        "type-fest": "^0.8.1"
+      },
+      "dependencies": {
+        "find-up": {
+          "version": "4.1.0",
+          "resolved": "https://registry.npmmirror.com/find-up/-/find-up-4.1.0.tgz",
+          "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
+          "dev": true,
+          "requires": {
+            "locate-path": "^5.0.0",
+            "path-exists": "^4.0.0"
+          }
+        },
+        "hosted-git-info": {
+          "version": "2.8.9",
+          "resolved": "https://registry.npmmirror.com/hosted-git-info/-/hosted-git-info-2.8.9.tgz",
+          "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==",
+          "dev": true
+        },
+        "json-parse-even-better-errors": {
+          "version": "2.3.1",
+          "resolved": "https://registry.npmmirror.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz",
+          "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",
+          "dev": true
+        },
+        "lines-and-columns": {
+          "version": "1.2.4",
+          "resolved": "https://registry.npmmirror.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
+          "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
+          "dev": true
+        },
+        "locate-path": {
+          "version": "5.0.0",
+          "resolved": "https://registry.npmmirror.com/locate-path/-/locate-path-5.0.0.tgz",
+          "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
+          "dev": true,
+          "requires": {
+            "p-locate": "^4.1.0"
+          }
+        },
+        "normalize-package-data": {
+          "version": "2.5.0",
+          "resolved": "https://registry.npmmirror.com/normalize-package-data/-/normalize-package-data-2.5.0.tgz",
+          "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==",
+          "dev": true,
+          "requires": {
+            "hosted-git-info": "^2.1.4",
+            "resolve": "^1.10.0",
+            "semver": "2 || 3 || 4 || 5",
+            "validate-npm-package-license": "^3.0.1"
+          }
+        },
+        "p-limit": {
+          "version": "2.3.0",
+          "resolved": "https://registry.npmmirror.com/p-limit/-/p-limit-2.3.0.tgz",
+          "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
+          "dev": true,
+          "requires": {
+            "p-try": "^2.0.0"
+          }
+        },
+        "p-locate": {
+          "version": "4.1.0",
+          "resolved": "https://registry.npmmirror.com/p-locate/-/p-locate-4.1.0.tgz",
+          "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
+          "dev": true,
+          "requires": {
+            "p-limit": "^2.2.0"
+          }
+        },
+        "parse-json": {
+          "version": "5.2.0",
+          "resolved": "https://registry.npmmirror.com/parse-json/-/parse-json-5.2.0.tgz",
+          "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==",
+          "dev": true,
+          "requires": {
+            "@babel/code-frame": "^7.0.0",
+            "error-ex": "^1.3.1",
+            "json-parse-even-better-errors": "^2.3.0",
+            "lines-and-columns": "^1.1.6"
+          }
+        },
+        "read-pkg": {
+          "version": "5.2.0",
+          "resolved": "https://registry.npmmirror.com/read-pkg/-/read-pkg-5.2.0.tgz",
+          "integrity": "sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==",
+          "dev": true,
+          "requires": {
+            "@types/normalize-package-data": "^2.4.0",
+            "normalize-package-data": "^2.5.0",
+            "parse-json": "^5.0.0",
+            "type-fest": "^0.6.0"
+          },
+          "dependencies": {
+            "type-fest": {
+              "version": "0.6.0",
+              "resolved": "https://registry.npmmirror.com/type-fest/-/type-fest-0.6.0.tgz",
+              "integrity": "sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg==",
+              "dev": true
+            }
+          }
+        },
+        "semver": {
+          "version": "5.7.2",
+          "resolved": "https://registry.npmmirror.com/semver/-/semver-5.7.2.tgz",
+          "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==",
+          "dev": true
+        },
+        "type-fest": {
+          "version": "0.8.1",
+          "resolved": "https://registry.npmmirror.com/type-fest/-/type-fest-0.8.1.tgz",
+          "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==",
+          "dev": true
+        }
+      }
+    },
+    "readable-stream": {
+      "version": "2.3.8",
+      "resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-2.3.8.tgz",
+      "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
+      "requires": {
+        "core-util-is": "~1.0.0",
+        "inherits": "~2.0.3",
+        "isarray": "~1.0.0",
+        "process-nextick-args": "~2.0.0",
+        "safe-buffer": "~5.1.1",
+        "string_decoder": "~1.1.1",
+        "util-deprecate": "~1.0.1"
+      }
+    },
+    "readdirp": {
+      "version": "3.6.0",
+      "resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-3.6.0.tgz",
+      "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
+      "dev": true,
+      "requires": {
+        "picomatch": "^2.2.1"
+      }
+    },
+    "redent": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmmirror.com/redent/-/redent-3.0.0.tgz",
+      "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==",
+      "dev": true,
+      "requires": {
+        "indent-string": "^4.0.0",
+        "strip-indent": "^3.0.0"
+      }
+    },
+    "require-directory": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmmirror.com/require-directory/-/require-directory-2.1.1.tgz",
+      "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
+      "dev": true
+    },
+    "require-from-string": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmmirror.com/require-from-string/-/require-from-string-2.0.2.tgz",
+      "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+      "dev": true
+    },
+    "resolve": {
+      "version": "1.22.8",
+      "resolved": "https://registry.npmmirror.com/resolve/-/resolve-1.22.8.tgz",
+      "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==",
+      "dev": true,
+      "requires": {
+        "is-core-module": "^2.13.0",
+        "path-parse": "^1.0.7",
+        "supports-preserve-symlinks-flag": "^1.0.0"
+      }
+    },
+    "resolve-from": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmmirror.com/resolve-from/-/resolve-from-4.0.0.tgz",
+      "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
+      "dev": true
+    },
+    "resolve-global": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmmirror.com/resolve-global/-/resolve-global-1.0.0.tgz",
+      "integrity": "sha512-zFa12V4OLtT5XUX/Q4VLvTfBf+Ok0SPc1FNGM/z9ctUdiU618qwKpWnd0CHs3+RqROfyEg/DhuHbMWYqcgljEw==",
+      "dev": true,
+      "requires": {
+        "global-dirs": "^0.1.1"
+      }
+    },
+    "reusify": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmmirror.com/reusify/-/reusify-1.0.4.tgz",
+      "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==",
+      "dev": true
+    },
+    "rfdc": {
+      "version": "1.4.1",
+      "resolved": "https://registry.npmmirror.com/rfdc/-/rfdc-1.4.1.tgz",
+      "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="
+    },
+    "rimraf": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmmirror.com/rimraf/-/rimraf-3.0.2.tgz",
+      "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
+      "dev": true,
+      "requires": {
+        "glob": "^7.1.3"
+      }
+    },
+    "rollup": {
+      "version": "4.19.0",
+      "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.19.0.tgz",
+      "integrity": "sha512-5r7EYSQIowHsK4eTZ0Y81qpZuJz+MUuYeqmmYmRMl1nwhdmbiYqt5jwzf6u7wyOzJgYqtCRMtVRKOtHANBz7rA==",
+      "dev": true,
+      "requires": {
+        "@rollup/rollup-android-arm-eabi": "4.19.0",
+        "@rollup/rollup-android-arm64": "4.19.0",
+        "@rollup/rollup-darwin-arm64": "4.19.0",
+        "@rollup/rollup-darwin-x64": "4.19.0",
+        "@rollup/rollup-linux-arm-gnueabihf": "4.19.0",
+        "@rollup/rollup-linux-arm-musleabihf": "4.19.0",
+        "@rollup/rollup-linux-arm64-gnu": "4.19.0",
+        "@rollup/rollup-linux-arm64-musl": "4.19.0",
+        "@rollup/rollup-linux-powerpc64le-gnu": "4.19.0",
+        "@rollup/rollup-linux-riscv64-gnu": "4.19.0",
+        "@rollup/rollup-linux-s390x-gnu": "4.19.0",
+        "@rollup/rollup-linux-x64-gnu": "4.19.0",
+        "@rollup/rollup-linux-x64-musl": "4.19.0",
+        "@rollup/rollup-win32-arm64-msvc": "4.19.0",
+        "@rollup/rollup-win32-ia32-msvc": "4.19.0",
+        "@rollup/rollup-win32-x64-msvc": "4.19.0",
+        "@types/estree": "1.0.5",
+        "fsevents": "~2.3.2"
+      }
+    },
+    "rope-sequence": {
+      "version": "1.3.4",
+      "resolved": "https://registry.npmmirror.com/rope-sequence/-/rope-sequence-1.3.4.tgz",
+      "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ=="
+    },
+    "rtf.js": {
+      "version": "3.0.9",
+      "resolved": "https://registry.npmjs.org/rtf.js/-/rtf.js-3.0.9.tgz",
+      "integrity": "sha512-I1GpDat4i548WzmeZXv27f/743984fvEeeBS8BC01/Sop17pMlUl3M7DYcdcB3PUvOZTrFIMxGZx8qw7cSMAKQ==",
+      "requires": {
+        "codepage": "^1.15.0"
+      }
+    },
+    "run-parallel": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmmirror.com/run-parallel/-/run-parallel-1.2.0.tgz",
+      "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
+      "dev": true,
+      "requires": {
+        "queue-microtask": "^1.2.2"
+      }
+    },
+    "safe-buffer": {
+      "version": "5.1.2",
+      "resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.1.2.tgz",
+      "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
+    },
+    "sass": {
+      "version": "1.69.6",
+      "resolved": "https://registry.npmmirror.com/sass/-/sass-1.69.6.tgz",
+      "integrity": "sha512-qbRr3k9JGHWXCvZU77SD2OTwUlC+gNT+61JOLcmLm+XqH4h/5D+p4IIsxvpkB89S9AwJOyb5+rWNpIucaFxSFQ==",
+      "dev": true,
+      "requires": {
+        "chokidar": ">=3.0.0 <4.0.0",
+        "immutable": "^4.0.0",
+        "source-map-js": ">=0.6.2 <2.0.0"
+      }
+    },
+    "select": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmmirror.com/select/-/select-1.1.2.tgz",
+      "integrity": "sha512-OwpTSOfy6xSs1+pwcNrv0RBMOzI39Lp3qQKUTPVVPRjCdNa5JH/oPRiqsesIskK8TVgmRiHwO4KXlV2Li9dANA=="
+    },
+    "semver": {
+      "version": "7.5.4",
+      "resolved": "https://registry.npmmirror.com/semver/-/semver-7.5.4.tgz",
+      "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==",
+      "requires": {
+        "lru-cache": "^6.0.0"
+      },
+      "dependencies": {
+        "lru-cache": {
+          "version": "6.0.0",
+          "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-6.0.0.tgz",
+          "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
+          "requires": {
+            "yallist": "^4.0.0"
+          }
+        }
+      }
+    },
+    "set-function-length": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmmirror.com/set-function-length/-/set-function-length-1.2.2.tgz",
+      "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
+      "requires": {
+        "define-data-property": "^1.1.4",
+        "es-errors": "^1.3.0",
+        "function-bind": "^1.1.2",
+        "get-intrinsic": "^1.2.4",
+        "gopd": "^1.0.1",
+        "has-property-descriptors": "^1.0.2"
+      }
+    },
+    "setimmediate": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmmirror.com/setimmediate/-/setimmediate-1.0.5.tgz",
+      "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="
+    },
+    "shebang-command": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmmirror.com/shebang-command/-/shebang-command-2.0.0.tgz",
+      "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+      "requires": {
+        "shebang-regex": "^3.0.0"
+      }
+    },
+    "shebang-regex": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmmirror.com/shebang-regex/-/shebang-regex-3.0.0.tgz",
+      "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="
+    },
+    "shell-quote": {
+      "version": "1.8.1",
+      "resolved": "https://registry.npmmirror.com/shell-quote/-/shell-quote-1.8.1.tgz",
+      "integrity": "sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA==",
+      "dev": true
+    },
+    "side-channel": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
+      "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
+      "requires": {
+        "es-errors": "^1.3.0",
+        "object-inspect": "^1.13.3",
+        "side-channel-list": "^1.0.0",
+        "side-channel-map": "^1.0.1",
+        "side-channel-weakmap": "^1.0.2"
+      }
+    },
+    "side-channel-list": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
+      "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
+      "requires": {
+        "es-errors": "^1.3.0",
+        "object-inspect": "^1.13.3"
+      }
+    },
+    "side-channel-map": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+      "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+      "requires": {
+        "call-bound": "^1.0.2",
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.5",
+        "object-inspect": "^1.13.3"
+      }
+    },
+    "side-channel-weakmap": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+      "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+      "requires": {
+        "call-bound": "^1.0.2",
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.5",
+        "object-inspect": "^1.13.3",
+        "side-channel-map": "^1.0.1"
+      }
+    },
+    "signal-exit": {
+      "version": "3.0.7",
+      "resolved": "https://registry.npmmirror.com/signal-exit/-/signal-exit-3.0.7.tgz",
+      "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
+      "dev": true
+    },
+    "slash": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmmirror.com/slash/-/slash-3.0.0.tgz",
+      "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==",
+      "dev": true
+    },
+    "sortablejs": {
+      "version": "1.14.0",
+      "resolved": "https://registry.npmmirror.com/sortablejs/-/sortablejs-1.14.0.tgz",
+      "integrity": "sha512-pBXvQCs5/33fdN1/39pPL0NZF20LeRbLQ5jtnheIPN9JQAaufGjKdWduZn4U7wCtVuzKhmRkI0DFYHYRbB2H1w=="
+    },
+    "source-map-js": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz",
+      "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="
+    },
+    "spdx-correct": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmmirror.com/spdx-correct/-/spdx-correct-3.2.0.tgz",
+      "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==",
+      "dev": true,
+      "requires": {
+        "spdx-expression-parse": "^3.0.0",
+        "spdx-license-ids": "^3.0.0"
+      }
+    },
+    "spdx-exceptions": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmmirror.com/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz",
+      "integrity": "sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A==",
+      "dev": true
+    },
+    "spdx-expression-parse": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmmirror.com/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz",
+      "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==",
+      "dev": true,
+      "requires": {
+        "spdx-exceptions": "^2.1.0",
+        "spdx-license-ids": "^3.0.0"
+      }
+    },
+    "spdx-license-ids": {
+      "version": "3.0.16",
+      "resolved": "https://registry.npmmirror.com/spdx-license-ids/-/spdx-license-ids-3.0.16.tgz",
+      "integrity": "sha512-eWN+LnM3GR6gPu35WxNgbGl8rmY1AEmoMDvL/QD6zYmPWgywxWqJWNdLGT+ke8dKNWrcYgYjPpG5gbTfghP8rw==",
+      "dev": true
+    },
+    "speakingurl": {
+      "version": "14.0.1",
+      "resolved": "https://registry.npmmirror.com/speakingurl/-/speakingurl-14.0.1.tgz",
+      "integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ=="
+    },
+    "split2": {
+      "version": "4.2.0",
+      "resolved": "https://registry.npmmirror.com/split2/-/split2-4.2.0.tgz",
+      "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
+      "dev": true
+    },
+    "string_decoder": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.1.1.tgz",
+      "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+      "requires": {
+        "safe-buffer": "~5.1.0"
+      }
+    },
+    "string-width": {
+      "version": "4.2.3",
+      "resolved": "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz",
+      "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+      "dev": true,
+      "requires": {
+        "emoji-regex": "^8.0.0",
+        "is-fullwidth-code-point": "^3.0.0",
+        "strip-ansi": "^6.0.1"
+      }
+    },
+    "strip-ansi": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz",
+      "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+      "dev": true,
+      "requires": {
+        "ansi-regex": "^5.0.1"
+      }
+    },
+    "strip-final-newline": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmmirror.com/strip-final-newline/-/strip-final-newline-2.0.0.tgz",
+      "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==",
+      "dev": true
+    },
+    "strip-indent": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmmirror.com/strip-indent/-/strip-indent-3.0.0.tgz",
+      "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==",
+      "dev": true,
+      "requires": {
+        "min-indent": "^1.0.0"
+      }
+    },
+    "strip-json-comments": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmmirror.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
+      "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==",
+      "dev": true
+    },
+    "superjson": {
+      "version": "2.2.2",
+      "resolved": "https://registry.npmmirror.com/superjson/-/superjson-2.2.2.tgz",
+      "integrity": "sha512-5JRxVqC8I8NuOUjzBbvVJAKNM8qoVuH0O77h4WInc/qC2q5IreqKxYwgkga3PfA22OayK2ikceb/B26dztPl+Q==",
+      "requires": {
+        "copy-anything": "^3.0.2"
+      }
+    },
+    "supports-color": {
+      "version": "7.2.0",
+      "resolved": "https://registry.npmmirror.com/supports-color/-/supports-color-7.2.0.tgz",
+      "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+      "requires": {
+        "has-flag": "^4.0.0"
+      }
+    },
+    "supports-preserve-symlinks-flag": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmmirror.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
+      "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
+      "dev": true
+    },
+    "svg-arc-to-cubic-bezier": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmmirror.com/svg-arc-to-cubic-bezier/-/svg-arc-to-cubic-bezier-3.2.0.tgz",
+      "integrity": "sha512-djbJ/vZKZO+gPoSDThGNpKDO+o+bAeA4XQKovvkNCqnIS2t+S4qnLAGQhyyrulhCFRl1WWzAp0wUDV8PpTVU3g=="
+    },
+    "svg-pathdata": {
+      "version": "7.2.0",
+      "resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-7.2.0.tgz",
+      "integrity": "sha512-qd+AxqMpfRrRQaWb2SrNFvn69cvl6piqY8TxhYl2Li1g4/LO5F9NJb5wI4vNwRryqgSgD43gYKLm/w3ag1bKvQ=="
+    },
+    "text-extensions": {
+      "version": "2.4.0",
+      "resolved": "https://registry.npmmirror.com/text-extensions/-/text-extensions-2.4.0.tgz",
+      "integrity": "sha512-te/NtwBwfiNRLf9Ijqx3T0nlqZiQ2XrrtBvu+cLL8ZRrGkO0NHTug8MYFKyoSrv/sHTaSKfilUkizV6XhxMJ3g==",
+      "dev": true
+    },
+    "text-segmentation": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
+      "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
+      "requires": {
+        "utrie": "^1.0.2"
+      }
+    },
+    "text-table": {
+      "version": "0.2.0",
+      "resolved": "https://registry.npmmirror.com/text-table/-/text-table-0.2.0.tgz",
+      "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==",
+      "dev": true
+    },
+    "through": {
+      "version": "2.3.8",
+      "resolved": "https://registry.npmmirror.com/through/-/through-2.3.8.tgz",
+      "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==",
+      "dev": true
+    },
+    "through2": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmmirror.com/through2/-/through2-3.0.2.tgz",
+      "integrity": "sha512-enaDQ4MUyP2W6ZyT6EsMzqBPZaM/avg8iuo+l2d3QCs0J+6RaqkHV/2/lOwDTueBHeJ/2LG9lrLW3d5rWPucuQ==",
+      "requires": {
+        "inherits": "^2.0.4",
+        "readable-stream": "2 || 3"
+      }
+    },
+    "tiny-emitter": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmmirror.com/tiny-emitter/-/tiny-emitter-2.1.0.tgz",
+      "integrity": "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q=="
+    },
+    "tinycolor2": {
+      "version": "1.6.0",
+      "resolved": "https://registry.npmmirror.com/tinycolor2/-/tinycolor2-1.6.0.tgz",
+      "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw=="
+    },
+    "tippy.js": {
+      "version": "6.3.7",
+      "resolved": "https://registry.npmmirror.com/tippy.js/-/tippy.js-6.3.7.tgz",
+      "integrity": "sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==",
+      "requires": {
+        "@popperjs/core": "^2.9.0"
+      }
+    },
+    "tmp": {
+      "version": "0.2.5",
+      "resolved": "https://registry.npmmirror.com/tmp/-/tmp-0.2.5.tgz",
+      "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow=="
+    },
+    "to-regex-range": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmmirror.com/to-regex-range/-/to-regex-range-5.0.1.tgz",
+      "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+      "requires": {
+        "is-number": "^7.0.0"
+      }
+    },
+    "trim-newlines": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmmirror.com/trim-newlines/-/trim-newlines-3.0.1.tgz",
+      "integrity": "sha512-c1PTsA3tYrIsLGkJkzHF+w9F2EyxfXGo4UyJc4pFL++FMjnq0HJS69T3M7d//gKrFKwy429bouPescbjecU+Zw==",
+      "dev": true
+    },
+    "ts-api-utils": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmmirror.com/ts-api-utils/-/ts-api-utils-1.0.3.tgz",
+      "integrity": "sha512-wNMeqtMz5NtwpT/UZGY5alT+VoKdSsOOP/kqHFcUW1P/VRhH2wJ48+DN2WwUliNbQ976ETwDL0Ifd2VVvgonvg==",
+      "dev": true,
+      "requires": {}
+    },
+    "tslib": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.3.0.tgz",
+      "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg=="
+    },
+    "txml": {
+      "version": "5.1.1",
+      "resolved": "https://registry.npmmirror.com/txml/-/txml-5.1.1.tgz",
+      "integrity": "sha512-TwMDLnXQ09enNaxybLVvKZU7rqog8LgnuAs4ZYXM0nV0eu10iLsSFwlX3AEknAXXtH1wT3CYfoiXAjyBexcmuw==",
+      "requires": {
+        "through2": "^3.0.1"
+      }
+    },
+    "type-check": {
+      "version": "0.4.0",
+      "resolved": "https://registry.npmmirror.com/type-check/-/type-check-0.4.0.tgz",
+      "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
+      "dev": true,
+      "requires": {
+        "prelude-ls": "^1.2.1"
+      }
+    },
+    "type-fest": {
+      "version": "0.20.2",
+      "resolved": "https://registry.npmmirror.com/type-fest/-/type-fest-0.20.2.tgz",
+      "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
+      "dev": true
+    },
+    "typescript": {
+      "version": "5.3.3",
+      "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.3.3.tgz",
+      "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==",
+      "devOptional": true
+    },
+    "uc.micro": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
+      "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A=="
+    },
+    "undici-types": {
+      "version": "5.26.5",
+      "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-5.26.5.tgz",
+      "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="
+    },
+    "universalify": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmmirror.com/universalify/-/universalify-2.0.1.tgz",
+      "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="
+    },
+    "update-browserslist-db": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
+      "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
+      "dev": true,
+      "requires": {
+        "escalade": "^3.2.0",
+        "picocolors": "^1.1.1"
+      }
+    },
+    "uri-js": {
+      "version": "4.4.1",
+      "resolved": "https://registry.npmmirror.com/uri-js/-/uri-js-4.4.1.tgz",
+      "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
+      "dev": true,
+      "requires": {
+        "punycode": "^2.1.0"
+      }
+    },
+    "utif": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/utif/-/utif-3.1.0.tgz",
+      "integrity": "sha512-WEo4D/xOvFW53K5f5QTaTbbiORcm2/pCL9P6qmJnup+17eYfKaEhDeX9PeQkuyEoIxlbGklDuGl8xwuXYMrrXQ==",
+      "requires": {
+        "pako": "^1.0.5"
+      }
+    },
+    "utif2": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/utif2/-/utif2-4.1.0.tgz",
+      "integrity": "sha512-+oknB9FHrJ7oW7A2WZYajOcv4FcDR4CfoGB0dPNfxbi4GO05RRnFmt5oa23+9w32EanrYcSJWspUiJkLMs+37w==",
+      "requires": {
+        "pako": "^1.0.11"
+      }
+    },
+    "util-deprecate": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz",
+      "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
+    },
+    "utrie": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
+      "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
+      "requires": {
+        "base64-arraybuffer": "^1.0.2"
+      }
+    },
+    "uuid": {
+      "version": "13.0.0",
+      "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
+      "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w=="
+    },
+    "validate-npm-package-license": {
+      "version": "3.0.4",
+      "resolved": "https://registry.npmmirror.com/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz",
+      "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==",
+      "dev": true,
+      "requires": {
+        "spdx-correct": "^3.0.0",
+        "spdx-expression-parse": "^3.0.0"
+      }
+    },
+    "vite": {
+      "version": "5.3.5",
+      "resolved": "https://registry.npmmirror.com/vite/-/vite-5.3.5.tgz",
+      "integrity": "sha512-MdjglKR6AQXQb9JGiS7Rc2wC6uMjcm7Go/NHNO63EwiJXfuk9PgqiP/n5IDJCziMkfw9n4Ubp7lttNwz+8ZVKA==",
+      "dev": true,
+      "requires": {
+        "esbuild": "^0.21.3",
+        "fsevents": "~2.3.3",
+        "postcss": "^8.4.39",
+        "rollup": "^4.13.0"
+      }
+    },
+    "vite-plugin-vue-inspector": {
+      "version": "5.4.0",
+      "resolved": "https://registry.npmjs.org/vite-plugin-vue-inspector/-/vite-plugin-vue-inspector-5.4.0.tgz",
+      "integrity": "sha512-Iq/024CydcE46FZqWPU4t4lw4uYOdLnFSO1RNxJVt2qY9zxIjmnkBqhHnYaReWM82kmNnaXs7OkfgRrV2GEjyw==",
+      "dev": true,
+      "requires": {
+        "@babel/core": "^7.23.0",
+        "@babel/plugin-proposal-decorators": "^7.23.0",
+        "@babel/plugin-syntax-import-attributes": "^7.22.5",
+        "@babel/plugin-syntax-import-meta": "^7.10.4",
+        "@babel/plugin-transform-typescript": "^7.22.15",
+        "@vue/babel-plugin-jsx": "^1.1.5",
+        "@vue/compiler-dom": "^3.3.4",
+        "kolorist": "^1.8.0",
+        "magic-string": "^0.30.4"
+      }
+    },
+    "vscode-uri": {
+      "version": "3.0.8",
+      "resolved": "https://registry.npmmirror.com/vscode-uri/-/vscode-uri-3.0.8.tgz",
+      "integrity": "sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==",
+      "dev": true
+    },
+    "vue": {
+      "version": "3.5.17",
+      "resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.17.tgz",
+      "integrity": "sha512-LbHV3xPN9BeljML+Xctq4lbz2lVHCR6DtbpTf5XIO6gugpXUN49j2QQPcMj086r9+AkJ0FfUT8xjulKKBkkr9g==",
+      "requires": {
+        "@vue/compiler-dom": "3.5.17",
+        "@vue/compiler-sfc": "3.5.17",
+        "@vue/runtime-dom": "3.5.17",
+        "@vue/server-renderer": "3.5.17",
+        "@vue/shared": "3.5.17"
+      }
+    },
+    "vue-eslint-parser": {
+      "version": "9.3.2",
+      "resolved": "https://registry.npmmirror.com/vue-eslint-parser/-/vue-eslint-parser-9.3.2.tgz",
+      "integrity": "sha512-q7tWyCVaV9f8iQyIA5Mkj/S6AoJ9KBN8IeUSf3XEmBrOtxOZnfTg5s4KClbZBCK3GtnT/+RyCLZyDHuZwTuBjg==",
+      "dev": true,
+      "requires": {
+        "debug": "^4.3.4",
+        "eslint-scope": "^7.1.1",
+        "eslint-visitor-keys": "^3.3.0",
+        "espree": "^9.3.1",
+        "esquery": "^1.4.0",
+        "lodash": "^4.17.21",
+        "semver": "^7.3.6"
+      }
+    },
+    "vue-tsc": {
+      "version": "2.0.29",
+      "resolved": "https://registry.npmmirror.com/vue-tsc/-/vue-tsc-2.0.29.tgz",
+      "integrity": "sha512-MHhsfyxO3mYShZCGYNziSbc63x7cQ5g9kvijV7dRe1TTXBRLxXyL0FnXWpUF1xII2mJ86mwYpYsUmMwkmerq7Q==",
+      "dev": true,
+      "requires": {
+        "@volar/typescript": "~2.4.0-alpha.18",
+        "@vue/language-core": "2.0.29",
+        "semver": "^7.5.4"
+      }
+    },
+    "vuedraggable": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmmirror.com/vuedraggable/-/vuedraggable-4.1.0.tgz",
+      "integrity": "sha512-FU5HCWBmsf20GpP3eudURW3WdWTKIbEIQxh9/8GE806hydR9qZqRRxRE3RjqX7PkuLuMQG/A7n3cfj9rCEchww==",
+      "requires": {
+        "sortablejs": "1.14.0"
+      }
+    },
+    "w3c-keyname": {
+      "version": "2.2.8",
+      "resolved": "https://registry.npmmirror.com/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
+      "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="
+    },
+    "wangeditor": {
+      "version": "4.7.15",
+      "resolved": "https://registry.npmjs.org/wangeditor/-/wangeditor-4.7.15.tgz",
+      "integrity": "sha512-aPTdREd8BxXVyJ5MI+LU83FQ7u1EPd341iXIorRNYSOvoimNoZ4nPg+yn3FGbB93/owEa6buLw8wdhYnMCJQLg==",
+      "requires": {
+        "@babel/runtime": "^7.11.2",
+        "@babel/runtime-corejs3": "^7.11.2",
+        "tslib": "^2.1.0"
+      }
+    },
+    "webcodecs-encoder": {
+      "version": "0.3.2",
+      "resolved": "https://registry.npmmirror.com/webcodecs-encoder/-/webcodecs-encoder-0.3.2.tgz",
+      "integrity": "sha512-F4XKzafAxVNhtnaQ5F8Q6xVn+bRrzLiUQs3p4PpogN3svBqGPR1y5OK66bunXEULOJ0XVJ0HqlJ/TSGGZTZ0AQ==",
+      "requires": {
+        "mp4-muxer": "^2.0.2",
+        "patch-package": "^8.0.0",
+        "webm-muxer": "^1.2.0"
+      }
+    },
+    "webm-muxer": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmmirror.com/webm-muxer/-/webm-muxer-1.2.1.tgz",
+      "integrity": "sha512-FaH7GVeK9tOZoWEW0gT/ryR9EoZnDe/RplJlTkG6zPWfEdrIy24PaXwEiyUnKwwO3zsjJWzFq4DLHECqGdJXIw==",
+      "requires": {
+        "@types/dom-webcodecs": "^0.1.4",
+        "@types/wicg-file-system-access": "^2020.9.5"
+      }
+    },
+    "which": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmmirror.com/which/-/which-2.0.2.tgz",
+      "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+      "requires": {
+        "isexe": "^2.0.0"
+      }
+    },
+    "wrap-ansi": {
+      "version": "7.0.0",
+      "resolved": "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+      "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
+      "dev": true,
+      "requires": {
+        "ansi-styles": "^4.0.0",
+        "string-width": "^4.1.0",
+        "strip-ansi": "^6.0.0"
+      }
+    },
+    "wrappy": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmmirror.com/wrappy/-/wrappy-1.0.2.tgz",
+      "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+      "dev": true
+    },
+    "xml-name-validator": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmmirror.com/xml-name-validator/-/xml-name-validator-4.0.0.tgz",
+      "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==",
+      "dev": true
+    },
+    "y-protocols": {
+      "version": "1.0.6",
+      "resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.6.tgz",
+      "integrity": "sha512-vHRF2L6iT3rwj1jub/K5tYcTT/mEYDUppgNPXwp8fmLpui9f7Yeq3OEtTLVF012j39QnV+KEQpNqoN7CWU7Y9Q==",
+      "requires": {
+        "lib0": "^0.2.85"
+      }
+    },
+    "y-websocket": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/y-websocket/-/y-websocket-3.0.0.tgz",
+      "integrity": "sha512-mUHy7AzkOZ834T/7piqtlA8Yk6AchqKqcrCXjKW8J1w2lPtRDjz8W5/CvXz9higKAHgKRKqpI3T33YkRFLkPtg==",
+      "requires": {
+        "lib0": "^0.2.102",
+        "y-protocols": "^1.0.5"
+      }
+    },
+    "y18n": {
+      "version": "5.0.8",
+      "resolved": "https://registry.npmmirror.com/y18n/-/y18n-5.0.8.tgz",
+      "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
+      "dev": true
+    },
+    "yallist": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmmirror.com/yallist/-/yallist-4.0.0.tgz",
+      "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="
+    },
+    "yaml": {
+      "version": "2.8.3",
+      "resolved": "https://registry.npmmirror.com/yaml/-/yaml-2.8.3.tgz",
+      "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="
+    },
+    "yargs": {
+      "version": "17.7.2",
+      "resolved": "https://registry.npmmirror.com/yargs/-/yargs-17.7.2.tgz",
+      "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
+      "dev": true,
+      "requires": {
+        "cliui": "^8.0.1",
+        "escalade": "^3.1.1",
+        "get-caller-file": "^2.0.5",
+        "require-directory": "^2.1.1",
+        "string-width": "^4.2.3",
+        "y18n": "^5.0.5",
+        "yargs-parser": "^21.1.1"
+      }
+    },
+    "yargs-parser": {
+      "version": "21.1.1",
+      "resolved": "https://registry.npmmirror.com/yargs-parser/-/yargs-parser-21.1.1.tgz",
+      "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
+      "dev": true
+    },
+    "yjs": {
+      "version": "13.6.27",
+      "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.27.tgz",
+      "integrity": "sha512-OIDwaflOaq4wC6YlPBy2L6ceKeKuF7DeTxx+jPzv1FHn9tCZ0ZwSRnUBxD05E3yed46fv/FWJbvR+Ud7x0L7zw==",
+      "requires": {
+        "lib0": "^0.2.99"
+      }
+    },
+    "yocto-queue": {
+      "version": "0.1.0",
+      "resolved": "https://registry.npmmirror.com/yocto-queue/-/yocto-queue-0.1.0.tgz",
+      "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
+      "dev": true
+    },
+    "zrender": {
+      "version": "5.6.1",
+      "resolved": "https://registry.npmjs.org/zrender/-/zrender-5.6.1.tgz",
+      "integrity": "sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==",
+      "requires": {
+        "tslib": "2.3.0"
+      }
+    }
+  }
+}

+ 16 - 1
package.json

@@ -7,10 +7,23 @@
     "dev": "vite",
     "build": "run-p type-check \"build-only {@}\" --",
     "preview": "vite preview",
+    "test:speaking-api-config": "node scripts/test-speaking-api-config.mjs",
+    "test:dialogue-stream-fallback": "node scripts/test-dialogue-stream-fallback.mjs",
     "build-only": "vite build",
     "type-check": "vue-tsc --build --force",
     "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
-    "prepare": "husky install"
+    "prepare": "husky install",
+    "test:article-reading-data": "node scripts/test-article-reading-data.mjs",
+    "test:article-reading-model": "node scripts/test-article-reading-model.mjs",
+    "test:article-reading-stream": "node scripts/test-article-reading-stream.mjs",
+    "test:article-reading-engine": "node scripts/test-article-reading-engine.mjs",
+    "test:article-recorder-sample-rate": "node scripts/test-article-recorder-sample-rate.mjs",
+    "test:article-reading-routing": "node scripts/test-article-reading-routing.mjs",
+    "test:article-reading-i18n": "node scripts/test-article-reading-i18n.mjs",
+    "test:article-auto-format": "node scripts/test-article-auto-format.mjs",
+    "test:article-auto-format-flow": "node scripts/test-article-auto-format-flow.mjs",
+    "test:article-errors": "node scripts/test-article-errors.mjs",
+    "test:article-class-summary-auto": "node scripts/test-article-class-summary-auto.mjs"
   },
   "author": "pipipi_pikachu@163.com",
   "homepage": "https://github.com/pipipi-pikachu/PPTist",
@@ -28,6 +41,7 @@
     "hfmath": "^0.0.2",
     "html-to-image": "^1.11.13",
     "html2canvas": "^1.4.1",
+    "jszip": "^3.10.1",
     "katex": "^0.16.22",
     "lodash": "^4.17.21",
     "markdown-it": "^14.1.1",
@@ -88,6 +102,7 @@
     "sass": "1.69.6",
     "typescript": "~5.3.0",
     "vite": "^5.3.5",
+    "vite-plugin-vue-inspector": "^5.4.0",
     "vue-tsc": "^2.0.29"
   }
 }

+ 26 - 0
public/pcm-recorder-worklet.js

@@ -0,0 +1,26 @@
+/**
+ * AudioWorklet processor: 收集麦克风 PCM 采样数据
+ * 运行在音频线程,通过 MessagePort 将 Float32Array 发回主线程
+ */
+class PCMRecorderProcessor extends AudioWorkletProcessor {
+  constructor() {
+    super()
+    this._stopped = false
+    this.port.onmessage = (e) => {
+      if (e.data === 'stop') this._stopped = true
+    }
+  }
+
+  process(inputs) {
+    if (this._stopped) return false
+
+    const input = inputs[0]
+    if (input && input[0] && input[0].length > 0) {
+      // 取第一个声道(mono),拷贝一份发给主线程
+      this.port.postMessage(new Float32Array(input[0]))
+    }
+    return true
+  }
+}
+
+registerProcessor('pcm-recorder-processor', PCMRecorderProcessor)

+ 3524 - 0
public/webcodecs-worker.js

@@ -0,0 +1,3524 @@
+"use strict";
+var EncoderWorkerGlobal = (() => {
+  var __create = Object.create;
+  var __defProp = Object.defineProperty;
+  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
+  var __getOwnPropNames = Object.getOwnPropertyNames;
+  var __getProtoOf = Object.getPrototypeOf;
+  var __hasOwnProp = Object.prototype.hasOwnProperty;
+  var __commonJS = (cb, mod) => function __require() {
+    return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
+  };
+  var __copyProps = (to, from, except, desc) => {
+    if (from && typeof from === "object" || typeof from === "function") {
+      for (let key of __getOwnPropNames(from))
+        if (!__hasOwnProp.call(to, key) && key !== except)
+          __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
+    }
+    return to;
+  };
+  var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
+    // If the importer is in node compatibility mode or this is not an ESM
+    // file that has been converted to a CommonJS file using a Babel-
+    // compatible transform (i.e. "__esModule" has not been set), then set
+    // "default" to the CommonJS "module.exports" for node compatibility.
+    isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
+    mod
+  ));
+
+  // node_modules/webm-muxer/build/webm-muxer.js
+  var require_webm_muxer = __commonJS({
+    "node_modules/webm-muxer/build/webm-muxer.js"(exports, module) {
+      "use strict";
+      var WebMMuxer2 = (() => {
+        var __defProp3 = Object.defineProperty;
+        var __getOwnPropDesc2 = Object.getOwnPropertyDescriptor;
+        var __getOwnPropNames2 = Object.getOwnPropertyNames;
+        var __hasOwnProp3 = Object.prototype.hasOwnProperty;
+        var __pow2 = Math.pow;
+        var __export = (target, all) => {
+          for (var name in all)
+            __defProp3(target, name, { get: all[name], enumerable: true });
+        };
+        var __copyProps2 = (to, from, except, desc) => {
+          if (from && typeof from === "object" || typeof from === "function") {
+            for (let key of __getOwnPropNames2(from))
+              if (!__hasOwnProp3.call(to, key) && key !== except)
+                __defProp3(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc2(from, key)) || desc.enumerable });
+          }
+          return to;
+        };
+        var __toCommonJS = (mod) => __copyProps2(__defProp3({}, "__esModule", { value: true }), mod);
+        var __accessCheck2 = (obj, member, msg) => {
+          if (!member.has(obj))
+            throw TypeError("Cannot " + msg);
+        };
+        var __privateGet2 = (obj, member, getter) => {
+          __accessCheck2(obj, member, "read from private field");
+          return getter ? getter.call(obj) : member.get(obj);
+        };
+        var __privateAdd2 = (obj, member, value) => {
+          if (member.has(obj))
+            throw TypeError("Cannot add the same private member more than once");
+          member instanceof WeakSet ? member.add(obj) : member.set(obj, value);
+        };
+        var __privateSet2 = (obj, member, value, setter) => {
+          __accessCheck2(obj, member, "write to private field");
+          setter ? setter.call(obj, value) : member.set(obj, value);
+          return value;
+        };
+        var __privateMethod2 = (obj, member, method) => {
+          __accessCheck2(obj, member, "access private method");
+          return method;
+        };
+        var main_exports = {};
+        __export(main_exports, {
+          default: () => main_default
+        });
+        var EBMLFloat32 = class {
+          constructor(value) {
+            this.value = value;
+          }
+        };
+        var EBMLFloat64 = class {
+          constructor(value) {
+            this.value = value;
+          }
+        };
+        var WriteTarget = class {
+          constructor() {
+            this.pos = 0;
+            this.helper = new Uint8Array(8);
+            this.helperView = new DataView(this.helper.buffer);
+            this.offsets = /* @__PURE__ */ new WeakMap();
+            this.dataOffsets = /* @__PURE__ */ new WeakMap();
+          }
+          writeFloat32(value) {
+            this.helperView.setFloat32(0, value, false);
+            this.write(this.helper.subarray(0, 4));
+          }
+          writeFloat64(value) {
+            this.helperView.setFloat64(0, value, false);
+            this.write(this.helper);
+          }
+          writeUnsignedInt(value, width = measureUnsignedInt(value)) {
+            let pos = 0;
+            switch (width) {
+              case 6:
+                this.helperView.setUint8(pos++, value / __pow2(2, 40) | 0);
+              case 5:
+                this.helperView.setUint8(pos++, value / __pow2(2, 32) | 0);
+              case 4:
+                this.helperView.setUint8(pos++, value >> 24);
+              case 3:
+                this.helperView.setUint8(pos++, value >> 16);
+              case 2:
+                this.helperView.setUint8(pos++, value >> 8);
+              case 1:
+                this.helperView.setUint8(pos++, value);
+                break;
+              default:
+                throw new Error("Bad UINT size " + width);
+            }
+            this.write(this.helper.subarray(0, pos));
+          }
+          writeEBMLVarInt(value, width = measureEBMLVarInt(value)) {
+            let pos = 0;
+            switch (width) {
+              case 1:
+                this.helperView.setUint8(pos++, 1 << 7 | value);
+                break;
+              case 2:
+                this.helperView.setUint8(pos++, 1 << 6 | value >> 8);
+                this.helperView.setUint8(pos++, value);
+                break;
+              case 3:
+                this.helperView.setUint8(pos++, 1 << 5 | value >> 16);
+                this.helperView.setUint8(pos++, value >> 8);
+                this.helperView.setUint8(pos++, value);
+                break;
+              case 4:
+                this.helperView.setUint8(pos++, 1 << 4 | value >> 24);
+                this.helperView.setUint8(pos++, value >> 16);
+                this.helperView.setUint8(pos++, value >> 8);
+                this.helperView.setUint8(pos++, value);
+                break;
+              case 5:
+                this.helperView.setUint8(pos++, 1 << 3 | value / __pow2(2, 32) & 7);
+                this.helperView.setUint8(pos++, value >> 24);
+                this.helperView.setUint8(pos++, value >> 16);
+                this.helperView.setUint8(pos++, value >> 8);
+                this.helperView.setUint8(pos++, value);
+                break;
+              case 6:
+                this.helperView.setUint8(pos++, 1 << 2 | value / __pow2(2, 40) & 3);
+                this.helperView.setUint8(pos++, value / __pow2(2, 32) | 0);
+                this.helperView.setUint8(pos++, value >> 24);
+                this.helperView.setUint8(pos++, value >> 16);
+                this.helperView.setUint8(pos++, value >> 8);
+                this.helperView.setUint8(pos++, value);
+                break;
+              default:
+                throw new Error("Bad EBML VINT size " + width);
+            }
+            this.write(this.helper.subarray(0, pos));
+          }
+          writeString(str) {
+            this.write(new Uint8Array(str.split("").map((x) => x.charCodeAt(0))));
+          }
+          writeEBML(data) {
+            var _a, _b;
+            if (data instanceof Uint8Array) {
+              this.write(data);
+            } else if (Array.isArray(data)) {
+              for (let elem of data) {
+                this.writeEBML(elem);
+              }
+            } else {
+              this.offsets.set(data, this.pos);
+              this.writeUnsignedInt(data.id);
+              if (Array.isArray(data.data)) {
+                let sizePos = this.pos;
+                let sizeSize = (_a = data.size) != null ? _a : 4;
+                this.seek(this.pos + sizeSize);
+                let startPos = this.pos;
+                this.dataOffsets.set(data, startPos);
+                this.writeEBML(data.data);
+                let size = this.pos - startPos;
+                let endPos = this.pos;
+                this.seek(sizePos);
+                this.writeEBMLVarInt(size, sizeSize);
+                this.seek(endPos);
+              } else if (typeof data.data === "number") {
+                let size = (_b = data.size) != null ? _b : measureUnsignedInt(data.data);
+                this.writeEBMLVarInt(size);
+                this.writeUnsignedInt(data.data, size);
+              } else if (typeof data.data === "string") {
+                this.writeEBMLVarInt(data.data.length);
+                this.writeString(data.data);
+              } else if (data.data instanceof Uint8Array) {
+                this.writeEBMLVarInt(data.data.byteLength, data.size);
+                this.write(data.data);
+              } else if (data.data instanceof EBMLFloat32) {
+                this.writeEBMLVarInt(4);
+                this.writeFloat32(data.data.value);
+              } else if (data.data instanceof EBMLFloat64) {
+                this.writeEBMLVarInt(8);
+                this.writeFloat64(data.data.value);
+              }
+            }
+          }
+        };
+        var measureUnsignedInt = (value) => {
+          if (value < 1 << 8) {
+            return 1;
+          } else if (value < 1 << 16) {
+            return 2;
+          } else if (value < 1 << 24) {
+            return 3;
+          } else if (value < __pow2(2, 32)) {
+            return 4;
+          } else if (value < __pow2(2, 40)) {
+            return 5;
+          } else {
+            return 6;
+          }
+        };
+        var measureEBMLVarInt = (value) => {
+          if (value < (1 << 7) - 1) {
+            return 1;
+          } else if (value < (1 << 14) - 1) {
+            return 2;
+          } else if (value < (1 << 21) - 1) {
+            return 3;
+          } else if (value < (1 << 28) - 1) {
+            return 4;
+          } else if (value < __pow2(2, 35) - 1) {
+            return 5;
+          } else if (value < __pow2(2, 42) - 1) {
+            return 6;
+          } else {
+            throw new Error("EBML VINT size not supported " + value);
+          }
+        };
+        var ArrayBufferWriteTarget = class extends WriteTarget {
+          constructor() {
+            super();
+            this.buffer = new ArrayBuffer(__pow2(2, 16));
+            this.bytes = new Uint8Array(this.buffer);
+          }
+          ensureSize(size) {
+            let newLength = this.buffer.byteLength;
+            while (newLength < size)
+              newLength *= 2;
+            if (newLength === this.buffer.byteLength)
+              return;
+            let newBuffer = new ArrayBuffer(newLength);
+            let newBytes = new Uint8Array(newBuffer);
+            newBytes.set(this.bytes, 0);
+            this.buffer = newBuffer;
+            this.bytes = newBytes;
+          }
+          write(data) {
+            this.ensureSize(this.pos + data.byteLength);
+            this.bytes.set(data, this.pos);
+            this.pos += data.byteLength;
+          }
+          seek(newPos) {
+            this.pos = newPos;
+          }
+          finalize() {
+            this.ensureSize(this.pos);
+            return this.buffer.slice(0, this.pos);
+          }
+        };
+        var FILE_CHUNK_SIZE = __pow2(2, 24);
+        var MAX_CHUNKS_AT_ONCE2 = 2;
+        var FileSystemWritableFileStreamWriteTarget = class extends WriteTarget {
+          constructor(stream) {
+            super();
+            this.chunks = [];
+            this.stream = stream;
+          }
+          write(data) {
+            this.writeDataIntoChunks(data, this.pos);
+            this.flushChunks();
+            this.pos += data.byteLength;
+          }
+          writeDataIntoChunks(data, position) {
+            let chunkIndex = this.chunks.findIndex((x) => x.start <= position && position < x.start + FILE_CHUNK_SIZE);
+            if (chunkIndex === -1)
+              chunkIndex = this.createChunk(position);
+            let chunk = this.chunks[chunkIndex];
+            let relativePosition = position - chunk.start;
+            let toWrite = data.subarray(0, Math.min(FILE_CHUNK_SIZE - relativePosition, data.byteLength));
+            chunk.data.set(toWrite, relativePosition);
+            let section = {
+              start: relativePosition,
+              end: relativePosition + toWrite.byteLength
+            };
+            insertSectionIntoFileChunk(chunk, section);
+            if (chunk.written[0].start === 0 && chunk.written[0].end === FILE_CHUNK_SIZE) {
+              chunk.shouldFlush = true;
+            }
+            if (this.chunks.length > MAX_CHUNKS_AT_ONCE2) {
+              for (let i = 0; i < this.chunks.length - 1; i++) {
+                this.chunks[i].shouldFlush = true;
+              }
+              this.flushChunks();
+            }
+            if (toWrite.byteLength < data.byteLength) {
+              this.writeDataIntoChunks(data.subarray(toWrite.byteLength), position + toWrite.byteLength);
+            }
+          }
+          createChunk(includesPosition) {
+            let start = Math.floor(includesPosition / FILE_CHUNK_SIZE) * FILE_CHUNK_SIZE;
+            let chunk = {
+              start,
+              data: new Uint8Array(FILE_CHUNK_SIZE),
+              written: [],
+              shouldFlush: false
+            };
+            this.chunks.push(chunk);
+            this.chunks.sort((a, b) => a.start - b.start);
+            return this.chunks.indexOf(chunk);
+          }
+          flushChunks(force = false) {
+            for (let i = 0; i < this.chunks.length; i++) {
+              let chunk = this.chunks[i];
+              if (!chunk.shouldFlush && !force)
+                continue;
+              for (let section of chunk.written) {
+                this.stream.write({
+                  type: "write",
+                  data: chunk.data.subarray(section.start, section.end),
+                  position: chunk.start + section.start
+                });
+              }
+              this.chunks.splice(i--, 1);
+            }
+          }
+          seek(newPos) {
+            this.pos = newPos;
+          }
+          finalize() {
+            this.flushChunks(true);
+          }
+        };
+        var insertSectionIntoFileChunk = (chunk, section) => {
+          let low = 0;
+          let high = chunk.written.length - 1;
+          let index = -1;
+          while (low <= high) {
+            let mid = Math.floor(low + (high - low + 1) / 2);
+            if (chunk.written[mid].start <= section.start) {
+              low = mid + 1;
+              index = mid;
+            } else {
+              high = mid - 1;
+            }
+          }
+          chunk.written.splice(index + 1, 0, section);
+          if (index === -1 || chunk.written[index].end < section.start)
+            index++;
+          while (index < chunk.written.length - 1 && chunk.written[index].end >= chunk.written[index + 1].start) {
+            chunk.written[index].end = Math.max(chunk.written[index].end, chunk.written[index + 1].end);
+            chunk.written.splice(index + 1, 1);
+          }
+        };
+        var VIDEO_TRACK_NUMBER = 1;
+        var AUDIO_TRACK_NUMBER = 2;
+        var VIDEO_TRACK_TYPE = 1;
+        var AUDIO_TRACK_TYPE = 2;
+        var MAX_CHUNK_LENGTH_MS = __pow2(2, 15);
+        var CODEC_PRIVATE_MAX_SIZE = __pow2(2, 12);
+        var APP_NAME = "https://github.com/Vanilagy/webm-muxer";
+        var SEGMENT_SIZE_BYTES = 6;
+        var CLUSTER_SIZE_BYTES = 5;
+        var _target4, _options2, _segment, _segmentInfo, _seekHead, _tracksElement, _segmentDuration, _colourElement, _videoCodecPrivate, _audioCodecPrivate, _cues, _currentCluster, _currentClusterTimestamp, _duration, _videoChunkQueue, _audioChunkQueue, _lastVideoTimestamp, _lastAudioTimestamp, _colorSpace, _finalized2, _validateOptions2, validateOptions_fn2, _createFileHeader, createFileHeader_fn, _writeEBMLHeader, writeEBMLHeader_fn, _createSeekHead, createSeekHead_fn, _createSegmentInfo, createSegmentInfo_fn, _createTracks, createTracks_fn, _createSegment, createSegment_fn, _createCues, createCues_fn, _segmentDataOffset, segmentDataOffset_get, _writeVideoDecoderConfig, writeVideoDecoderConfig_fn, _fixVP9ColorSpace, fixVP9ColorSpace_fn, _createInternalChunk, createInternalChunk_fn, _writeSimpleBlock, writeSimpleBlock_fn, _writeCodecPrivate, writeCodecPrivate_fn, _createNewCluster, createNewCluster_fn, _finalizeCurrentCluster, finalizeCurrentCluster_fn, _ensureNotFinalized2, ensureNotFinalized_fn2;
+        var WebMMuxer3 = class {
+          constructor(options) {
+            __privateAdd2(this, _validateOptions2);
+            __privateAdd2(this, _createFileHeader);
+            __privateAdd2(this, _writeEBMLHeader);
+            __privateAdd2(this, _createSeekHead);
+            __privateAdd2(this, _createSegmentInfo);
+            __privateAdd2(this, _createTracks);
+            __privateAdd2(this, _createSegment);
+            __privateAdd2(this, _createCues);
+            __privateAdd2(this, _segmentDataOffset);
+            __privateAdd2(this, _writeVideoDecoderConfig);
+            __privateAdd2(this, _fixVP9ColorSpace);
+            __privateAdd2(this, _createInternalChunk);
+            __privateAdd2(this, _writeSimpleBlock);
+            __privateAdd2(this, _writeCodecPrivate);
+            __privateAdd2(this, _createNewCluster);
+            __privateAdd2(this, _finalizeCurrentCluster);
+            __privateAdd2(this, _ensureNotFinalized2);
+            __privateAdd2(this, _target4, void 0);
+            __privateAdd2(this, _options2, void 0);
+            __privateAdd2(this, _segment, void 0);
+            __privateAdd2(this, _segmentInfo, void 0);
+            __privateAdd2(this, _seekHead, void 0);
+            __privateAdd2(this, _tracksElement, void 0);
+            __privateAdd2(this, _segmentDuration, void 0);
+            __privateAdd2(this, _colourElement, void 0);
+            __privateAdd2(this, _videoCodecPrivate, void 0);
+            __privateAdd2(this, _audioCodecPrivate, void 0);
+            __privateAdd2(this, _cues, void 0);
+            __privateAdd2(this, _currentCluster, void 0);
+            __privateAdd2(this, _currentClusterTimestamp, void 0);
+            __privateAdd2(this, _duration, 0);
+            __privateAdd2(this, _videoChunkQueue, []);
+            __privateAdd2(this, _audioChunkQueue, []);
+            __privateAdd2(this, _lastVideoTimestamp, 0);
+            __privateAdd2(this, _lastAudioTimestamp, 0);
+            __privateAdd2(this, _colorSpace, void 0);
+            __privateAdd2(this, _finalized2, false);
+            __privateMethod2(this, _validateOptions2, validateOptions_fn2).call(this, options);
+            __privateSet2(this, _options2, options);
+            if (options.target === "buffer") {
+              __privateSet2(this, _target4, new ArrayBufferWriteTarget());
+            } else {
+              __privateSet2(this, _target4, new FileSystemWritableFileStreamWriteTarget(options.target));
+            }
+            __privateMethod2(this, _createFileHeader, createFileHeader_fn).call(this);
+          }
+          addVideoChunk(chunk, meta, timestamp) {
+            let data = new Uint8Array(chunk.byteLength);
+            chunk.copyTo(data);
+            this.addVideoChunkRaw(data, chunk.type, timestamp != null ? timestamp : chunk.timestamp, meta);
+          }
+          addVideoChunkRaw(data, type, timestamp, meta) {
+            __privateMethod2(this, _ensureNotFinalized2, ensureNotFinalized_fn2).call(this);
+            if (!__privateGet2(this, _options2).video)
+              throw new Error("No video track declared.");
+            if (meta)
+              __privateMethod2(this, _writeVideoDecoderConfig, writeVideoDecoderConfig_fn).call(this, meta);
+            let internalChunk = __privateMethod2(this, _createInternalChunk, createInternalChunk_fn).call(this, data, type, timestamp, VIDEO_TRACK_NUMBER);
+            if (__privateGet2(this, _options2).video.codec === "V_VP9")
+              __privateMethod2(this, _fixVP9ColorSpace, fixVP9ColorSpace_fn).call(this, internalChunk);
+            __privateSet2(this, _lastVideoTimestamp, internalChunk.timestamp);
+            while (__privateGet2(this, _audioChunkQueue).length > 0 && __privateGet2(this, _audioChunkQueue)[0].timestamp <= internalChunk.timestamp) {
+              let audioChunk = __privateGet2(this, _audioChunkQueue).shift();
+              __privateMethod2(this, _writeSimpleBlock, writeSimpleBlock_fn).call(this, audioChunk);
+            }
+            if (!__privateGet2(this, _options2).audio || internalChunk.timestamp <= __privateGet2(this, _lastAudioTimestamp)) {
+              __privateMethod2(this, _writeSimpleBlock, writeSimpleBlock_fn).call(this, internalChunk);
+            } else {
+              __privateGet2(this, _videoChunkQueue).push(internalChunk);
+            }
+          }
+          addAudioChunk(chunk, meta, timestamp) {
+            let data = new Uint8Array(chunk.byteLength);
+            chunk.copyTo(data);
+            this.addAudioChunkRaw(data, chunk.type, timestamp != null ? timestamp : chunk.timestamp, meta);
+          }
+          addAudioChunkRaw(data, type, timestamp, meta) {
+            __privateMethod2(this, _ensureNotFinalized2, ensureNotFinalized_fn2).call(this);
+            if (!__privateGet2(this, _options2).audio)
+              throw new Error("No audio track declared.");
+            let internalChunk = __privateMethod2(this, _createInternalChunk, createInternalChunk_fn).call(this, data, type, timestamp, AUDIO_TRACK_NUMBER);
+            __privateSet2(this, _lastAudioTimestamp, internalChunk.timestamp);
+            while (__privateGet2(this, _videoChunkQueue).length > 0 && __privateGet2(this, _videoChunkQueue)[0].timestamp <= internalChunk.timestamp) {
+              let videoChunk = __privateGet2(this, _videoChunkQueue).shift();
+              __privateMethod2(this, _writeSimpleBlock, writeSimpleBlock_fn).call(this, videoChunk);
+            }
+            if (!__privateGet2(this, _options2).video || internalChunk.timestamp <= __privateGet2(this, _lastVideoTimestamp)) {
+              __privateMethod2(this, _writeSimpleBlock, writeSimpleBlock_fn).call(this, internalChunk);
+            } else {
+              __privateGet2(this, _audioChunkQueue).push(internalChunk);
+            }
+            if (meta == null ? void 0 : meta.decoderConfig) {
+              __privateMethod2(this, _writeCodecPrivate, writeCodecPrivate_fn).call(this, __privateGet2(this, _audioCodecPrivate), meta.decoderConfig.description);
+            }
+          }
+          finalize() {
+            while (__privateGet2(this, _videoChunkQueue).length > 0)
+              __privateMethod2(this, _writeSimpleBlock, writeSimpleBlock_fn).call(this, __privateGet2(this, _videoChunkQueue).shift());
+            while (__privateGet2(this, _audioChunkQueue).length > 0)
+              __privateMethod2(this, _writeSimpleBlock, writeSimpleBlock_fn).call(this, __privateGet2(this, _audioChunkQueue).shift());
+            __privateMethod2(this, _finalizeCurrentCluster, finalizeCurrentCluster_fn).call(this);
+            __privateGet2(this, _target4).writeEBML(__privateGet2(this, _cues));
+            let endPos = __privateGet2(this, _target4).pos;
+            let segmentSize = __privateGet2(this, _target4).pos - __privateGet2(this, _segmentDataOffset, segmentDataOffset_get);
+            __privateGet2(this, _target4).seek(__privateGet2(this, _target4).offsets.get(__privateGet2(this, _segment)) + 4);
+            __privateGet2(this, _target4).writeEBMLVarInt(segmentSize, SEGMENT_SIZE_BYTES);
+            __privateGet2(this, _segmentDuration).data = new EBMLFloat64(__privateGet2(this, _duration));
+            __privateGet2(this, _target4).seek(__privateGet2(this, _target4).offsets.get(__privateGet2(this, _segmentDuration)));
+            __privateGet2(this, _target4).writeEBML(__privateGet2(this, _segmentDuration));
+            __privateGet2(this, _seekHead).data[0].data[1].data = __privateGet2(this, _target4).offsets.get(__privateGet2(this, _cues)) - __privateGet2(this, _segmentDataOffset, segmentDataOffset_get);
+            __privateGet2(this, _seekHead).data[1].data[1].data = __privateGet2(this, _target4).offsets.get(__privateGet2(this, _segmentInfo)) - __privateGet2(this, _segmentDataOffset, segmentDataOffset_get);
+            __privateGet2(this, _seekHead).data[2].data[1].data = __privateGet2(this, _target4).offsets.get(__privateGet2(this, _tracksElement)) - __privateGet2(this, _segmentDataOffset, segmentDataOffset_get);
+            __privateGet2(this, _target4).seek(__privateGet2(this, _target4).offsets.get(__privateGet2(this, _seekHead)));
+            __privateGet2(this, _target4).writeEBML(__privateGet2(this, _seekHead));
+            __privateGet2(this, _target4).seek(endPos);
+            __privateSet2(this, _finalized2, true);
+            if (__privateGet2(this, _target4) instanceof ArrayBufferWriteTarget) {
+              return __privateGet2(this, _target4).finalize();
+            } else if (__privateGet2(this, _target4) instanceof FileSystemWritableFileStreamWriteTarget) {
+              __privateGet2(this, _target4).finalize();
+            }
+            return null;
+          }
+        };
+        _target4 = /* @__PURE__ */ new WeakMap();
+        _options2 = /* @__PURE__ */ new WeakMap();
+        _segment = /* @__PURE__ */ new WeakMap();
+        _segmentInfo = /* @__PURE__ */ new WeakMap();
+        _seekHead = /* @__PURE__ */ new WeakMap();
+        _tracksElement = /* @__PURE__ */ new WeakMap();
+        _segmentDuration = /* @__PURE__ */ new WeakMap();
+        _colourElement = /* @__PURE__ */ new WeakMap();
+        _videoCodecPrivate = /* @__PURE__ */ new WeakMap();
+        _audioCodecPrivate = /* @__PURE__ */ new WeakMap();
+        _cues = /* @__PURE__ */ new WeakMap();
+        _currentCluster = /* @__PURE__ */ new WeakMap();
+        _currentClusterTimestamp = /* @__PURE__ */ new WeakMap();
+        _duration = /* @__PURE__ */ new WeakMap();
+        _videoChunkQueue = /* @__PURE__ */ new WeakMap();
+        _audioChunkQueue = /* @__PURE__ */ new WeakMap();
+        _lastVideoTimestamp = /* @__PURE__ */ new WeakMap();
+        _lastAudioTimestamp = /* @__PURE__ */ new WeakMap();
+        _colorSpace = /* @__PURE__ */ new WeakMap();
+        _finalized2 = /* @__PURE__ */ new WeakMap();
+        _validateOptions2 = /* @__PURE__ */ new WeakSet();
+        validateOptions_fn2 = function(options) {
+          if (options.type && options.type !== "webm" && options.type !== "matroska") {
+            throw new Error(`Invalid type: ${options.type}`);
+          }
+        };
+        _createFileHeader = /* @__PURE__ */ new WeakSet();
+        createFileHeader_fn = function() {
+          __privateMethod2(this, _writeEBMLHeader, writeEBMLHeader_fn).call(this);
+          __privateMethod2(this, _createSeekHead, createSeekHead_fn).call(this);
+          __privateMethod2(this, _createSegmentInfo, createSegmentInfo_fn).call(this);
+          __privateMethod2(this, _createTracks, createTracks_fn).call(this);
+          __privateMethod2(this, _createSegment, createSegment_fn).call(this);
+          __privateMethod2(this, _createCues, createCues_fn).call(this);
+        };
+        _writeEBMLHeader = /* @__PURE__ */ new WeakSet();
+        writeEBMLHeader_fn = function() {
+          var _a;
+          let ebmlHeader = { id: 440786851, data: [
+            { id: 17030, data: 1 },
+            { id: 17143, data: 1 },
+            { id: 17138, data: 4 },
+            { id: 17139, data: 8 },
+            { id: 17026, data: (_a = __privateGet2(this, _options2).type) != null ? _a : "webm" },
+            { id: 17031, data: 2 },
+            { id: 17029, data: 2 }
+          ] };
+          __privateGet2(this, _target4).writeEBML(ebmlHeader);
+        };
+        _createSeekHead = /* @__PURE__ */ new WeakSet();
+        createSeekHead_fn = function() {
+          const kaxCues = new Uint8Array([28, 83, 187, 107]);
+          const kaxInfo = new Uint8Array([21, 73, 169, 102]);
+          const kaxTracks = new Uint8Array([22, 84, 174, 107]);
+          let seekHead = { id: 290298740, data: [
+            { id: 19899, data: [
+              { id: 21419, data: kaxCues },
+              { id: 21420, size: 5, data: 0 }
+            ] },
+            { id: 19899, data: [
+              { id: 21419, data: kaxInfo },
+              { id: 21420, size: 5, data: 0 }
+            ] },
+            { id: 19899, data: [
+              { id: 21419, data: kaxTracks },
+              { id: 21420, size: 5, data: 0 }
+            ] }
+          ] };
+          __privateSet2(this, _seekHead, seekHead);
+        };
+        _createSegmentInfo = /* @__PURE__ */ new WeakSet();
+        createSegmentInfo_fn = function() {
+          let segmentDuration = { id: 17545, data: new EBMLFloat64(0) };
+          __privateSet2(this, _segmentDuration, segmentDuration);
+          let segmentInfo = { id: 357149030, data: [
+            { id: 2807729, data: 1e6 },
+            { id: 19840, data: APP_NAME },
+            { id: 22337, data: APP_NAME },
+            segmentDuration
+          ] };
+          __privateSet2(this, _segmentInfo, segmentInfo);
+        };
+        _createTracks = /* @__PURE__ */ new WeakSet();
+        createTracks_fn = function() {
+          let tracksElement = { id: 374648427, data: [] };
+          __privateSet2(this, _tracksElement, tracksElement);
+          if (__privateGet2(this, _options2).video) {
+            __privateSet2(this, _videoCodecPrivate, { id: 236, size: 4, data: new Uint8Array(CODEC_PRIVATE_MAX_SIZE) });
+            let colourElement = { id: 21936, data: [
+              { id: 21937, data: 2 },
+              { id: 21946, data: 2 },
+              { id: 21947, data: 2 },
+              { id: 21945, data: 0 }
+            ] };
+            __privateSet2(this, _colourElement, colourElement);
+            tracksElement.data.push({ id: 174, data: [
+              { id: 215, data: VIDEO_TRACK_NUMBER },
+              { id: 29637, data: VIDEO_TRACK_NUMBER },
+              { id: 131, data: VIDEO_TRACK_TYPE },
+              { id: 134, data: __privateGet2(this, _options2).video.codec },
+              __privateGet2(this, _videoCodecPrivate),
+              __privateGet2(this, _options2).video.frameRate ? { id: 2352003, data: 1e9 / __privateGet2(this, _options2).video.frameRate } : null,
+              { id: 224, data: [
+                { id: 176, data: __privateGet2(this, _options2).video.width },
+                { id: 186, data: __privateGet2(this, _options2).video.height },
+                colourElement
+              ] }
+            ].filter(Boolean) });
+          }
+          if (__privateGet2(this, _options2).audio) {
+            __privateSet2(this, _audioCodecPrivate, { id: 236, size: 4, data: new Uint8Array(CODEC_PRIVATE_MAX_SIZE) });
+            tracksElement.data.push({ id: 174, data: [
+              { id: 215, data: AUDIO_TRACK_NUMBER },
+              { id: 29637, data: AUDIO_TRACK_NUMBER },
+              { id: 131, data: AUDIO_TRACK_TYPE },
+              { id: 134, data: __privateGet2(this, _options2).audio.codec },
+              __privateGet2(this, _audioCodecPrivate),
+              { id: 225, data: [
+                { id: 181, data: new EBMLFloat32(__privateGet2(this, _options2).audio.sampleRate) },
+                { id: 159, data: __privateGet2(this, _options2).audio.numberOfChannels },
+                __privateGet2(this, _options2).audio.bitDepth ? { id: 25188, data: __privateGet2(this, _options2).audio.bitDepth } : null
+              ].filter(Boolean) }
+            ] });
+          }
+        };
+        _createSegment = /* @__PURE__ */ new WeakSet();
+        createSegment_fn = function() {
+          let segment = { id: 408125543, size: SEGMENT_SIZE_BYTES, data: [
+            __privateGet2(this, _seekHead),
+            __privateGet2(this, _segmentInfo),
+            __privateGet2(this, _tracksElement)
+          ] };
+          __privateSet2(this, _segment, segment);
+          __privateGet2(this, _target4).writeEBML(segment);
+        };
+        _createCues = /* @__PURE__ */ new WeakSet();
+        createCues_fn = function() {
+          __privateSet2(this, _cues, { id: 475249515, data: [] });
+        };
+        _segmentDataOffset = /* @__PURE__ */ new WeakSet();
+        segmentDataOffset_get = function() {
+          return __privateGet2(this, _target4).dataOffsets.get(__privateGet2(this, _segment));
+        };
+        _writeVideoDecoderConfig = /* @__PURE__ */ new WeakSet();
+        writeVideoDecoderConfig_fn = function(meta) {
+          if (meta.decoderConfig) {
+            if (meta.decoderConfig.colorSpace) {
+              let colorSpace = meta.decoderConfig.colorSpace;
+              __privateSet2(this, _colorSpace, colorSpace);
+              __privateGet2(this, _colourElement).data = [
+                { id: 21937, data: {
+                  "rgb": 1,
+                  "bt709": 1,
+                  "bt470bg": 5,
+                  "smpte170m": 6
+                }[colorSpace.matrix] },
+                { id: 21946, data: {
+                  "bt709": 1,
+                  "smpte170m": 6,
+                  "iec61966-2-1": 13
+                }[colorSpace.transfer] },
+                { id: 21947, data: {
+                  "bt709": 1,
+                  "bt470bg": 5,
+                  "smpte170m": 6
+                }[colorSpace.primaries] },
+                { id: 21945, data: [1, 2][Number(colorSpace.fullRange)] }
+              ];
+              let endPos = __privateGet2(this, _target4).pos;
+              __privateGet2(this, _target4).seek(__privateGet2(this, _target4).offsets.get(__privateGet2(this, _colourElement)));
+              __privateGet2(this, _target4).writeEBML(__privateGet2(this, _colourElement));
+              __privateGet2(this, _target4).seek(endPos);
+            }
+            if (meta.decoderConfig.description) {
+              __privateMethod2(this, _writeCodecPrivate, writeCodecPrivate_fn).call(this, __privateGet2(this, _videoCodecPrivate), meta.decoderConfig.description);
+            }
+          }
+        };
+        _fixVP9ColorSpace = /* @__PURE__ */ new WeakSet();
+        fixVP9ColorSpace_fn = function(chunk) {
+          if (chunk.type !== "key")
+            return;
+          if (!__privateGet2(this, _colorSpace))
+            return;
+          let i = 0;
+          if (readBits(chunk.data, 0, 2) !== 2)
+            return;
+          i += 2;
+          let profile = (readBits(chunk.data, i + 1, i + 2) << 1) + readBits(chunk.data, i + 0, i + 1);
+          i += 2;
+          if (profile === 3)
+            i++;
+          let showExistingFrame = readBits(chunk.data, i + 0, i + 1);
+          i++;
+          if (showExistingFrame)
+            return;
+          let frameType = readBits(chunk.data, i + 0, i + 1);
+          i++;
+          if (frameType !== 0)
+            return;
+          i += 2;
+          let syncCode = readBits(chunk.data, i + 0, i + 24);
+          i += 24;
+          if (syncCode !== 4817730)
+            return;
+          if (profile >= 2)
+            i++;
+          let colorSpaceID = {
+            "rgb": 7,
+            "bt709": 2,
+            "bt470bg": 1,
+            "smpte170m": 3
+          }[__privateGet2(this, _colorSpace).matrix];
+          writeBits(chunk.data, i + 0, i + 3, colorSpaceID);
+        };
+        _createInternalChunk = /* @__PURE__ */ new WeakSet();
+        createInternalChunk_fn = function(data, type, timestamp, trackNumber) {
+          let internalChunk = {
+            data,
+            type,
+            timestamp,
+            trackNumber
+          };
+          return internalChunk;
+        };
+        _writeSimpleBlock = /* @__PURE__ */ new WeakSet();
+        writeSimpleBlock_fn = function(chunk) {
+          let msTime = Math.floor(chunk.timestamp / 1e3);
+          let clusterIsTooLong = chunk.type !== "key" && msTime - __privateGet2(this, _currentClusterTimestamp) >= MAX_CHUNK_LENGTH_MS;
+          if (clusterIsTooLong) {
+            throw new Error(
+              `Current Matroska cluster exceeded its maximum allowed length of ${MAX_CHUNK_LENGTH_MS} milliseconds. In order to produce a correct WebM file, you must pass in a video key frame at least every ${MAX_CHUNK_LENGTH_MS} milliseconds.`
+            );
+          }
+          let shouldCreateNewClusterFromKeyFrame = (chunk.trackNumber === VIDEO_TRACK_NUMBER || !__privateGet2(this, _options2).video) && chunk.type === "key" && msTime - __privateGet2(this, _currentClusterTimestamp) >= 1e3;
+          if (!__privateGet2(this, _currentCluster) || shouldCreateNewClusterFromKeyFrame) {
+            __privateMethod2(this, _createNewCluster, createNewCluster_fn).call(this, msTime);
+          }
+          let prelude = new Uint8Array(4);
+          let view2 = new DataView(prelude.buffer);
+          view2.setUint8(0, 128 | chunk.trackNumber);
+          view2.setUint16(1, msTime - __privateGet2(this, _currentClusterTimestamp), false);
+          view2.setUint8(3, Number(chunk.type === "key") << 7);
+          let simpleBlock = { id: 163, data: [
+            prelude,
+            chunk.data
+          ] };
+          __privateGet2(this, _target4).writeEBML(simpleBlock);
+          __privateSet2(this, _duration, Math.max(__privateGet2(this, _duration), msTime));
+        };
+        _writeCodecPrivate = /* @__PURE__ */ new WeakSet();
+        writeCodecPrivate_fn = function(element, data) {
+          let endPos = __privateGet2(this, _target4).pos;
+          __privateGet2(this, _target4).seek(__privateGet2(this, _target4).offsets.get(element));
+          element = [
+            { id: 25506, size: 4, data: new Uint8Array(data) },
+            { id: 236, size: 4, data: new Uint8Array(CODEC_PRIVATE_MAX_SIZE - 2 - 4 - data.byteLength) }
+          ];
+          __privateGet2(this, _target4).writeEBML(element);
+          __privateGet2(this, _target4).seek(endPos);
+        };
+        _createNewCluster = /* @__PURE__ */ new WeakSet();
+        createNewCluster_fn = function(timestamp) {
+          if (__privateGet2(this, _currentCluster)) {
+            __privateMethod2(this, _finalizeCurrentCluster, finalizeCurrentCluster_fn).call(this);
+          }
+          __privateSet2(this, _currentCluster, { id: 524531317, size: CLUSTER_SIZE_BYTES, data: [
+            { id: 231, data: timestamp }
+          ] });
+          __privateGet2(this, _target4).writeEBML(__privateGet2(this, _currentCluster));
+          __privateSet2(this, _currentClusterTimestamp, timestamp);
+          let clusterOffsetFromSegment = __privateGet2(this, _target4).offsets.get(__privateGet2(this, _currentCluster)) - __privateGet2(this, _segmentDataOffset, segmentDataOffset_get);
+          __privateGet2(this, _cues).data.push({ id: 187, data: [
+            { id: 179, data: timestamp },
+            { id: 183, data: [
+              { id: 247, data: VIDEO_TRACK_NUMBER },
+              { id: 241, data: clusterOffsetFromSegment }
+            ] }
+          ] });
+        };
+        _finalizeCurrentCluster = /* @__PURE__ */ new WeakSet();
+        finalizeCurrentCluster_fn = function() {
+          let clusterSize = __privateGet2(this, _target4).pos - __privateGet2(this, _target4).dataOffsets.get(__privateGet2(this, _currentCluster));
+          let endPos = __privateGet2(this, _target4).pos;
+          __privateGet2(this, _target4).seek(__privateGet2(this, _target4).offsets.get(__privateGet2(this, _currentCluster)) + 4);
+          __privateGet2(this, _target4).writeEBMLVarInt(clusterSize, CLUSTER_SIZE_BYTES);
+          __privateGet2(this, _target4).seek(endPos);
+        };
+        _ensureNotFinalized2 = /* @__PURE__ */ new WeakSet();
+        ensureNotFinalized_fn2 = function() {
+          if (__privateGet2(this, _finalized2)) {
+            throw new Error("Cannot add new video or audio chunks after the file has been finalized.");
+          }
+        };
+        var main_default = WebMMuxer3;
+        var readBits = (bytes2, start, end) => {
+          let result = 0;
+          for (let i = start; i < end; i++) {
+            let byteIndex = Math.floor(i / 8);
+            let byte = bytes2[byteIndex];
+            let bitIndex = 7 - (i & 7);
+            let bit = (byte & 1 << bitIndex) >> bitIndex;
+            result <<= 1;
+            result |= bit;
+          }
+          return result;
+        };
+        var writeBits = (bytes2, start, end, value) => {
+          for (let i = start; i < end; i++) {
+            let byteIndex = Math.floor(i / 8);
+            let byte = bytes2[byteIndex];
+            let bitIndex = 7 - (i & 7);
+            byte &= ~(1 << bitIndex);
+            byte |= (value & 1 << end - i - 1) >> end - i - 1 << bitIndex;
+            bytes2[byteIndex] = byte;
+          }
+        };
+        return __toCommonJS(main_exports);
+      })();
+      WebMMuxer2 = WebMMuxer2.default;
+      if (typeof module === "object" && typeof module.exports === "object") module.exports = WebMMuxer2;
+    }
+  });
+
+  // node_modules/mp4-muxer/build/mp4-muxer.mjs
+  var __defProp2 = Object.defineProperty;
+  var __getOwnPropSymbols = Object.getOwnPropertySymbols;
+  var __hasOwnProp2 = Object.prototype.hasOwnProperty;
+  var __propIsEnum = Object.prototype.propertyIsEnumerable;
+  var __pow = Math.pow;
+  var __defNormalProp = (obj, key, value) => key in obj ? __defProp2(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
+  var __spreadValues = (a, b) => {
+    for (var prop in b || (b = {}))
+      if (__hasOwnProp2.call(b, prop))
+        __defNormalProp(a, prop, b[prop]);
+    if (__getOwnPropSymbols)
+      for (var prop of __getOwnPropSymbols(b)) {
+        if (__propIsEnum.call(b, prop))
+          __defNormalProp(a, prop, b[prop]);
+      }
+    return a;
+  };
+  var __accessCheck = (obj, member, msg) => {
+    if (!member.has(obj))
+      throw TypeError("Cannot " + msg);
+  };
+  var __privateGet = (obj, member, getter) => {
+    __accessCheck(obj, member, "read from private field");
+    return getter ? getter.call(obj) : member.get(obj);
+  };
+  var __privateAdd = (obj, member, value) => {
+    if (member.has(obj))
+      throw TypeError("Cannot add the same private member more than once");
+    member instanceof WeakSet ? member.add(obj) : member.set(obj, value);
+  };
+  var __privateSet = (obj, member, value, setter) => {
+    __accessCheck(obj, member, "write to private field");
+    setter ? setter.call(obj, value) : member.set(obj, value);
+    return value;
+  };
+  var __privateMethod = (obj, member, method) => {
+    __accessCheck(obj, member, "access private method");
+    return method;
+  };
+  var bytes = new Uint8Array(8);
+  var view = new DataView(bytes.buffer);
+  var u8 = (value) => {
+    return [(value % 256 + 256) % 256];
+  };
+  var u16 = (value) => {
+    view.setUint16(0, value, false);
+    return [bytes[0], bytes[1]];
+  };
+  var i16 = (value) => {
+    view.setInt16(0, value, false);
+    return [bytes[0], bytes[1]];
+  };
+  var u24 = (value) => {
+    view.setUint32(0, value, false);
+    return [bytes[1], bytes[2], bytes[3]];
+  };
+  var u32 = (value) => {
+    view.setUint32(0, value, false);
+    return [bytes[0], bytes[1], bytes[2], bytes[3]];
+  };
+  var u64 = (value) => {
+    view.setUint32(0, Math.floor(value / __pow(2, 32)), false);
+    view.setUint32(4, value, false);
+    return [bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7]];
+  };
+  var fixed_8_8 = (value) => {
+    view.setInt16(0, __pow(2, 8) * value, false);
+    return [bytes[0], bytes[1]];
+  };
+  var fixed_16_16 = (value) => {
+    view.setInt32(0, __pow(2, 16) * value, false);
+    return [bytes[0], bytes[1], bytes[2], bytes[3]];
+  };
+  var fixed_2_30 = (value) => {
+    view.setInt32(0, __pow(2, 30) * value, false);
+    return [bytes[0], bytes[1], bytes[2], bytes[3]];
+  };
+  var ascii = (text, nullTerminated = false) => {
+    let bytes2 = Array(text.length).fill(null).map((_, i) => text.charCodeAt(i));
+    if (nullTerminated)
+      bytes2.push(0);
+    return bytes2;
+  };
+  var last = (arr) => {
+    return arr && arr[arr.length - 1];
+  };
+  var intoTimescale = (timeInSeconds, timescale, round = true) => {
+    let value = timeInSeconds * timescale;
+    return round ? Math.round(value) : value;
+  };
+  var rotationMatrix = (rotationInDegrees) => {
+    let theta = rotationInDegrees * (Math.PI / 180);
+    let cosTheta = Math.cos(theta);
+    let sinTheta = Math.sin(theta);
+    return [
+      cosTheta,
+      sinTheta,
+      0,
+      -sinTheta,
+      cosTheta,
+      0,
+      0,
+      0,
+      1
+    ];
+  };
+  var IDENTITY_MATRIX = rotationMatrix(0);
+  var matrixToBytes = (matrix) => {
+    return [
+      fixed_16_16(matrix[0]),
+      fixed_16_16(matrix[1]),
+      fixed_2_30(matrix[2]),
+      fixed_16_16(matrix[3]),
+      fixed_16_16(matrix[4]),
+      fixed_2_30(matrix[5]),
+      fixed_16_16(matrix[6]),
+      fixed_16_16(matrix[7]),
+      fixed_2_30(matrix[8])
+    ];
+  };
+  var box = (type, contents, children) => ({
+    type,
+    contents: contents && new Uint8Array(contents.flat(10)),
+    children
+  });
+  var fullBox = (type, version, flags, contents, children) => box(
+    type,
+    [u8(version), u24(flags), contents != null ? contents : []],
+    children
+  );
+  var ftyp = (holdsHevc) => {
+    if (holdsHevc)
+      return box("ftyp", [
+        ascii("isom"),
+        // Major brand
+        u32(0),
+        // Minor version
+        ascii("iso4"),
+        // Compatible brand 1
+        ascii("hvc1")
+        // Compatible brand 2
+      ]);
+    return box("ftyp", [
+      ascii("isom"),
+      // Major brand
+      u32(0),
+      // Minor version
+      ascii("isom"),
+      // Compatible brand 1
+      ascii("avc1"),
+      // Compatible brand 2
+      ascii("mp41")
+      // Compatible brand 3
+    ]);
+  };
+  var mdat = () => ({ type: "mdat", largeSize: true });
+  var moov = (tracks, creationTime) => box("moov", null, [
+    mvhd(creationTime, tracks),
+    ...tracks.map((x) => trak(x, creationTime))
+  ]);
+  var mvhd = (creationTime, tracks) => {
+    let duration = intoTimescale(Math.max(
+      0,
+      ...tracks.filter((x) => x.samples.length > 0).map((x) => last(x.samples).timestamp + last(x.samples).duration)
+    ), GLOBAL_TIMESCALE);
+    let nextTrackId = Math.max(...tracks.map((x) => x.id)) + 1;
+    return fullBox("mvhd", 0, 0, [
+      u32(creationTime),
+      // Creation time
+      u32(creationTime),
+      // Modification time
+      u32(GLOBAL_TIMESCALE),
+      // Timescale
+      u32(duration),
+      // Duration
+      fixed_16_16(1),
+      // Preferred rate
+      fixed_8_8(1),
+      // Preferred volume
+      Array(10).fill(0),
+      // Reserved
+      matrixToBytes(IDENTITY_MATRIX),
+      // Matrix
+      Array(24).fill(0),
+      // Pre-defined
+      u32(nextTrackId)
+      // Next track ID
+    ]);
+  };
+  var trak = (track, creationTime) => box("trak", null, [
+    tkhd(track, creationTime),
+    mdia(track, creationTime)
+  ]);
+  var tkhd = (track, creationTime) => {
+    let lastSample = last(track.samples);
+    let durationInGlobalTimescale = intoTimescale(
+      lastSample ? lastSample.timestamp + lastSample.duration : 0,
+      GLOBAL_TIMESCALE
+    );
+    return fullBox("tkhd", 0, 3, [
+      u32(creationTime),
+      // Creation time
+      u32(creationTime),
+      // Modification time
+      u32(track.id),
+      // Track ID
+      u32(0),
+      // Reserved
+      u32(durationInGlobalTimescale),
+      // Duration
+      Array(8).fill(0),
+      // Reserved
+      u16(0),
+      // Layer
+      u16(0),
+      // Alternate group
+      fixed_8_8(track.info.type === "audio" ? 1 : 0),
+      // Volume
+      u16(0),
+      // Reserved
+      matrixToBytes(rotationMatrix(track.info.type === "video" ? track.info.rotation : 0)),
+      // Matrix
+      fixed_16_16(track.info.type === "video" ? track.info.width : 0),
+      // Track width
+      fixed_16_16(track.info.type === "video" ? track.info.height : 0)
+      // Track height
+    ]);
+  };
+  var mdia = (track, creationTime) => box("mdia", null, [
+    mdhd(track, creationTime),
+    hdlr(track.info.type === "video" ? "vide" : "soun"),
+    minf(track)
+  ]);
+  var mdhd = (track, creationTime) => {
+    let lastSample = last(track.samples);
+    let localDuration = intoTimescale(
+      lastSample ? lastSample.timestamp + lastSample.duration : 0,
+      track.timescale
+    );
+    return fullBox("mdhd", 0, 0, [
+      u32(creationTime),
+      // Creation time
+      u32(creationTime),
+      // Modification time
+      u32(track.timescale),
+      // Timescale
+      u32(localDuration),
+      // Duration
+      u16(21956),
+      // Language ("und", undetermined)
+      u16(0)
+      // Quality
+    ]);
+  };
+  var hdlr = (componentSubtype) => fullBox("hdlr", 0, 0, [
+    ascii("mhlr"),
+    // Component type
+    ascii(componentSubtype),
+    // Component subtype
+    u32(0),
+    // Component manufacturer
+    u32(0),
+    // Component flags
+    u32(0),
+    // Component flags mask
+    ascii("mp4-muxer-hdlr")
+    // Component name
+  ]);
+  var minf = (track) => box("minf", null, [
+    track.info.type === "video" ? vmhd() : smhd(),
+    dinf(),
+    stbl(track)
+  ]);
+  var vmhd = () => fullBox("vmhd", 0, 1, [
+    u16(0),
+    // Graphics mode
+    u16(0),
+    // Opcolor R
+    u16(0),
+    // Opcolor G
+    u16(0)
+    // Opcolor B
+  ]);
+  var smhd = () => fullBox("smhd", 0, 0, [
+    u16(0),
+    // Balance
+    u16(0)
+    // Reserved
+  ]);
+  var dinf = () => box("dinf", null, [
+    dref()
+  ]);
+  var dref = () => fullBox("dref", 0, 0, [
+    u32(1)
+    // Entry count
+  ], [
+    url()
+  ]);
+  var url = () => fullBox("url ", 0, 1);
+  var stbl = (track) => box("stbl", null, [
+    stsd(track),
+    stts(track),
+    stss(track),
+    stsc(track),
+    stsz(track),
+    stco(track)
+  ]);
+  var stsd = (track) => fullBox("stsd", 0, 0, [
+    u32(1)
+    // Entry count
+  ], [
+    track.info.type === "video" ? videoSampleDescription(
+      VIDEO_CODEC_TO_BOX_NAME[track.info.codec],
+      track
+    ) : soundSampleDescription(
+      AUDIO_CODEC_TO_BOX_NAME[track.info.codec],
+      track
+    )
+  ]);
+  var videoSampleDescription = (compressionType, track) => box(compressionType, [
+    Array(6).fill(0),
+    // Reserved
+    u16(1),
+    // Data reference index
+    u16(0),
+    // Pre-defined
+    u16(0),
+    // Reserved
+    Array(12).fill(0),
+    // Pre-defined
+    u16(track.info.width),
+    // Width
+    u16(track.info.height),
+    // Height
+    u32(4718592),
+    // Horizontal resolution
+    u32(4718592),
+    // Vertical resolution
+    u32(0),
+    // Reserved
+    u16(1),
+    // Frame count
+    Array(32).fill(0),
+    // Compressor name
+    u16(24),
+    // Depth
+    i16(65535)
+    // Pre-defined
+  ], [
+    VIDEO_CODEC_TO_CONFIGURATION_BOX[track.info.codec](track)
+  ]);
+  var avcC = (track) => track.codecPrivate && box("avcC", [...track.codecPrivate]);
+  var hvcC = (track) => track.codecPrivate && box("hvcC", [...track.codecPrivate]);
+  var vpcC = (track) => track.codecPrivate && box("vpcC", [...track.codecPrivate]);
+  var av1C = (track) => track.codecPrivate && box("av1C", [...track.codecPrivate]);
+  var soundSampleDescription = (compressionType, track) => box(compressionType, [
+    Array(6).fill(0),
+    // Reserved
+    u16(1),
+    // Data reference index
+    u16(0),
+    // Version
+    u16(0),
+    // Revision level
+    u32(0),
+    // Vendor
+    u16(track.info.numberOfChannels),
+    // Number of channels
+    u16(16),
+    // Sample size (bits)
+    u16(0),
+    // Compression ID
+    u16(0),
+    // Packet size
+    fixed_16_16(track.info.sampleRate)
+    // Sample rate
+  ], [
+    AUDIO_CODEC_TO_CONFIGURATION_BOX[track.info.codec](track)
+  ]);
+  var esds = (track) => fullBox("esds", 0, 0, [
+    // https://stackoverflow.com/a/54803118
+    u32(58753152),
+    // TAG(3) = Object Descriptor ([2])
+    u8(32 + track.codecPrivate.byteLength),
+    // length of this OD (which includes the next 2 tags)
+    u16(1),
+    // ES_ID = 1
+    u8(0),
+    // flags etc = 0
+    u32(75530368),
+    // TAG(4) = ES Descriptor ([2]) embedded in above OD
+    u8(18 + track.codecPrivate.byteLength),
+    // length of this ESD
+    u8(64),
+    // MPEG-4 Audio
+    u8(21),
+    // stream type(6bits)=5 audio, flags(2bits)=1
+    u24(0),
+    // 24bit buffer size
+    u32(130071),
+    // max bitrate
+    u32(130071),
+    // avg bitrate
+    u32(92307584),
+    // TAG(5) = ASC ([2],[3]) embedded in above OD
+    u8(track.codecPrivate.byteLength),
+    // length
+    ...track.codecPrivate,
+    u32(109084800),
+    // TAG(6)
+    u8(1),
+    // length
+    u8(2)
+    // data
+  ]);
+  var dOps = (track) => box("dOps", [
+    u8(0),
+    // Version
+    u8(track.info.numberOfChannels),
+    // OutputChannelCount
+    u16(3840),
+    // PreSkip, should be at least 80 milliseconds worth of playback, measured in 48000 Hz samples
+    u32(track.info.sampleRate),
+    // InputSampleRate
+    fixed_8_8(0),
+    // OutputGain
+    u8(0)
+    // ChannelMappingFamily
+  ]);
+  var stts = (track) => {
+    return fullBox("stts", 0, 0, [
+      u32(track.timeToSampleTable.length),
+      // Number of entries
+      track.timeToSampleTable.map((x) => [
+        // Time-to-sample table
+        u32(x.sampleCount),
+        // Sample count
+        u32(x.sampleDelta)
+        // Sample duration
+      ])
+    ]);
+  };
+  var stss = (track) => {
+    if (track.samples.every((x) => x.type === "key"))
+      return null;
+    let keySamples = [...track.samples.entries()].filter(([, sample]) => sample.type === "key");
+    return fullBox("stss", 0, 0, [
+      u32(keySamples.length),
+      // Number of entries
+      keySamples.map(([index]) => u32(index + 1))
+      // Sync sample table
+    ]);
+  };
+  var stsc = (track) => {
+    return fullBox("stsc", 0, 0, [
+      u32(track.compactlyCodedChunkTable.length),
+      // Number of entries
+      track.compactlyCodedChunkTable.map((x) => [
+        // Sample-to-chunk table
+        u32(x.firstChunk),
+        // First chunk
+        u32(x.samplesPerChunk),
+        // Samples per chunk
+        u32(1)
+        // Sample description index
+      ])
+    ]);
+  };
+  var stsz = (track) => fullBox("stsz", 0, 0, [
+    u32(0),
+    // Sample size (0 means non-constant size)
+    u32(track.samples.length),
+    // Number of entries
+    track.samples.map((x) => u32(x.size))
+    // Sample size table
+  ]);
+  var stco = (track) => {
+    if (track.writtenChunks.length > 0 && last(track.writtenChunks).offset >= __pow(2, 32)) {
+      return fullBox("co64", 0, 0, [
+        u32(track.writtenChunks.length),
+        // Number of entries
+        track.writtenChunks.map((x) => u64(x.offset))
+        // Chunk offset table
+      ]);
+    }
+    return fullBox("stco", 0, 0, [
+      u32(track.writtenChunks.length),
+      // Number of entries
+      track.writtenChunks.map((x) => u32(x.offset))
+      // Chunk offset table
+    ]);
+  };
+  var VIDEO_CODEC_TO_BOX_NAME = {
+    "avc": "avc1",
+    "hevc": "hvc1",
+    "vp9": "vp09",
+    "av1": "av01"
+  };
+  var VIDEO_CODEC_TO_CONFIGURATION_BOX = {
+    "avc": avcC,
+    "hevc": hvcC,
+    "vp9": vpcC,
+    "av1": av1C
+  };
+  var AUDIO_CODEC_TO_BOX_NAME = {
+    "aac": "mp4a",
+    "opus": "opus"
+  };
+  var AUDIO_CODEC_TO_CONFIGURATION_BOX = {
+    "aac": esds,
+    "opus": dOps
+  };
+  var ArrayBufferTarget = class {
+    constructor() {
+      this.buffer = null;
+    }
+  };
+  var StreamTarget = class {
+    constructor(onData, onDone, options) {
+      this.onData = onData;
+      this.onDone = onDone;
+      this.options = options;
+    }
+  };
+  var FileSystemWritableFileStreamTarget = class {
+    constructor(stream, options) {
+      this.stream = stream;
+      this.options = options;
+    }
+  };
+  var _helper;
+  var _helperView;
+  var Writer = class {
+    constructor() {
+      this.pos = 0;
+      __privateAdd(this, _helper, new Uint8Array(8));
+      __privateAdd(this, _helperView, new DataView(__privateGet(this, _helper).buffer));
+      this.offsets = /* @__PURE__ */ new WeakMap();
+    }
+    /** Sets the current position for future writes to a new one. */
+    seek(newPos) {
+      this.pos = newPos;
+    }
+    writeU32(value) {
+      __privateGet(this, _helperView).setUint32(0, value, false);
+      this.write(__privateGet(this, _helper).subarray(0, 4));
+    }
+    writeU64(value) {
+      __privateGet(this, _helperView).setUint32(0, Math.floor(value / __pow(2, 32)), false);
+      __privateGet(this, _helperView).setUint32(4, value, false);
+      this.write(__privateGet(this, _helper).subarray(0, 8));
+    }
+    writeAscii(text) {
+      for (let i = 0; i < text.length; i++) {
+        __privateGet(this, _helperView).setUint8(i % 8, text.charCodeAt(i));
+        if (i % 8 === 7)
+          this.write(__privateGet(this, _helper));
+      }
+      if (text.length % 8 !== 0) {
+        this.write(__privateGet(this, _helper).subarray(0, text.length % 8));
+      }
+    }
+    writeBox(box2) {
+      var _a, _b;
+      this.offsets.set(box2, this.pos);
+      if (box2.contents && !box2.children) {
+        this.writeBoxHeader(box2, (_a = box2.size) != null ? _a : box2.contents.byteLength + 8);
+        this.write(box2.contents);
+      } else {
+        let startPos = this.pos;
+        this.writeBoxHeader(box2, 0);
+        if (box2.contents)
+          this.write(box2.contents);
+        if (box2.children) {
+          for (let child of box2.children)
+            if (child)
+              this.writeBox(child);
+        }
+        let endPos = this.pos;
+        let size = (_b = box2.size) != null ? _b : endPos - startPos;
+        this.seek(startPos);
+        this.writeBoxHeader(box2, size);
+        this.seek(endPos);
+      }
+    }
+    writeBoxHeader(box2, size) {
+      this.writeU32(box2.largeSize ? 1 : size);
+      this.writeAscii(box2.type);
+      if (box2.largeSize)
+        this.writeU64(size);
+    }
+    patchBox(box2) {
+      let endPos = this.pos;
+      this.seek(this.offsets.get(box2));
+      this.writeBox(box2);
+      this.seek(endPos);
+    }
+  };
+  _helper = /* @__PURE__ */ new WeakMap();
+  _helperView = /* @__PURE__ */ new WeakMap();
+  var _target;
+  var _buffer;
+  var _bytes;
+  var _ensureSize;
+  var ensureSize_fn;
+  var ArrayBufferTargetWriter = class extends Writer {
+    constructor(target) {
+      super();
+      __privateAdd(this, _ensureSize);
+      __privateAdd(this, _target, void 0);
+      __privateAdd(this, _buffer, new ArrayBuffer(__pow(2, 16)));
+      __privateAdd(this, _bytes, new Uint8Array(__privateGet(this, _buffer)));
+      __privateSet(this, _target, target);
+    }
+    write(data) {
+      __privateMethod(this, _ensureSize, ensureSize_fn).call(this, this.pos + data.byteLength);
+      __privateGet(this, _bytes).set(data, this.pos);
+      this.pos += data.byteLength;
+    }
+    finalize() {
+      __privateMethod(this, _ensureSize, ensureSize_fn).call(this, this.pos);
+      __privateGet(this, _target).buffer = __privateGet(this, _buffer).slice(0, this.pos);
+    }
+  };
+  _target = /* @__PURE__ */ new WeakMap();
+  _buffer = /* @__PURE__ */ new WeakMap();
+  _bytes = /* @__PURE__ */ new WeakMap();
+  _ensureSize = /* @__PURE__ */ new WeakSet();
+  ensureSize_fn = function(size) {
+    let newLength = __privateGet(this, _buffer).byteLength;
+    while (newLength < size)
+      newLength *= 2;
+    if (newLength === __privateGet(this, _buffer).byteLength)
+      return;
+    let newBuffer = new ArrayBuffer(newLength);
+    let newBytes = new Uint8Array(newBuffer);
+    newBytes.set(__privateGet(this, _bytes), 0);
+    __privateSet(this, _buffer, newBuffer);
+    __privateSet(this, _bytes, newBytes);
+  };
+  var _target2;
+  var _sections;
+  var StreamTargetWriter = class extends Writer {
+    constructor(target) {
+      super();
+      __privateAdd(this, _target2, void 0);
+      __privateAdd(this, _sections, []);
+      __privateSet(this, _target2, target);
+    }
+    write(data) {
+      __privateGet(this, _sections).push({
+        data: data.slice(),
+        start: this.pos
+      });
+      this.pos += data.byteLength;
+    }
+    flush() {
+      if (__privateGet(this, _sections).length === 0)
+        return;
+      let chunks = [];
+      let sorted = [...__privateGet(this, _sections)].sort((a, b) => a.start - b.start);
+      chunks.push({
+        start: sorted[0].start,
+        size: sorted[0].data.byteLength
+      });
+      for (let i = 1; i < sorted.length; i++) {
+        let lastChunk = chunks[chunks.length - 1];
+        let section = sorted[i];
+        if (section.start <= lastChunk.start + lastChunk.size) {
+          lastChunk.size = Math.max(lastChunk.size, section.start + section.data.byteLength - lastChunk.start);
+        } else {
+          chunks.push({
+            start: section.start,
+            size: section.data.byteLength
+          });
+        }
+      }
+      for (let chunk of chunks) {
+        chunk.data = new Uint8Array(chunk.size);
+        for (let section of __privateGet(this, _sections)) {
+          if (chunk.start <= section.start && section.start < chunk.start + chunk.size) {
+            chunk.data.set(section.data, section.start - chunk.start);
+          }
+        }
+        __privateGet(this, _target2).onData(chunk.data, chunk.start);
+      }
+      __privateGet(this, _sections).length = 0;
+    }
+    finalize() {
+      var _a, _b;
+      (_b = (_a = __privateGet(this, _target2)).onDone) == null ? void 0 : _b.call(_a);
+    }
+  };
+  _target2 = /* @__PURE__ */ new WeakMap();
+  _sections = /* @__PURE__ */ new WeakMap();
+  var DEFAULT_CHUNK_SIZE = __pow(2, 24);
+  var MAX_CHUNKS_AT_ONCE = 2;
+  var _target3;
+  var _chunkSize;
+  var _chunks;
+  var _writeDataIntoChunks;
+  var writeDataIntoChunks_fn;
+  var _insertSectionIntoChunk;
+  var insertSectionIntoChunk_fn;
+  var _createChunk;
+  var createChunk_fn;
+  var _flushChunks;
+  var flushChunks_fn;
+  var ChunkedStreamTargetWriter = class extends Writer {
+    constructor(target) {
+      var _a, _b;
+      super();
+      __privateAdd(this, _writeDataIntoChunks);
+      __privateAdd(this, _insertSectionIntoChunk);
+      __privateAdd(this, _createChunk);
+      __privateAdd(this, _flushChunks);
+      __privateAdd(this, _target3, void 0);
+      __privateAdd(this, _chunkSize, void 0);
+      __privateAdd(this, _chunks, []);
+      __privateSet(this, _target3, target);
+      __privateSet(this, _chunkSize, (_b = (_a = target.options) == null ? void 0 : _a.chunkSize) != null ? _b : DEFAULT_CHUNK_SIZE);
+      if (!Number.isInteger(__privateGet(this, _chunkSize)) || __privateGet(this, _chunkSize) < __pow(2, 10)) {
+        throw new Error("Invalid StreamTarget options: chunkSize must be an integer not smaller than 1024.");
+      }
+    }
+    write(data) {
+      __privateMethod(this, _writeDataIntoChunks, writeDataIntoChunks_fn).call(this, data, this.pos);
+      __privateMethod(this, _flushChunks, flushChunks_fn).call(this);
+      this.pos += data.byteLength;
+    }
+    finalize() {
+      var _a, _b;
+      __privateMethod(this, _flushChunks, flushChunks_fn).call(this, true);
+      (_b = (_a = __privateGet(this, _target3)).onDone) == null ? void 0 : _b.call(_a);
+    }
+  };
+  _target3 = /* @__PURE__ */ new WeakMap();
+  _chunkSize = /* @__PURE__ */ new WeakMap();
+  _chunks = /* @__PURE__ */ new WeakMap();
+  _writeDataIntoChunks = /* @__PURE__ */ new WeakSet();
+  writeDataIntoChunks_fn = function(data, position) {
+    let chunkIndex = __privateGet(this, _chunks).findIndex((x) => x.start <= position && position < x.start + __privateGet(this, _chunkSize));
+    if (chunkIndex === -1)
+      chunkIndex = __privateMethod(this, _createChunk, createChunk_fn).call(this, position);
+    let chunk = __privateGet(this, _chunks)[chunkIndex];
+    let relativePosition = position - chunk.start;
+    let toWrite = data.subarray(0, Math.min(__privateGet(this, _chunkSize) - relativePosition, data.byteLength));
+    chunk.data.set(toWrite, relativePosition);
+    let section = {
+      start: relativePosition,
+      end: relativePosition + toWrite.byteLength
+    };
+    __privateMethod(this, _insertSectionIntoChunk, insertSectionIntoChunk_fn).call(this, chunk, section);
+    if (chunk.written[0].start === 0 && chunk.written[0].end === __privateGet(this, _chunkSize)) {
+      chunk.shouldFlush = true;
+    }
+    if (__privateGet(this, _chunks).length > MAX_CHUNKS_AT_ONCE) {
+      for (let i = 0; i < __privateGet(this, _chunks).length - 1; i++) {
+        __privateGet(this, _chunks)[i].shouldFlush = true;
+      }
+      __privateMethod(this, _flushChunks, flushChunks_fn).call(this);
+    }
+    if (toWrite.byteLength < data.byteLength) {
+      __privateMethod(this, _writeDataIntoChunks, writeDataIntoChunks_fn).call(this, data.subarray(toWrite.byteLength), position + toWrite.byteLength);
+    }
+  };
+  _insertSectionIntoChunk = /* @__PURE__ */ new WeakSet();
+  insertSectionIntoChunk_fn = function(chunk, section) {
+    let low = 0;
+    let high = chunk.written.length - 1;
+    let index = -1;
+    while (low <= high) {
+      let mid = Math.floor(low + (high - low + 1) / 2);
+      if (chunk.written[mid].start <= section.start) {
+        low = mid + 1;
+        index = mid;
+      } else {
+        high = mid - 1;
+      }
+    }
+    chunk.written.splice(index + 1, 0, section);
+    if (index === -1 || chunk.written[index].end < section.start)
+      index++;
+    while (index < chunk.written.length - 1 && chunk.written[index].end >= chunk.written[index + 1].start) {
+      chunk.written[index].end = Math.max(chunk.written[index].end, chunk.written[index + 1].end);
+      chunk.written.splice(index + 1, 1);
+    }
+  };
+  _createChunk = /* @__PURE__ */ new WeakSet();
+  createChunk_fn = function(includesPosition) {
+    let start = Math.floor(includesPosition / __privateGet(this, _chunkSize)) * __privateGet(this, _chunkSize);
+    let chunk = {
+      start,
+      data: new Uint8Array(__privateGet(this, _chunkSize)),
+      written: [],
+      shouldFlush: false
+    };
+    __privateGet(this, _chunks).push(chunk);
+    __privateGet(this, _chunks).sort((a, b) => a.start - b.start);
+    return __privateGet(this, _chunks).indexOf(chunk);
+  };
+  _flushChunks = /* @__PURE__ */ new WeakSet();
+  flushChunks_fn = function(force = false) {
+    for (let i = 0; i < __privateGet(this, _chunks).length; i++) {
+      let chunk = __privateGet(this, _chunks)[i];
+      if (!chunk.shouldFlush && !force)
+        continue;
+      for (let section of chunk.written) {
+        __privateGet(this, _target3).onData(
+          chunk.data.subarray(section.start, section.end),
+          chunk.start + section.start
+        );
+      }
+      __privateGet(this, _chunks).splice(i--, 1);
+    }
+  };
+  var FileSystemWritableFileStreamTargetWriter = class extends ChunkedStreamTargetWriter {
+    constructor(target) {
+      var _a;
+      super(new StreamTarget(
+        (data, position) => target.stream.write({
+          type: "write",
+          data,
+          position
+        }),
+        void 0,
+        { chunkSize: (_a = target.options) == null ? void 0 : _a.chunkSize }
+      ));
+    }
+  };
+  var GLOBAL_TIMESCALE = 1e3;
+  var SUPPORTED_VIDEO_CODECS2 = ["avc", "hevc", "vp9", "av1"];
+  var SUPPORTED_AUDIO_CODECS2 = ["aac", "opus"];
+  var TIMESTAMP_OFFSET = 2082844800;
+  var MAX_CHUNK_DURATION = 0.5;
+  var FIRST_TIMESTAMP_BEHAVIORS = ["strict", "offset"];
+  var _options;
+  var _writer;
+  var _mdat;
+  var _videoTrack;
+  var _audioTrack;
+  var _creationTime;
+  var _finalized;
+  var _validateOptions;
+  var validateOptions_fn;
+  var _writeHeader;
+  var writeHeader_fn;
+  var _prepareTracks;
+  var prepareTracks_fn;
+  var _generateMpeg4AudioSpecificConfig;
+  var generateMpeg4AudioSpecificConfig_fn;
+  var _addSampleToTrack;
+  var addSampleToTrack_fn;
+  var _validateTimestamp;
+  var validateTimestamp_fn;
+  var _writeCurrentChunk;
+  var writeCurrentChunk_fn;
+  var _maybeFlushStreamingTargetWriter;
+  var maybeFlushStreamingTargetWriter_fn;
+  var _ensureNotFinalized;
+  var ensureNotFinalized_fn;
+  var Muxer = class {
+    constructor(options) {
+      __privateAdd(this, _validateOptions);
+      __privateAdd(this, _writeHeader);
+      __privateAdd(this, _prepareTracks);
+      __privateAdd(this, _generateMpeg4AudioSpecificConfig);
+      __privateAdd(this, _addSampleToTrack);
+      __privateAdd(this, _validateTimestamp);
+      __privateAdd(this, _writeCurrentChunk);
+      __privateAdd(this, _maybeFlushStreamingTargetWriter);
+      __privateAdd(this, _ensureNotFinalized);
+      __privateAdd(this, _options, void 0);
+      __privateAdd(this, _writer, void 0);
+      __privateAdd(this, _mdat, void 0);
+      __privateAdd(this, _videoTrack, null);
+      __privateAdd(this, _audioTrack, null);
+      __privateAdd(this, _creationTime, Math.floor(Date.now() / 1e3) + TIMESTAMP_OFFSET);
+      __privateAdd(this, _finalized, false);
+      var _a;
+      __privateMethod(this, _validateOptions, validateOptions_fn).call(this, options);
+      this.target = options.target;
+      __privateSet(this, _options, __spreadValues({
+        firstTimestampBehavior: "strict"
+      }, options));
+      if (options.target instanceof ArrayBufferTarget) {
+        __privateSet(this, _writer, new ArrayBufferTargetWriter(options.target));
+      } else if (options.target instanceof StreamTarget) {
+        __privateSet(this, _writer, ((_a = options.target.options) == null ? void 0 : _a.chunked) ? new ChunkedStreamTargetWriter(options.target) : new StreamTargetWriter(options.target));
+      } else if (options.target instanceof FileSystemWritableFileStreamTarget) {
+        __privateSet(this, _writer, new FileSystemWritableFileStreamTargetWriter(options.target));
+      } else {
+        throw new Error(`Invalid target: ${options.target}`);
+      }
+      __privateMethod(this, _writeHeader, writeHeader_fn).call(this);
+      __privateMethod(this, _prepareTracks, prepareTracks_fn).call(this);
+    }
+    addVideoChunk(sample, meta, timestamp) {
+      let data = new Uint8Array(sample.byteLength);
+      sample.copyTo(data);
+      this.addVideoChunkRaw(data, sample.type, timestamp != null ? timestamp : sample.timestamp, sample.duration, meta);
+    }
+    addVideoChunkRaw(data, type, timestamp, duration, meta) {
+      __privateMethod(this, _ensureNotFinalized, ensureNotFinalized_fn).call(this);
+      if (!__privateGet(this, _options).video)
+        throw new Error("No video track declared.");
+      __privateMethod(this, _addSampleToTrack, addSampleToTrack_fn).call(this, __privateGet(this, _videoTrack), data, type, timestamp, duration, meta);
+    }
+    addAudioChunk(sample, meta, timestamp) {
+      let data = new Uint8Array(sample.byteLength);
+      sample.copyTo(data);
+      this.addAudioChunkRaw(data, sample.type, timestamp != null ? timestamp : sample.timestamp, sample.duration, meta);
+    }
+    addAudioChunkRaw(data, type, timestamp, duration, meta) {
+      __privateMethod(this, _ensureNotFinalized, ensureNotFinalized_fn).call(this);
+      if (!__privateGet(this, _options).audio)
+        throw new Error("No audio track declared.");
+      __privateMethod(this, _addSampleToTrack, addSampleToTrack_fn).call(this, __privateGet(this, _audioTrack), data, type, timestamp, duration, meta);
+    }
+    /** Finalizes the file, making it ready for use. Must be called after all video and audio chunks have been added. */
+    finalize() {
+      if (__privateGet(this, _videoTrack))
+        __privateMethod(this, _writeCurrentChunk, writeCurrentChunk_fn).call(this, __privateGet(this, _videoTrack));
+      if (__privateGet(this, _audioTrack))
+        __privateMethod(this, _writeCurrentChunk, writeCurrentChunk_fn).call(this, __privateGet(this, _audioTrack));
+      let mdatPos = __privateGet(this, _writer).offsets.get(__privateGet(this, _mdat));
+      let mdatSize = __privateGet(this, _writer).pos - mdatPos;
+      __privateGet(this, _mdat).size = mdatSize;
+      __privateGet(this, _writer).patchBox(__privateGet(this, _mdat));
+      let movieBox = moov([__privateGet(this, _videoTrack), __privateGet(this, _audioTrack)].filter(Boolean), __privateGet(this, _creationTime));
+      __privateGet(this, _writer).writeBox(movieBox);
+      __privateMethod(this, _maybeFlushStreamingTargetWriter, maybeFlushStreamingTargetWriter_fn).call(this);
+      __privateGet(this, _writer).finalize();
+      __privateSet(this, _finalized, true);
+    }
+  };
+  _options = /* @__PURE__ */ new WeakMap();
+  _writer = /* @__PURE__ */ new WeakMap();
+  _mdat = /* @__PURE__ */ new WeakMap();
+  _videoTrack = /* @__PURE__ */ new WeakMap();
+  _audioTrack = /* @__PURE__ */ new WeakMap();
+  _creationTime = /* @__PURE__ */ new WeakMap();
+  _finalized = /* @__PURE__ */ new WeakMap();
+  _validateOptions = /* @__PURE__ */ new WeakSet();
+  validateOptions_fn = function(options) {
+    if (options.video) {
+      if (!SUPPORTED_VIDEO_CODECS2.includes(options.video.codec)) {
+        throw new Error(`Unsupported video codec: ${options.video.codec}`);
+      }
+      if (options.video.rotation !== void 0 && ![0, 90, 180, 270].includes(options.video.rotation)) {
+        throw new Error(`Invalid video rotation: ${options.video.rotation}. Has to be 0, 90, 180 or 270.`);
+      }
+    }
+    if (options.audio && !SUPPORTED_AUDIO_CODECS2.includes(options.audio.codec)) {
+      throw new Error(`Unsupported audio codec: ${options.audio.codec}`);
+    }
+    if (options.firstTimestampBehavior && !FIRST_TIMESTAMP_BEHAVIORS.includes(options.firstTimestampBehavior)) {
+      throw new Error(`Invalid first timestamp behavior: ${options.firstTimestampBehavior}`);
+    }
+  };
+  _writeHeader = /* @__PURE__ */ new WeakSet();
+  writeHeader_fn = function() {
+    var _a;
+    let holdsHevc = ((_a = __privateGet(this, _options).video) == null ? void 0 : _a.codec) === "hevc";
+    __privateGet(this, _writer).writeBox(ftyp(holdsHevc));
+    __privateSet(this, _mdat, mdat());
+    __privateGet(this, _writer).writeBox(__privateGet(this, _mdat));
+    __privateMethod(this, _maybeFlushStreamingTargetWriter, maybeFlushStreamingTargetWriter_fn).call(this);
+  };
+  _prepareTracks = /* @__PURE__ */ new WeakSet();
+  prepareTracks_fn = function() {
+    var _a;
+    if (__privateGet(this, _options).video) {
+      __privateSet(this, _videoTrack, {
+        id: 1,
+        info: {
+          type: "video",
+          codec: __privateGet(this, _options).video.codec,
+          width: __privateGet(this, _options).video.width,
+          height: __privateGet(this, _options).video.height,
+          rotation: (_a = __privateGet(this, _options).video.rotation) != null ? _a : 0
+        },
+        timescale: 720,
+        // = lcm(24, 30, 60, 120, 144, 240, 360), so should fit with many framerates
+        codecPrivate: new Uint8Array(0),
+        samples: [],
+        writtenChunks: [],
+        currentChunk: null,
+        firstTimestamp: void 0,
+        lastTimestamp: -1,
+        timeToSampleTable: [],
+        lastTimescaleUnits: null,
+        compactlyCodedChunkTable: []
+      });
+    }
+    if (__privateGet(this, _options).audio) {
+      let guessedCodecPrivate = __privateMethod(this, _generateMpeg4AudioSpecificConfig, generateMpeg4AudioSpecificConfig_fn).call(
+        this,
+        2,
+        // Object type for AAC-LC, since it's the most common
+        __privateGet(this, _options).audio.sampleRate,
+        __privateGet(this, _options).audio.numberOfChannels
+      );
+      __privateSet(this, _audioTrack, {
+        id: __privateGet(this, _options).video ? 2 : 1,
+        info: {
+          type: "audio",
+          codec: __privateGet(this, _options).audio.codec,
+          numberOfChannels: __privateGet(this, _options).audio.numberOfChannels,
+          sampleRate: __privateGet(this, _options).audio.sampleRate
+        },
+        timescale: __privateGet(this, _options).audio.sampleRate,
+        codecPrivate: guessedCodecPrivate,
+        samples: [],
+        writtenChunks: [],
+        currentChunk: null,
+        firstTimestamp: void 0,
+        lastTimestamp: -1,
+        timeToSampleTable: [],
+        lastTimescaleUnits: null,
+        compactlyCodedChunkTable: []
+      });
+    }
+  };
+  _generateMpeg4AudioSpecificConfig = /* @__PURE__ */ new WeakSet();
+  generateMpeg4AudioSpecificConfig_fn = function(objectType, sampleRate, numberOfChannels) {
+    let frequencyIndices = [96e3, 88200, 64e3, 48e3, 44100, 32e3, 24e3, 22050, 16e3, 12e3, 11025, 8e3, 7350];
+    let frequencyIndex = frequencyIndices.indexOf(sampleRate);
+    let channelConfig = numberOfChannels;
+    let configBits = "";
+    configBits += objectType.toString(2).padStart(5, "0");
+    configBits += frequencyIndex.toString(2).padStart(4, "0");
+    if (frequencyIndex === 15)
+      configBits += sampleRate.toString(2).padStart(24, "0");
+    configBits += channelConfig.toString(2).padStart(4, "0");
+    let paddingLength = Math.ceil(configBits.length / 8) * 8;
+    configBits = configBits.padEnd(paddingLength, "0");
+    let configBytes = new Uint8Array(configBits.length / 8);
+    for (let i = 0; i < configBits.length; i += 8) {
+      configBytes[i / 8] = parseInt(configBits.slice(i, i + 8), 2);
+    }
+    return configBytes;
+  };
+  _addSampleToTrack = /* @__PURE__ */ new WeakSet();
+  addSampleToTrack_fn = function(track, data, type, timestamp, duration, meta) {
+    var _a;
+    let timestampInSeconds = timestamp / 1e6;
+    let durationInSeconds = duration / 1e6;
+    if (track.firstTimestamp === void 0)
+      track.firstTimestamp = timestampInSeconds;
+    timestampInSeconds = __privateMethod(this, _validateTimestamp, validateTimestamp_fn).call(this, timestampInSeconds, track);
+    track.lastTimestamp = timestampInSeconds;
+    if (!track.currentChunk || timestampInSeconds - track.currentChunk.startTimestamp >= MAX_CHUNK_DURATION) {
+      if (track.currentChunk)
+        __privateMethod(this, _writeCurrentChunk, writeCurrentChunk_fn).call(this, track);
+      track.currentChunk = {
+        startTimestamp: timestampInSeconds,
+        sampleData: [],
+        sampleCount: 0
+      };
+    }
+    track.currentChunk.sampleData.push(data);
+    track.currentChunk.sampleCount++;
+    if ((_a = meta == null ? void 0 : meta.decoderConfig) == null ? void 0 : _a.description) {
+      track.codecPrivate = new Uint8Array(meta.decoderConfig.description);
+    }
+    track.samples.push({
+      timestamp: timestampInSeconds,
+      duration: durationInSeconds,
+      size: data.byteLength,
+      type
+    });
+    if (track.lastTimescaleUnits !== null) {
+      let timescaleUnits = intoTimescale(timestampInSeconds, track.timescale, false);
+      let delta = Math.round(timescaleUnits - track.lastTimescaleUnits);
+      track.lastTimescaleUnits += delta;
+      let lastTableEntry = last(track.timeToSampleTable);
+      if (lastTableEntry.sampleCount === 1) {
+        lastTableEntry.sampleDelta = delta;
+        lastTableEntry.sampleCount++;
+      } else if (lastTableEntry.sampleDelta === delta) {
+        lastTableEntry.sampleCount++;
+      } else {
+        lastTableEntry.sampleCount--;
+        track.timeToSampleTable.push({
+          sampleCount: 2,
+          sampleDelta: delta
+        });
+      }
+    } else {
+      track.lastTimescaleUnits = 0;
+      track.timeToSampleTable.push({
+        sampleCount: 1,
+        sampleDelta: intoTimescale(durationInSeconds, track.timescale)
+      });
+    }
+  };
+  _validateTimestamp = /* @__PURE__ */ new WeakSet();
+  validateTimestamp_fn = function(timestamp, track) {
+    if (__privateGet(this, _options).firstTimestampBehavior === "strict" && track.lastTimestamp === -1 && timestamp !== 0) {
+      throw new Error(
+        `The first chunk for your media track must have a timestamp of 0 (received ${timestamp}). Non-zero first timestamps are often caused by directly piping frames or audio data from a MediaStreamTrack into the encoder. Their timestamps are typically relative to the age of the document, which is probably what you want.
+
+If you want to offset all timestamps of a track such that the first one is zero, set firstTimestampBehavior: 'offset' in the options.
+`
+      );
+    } else if (__privateGet(this, _options).firstTimestampBehavior === "offset") {
+      timestamp -= track.firstTimestamp;
+    }
+    if (timestamp < track.lastTimestamp) {
+      throw new Error(
+        `Timestamps must be monotonically increasing (went from ${track.lastTimestamp * 1e6} to ${timestamp * 1e6}).`
+      );
+    }
+    return timestamp;
+  };
+  _writeCurrentChunk = /* @__PURE__ */ new WeakSet();
+  writeCurrentChunk_fn = function(track) {
+    if (!track.currentChunk)
+      return;
+    track.currentChunk.offset = __privateGet(this, _writer).pos;
+    for (let bytes2 of track.currentChunk.sampleData)
+      __privateGet(this, _writer).write(bytes2);
+    track.currentChunk.sampleData = null;
+    if (track.compactlyCodedChunkTable.length === 0 || last(track.compactlyCodedChunkTable).samplesPerChunk !== track.currentChunk.sampleCount) {
+      track.compactlyCodedChunkTable.push({
+        firstChunk: track.writtenChunks.length + 1,
+        // 1-indexed
+        samplesPerChunk: track.currentChunk.sampleCount
+      });
+    }
+    track.writtenChunks.push(track.currentChunk);
+    __privateMethod(this, _maybeFlushStreamingTargetWriter, maybeFlushStreamingTargetWriter_fn).call(this);
+  };
+  _maybeFlushStreamingTargetWriter = /* @__PURE__ */ new WeakSet();
+  maybeFlushStreamingTargetWriter_fn = function() {
+    if (__privateGet(this, _writer) instanceof StreamTargetWriter) {
+      __privateGet(this, _writer).flush();
+    }
+  };
+  _ensureNotFinalized = /* @__PURE__ */ new WeakSet();
+  ensureNotFinalized_fn = function() {
+    if (__privateGet(this, _finalized)) {
+      throw new Error("Cannot add new video or audio chunks after the file has been finalized.");
+    }
+  };
+
+  // src/muxers/mp4muxer.ts
+  var Mp4MuxerWrapper = class {
+    constructor(config, postMessageCallback, options) {
+      this.videoConfigured = false;
+      this.audioConfigured = false;
+      this.firstAudioTimestamp = null;
+      this.firstVideoTimestamp = null;
+      this.firstTimestamp = null;
+      this.config = config;
+      this.postMessageToMain = postMessageCallback;
+      let disableAudio = options?.disableAudio ?? false;
+      const videoDisabled = config.width <= 0 || config.height <= 0 || config.videoBitrate <= 0;
+      const audioCodecOption = config.codec?.audio ?? "aac";
+      const supportedAudioCodecsForMp4 = /* @__PURE__ */ new Set(["aac", "mp3"]);
+      if (!supportedAudioCodecsForMp4.has(audioCodecOption)) {
+        console.warn(
+          `MP4 muxer: Audio codec ${audioCodecOption} is not supported. Disabling audio track.`
+        );
+        disableAudio = true;
+      }
+      const muxerAudioCodec = audioCodecOption;
+      const commonMuxerOptions = {};
+      if (!videoDisabled) {
+        const videoCodecOption = config.codec?.video ?? "avc";
+        let muxerVideoCodec;
+        switch (videoCodecOption) {
+          case "hevc":
+            muxerVideoCodec = "hevc";
+            break;
+          case "vp9":
+            muxerVideoCodec = "vp9";
+            break;
+          case "av1":
+            muxerVideoCodec = "av1";
+            break;
+          case "avc":
+          default:
+            muxerVideoCodec = "avc";
+            break;
+        }
+        commonMuxerOptions.video = {
+          codec: muxerVideoCodec,
+          width: config.width,
+          height: config.height
+          // framerate is not directly a muxer option here, but good to have in config
+        };
+      }
+      if (!disableAudio) {
+        commonMuxerOptions.audio = {
+          codec: muxerAudioCodec,
+          sampleRate: config.sampleRate,
+          numberOfChannels: config.channels
+        };
+      }
+      if (config.latencyMode === "realtime") {
+        this.target = new StreamTarget({
+          onData: (chunk, position) => {
+            const chunkCopy = new Uint8Array(chunk.slice(0));
+            const isHeader = position === 0;
+            const message = {
+              type: "dataChunk",
+              chunk: chunkCopy,
+              offset: position,
+              // Use position as offset
+              isHeader,
+              container: "mp4"
+            };
+            this.postMessageToMain(message, [chunkCopy.buffer]);
+          }
+        });
+        this.muxer = new Muxer({
+          target: this.target,
+          ...commonMuxerOptions,
+          fastStart: "fragmented"
+        });
+      } else {
+        this.target = new ArrayBufferTarget();
+        this.muxer = new Muxer({
+          target: this.target,
+          ...commonMuxerOptions,
+          fastStart: "in-memory"
+          // or false, depending on desired behavior for non-realtime
+        });
+      }
+      this.videoConfigured = !videoDisabled;
+      this.audioConfigured = !disableAudio;
+    }
+    addVideoChunk(chunk, meta) {
+      if (!this.videoConfigured) {
+        this.postMessageToMain({
+          type: "error",
+          errorDetail: {
+            message: "MP4: Video track not configured.",
+            type: "configuration-error" /* ConfigurationError */
+          }
+        });
+        return;
+      }
+      try {
+        let adjustedChunk = chunk;
+        const adjustedMeta = meta;
+        if (this.config.firstTimestampBehavior === "offset" && typeof chunk.timestamp === "number") {
+          if (this.firstVideoTimestamp === null) {
+            this.firstVideoTimestamp = chunk.timestamp;
+            if (this.firstTimestamp === null) {
+              this.firstTimestamp = chunk.timestamp;
+            } else {
+              this.firstTimestamp = Math.min(
+                this.firstTimestamp,
+                chunk.timestamp
+              );
+            }
+          }
+          const newTimestamp = Math.max(
+            0,
+            chunk.timestamp - (this.firstTimestamp || 0)
+          );
+          const data = new Uint8Array(chunk.byteLength);
+          chunk.copyTo(data.buffer);
+          chunk.close?.();
+          adjustedChunk = new EncodedVideoChunk({
+            type: chunk.type,
+            timestamp: newTimestamp,
+            duration: chunk.duration ?? void 0,
+            data: data.buffer
+          });
+        } else if (typeof chunk.timestamp !== "number" && this.config.firstTimestampBehavior === "offset") {
+        }
+        this.muxer.addVideoChunk(adjustedChunk, adjustedMeta);
+      } catch (e) {
+        this.postMessageToMain({
+          type: "error",
+          errorDetail: {
+            message: `MP4: Error adding video chunk: ${e.message}`,
+            type: "muxing-failed" /* MuxingFailed */,
+            stack: e.stack
+          }
+        });
+      }
+    }
+    addAudioChunk(chunk, meta) {
+      if (!this.audioConfigured) {
+        return;
+      }
+      try {
+        let adjustedChunk = chunk;
+        const adjustedMeta = meta;
+        if (this.config.firstTimestampBehavior === "offset" && typeof chunk.timestamp === "number") {
+          if (this.firstAudioTimestamp === null) {
+            this.firstAudioTimestamp = chunk.timestamp;
+            if (this.firstTimestamp === null) {
+              this.firstTimestamp = chunk.timestamp;
+            } else {
+              this.firstTimestamp = Math.min(
+                this.firstTimestamp,
+                chunk.timestamp
+              );
+            }
+          }
+          const newTimestamp = Math.max(
+            0,
+            chunk.timestamp - (this.firstTimestamp || 0)
+          );
+          const data = new Uint8Array(chunk.byteLength);
+          chunk.copyTo(data.buffer);
+          chunk.close?.();
+          adjustedChunk = new EncodedAudioChunk({
+            type: chunk.type,
+            timestamp: newTimestamp,
+            duration: chunk.duration ?? void 0,
+            data: data.buffer
+          });
+        } else if (typeof chunk.timestamp !== "number" && this.config.firstTimestampBehavior === "offset") {
+        }
+        this.muxer.addAudioChunk(adjustedChunk, adjustedMeta);
+      } catch (e) {
+        this.postMessageToMain({
+          type: "error",
+          errorDetail: {
+            message: `MP4: Error adding audio chunk: ${e.message}`,
+            type: "muxing-failed" /* MuxingFailed */,
+            stack: e.stack
+          }
+        });
+      }
+    }
+    finalize() {
+      if (this.config.latencyMode === "realtime") {
+        try {
+          this.muxer.finalize();
+        } catch (e) {
+          this.postMessageToMain({
+            type: "error",
+            errorDetail: {
+              message: `MP4: Error finalizing muxer (realtime): ${e.message}`,
+              type: "muxing-failed" /* MuxingFailed */,
+              stack: e.stack
+            }
+          });
+        }
+        return null;
+      }
+      if (!(this.target instanceof ArrayBufferTarget)) {
+        this.postMessageToMain({
+          type: "error",
+          errorDetail: {
+            message: "MP4: Muxer target is not ArrayBufferTarget in non-realtime mode.",
+            type: "unknown" /* Unknown */
+          }
+        });
+        return null;
+      }
+      try {
+        this.muxer.finalize();
+        const buffer = this.target.buffer;
+        this.target = new ArrayBufferTarget();
+        return new Uint8Array(buffer);
+      } catch (e) {
+        this.postMessageToMain({
+          type: "error",
+          errorDetail: {
+            message: `MP4: Error finalizing muxer (non-realtime): ${e.message}`,
+            type: "muxing-failed" /* MuxingFailed */,
+            stack: e.stack
+          }
+        });
+        return null;
+      }
+    }
+  };
+
+  // src/muxers/webmmuxer.ts
+  var import_webm_muxer = __toESM(require_webm_muxer(), 1);
+  var CallbackWritableStream = class {
+    constructor(onData) {
+      this.onData = onData;
+      this.position = 0;
+    }
+    write({ data, position }) {
+      this.onData(data, position);
+      this.position = position + data.byteLength;
+    }
+  };
+  var WebMMuxerWrapper = class {
+    constructor(config, postMessageCallback, options) {
+      this.videoConfigured = false;
+      this.audioConfigured = false;
+      this.firstAudioTimestamp = null;
+      this.firstVideoTimestamp = null;
+      this.firstTimestamp = null;
+      this.config = config;
+      this.postMessageToMain = postMessageCallback;
+      let disableAudio = options?.disableAudio ?? false;
+      const videoCodecOption = config.codec?.video ?? "vp9";
+      let muxerVideoCodec;
+      switch (videoCodecOption) {
+        case "vp8":
+          muxerVideoCodec = "V_VP8";
+          break;
+        case "vp9":
+          muxerVideoCodec = "V_VP9";
+          break;
+        case "av1":
+          muxerVideoCodec = "V_AV1";
+          break;
+        default:
+          muxerVideoCodec = "V_VP9";
+          break;
+      }
+      const requestedAudioCodec = config.codec?.audio ?? "opus";
+      let muxerAudioCodec = null;
+      switch (requestedAudioCodec) {
+        case "opus":
+          muxerAudioCodec = "A_OPUS";
+          break;
+        case "vorbis":
+          muxerAudioCodec = "A_VORBIS";
+          break;
+        case "flac":
+          muxerAudioCodec = "A_FLAC";
+          break;
+        default:
+          if (!disableAudio) {
+            console.warn(
+              `WebM muxer: Audio codec ${requestedAudioCodec} is not supported. Disabling audio track.`
+            );
+            disableAudio = true;
+          }
+          break;
+      }
+      const target = config.latencyMode === "realtime" ? new CallbackWritableStream((chunk, position) => {
+        const chunkCopy = new Uint8Array(chunk.slice(0));
+        const isHeader = position === 0;
+        const message = {
+          type: "dataChunk",
+          chunk: chunkCopy,
+          offset: position,
+          isHeader,
+          container: "webm"
+        };
+        this.postMessageToMain(message, [chunkCopy.buffer]);
+      }) : "buffer";
+      const videoDisabled = config.width === 0 || config.height === 0 || config.videoBitrate === 0;
+      const optionsForMuxer = {
+        target
+      };
+      if (!videoDisabled) {
+        optionsForMuxer.video = {
+          codec: muxerVideoCodec,
+          width: config.width,
+          height: config.height,
+          frameRate: config.frameRate
+        };
+      }
+      if (!disableAudio && muxerAudioCodec) {
+        optionsForMuxer.audio = {
+          codec: muxerAudioCodec,
+          numberOfChannels: config.channels,
+          sampleRate: config.sampleRate
+        };
+      }
+      this.muxer = new import_webm_muxer.default(optionsForMuxer);
+      this.videoConfigured = !videoDisabled;
+      this.audioConfigured = !disableAudio;
+    }
+    addVideoChunk(chunk, meta) {
+      if (!this.videoConfigured) {
+        this.postMessageToMain({
+          type: "error",
+          errorDetail: {
+            message: "WebM: Video track not configured.",
+            type: "configuration-error" /* ConfigurationError */
+          }
+        });
+        return;
+      }
+      try {
+        let adjustedChunk = chunk;
+        const adjustedMeta = meta;
+        if (this.config.firstTimestampBehavior === "offset" && typeof chunk.timestamp === "number") {
+          if (this.firstVideoTimestamp === null) {
+            this.firstVideoTimestamp = chunk.timestamp;
+            if (this.firstTimestamp === null) {
+              this.firstTimestamp = chunk.timestamp;
+            } else {
+              this.firstTimestamp = Math.min(
+                this.firstTimestamp,
+                chunk.timestamp
+              );
+            }
+          }
+          const newTimestamp = Math.max(
+            0,
+            chunk.timestamp - (this.firstTimestamp || 0)
+          );
+          const data = new Uint8Array(chunk.byteLength);
+          chunk.copyTo(data.buffer);
+          chunk.close?.();
+          adjustedChunk = new EncodedVideoChunk({
+            type: chunk.type,
+            timestamp: newTimestamp,
+            duration: chunk.duration ?? void 0,
+            data: data.buffer
+          });
+        }
+        this.muxer.addVideoChunk(adjustedChunk, adjustedMeta);
+      } catch (e) {
+        this.postMessageToMain({
+          type: "error",
+          errorDetail: {
+            message: `WebM: Error adding video chunk: ${e.message}`,
+            type: "muxing-failed" /* MuxingFailed */,
+            stack: e.stack
+          }
+        });
+      }
+    }
+    addAudioChunk(chunk, meta) {
+      if (!this.audioConfigured) return;
+      try {
+        let adjustedChunk = chunk;
+        const adjustedMeta = meta;
+        if (this.config.firstTimestampBehavior === "offset" && typeof chunk.timestamp === "number") {
+          if (this.firstAudioTimestamp === null) {
+            this.firstAudioTimestamp = chunk.timestamp;
+            if (this.firstTimestamp === null) {
+              this.firstTimestamp = chunk.timestamp;
+            } else {
+              this.firstTimestamp = Math.min(
+                this.firstTimestamp,
+                chunk.timestamp
+              );
+            }
+          }
+          const newTimestamp = Math.max(
+            0,
+            chunk.timestamp - (this.firstTimestamp || 0)
+          );
+          const data = new Uint8Array(chunk.byteLength);
+          chunk.copyTo(data.buffer);
+          chunk.close?.();
+          adjustedChunk = new EncodedAudioChunk({
+            type: chunk.type,
+            timestamp: newTimestamp,
+            duration: chunk.duration ?? void 0,
+            data: data.buffer
+          });
+        }
+        this.muxer.addAudioChunk(adjustedChunk, adjustedMeta);
+      } catch (e) {
+        this.postMessageToMain({
+          type: "error",
+          errorDetail: {
+            message: `WebM: Error adding audio chunk: ${e.message}`,
+            type: "muxing-failed" /* MuxingFailed */,
+            stack: e.stack
+          }
+        });
+      }
+    }
+    finalize() {
+      if (this.config.latencyMode === "realtime") {
+        try {
+          this.muxer.finalize();
+        } catch (e) {
+          this.postMessageToMain({
+            type: "error",
+            errorDetail: {
+              message: `WebM: Error finalizing muxer (realtime): ${e.message}`,
+              type: "muxing-failed" /* MuxingFailed */,
+              stack: e.stack
+            }
+          });
+        }
+        return null;
+      }
+      try {
+        const buffer = this.muxer.finalize();
+        if (buffer) return new Uint8Array(buffer);
+        this.postMessageToMain({
+          type: "error",
+          errorDetail: {
+            message: "WebM: Muxer finalized without output in non-realtime mode.",
+            type: "muxing-failed" /* MuxingFailed */
+          }
+        });
+        return null;
+      } catch (e) {
+        this.postMessageToMain({
+          type: "error",
+          errorDetail: {
+            message: `WebM: Error finalizing muxer (non-realtime): ${e.message}`,
+            type: "muxing-failed" /* MuxingFailed */,
+            stack: e.stack
+          }
+        });
+        return null;
+      }
+    }
+  };
+
+  // src/worker/encoder-worker.ts
+  if (typeof self !== "undefined" && typeof self.addEventListener === "function") {
+    self.addEventListener("error", (event) => {
+      console.error("Unhandled global error in worker. Event:", event);
+      const message = event.message || `Unhandled global error${event.filename ? ` at ${event.filename}` : ""}`;
+      self.postMessage({
+        type: "error",
+        errorDetail: {
+          message,
+          type: "worker-error" /* WorkerError */,
+          stack: event.error?.stack
+        }
+      });
+    });
+  }
+  if (typeof self !== "undefined" && typeof self.addEventListener === "function") {
+    self.addEventListener(
+      "unhandledrejection",
+      (event) => {
+        console.error(
+          "Unhandled promise rejection in worker. Reason:",
+          event.reason
+        );
+        const reason = event.reason;
+        const message = reason instanceof Error ? `Unhandled promise rejection: ${reason.message}` : `Unhandled promise rejection: ${String(reason)}`;
+        self.postMessage({
+          type: "error",
+          errorDetail: {
+            message,
+            type: "worker-error" /* WorkerError */,
+            stack: reason instanceof Error ? reason.stack : void 0
+          }
+        });
+      }
+    );
+  }
+  var getVideoEncoder = () => self.VideoEncoder ?? globalThis.VideoEncoder;
+  var getAudioEncoder = () => self.AudioEncoder ?? globalThis.AudioEncoder;
+  var getAudioData = () => self.AudioData ?? globalThis.AudioData;
+  var EncoderWorker = class {
+    constructor() {
+      this.videoEncoder = null;
+      this.audioEncoder = null;
+      this.muxer = null;
+      this.currentConfig = null;
+      this.processedFrames = 0;
+      this.videoFrameCount = 0;
+      this.isCancelled = false;
+    }
+    postMessageToMainThread(message, transfer) {
+      if (transfer && transfer.length > 0) {
+        self.postMessage(message, transfer);
+      } else {
+        self.postMessage(message);
+      }
+    }
+    defaultAvcCodecString(width, height, frameRate, profile) {
+      const mbPerSec = Math.ceil(width / 16) * Math.ceil(height / 16) * frameRate;
+      let level;
+      if (mbPerSec <= 108e3) level = 31;
+      else if (mbPerSec <= 216e3) level = 32;
+      else if (mbPerSec <= 245760) level = 40;
+      else if (mbPerSec <= 589824) level = 50;
+      else if (mbPerSec <= 983040) level = 51;
+      else level = 52;
+      const chosenProfile = profile ?? (width >= 1280 || height >= 720 ? "high" : "baseline");
+      const profileHex = chosenProfile === "high" ? "64" : chosenProfile === "main" ? "4d" : "42";
+      const levelHex = level.toString(16).padStart(2, "0");
+      return `avc1.${profileHex}00${levelHex}`;
+    }
+    getCodecString(codecType, width, height, frameRate) {
+      switch (codecType) {
+        case "avc":
+          return this.defaultAvcCodecString(width, height, frameRate);
+        case "vp9":
+          return "vp09.00.50.08";
+        case "vp8":
+          return "vp8";
+        case "hevc":
+          return "hvc1";
+        case "av1":
+          return "av01.0.04M.08";
+        default:
+          return codecType;
+      }
+    }
+    async isConfigSupportedWithHwFallback(Ctor, config, label) {
+      let support = await Ctor.isConfigSupported(config);
+      if (support?.supported && support.config) return support.config;
+      const pref = config.hardwareAcceleration;
+      if (pref) {
+        let altPref;
+        if (pref === "prefer-hardware") altPref = "prefer-software";
+        else if (pref === "prefer-software") altPref = "prefer-hardware";
+        if (altPref) {
+          const opposite = { ...config, hardwareAcceleration: altPref };
+          support = await Ctor.isConfigSupported(opposite);
+          if (support?.supported && support.config) {
+            console.warn(
+              `${label}: hardwareAcceleration preference '${pref}' not supported. Using '${altPref}'.`
+            );
+            return support.config;
+          }
+        }
+        const noPref = { ...config };
+        delete noPref.hardwareAcceleration;
+        support = await Ctor.isConfigSupported(noPref);
+        if (support?.supported && support.config) {
+          console.warn(
+            `${label}: hardwareAcceleration preference '${pref}' not supported. Using no preference.`
+          );
+          return support.config;
+        }
+        console.warn(
+          `${label}: Failed to find a supported hardware acceleration configuration for codec ${config.codec}.`
+        );
+      }
+      return null;
+    }
+    postQueueSize() {
+      this.postMessageToMainThread({
+        type: "queueSize",
+        videoQueueSize: this.videoEncoder?.encodeQueueSize ?? 0,
+        audioQueueSize: this.audioEncoder?.encodeQueueSize ?? 0
+      });
+    }
+    async prepareAudioCodec(container, audioDisabled) {
+      if (audioDisabled) {
+        return {
+          audioDisabled: true,
+          selectedCodec: null,
+          finalConfig: null,
+          encoderCtor: null
+        };
+      }
+      const AudioEncoderCtor = getAudioEncoder();
+      if (!AudioEncoderCtor) {
+        this.postMessageToMainThread({
+          type: "error",
+          errorDetail: {
+            message: "Worker: AudioEncoder not available",
+            type: "not-supported" /* NotSupported */
+          }
+        });
+        return {
+          audioDisabled: true,
+          selectedCodec: null,
+          finalConfig: null,
+          encoderCtor: null
+        };
+      }
+      const config = this.currentConfig;
+      if (!config) {
+        return {
+          audioDisabled: true,
+          selectedCodec: null,
+          finalConfig: null,
+          encoderCtor: AudioEncoderCtor
+        };
+      }
+      const requestedCodec = config.codec?.audio;
+      const requestedCodecString = config.codecString?.audio;
+      const preference = buildAudioCodecPreference(container, requestedCodec);
+      let attemptedDefaultCodec = false;
+      for (const candidate of preference) {
+        if (candidate === "aac") {
+          attemptedDefaultCodec = true;
+        }
+        const codecString = requestedCodec && requestedCodecString && candidate === requestedCodec ? requestedCodecString : getAudioEncoderCodecStringFromAudioCodec(candidate);
+        const baseConfig = {
+          codec: codecString,
+          sampleRate: config.sampleRate,
+          numberOfChannels: config.channels,
+          bitrate: config.audioBitrate,
+          ...config.audioBitrateMode && {
+            bitrateMode: config.audioBitrateMode
+          },
+          ...config.hardwareAcceleration && {
+            hardwareAcceleration: config.hardwareAcceleration
+          },
+          ...getAudioEncoderConfigOverridesForCodec(
+            candidate,
+            config.audioEncoderConfig
+          )
+        };
+        const support = await this.isConfigSupportedWithHwFallback(
+          AudioEncoderCtor,
+          baseConfig,
+          "AudioEncoder"
+        );
+        if (!support) {
+          if (candidate === "aac" && container === "mp4") {
+            console.warn(
+              "Worker: AAC audio codec is not supported. Falling back to MP3."
+            );
+            const mp3AttemptConfig = {
+              codec: getAudioEncoderCodecStringFromAudioCodec("mp3"),
+              sampleRate: config.sampleRate,
+              numberOfChannels: config.channels,
+              bitrate: config.audioBitrate,
+              ...config.audioBitrateMode && {
+                bitrateMode: config.audioBitrateMode
+              },
+              ...config.hardwareAcceleration && {
+                hardwareAcceleration: config.hardwareAcceleration
+              },
+              ...getAudioEncoderConfigOverridesForCodec(
+                "mp3",
+                config.audioEncoderConfig
+              )
+            };
+            const mp3Support = await this.isConfigSupportedWithHwFallback(
+              AudioEncoderCtor,
+              mp3AttemptConfig,
+              "AudioEncoder"
+            );
+            if (mp3Support) {
+              const resolvedMp3Config = mp3Support;
+              if (resolvedMp3Config.numberOfChannels !== void 0 && resolvedMp3Config.numberOfChannels !== config.channels) {
+                this.postMessageToMainThread({
+                  type: "error",
+                  errorDetail: {
+                    message: `AudioEncoder reported numberOfChannels (${resolvedMp3Config.numberOfChannels}) does not match configured channels (${config.channels}).`,
+                    type: "configuration-error" /* ConfigurationError */
+                  }
+                });
+                return {
+                  audioDisabled: true,
+                  selectedCodec: null,
+                  finalConfig: null,
+                  encoderCtor: AudioEncoderCtor
+                };
+              }
+              if (resolvedMp3Config.sampleRate !== void 0 && resolvedMp3Config.sampleRate !== config.sampleRate) {
+                this.postMessageToMainThread({
+                  type: "error",
+                  errorDetail: {
+                    message: `AudioEncoder reported sampleRate (${resolvedMp3Config.sampleRate}) does not match configured sampleRate (${config.sampleRate}).`,
+                    type: "configuration-error" /* ConfigurationError */
+                  }
+                });
+                return {
+                  audioDisabled: true,
+                  selectedCodec: null,
+                  finalConfig: null,
+                  encoderCtor: AudioEncoderCtor
+                };
+              }
+              if (!isAudioCodecMuxerCompatible(container, "mp3")) {
+                console.warn(
+                  "Worker: Audio codec mp3 is not compatible with MP4 muxer. Audio will be disabled."
+                );
+              } else {
+                console.warn("Worker: Falling back to MP3 for MP4 container.");
+                return {
+                  audioDisabled: false,
+                  selectedCodec: "mp3",
+                  finalConfig: resolvedMp3Config,
+                  encoderCtor: AudioEncoderCtor
+                };
+              }
+            }
+          } else {
+            console.warn(
+              `Worker: Audio codec ${candidate} not supported or config invalid.`
+            );
+          }
+          continue;
+        }
+        const resolvedConfig = support;
+        if (resolvedConfig.numberOfChannels !== void 0 && resolvedConfig.numberOfChannels !== config.channels) {
+          this.postMessageToMainThread({
+            type: "error",
+            errorDetail: {
+              message: `AudioEncoder reported numberOfChannels (${resolvedConfig.numberOfChannels}) does not match configured channels (${config.channels}).`,
+              type: "configuration-error" /* ConfigurationError */
+            }
+          });
+          return {
+            audioDisabled: true,
+            selectedCodec: null,
+            finalConfig: null,
+            encoderCtor: AudioEncoderCtor
+          };
+        }
+        if (resolvedConfig.sampleRate !== void 0 && resolvedConfig.sampleRate !== config.sampleRate) {
+          this.postMessageToMainThread({
+            type: "error",
+            errorDetail: {
+              message: `AudioEncoder reported sampleRate (${resolvedConfig.sampleRate}) does not match configured sampleRate (${config.sampleRate}).`,
+              type: "configuration-error" /* ConfigurationError */
+            }
+          });
+          return {
+            audioDisabled: true,
+            selectedCodec: null,
+            finalConfig: null,
+            encoderCtor: AudioEncoderCtor
+          };
+        }
+        if (!isAudioCodecMuxerCompatible(container, candidate)) {
+          console.warn(
+            `Worker: Audio codec ${candidate} is not compatible with ${container.toUpperCase()} muxer. Trying fallback codec.`
+          );
+          continue;
+        }
+        if (candidate === "aac") {
+          attemptedDefaultCodec = true;
+          if (container === "mp4" && requestedCodec && requestedCodec !== "aac") {
+            console.warn("Worker: Falling back to AAC for MP4 container.");
+          }
+        }
+        if (container === "mp4" && candidate === "mp3" && attemptedDefaultCodec) {
+          console.warn("Worker: Falling back to MP3 for MP4 container.");
+        }
+        return {
+          audioDisabled: false,
+          selectedCodec: candidate,
+          finalConfig: resolvedConfig,
+          encoderCtor: AudioEncoderCtor
+        };
+      }
+      const defaultCodec = DEFAULT_AUDIO_CODEC_BY_CONTAINER[container];
+      const noCodecMessage = container === "mp4" ? "Worker: No supported audio codec (AAC, MP3) found for MP4 container." : `Worker: No supported audio codec found. Requested: ${requestedCodec ?? "(auto)"}. Tried: ${preference.join(", ")}.`;
+      this.postMessageToMainThread({
+        type: "error",
+        errorDetail: {
+          message: noCodecMessage,
+          type: "not-supported" /* NotSupported */
+        }
+      });
+      console.warn(
+        `Worker: Disabling audio. Consider using ${defaultCodec} for container ${container}.`
+      );
+      return {
+        audioDisabled: true,
+        selectedCodec: null,
+        finalConfig: null,
+        encoderCtor: AudioEncoderCtor
+      };
+    }
+    async initializeEncoders(data) {
+      this.currentConfig = data.config;
+      this.totalFramesToProcess = data.totalFrames;
+      this.processedFrames = 0;
+      this.videoFrameCount = 0;
+      this.isCancelled = false;
+      if (!this.currentConfig) {
+        this.postMessageToMainThread({
+          type: "error",
+          errorDetail: {
+            message: "Worker: Configuration is missing.",
+            type: "initialization-failed" /* InitializationFailed */
+          }
+        });
+        return;
+      }
+      let audioDisabled = !this.currentConfig.audioBitrate || this.currentConfig.audioBitrate <= 0 || !this.currentConfig.channels || this.currentConfig.channels <= 0 || !this.currentConfig.sampleRate || this.currentConfig.sampleRate <= 0 || !this.currentConfig.codec?.audio;
+      const containerType = getContainerType(this.currentConfig.container);
+      const audioOriginallyDisabled = audioDisabled;
+      const audioPlan = await this.prepareAudioCodec(
+        containerType,
+        audioDisabled
+      );
+      audioDisabled = audioPlan.audioDisabled;
+      let selectedAudioCodec = audioPlan.selectedCodec;
+      let finalAudioEncoderConfig = audioPlan.finalConfig;
+      const preparedAudioEncoderCtor = audioPlan.encoderCtor;
+      if (audioDisabled) {
+        selectedAudioCodec = null;
+        finalAudioEncoderConfig = null;
+      }
+      if (!audioOriginallyDisabled && audioDisabled) {
+        this.cleanup();
+        return;
+      }
+      const videoDisabled = this.currentConfig.width === 0 || this.currentConfig.height === 0 || this.currentConfig.videoBitrate === 0;
+      let videoCodec = this.currentConfig.codec?.video ?? (this.currentConfig.container === "webm" ? "vp9" : "avc");
+      const requestedVideoCodec = videoCodec;
+      let finalVideoEncoderConfig = null;
+      let resolvedVideoCodecString = null;
+      let VideoEncoderCtor;
+      if (!videoDisabled) {
+        if (this.currentConfig.container === "webm" && (videoCodec === "avc" || videoCodec === "hevc")) {
+          console.warn(
+            `Worker: Video codec ${videoCodec} not compatible with WebM. Switching to VP9.`
+          );
+          videoCodec = "vp9";
+        }
+        resolvedVideoCodecString = (this.currentConfig.codecString?.video && videoCodec === requestedVideoCodec ? this.currentConfig.codecString.video : void 0) ?? (videoCodec === "avc" ? this.defaultAvcCodecString(
+          this.currentConfig.width,
+          this.currentConfig.height,
+          this.currentConfig.frameRate
+        ) : videoCodec === "vp9" ? "vp09.00.50.08" : videoCodec === "vp8" ? "vp8" : videoCodec === "hevc" ? "hvc1" : videoCodec === "av1" ? "av01.0.04M.08" : videoCodec);
+        const videoEncoderConfig = {
+          codec: resolvedVideoCodecString,
+          width: this.currentConfig.width,
+          height: this.currentConfig.height,
+          bitrate: this.currentConfig.videoBitrate,
+          framerate: this.currentConfig.frameRate,
+          ...this.currentConfig.container === "mp4" && videoCodec === "avc" ? { avc: { format: "avc" } } : {},
+          ...this.currentConfig.hardwareAcceleration ? { hardwareAcceleration: this.currentConfig.hardwareAcceleration } : {},
+          ...getVideoEncoderConfigOverridesForCodec(
+            videoCodec,
+            this.currentConfig.videoEncoderConfig
+          )
+        };
+        VideoEncoderCtor = getVideoEncoder();
+        if (!VideoEncoderCtor) {
+          this.postMessageToMainThread({
+            type: "error",
+            errorDetail: {
+              message: "Worker: VideoEncoder not available",
+              type: "not-supported" /* NotSupported */
+            }
+          });
+          this.cleanup();
+          return;
+        }
+        const initialSupport = await VideoEncoderCtor.isConfigSupported(videoEncoderConfig);
+        if (initialSupport?.supported && initialSupport.config) {
+          finalVideoEncoderConfig = initialSupport.config;
+        } else {
+          if (videoCodec === "vp9" || videoCodec === "vp8" || videoCodec === "av1") {
+            console.warn(
+              "Worker: Video codec " + videoCodec + " not supported or config invalid. Looking for fallback..."
+            );
+            let fallbackSuccessful = false;
+            if (this.currentConfig.container === "webm") {
+              const webmCodecs = ["vp9", "vp8"];
+              for (const fallbackCodec of webmCodecs) {
+                if (fallbackCodec === videoCodec) continue;
+                console.warn(
+                  `Worker: Trying fallback to ${fallbackCodec} for WebM container.`
+                );
+                const fallbackCodecString = this.getCodecString(
+                  fallbackCodec,
+                  this.currentConfig.width,
+                  this.currentConfig.height,
+                  this.currentConfig.frameRate
+                );
+                const fallbackConfig = {
+                  ...videoEncoderConfig,
+                  codec: fallbackCodecString,
+                  ...getVideoEncoderConfigOverridesForCodec(
+                    fallbackCodec,
+                    this.currentConfig.videoEncoderConfig
+                  )
+                };
+                const support = await this.isConfigSupportedWithHwFallback(
+                  VideoEncoderCtor,
+                  fallbackConfig,
+                  "VideoEncoder"
+                );
+                if (support) {
+                  console.warn(
+                    `Worker: Successfully fell back to ${fallbackCodec} for WebM.`
+                  );
+                  videoCodec = fallbackCodec;
+                  finalVideoEncoderConfig = support;
+                  fallbackSuccessful = true;
+                  break;
+                }
+              }
+              if (!fallbackSuccessful) {
+                this.postMessageToMainThread({
+                  type: "error",
+                  errorDetail: {
+                    message: "Worker: No compatible video codec (VP9, VP8) found for WebM container.",
+                    type: "not-supported" /* NotSupported */
+                  }
+                });
+                this.cleanup();
+                return;
+              }
+            } else {
+              console.warn("Worker: Falling back to AVC for MP4 container.");
+              videoCodec = "avc";
+              const avcCodecString = this.defaultAvcCodecString(
+                this.currentConfig.width,
+                this.currentConfig.height,
+                this.currentConfig.frameRate
+              );
+              const avcConfig = {
+                ...videoEncoderConfig,
+                codec: avcCodecString,
+                ...this.currentConfig.container === "mp4" ? { avc: { format: "avc" } } : {},
+                ...getVideoEncoderConfigOverridesForCodec(
+                  "avc",
+                  this.currentConfig.videoEncoderConfig
+                )
+              };
+              const support = await this.isConfigSupportedWithHwFallback(
+                VideoEncoderCtor,
+                avcConfig,
+                "VideoEncoder"
+              );
+              if (support) {
+                finalVideoEncoderConfig = support;
+                fallbackSuccessful = true;
+              } else {
+                this.postMessageToMainThread({
+                  type: "error",
+                  errorDetail: {
+                    message: "Worker: AVC (H.264) video codec is not supported after fallback.",
+                    type: "not-supported" /* NotSupported */
+                  }
+                });
+                this.cleanup();
+                return;
+              }
+            }
+          } else {
+            const result = await this.isConfigSupportedWithHwFallback(
+              VideoEncoderCtor,
+              videoEncoderConfig,
+              "VideoEncoder"
+            );
+            if (result) {
+              finalVideoEncoderConfig = result;
+            } else {
+              this.postMessageToMainThread({
+                type: "error",
+                errorDetail: {
+                  message: `Worker: Video codec ${videoCodec} config not supported.`,
+                  type: "not-supported" /* NotSupported */
+                }
+              });
+              this.cleanup();
+              return;
+            }
+          }
+        }
+      } else {
+        videoCodec = void 0;
+      }
+      const codecConfigForMuxer = {
+        ...this.currentConfig.codec ?? {},
+        video: videoDisabled ? void 0 : videoCodec,
+        audio: audioDisabled || !selectedAudioCodec ? void 0 : selectedAudioCodec
+      };
+      this.currentConfig.codec = codecConfigForMuxer;
+      try {
+        const MuxerCtor = containerType === "webm" ? WebMMuxerWrapper : Mp4MuxerWrapper;
+        this.muxer = new MuxerCtor(
+          this.currentConfig,
+          this.postMessageToMainThread.bind(this),
+          {
+            disableAudio: audioDisabled
+          }
+        );
+      } catch (e) {
+        this.postMessageToMainThread({
+          type: "error",
+          errorDetail: {
+            message: `Worker: Failed to initialize Muxer: ${e.message}`,
+            type: "initialization-failed" /* InitializationFailed */,
+            stack: e.stack
+          }
+        });
+        this.cleanup();
+        return;
+      }
+      if (!videoDisabled) {
+        try {
+          if (!VideoEncoderCtor) {
+            this.postMessageToMainThread({
+              type: "error",
+              errorDetail: {
+                message: "Worker: VideoEncoder not available",
+                type: "not-supported" /* NotSupported */
+              }
+            });
+            this.cleanup();
+            return;
+          }
+          this.videoEncoder = new VideoEncoderCtor({
+            output: (chunk, meta) => {
+              if (this.isCancelled || !this.muxer) return;
+              this.muxer.addVideoChunk(chunk, meta);
+            },
+            error: (error) => {
+              if (this.isCancelled) return;
+              this.postMessageToMainThread({
+                type: "error",
+                errorDetail: {
+                  message: `VideoEncoder error: ${error.message}`,
+                  type: "video-encoding-error" /* VideoEncodingError */,
+                  stack: error.stack
+                }
+              });
+              this.cleanup();
+            }
+          });
+          if (finalVideoEncoderConfig) {
+            if (this.videoEncoder) {
+              this.videoEncoder.configure(finalVideoEncoderConfig);
+            } else {
+              this.postMessageToMainThread({
+                type: "error",
+                errorDetail: {
+                  message: "Worker: VideoEncoder instance is null after creation.",
+                  type: "initialization-failed" /* InitializationFailed */
+                }
+              });
+              this.cleanup();
+              return;
+            }
+          } else {
+            this.postMessageToMainThread({
+              type: "error",
+              errorDetail: {
+                message: `Worker: VideoEncoder: Failed to find a supported hardware acceleration configuration for codec ${resolvedVideoCodecString ?? "(unknown)"}`,
+                type: "not-supported" /* NotSupported */
+              }
+            });
+            this.cleanup();
+            return;
+          }
+        } catch (e) {
+          this.postMessageToMainThread({
+            type: "error",
+            errorDetail: {
+              message: `Worker: Failed to initialize VideoEncoder: ${e.message}`,
+              type: "initialization-failed" /* InitializationFailed */,
+              stack: e.stack
+            }
+          });
+          this.cleanup();
+          return;
+        }
+      }
+      if (!audioDisabled) {
+        if (!selectedAudioCodec || !finalAudioEncoderConfig || !preparedAudioEncoderCtor) {
+          this.cleanup();
+          return;
+        }
+        try {
+          this.audioEncoder = new preparedAudioEncoderCtor({
+            output: (chunk, meta) => {
+              if (this.isCancelled || !this.muxer) return;
+              this.muxer.addAudioChunk(chunk, meta);
+            },
+            error: (error) => {
+              if (this.isCancelled) return;
+              this.postMessageToMainThread({
+                type: "error",
+                errorDetail: {
+                  message: `AudioEncoder error: ${error.message}`,
+                  type: "audio-encoding-error" /* AudioEncodingError */,
+                  stack: error.stack
+                }
+              });
+              this.cleanup();
+            }
+          });
+          if (this.audioEncoder) {
+            this.audioEncoder.configure(finalAudioEncoderConfig);
+          } else {
+            this.postMessageToMainThread({
+              type: "error",
+              errorDetail: {
+                message: "Worker: AudioEncoder instance is null after creation.",
+                type: "initialization-failed" /* InitializationFailed */
+              }
+            });
+            this.cleanup();
+            return;
+          }
+        } catch (e) {
+          this.postMessageToMainThread({
+            type: "error",
+            errorDetail: {
+              message: `Worker: Failed to initialize AudioEncoder: ${e.message}`,
+              type: "initialization-failed" /* InitializationFailed */,
+              stack: e.stack
+            }
+          });
+          this.cleanup();
+          return;
+        }
+      }
+      this.postMessageToMainThread({
+        type: "initialized",
+        actualVideoCodec: finalVideoEncoderConfig?.codec,
+        actualAudioCodec: audioDisabled ? null : finalAudioEncoderConfig?.codec
+      });
+      console.warn("Worker: Initialized successfully");
+    }
+    async handleAddVideoFrame(data) {
+      if (this.isCancelled || !this.videoEncoder || !this.currentConfig) return;
+      try {
+        const frame = data.frame;
+        const currentQueueSize = this.videoEncoder.encodeQueueSize;
+        const maxQueueSize = this.currentConfig.maxVideoQueueSize || 30;
+        const strategy = this.currentConfig.backpressureStrategy || "drop";
+        if (currentQueueSize >= maxQueueSize) {
+          if (strategy === "drop") {
+            console.warn(
+              `Video queue full (${currentQueueSize}/${maxQueueSize}), dropping frame`
+            );
+            try {
+              frame.close();
+            } catch (closeErr) {
+              console.warn(
+                "Worker: Ignored error closing dropped VideoFrame",
+                closeErr
+              );
+            }
+            return;
+          } else if (strategy === "wait") {
+            let waitTime = 10;
+            const maxWaitTime = 100;
+            const maxRetries = 5;
+            let attempts = 0;
+            while (this.videoEncoder.encodeQueueSize >= maxQueueSize && attempts < maxRetries) {
+              await new Promise((resolve) => setTimeout(resolve, waitTime));
+              waitTime = Math.min(waitTime * 1.5, maxWaitTime);
+              attempts++;
+            }
+            if (this.videoEncoder.encodeQueueSize >= maxQueueSize) {
+              console.warn(
+                `Video queue still full after waiting, dropping frame`
+              );
+              try {
+                frame.close();
+              } catch (closeErr) {
+                console.warn(
+                  "Worker: Ignored error closing waited VideoFrame",
+                  closeErr
+                );
+              }
+              return;
+            }
+          }
+        }
+        const interval = this.currentConfig.keyFrameInterval;
+        const opts = interval && this.videoFrameCount % interval === 0 ? { keyFrame: true } : void 0;
+        this.videoEncoder.encode(frame, opts);
+        try {
+          frame.close();
+        } catch (closeErr) {
+          console.warn("Worker: Ignored error closing VideoFrame", closeErr);
+        }
+        this.videoFrameCount++;
+        this.processedFrames++;
+        const progressMessage = {
+          type: "progress",
+          processedFrames: this.processedFrames
+        };
+        if (typeof this.totalFramesToProcess !== "undefined") {
+          progressMessage.totalFrames = this.totalFramesToProcess;
+        }
+        this.postMessageToMainThread(progressMessage);
+        this.postQueueSize();
+      } catch (error) {
+        this.postMessageToMainThread({
+          type: "error",
+          errorDetail: {
+            message: `Error encoding video frame: ${error.message}`,
+            type: "video-encoding-error" /* VideoEncodingError */,
+            stack: error.stack
+          }
+        });
+        this.cleanup();
+      }
+    }
+    async handleAddAudioData(data) {
+      if (this.isCancelled || !this.audioEncoder || !this.currentConfig) return;
+      if (data.audio) {
+        const audioData = data.audio;
+        let audioClosed = false;
+        const closeAudioData = (context) => {
+          if (audioClosed) return;
+          try {
+            audioData.close();
+            audioClosed = true;
+          } catch (closeErr) {
+            console.warn(`Worker: Ignored error closing ${context}`, closeErr);
+          }
+        };
+        try {
+          const currentQueueSize = this.audioEncoder.encodeQueueSize;
+          const maxQueueSize = this.currentConfig.maxAudioQueueSize || 30;
+          const strategy = this.currentConfig.backpressureStrategy || "drop";
+          if (currentQueueSize >= maxQueueSize) {
+            if (strategy === "drop") {
+              console.warn(
+                `Audio queue full (${currentQueueSize}/${maxQueueSize}), dropping audio data`
+              );
+              closeAudioData("dropped AudioData");
+              return;
+            } else if (strategy === "wait") {
+              let waitTime = 10;
+              const maxWaitTime = 100;
+              const maxRetries = 5;
+              let attempts = 0;
+              while (this.audioEncoder.encodeQueueSize >= maxQueueSize && attempts < maxRetries) {
+                await new Promise((resolve) => setTimeout(resolve, waitTime));
+                waitTime = Math.min(waitTime * 1.5, maxWaitTime);
+                attempts++;
+              }
+              if (this.audioEncoder.encodeQueueSize >= maxQueueSize) {
+                console.warn(
+                  `Audio queue still full after waiting, dropping audio data`
+                );
+                closeAudioData("waited AudioData");
+                return;
+              }
+            }
+          }
+          this.audioEncoder.encode(audioData);
+          this.postQueueSize();
+        } catch (error) {
+          this.postMessageToMainThread({
+            type: "error",
+            errorDetail: {
+              message: `Error encoding audio data: ${error.message}`,
+              type: "audio-encoding-error" /* AudioEncodingError */,
+              stack: error.stack
+            }
+          });
+          this.cleanup();
+        } finally {
+          closeAudioData("AudioData");
+        }
+        return;
+      }
+      if (!data.audioData || data.audioData.length === 0) return;
+      if (data.audioData.length !== this.currentConfig.channels) {
+        this.postMessageToMainThread({
+          type: "error",
+          errorDetail: {
+            message: `Audio data channel count (${data.audioData.length}) does not match configured channels (${this.currentConfig.channels}).`,
+            type: "configuration-error" /* ConfigurationError */
+          }
+        });
+        return;
+      }
+      const AudioDataCtor = getAudioData();
+      if (!AudioDataCtor) {
+        this.postMessageToMainThread({
+          type: "error",
+          errorDetail: {
+            message: "Worker: AudioData not available",
+            type: "not-supported" /* NotSupported */
+          }
+        });
+        this.cleanup();
+        return;
+      }
+      try {
+        const interleaveFloat32Arrays = (planarArrays) => {
+          if (!planarArrays || planarArrays.length === 0) {
+            return new Float32Array(0);
+          }
+          const numChannels = planarArrays.length;
+          const numFrames = Math.min(...planarArrays.map((arr) => arr.length));
+          const interleaved = new Float32Array(numFrames * numChannels);
+          for (let i = 0; i < numFrames; i++) {
+            for (let ch = 0; ch < numChannels; ch++) {
+              interleaved[i * numChannels + ch] = planarArrays[ch][i];
+            }
+          }
+          return interleaved;
+        };
+        const interleavedData = interleaveFloat32Arrays(data.audioData);
+        const audioData = new AudioDataCtor({
+          format: "f32",
+          sampleRate: data.sampleRate,
+          numberOfFrames: data.numberOfFrames,
+          numberOfChannels: data.numberOfChannels,
+          timestamp: data.timestamp,
+          data: interleavedData.buffer
+        });
+        try {
+          this.audioEncoder.encode(audioData);
+          this.postQueueSize();
+        } finally {
+          audioData.close();
+        }
+      } catch (error) {
+        this.postMessageToMainThread({
+          type: "error",
+          errorDetail: {
+            message: `Error encoding audio data: ${error.message}`,
+            type: "audio-encoding-error" /* AudioEncodingError */,
+            stack: error.stack
+          }
+        });
+        this.cleanup();
+      }
+    }
+    async handleFinalize(_message) {
+      if (this.isCancelled) return;
+      try {
+        if (this.videoEncoder) await this.videoEncoder.flush();
+        if (this.audioEncoder) await this.audioEncoder.flush();
+        if (this.muxer) {
+          const uint8ArrayOrNullOutput = this.muxer.finalize();
+          if (uint8ArrayOrNullOutput) {
+            this.postMessageToMainThread(
+              { type: "finalized", output: uint8ArrayOrNullOutput },
+              [uint8ArrayOrNullOutput.buffer]
+            );
+          } else if (this.currentConfig?.latencyMode === "realtime") {
+            this.postMessageToMainThread({ type: "finalized", output: null });
+          } else {
+            this.postMessageToMainThread({
+              type: "error",
+              errorDetail: {
+                message: "Muxer finalized without output in non-realtime mode.",
+                type: "muxing-failed" /* MuxingFailed */
+              }
+            });
+          }
+        } else {
+          this.postMessageToMainThread({
+            type: "error",
+            errorDetail: {
+              message: "Muxer not initialized during finalize.",
+              type: "muxing-failed" /* MuxingFailed */
+            }
+          });
+        }
+      } catch (error) {
+        this.postMessageToMainThread({
+          type: "error",
+          errorDetail: {
+            message: `Error during finalization: ${error.message}`,
+            type: "muxing-failed" /* MuxingFailed */,
+            stack: error.stack
+          }
+        });
+      } finally {
+        this.cleanup();
+      }
+    }
+    handleCancel(_message) {
+      if (this.isCancelled) return;
+      this.isCancelled = true;
+      console.warn("Worker: Received cancel signal.");
+      this.postMessageToMainThread({ type: "cancelled" });
+      this.videoEncoder?.close();
+      this.audioEncoder?.close();
+      this.cleanup();
+    }
+    cleanup() {
+      console.warn("Worker: Cleaning up resources.");
+      if (this.videoEncoder && this.videoEncoder.state !== "closed")
+        this.videoEncoder.close();
+      if (this.audioEncoder && this.audioEncoder.state !== "closed")
+        this.audioEncoder.close();
+      this.videoEncoder = null;
+      this.audioEncoder = null;
+      this.muxer = null;
+      this.currentConfig = null;
+      this.totalFramesToProcess = void 0;
+      this.processedFrames = 0;
+      this.videoFrameCount = 0;
+    }
+    async handleMessage(eventData) {
+      if (this.isCancelled && eventData.type !== "initialize" && eventData.type !== "cancel") {
+        console.warn(
+          `Worker: Ignoring message type '${eventData.type}' because worker is cancelled.`
+        );
+        return;
+      }
+      try {
+        switch (eventData.type) {
+          case "initialize":
+            this.isCancelled = false;
+            this.cleanup();
+            await this.initializeEncoders(eventData);
+            break;
+          case "addVideoFrame":
+            await this.handleAddVideoFrame(eventData);
+            break;
+          case "addAudioData":
+            await this.handleAddAudioData(eventData);
+            break;
+          case "finalize":
+            await this.handleFinalize(eventData);
+            break;
+          case "cancel":
+            this.handleCancel(eventData);
+            break;
+          default:
+            console.warn(
+              "Worker received unknown message type:",
+              eventData.type
+            );
+        }
+      } catch (error) {
+        this.postMessageToMainThread({
+          type: "error",
+          errorDetail: {
+            message: `Unhandled error in worker onmessage: ${error.message}`,
+            type: "unknown" /* Unknown */,
+            stack: error.stack
+          }
+        });
+        this.cleanup();
+      }
+    }
+  };
+  var encoder = new EncoderWorker();
+  self.onmessage = async (event) => {
+    await encoder.handleMessage(event.data);
+  };
+  var DEFAULT_AUDIO_CODEC_BY_CONTAINER = {
+    mp4: "aac",
+    webm: "opus"
+  };
+  var AUDIO_ENCODER_CODEC_MAP = {
+    aac: "mp4a.40.2",
+    opus: "opus",
+    flac: "flac",
+    mp3: "mp3",
+    vorbis: "vorbis",
+    pcm: "pcm",
+    ulaw: "ulaw",
+    alaw: "alaw"
+  };
+  var MUXER_COMPATIBLE_AUDIO = {
+    mp4: /* @__PURE__ */ new Set(["aac", "mp3"]),
+    webm: /* @__PURE__ */ new Set(["opus", "vorbis", "flac"])
+  };
+  function getAudioEncoderCodecStringFromAudioCodec(codec) {
+    return AUDIO_ENCODER_CODEC_MAP[codec] ?? codec;
+  }
+  function getVideoEncoderConfigOverridesForCodec(codec, overrides) {
+    if (!overrides) {
+      return {};
+    }
+    const sanitized = { ...overrides };
+    if (codec !== "avc") {
+      delete sanitized.avc;
+    }
+    if (codec !== "hevc") {
+      delete sanitized.hevc;
+    }
+    return sanitized;
+  }
+  function getAudioEncoderConfigOverridesForCodec(codec, overrides) {
+    if (!overrides) {
+      return {};
+    }
+    const sanitized = { ...overrides };
+    if (codec !== "aac") {
+      delete sanitized.aac;
+    }
+    return sanitized;
+  }
+  function getContainerType(container) {
+    return container === "webm" ? "webm" : "mp4";
+  }
+  function buildAudioCodecPreference(container, requested) {
+    const preference = [];
+    const addCodec = (codec) => {
+      if (!preference.includes(codec)) {
+        preference.push(codec);
+      }
+    };
+    if (requested) {
+      addCodec(requested);
+    }
+    addCodec(DEFAULT_AUDIO_CODEC_BY_CONTAINER[container]);
+    for (const codec of MUXER_COMPATIBLE_AUDIO[container]) {
+      addCodec(codec);
+    }
+    if (container === "mp4") {
+      addCodec("aac");
+      addCodec("mp3");
+    } else {
+      addCodec("opus");
+      addCodec("vorbis");
+      addCodec("flac");
+      addCodec("aac");
+    }
+    return preference;
+  }
+  function isAudioCodecMuxerCompatible(container, codec) {
+    return MUXER_COMPATIBLE_AUDIO[container].has(codec);
+  }
+})();
+//# sourceMappingURL=worker.js.map

+ 250 - 0
scripts/test-article-auto-format-flow.mjs

@@ -0,0 +1,250 @@
+import assert from 'node:assert/strict'
+import { readFile } from 'node:fs/promises'
+import ts from 'typescript'
+
+const root = new URL('../', import.meta.url)
+const read = p => readFile(new URL(p, root), 'utf8')
+const transpile = source => ts.transpileModule(source, {
+  compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
+}).outputText
+
+const base = 'src/views/Editor/EnglishSpeaking/composables/'
+
+// 纯规则模块先转译成 data URL,供 composable 原样 import(相对路径在 data: 里无法解析)
+const rulesUrl = `data:text/javascript,${encodeURIComponent(
+  transpile(await read(`${base}articleAutoFormatRules.ts`)),
+)}`
+
+let source = await read(`${base}useArticleAutoFormat.ts`)
+source = source
+  .replace(
+    "import { onScopeDispose, ref, type Ref } from 'vue'",
+    // onScopeDispose 桩:把清理回调存到 globalThis,供测试手动模拟"组件已卸载"
+    'const ref = value => ({ value }); const onScopeDispose = fn => { globalThis.__onDispose = fn }',
+  )
+  .replace(
+    "import { lang } from '@/main'",
+    "const lang = { ssArticleSegmentFailed: 'segment-failed' }",
+  )
+  .replace(
+    "import message from '@/utils/message'",
+    'const message = { error: text => globalThis.__errors.push(text) }',
+  )
+  .replace(
+    "import { segmentArticle } from '../services/articleReading'",
+    'const segmentArticle = (...args) => globalThis.__segment(...args)',
+  )
+  // 双引号做外层定界符:rulesUrl 是 encodeURIComponent 输出,单引号/反引号在其字符集里不转义,
+  // 若仍用单引号包裹会被规则文件注释里的反引号/`.join(' ')` 里的单引号提前截断字符串。
+  .replace("from './articleAutoFormatRules'", `from "${rulesUrl}"`)
+
+const { useArticleAutoFormat } = await import(
+  `data:text/javascript,${encodeURIComponent(transpile(source))}`
+)
+
+const words = n => Array.from({ length: n }, (_, i) => `w${i}`).join(' ')
+
+function setup({ segment } = {}) {
+  globalThis.__errors = []
+  globalThis.__segment = segment ?? (async () => ({ content: 'P1.\n\nP2.' }))
+  const content = { value: '' }
+  return { content, api: useArticleAutoFormat(content), errors: globalThis.__errors }
+}
+
+// 失败用例是故意的,composable 会如实 console.error。但它的栈里带着整个 data: URL
+// (被转译进去的源码),一条就是好几 KB,会把真正的测试输出淹掉。捕获掉,让通过时的
+// 输出保持干净;断言仍然验证 message.error 收到了什么。
+async function withSilencedConsoleError(fn) {
+  const original = console.error
+  console.error = () => {}
+  try {
+    await fn()
+  }
+  finally {
+    console.error = original
+  }
+}
+
+// ---------------- 粘贴触发 ----------------
+{
+  const { content, api } = setup()
+  api.onContentInput(words(60))
+  assert.equal(content.value, words(60), '内容恒写入(取消时保留原始粘贴)')
+  assert.equal(api.dialogVisible.value, true)
+  assert.equal(api.dialogMode.value, 'paste')
+}
+{
+  const { api } = setup()
+  api.onContentInput(words(40))
+  assert.equal(api.dialogVisible.value, false, '短文不弹')
+}
+{
+  // 已有长文再敲一个字符 —— 绝不能弹(prev 取自 content.value,不是陈旧变量)
+  const { content, api } = setup()
+  content.value = words(300)
+  api.onContentInput(`${words(300)} x`)
+  assert.equal(api.dialogVisible.value, false)
+}
+
+// ---------------- 粘贴 → 取消 → 内容不变(spec §3.2 step1「取消 → 保留原始粘贴」)----------------
+{
+  const { content, api } = setup()
+  api.onContentInput(words(60))
+  assert.equal(api.dialogVisible.value, true)
+  api.cancelDialog()
+  assert.equal(api.dialogVisible.value, false, '取消关框')
+  assert.equal(content.value, words(60), '取消不清空/不改写老师刚粘贴的内容')
+}
+
+// ---------------- 按钮触发 ----------------
+{
+  const { content, api } = setup()
+  content.value = words(80)
+  api.openOverwrite()
+  assert.equal(api.dialogMode.value, 'overwrite')
+  assert.equal(api.dialogVisible.value, true)
+}
+{
+  const { api } = setup() // 空文本
+  api.openOverwrite()
+  assert.equal(api.dialogVisible.value, false, '空文本不开框')
+}
+
+// ---------------- 应用配置守卫 ----------------
+{
+  const { content, api } = setup()
+  content.value = 'A one.\nB   two.\n\n\nC three.'
+  assert.equal(api.guardBeforeApply(), true, '无超长段 → 放行')
+  assert.equal(content.value, 'A one.\n\nB two.\n\nC three.', '归一化回写 textarea')
+  assert.equal(api.dialogVisible.value, false)
+}
+{
+  // 拦截路径也要归一化回写(脏空白不能留在 textarea)——通行路径已断言,这里补上拦截路径
+  const { content, api } = setup()
+  const messyOverlong = words(301).replace(/ /g, '   ') // 词间三倍空格
+  content.value = `A   one.\n\n\n\n${messyOverlong}`
+  assert.equal(api.guardBeforeApply(), false, '超长段 → 拦截')
+  assert.equal(content.value, `A one.\n\n${words(301)}`, '拦截也归一化回写')
+  assert.equal(api.dialogMode.value, 'guard')
+  assert.equal(api.dialogVisible.value, true)
+}
+{
+  // 请求进行中点「应用配置」:guardBeforeApply 必须早退,程序化调用也绕不过去(不止按钮 disabled)
+  let release
+  const pending = new Promise(resolve => { release = resolve })
+  const { content, api } = setup({
+    segment: async () => { await pending; return { content: 'Done.' } },
+  })
+  content.value = words(60)
+  api.openOverwrite()
+  const confirming = api.confirmDialog() // isFormatting → true
+  assert.equal(api.guardBeforeApply(), false, '格式化进行中,guardBeforeApply 必须早退')
+  assert.equal(content.value, words(60), '早退不应触碰内容(不归一化、不误判超长)')
+  assert.equal(api.dialogMode.value, 'overwrite', 'dialogMode 不应被早退的 guardBeforeApply 改成 guard')
+  release()
+  await confirming
+}
+
+// ---------------- 确认键:文本清空后点确认 ----------------
+{
+  // 可达路径(非 UI-unreachable 早退):老师清空文本框再点确认,弹框不能卡住
+  const { content, api } = setup()
+  content.value = words(60)
+  api.openOverwrite()
+  content.value = '   '
+  await api.confirmDialog()
+  assert.equal(api.dialogVisible.value, false, '空文本点确认 —— 关框而非静默无反应')
+  assert.equal(api.isFormatting.value, false)
+}
+
+// ---------------- 分段成功 ----------------
+{
+  const calls = []
+  const { content, api } = setup({
+    segment: async text => { calls.push(text); return { content: 'One.\n\n\nTwo.' } },
+  })
+  content.value = words(60)
+  api.openOverwrite()
+  await api.confirmDialog()
+  assert.deepEqual(calls, [words(60)], '整篇送后端')
+  assert.equal(content.value, 'One.\n\nTwo.', '回填并归一化')
+  assert.equal(api.dialogVisible.value, false)
+  assert.equal(api.isFormatting.value, false)
+}
+
+// ---------------- 分段失败:非守卫框关闭、守卫框保持打开 ----------------
+{
+  const { content, api, errors } = setup({ segment: async () => { throw new Error('down') } })
+  content.value = words(60)
+  api.openOverwrite()
+  await withSilencedConsoleError(() => api.confirmDialog())
+  assert.equal(content.value, words(60), '失败保留原文本')
+  assert.equal(api.dialogVisible.value, false)
+  assert.deepEqual(errors, ['segment-failed'])
+}
+{
+  const { content, api } = setup({ segment: async () => { throw new Error('down') } })
+  content.value = words(400)
+  api.guardBeforeApply()
+  await withSilencedConsoleError(() => api.confirmDialog())
+  assert.equal(api.dialogVisible.value, true, '守卫框失败后保持打开,可原地重试')
+  assert.equal(api.isFormatting.value, false)
+  assert.equal(content.value, words(400), '守卫框失败同样保留内容(非守卫失败已断言,这里补上守卫路径)')
+}
+
+// ---------------- 防重复点击 ----------------
+{
+  let calls = 0
+  let release
+  const pending = new Promise(resolve => { release = resolve })
+  const { content, api } = setup({
+    segment: async () => { calls += 1; await pending; return { content: 'Done.' } },
+  })
+  content.value = words(60)
+  api.openOverwrite()
+  const first = api.confirmDialog()
+  await api.confirmDialog()          // 进行中再点 —— 必须早退
+  assert.equal(calls, 1)
+  api.cancelDialog()                  // 进行中取消 —— 必须无效
+  assert.equal(api.dialogVisible.value, true)
+  release()
+  await first
+  assert.equal(calls, 1)
+  assert.equal(api.dialogVisible.value, false)
+}
+
+// ---------------- 陈旧性守卫:等待期间内容被改,响应作废不回写 ----------------
+{
+  let release
+  const pending = new Promise(resolve => { release = resolve })
+  const { content, api } = setup({
+    segment: async () => { await pending; return { content: 'Stale.' } },
+  })
+  content.value = words(60)
+  api.openOverwrite()
+  const confirming = api.confirmDialog()
+  content.value = 'teacher typed something newer' // 30 秒等待期间老师又改了文本
+  release()
+  await confirming
+  assert.equal(content.value, 'teacher typed something newer', '内容中途被改 → 响应作废,绝不覆盖')
+  assert.equal(api.dialogVisible.value, false, '仍然关框,不让弹框卡住')
+}
+
+// ---------------- 组件卸载(effect scope 销毁)后到达的响应是 no-op ----------------
+{
+  let release
+  const pending = new Promise(resolve => { release = resolve })
+  const { content, api } = setup({
+    segment: async () => { await pending; return { content: 'Late.' } },
+  })
+  content.value = words(60)
+  api.openOverwrite()
+  const confirming = api.confirmDialog()
+  globalThis.__onDispose() // 模拟宿主组件在响应落地前已卸载
+  release()
+  await confirming
+  assert.equal(content.value, words(60), '销毁后到达的响应不得写 store')
+  assert.equal(api.dialogVisible.value, true, '销毁后不再触碰任何本地状态')
+}
+
+console.log('article auto-format flow OK')

+ 70 - 0
scripts/test-article-auto-format.mjs

@@ -0,0 +1,70 @@
+import assert from 'node:assert/strict'
+import { readFile } from 'node:fs/promises'
+import ts from 'typescript'
+
+const sourceUrl = new URL(
+  '../src/views/Editor/EnglishSpeaking/composables/articleAutoFormatRules.ts',
+  import.meta.url,
+)
+const compiled = ts.transpileModule(await readFile(sourceUrl, 'utf8'), {
+  compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
+}).outputText
+const {
+  countWords,
+  normalizeParagraphs,
+  hasOverlongParagraph,
+  shouldPromptOnPaste,
+  MAX_PARAGRAPH_WORDS,
+  PASTE_PROMPT_MIN_WORDS,
+} = await import(`data:text/javascript,${encodeURIComponent(compiled)}`)
+
+/** 生成 n 个单词的文本 */
+const words = n => Array.from({ length: n }, (_, i) => `w${i}`).join(' ')
+
+// ---------------- countWords ----------------
+assert.equal(countWords(''), 0)
+assert.equal(countWords('   \n\n  '), 0)
+assert.equal(countWords('one two   three'), 3)
+assert.equal(countWords('  leading and trailing  '), 3)
+assert.equal(countWords(words(300)), 300)
+
+// ---------------- normalizeParagraphs ----------------
+// 与后端 split_paragraphs 同规则:单 \n 与空行皆为段界
+assert.equal(normalizeParagraphs('A one.\n\nB two.'), 'A one.\n\nB two.')
+assert.equal(normalizeParagraphs('A one.\nB two.'), 'A one.\n\nB two.')
+assert.equal(normalizeParagraphs('A one.\n\n\n\n   \nB two.'), 'A one.\n\nB two.')
+assert.equal(normalizeParagraphs('  A   one    here.  '), 'A one here.')
+assert.equal(normalizeParagraphs(''), '')
+assert.equal(normalizeParagraphs('   \n\n  '), '')
+// 幂等:归一化结果再归一化不变(会回写 textarea,必须稳定)
+assert.equal(normalizeParagraphs(normalizeParagraphs('A.\nB.\n\n\nC.')), 'A.\n\nB.\n\nC.')
+
+// ---------------- hasOverlongParagraph(299 / 300 / 301 边界)----------------
+assert.equal(MAX_PARAGRAPH_WORDS, 300)
+assert.equal(hasOverlongParagraph(''), false)
+assert.equal(hasOverlongParagraph(words(299)), false)
+assert.equal(hasOverlongParagraph(words(300)), false)
+assert.equal(hasOverlongParagraph(words(301)), true)
+// 多段:只要有一段超标就 true
+assert.equal(hasOverlongParagraph(`${words(10)}\n\n${words(301)}`), true)
+assert.equal(hasOverlongParagraph(`${words(200)}\n\n${words(200)}`), false)
+// 单 \n 也是段界,故 200+200 不算一段 400
+assert.equal(hasOverlongParagraph(`${words(200)}\n${words(200)}`), false)
+
+// ---------------- shouldPromptOnPaste ----------------
+assert.equal(PASTE_PROMPT_MIN_WORDS, 50)
+// spec §3.2 step 1 只有两条:原内容 trim 后为空,且新内容 > 50 词。
+assert.equal(shouldPromptOnPaste('', words(60)), true)       // 空框粘贴长文 → 弹
+assert.equal(shouldPromptOnPaste('   ', words(51)), true)    // 纯空白算空框;51 词过阈值
+assert.equal(shouldPromptOnPaste('\n\n  \n', words(60)), true) // 只剩换行的框同样算空
+assert.equal(shouldPromptOnPaste('', words(50)), false)      // 恰好 50 词不弹(严格 >)
+assert.equal(shouldPromptOnPaste('', words(40)), false)      // 短文不弹
+assert.equal(shouldPromptOnPaste(words(300), words(400)), false) // 原文非空 → 不打断编辑
+assert.equal(shouldPromptOnPaste('x', words(400)), false)    // 哪怕只有一个字符也不算空框
+// 粘贴内容自带空行照样提示:带 \n\n ≠ 分得适合朗读(三段各 400 词就是反例)
+assert.equal(shouldPromptOnPaste('', `${words(30)}\n\n${words(30)}`), true)
+assert.equal(shouldPromptOnPaste('', `${words(400)}\n\n${words(400)}`), true)
+// enspeak 的 sentenceCount>=2 条件已弃用:无标点长文照样提示
+assert.equal(shouldPromptOnPaste('', words(60).replace(/\./g, '')), true)
+
+console.log('article auto-format rules OK')

+ 238 - 0
scripts/test-article-class-summary-auto.mjs

@@ -0,0 +1,238 @@
+/**
+ * 老师端班级总结的自动刷新(spec §8.2)
+ *
+ * 覆盖:班级状态指纹的口径;定时 tick 的四道闸门(重入 / 分页隐藏 / 无人完成 /
+ * 指纹未变);失败不记指纹于是下次 tick 重试;手动刷新同样更新指纹;计时器
+ * 间隔与卸载清理。手法承 test-article-auto-format-flow.mjs —— 转译 composable,
+ * 把 vue 与 service 换成桩。
+ */
+import assert from 'node:assert/strict'
+import { readFile } from 'node:fs/promises'
+import ts from 'typescript'
+
+const root = new URL('../', import.meta.url)
+const read = p => readFile(new URL(p, root), 'utf8')
+const transpile = source => ts.transpileModule(source, {
+  compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
+}).outputText
+
+let source = await read(
+  'src/views/Student/components/ArticleReadingClassPanel/useArticleClassSummary.ts',
+)
+source = source
+  .replace(
+    "import { ref, watch, onMounted, onUnmounted, type Ref } from 'vue'",
+    [
+      'const ref = value => ({ value })',
+      // watch 的 immediate 由测试自己触发(__watch()),好精确控制每一次 tick
+      'const watch = (_src, cb) => { globalThis.__watch = cb }',
+      'const onMounted = fn => { globalThis.__mounted = fn }',
+      'const onUnmounted = fn => { globalThis.__unmounted = fn }',
+    ].join('; '),
+  )
+  .replace(
+    /import \{\s*listArticleSessionsByConfig,\s*generateArticleClassSummary,\s*\} from '@\/views\/Editor\/EnglishSpeaking\/services\/articleReading'/,
+    [
+      'const listArticleSessionsByConfig = (...a) => globalThis.__list(...a)',
+      'const generateArticleClassSummary = (...a) => globalThis.__summary(...a)',
+    ].join('; '),
+  )
+
+const { useArticleClassSummary, classStateKey } = await import(
+  `data:text/javascript,${encodeURIComponent(transpile(source))}`
+)
+
+const flush = () => new Promise(resolve => setTimeout(resolve, 0))
+const row = (userId, status, overallScore = null) => ({
+  userId, name: userId, status, overallScore, dims: null, sessionId: 's-' + userId, completedAt: null,
+})
+
+/** 组件挂载:驱动 watch 的 immediate 那一次,并跑 onMounted */
+function setup({ rows = [], summary } = {}) {
+  globalThis.document = { hidden: false, addEventListener() {}, removeEventListener() {} }
+  globalThis.__summaryCalls = 0
+  globalThis.__list = async () => ({ summaries: current.rows })
+  globalThis.__summary = async () => {
+    globalThis.__summaryCalls += 1
+    if (summary) return summary()
+    return { summary: 'text-' + globalThis.__summaryCalls, generatedAt: 'now', fromCache: false, llmStatus: 'ok' }
+  }
+  const current = { rows }
+  const api = useArticleClassSummary({
+    configId: { value: 'cfg-1' },
+    studentArray: { value: [{ userid: 'u1', name: 'u1' }, { userid: 'u2', name: 'u2' }] },
+  })
+  globalThis.__mounted()
+  return { api, current, tick: async () => { globalThis.__watch(); await flush(); await flush() } }
+}
+
+function teardown() {
+  globalThis.__unmounted()
+}
+
+// ---------------- 指纹口径 ----------------
+{
+  const a = [row('u1', 'completed', 80), row('u2', 'reading')]
+  const b = [row('u2', 'reading'), row('u1', 'completed', 80)]
+  assert.equal(classStateKey('c', a), classStateKey('c', b), '与数组顺序无关')
+
+  assert.notEqual(
+    classStateKey('c', [row('u1', 'reading')]),
+    classStateKey('c', [row('u1', 'completed')]),
+    '状态变化要进指纹',
+  )
+  assert.notEqual(
+    classStateKey('c', [row('u1', 'completed', 80)]),
+    classStateKey('c', [row('u1', 'completed', 81)]),
+    '分数变化要进指纹(分数晚于 status 落地)',
+  )
+  assert.notEqual(
+    classStateKey('c', [row('u1', 'completed', null)]),
+    classStateKey('c', [row('u1', 'completed', 0)]),
+    'null 与 0 不可混同',
+  )
+  assert.notEqual(
+    classStateKey('cfg-1', a),
+    classStateKey('cfg-2', a),
+    'configId 进指纹 —— 换配置自动失效',
+  )
+}
+
+// ---------------- 闸门:全班都没开始 ----------------
+{
+  const { current, tick } = setup({ rows: [row('u1', 'not_started'), row('u2', 'not_started')] })
+  await tick()
+  assert.equal(globalThis.__summaryCalls, 0, '一人都没开始 → 不生成(只能写「未开始 N 人」)')
+
+  current.rows = [row('u1', 'reading'), row('u2', 'not_started')]
+  await tick()
+  assert.equal(globalThis.__summaryCalls, 1, '有人开始朗读就生成,不必等到有人完成')
+
+  current.rows = [row('u1', 'completed', 70), row('u2', 'not_started')]
+  await tick()
+  assert.equal(globalThis.__summaryCalls, 2, '同一人从进行中变完成 → 再刷新一次')
+  teardown()
+}
+
+// ---------------- 闸门:指纹未变 ----------------
+{
+  const { api, current, tick } = setup({ rows: [row('u1', 'completed', 70)] })
+  await tick()
+  assert.equal(globalThis.__summaryCalls, 1)
+  assert.equal(api.aiSummary.value, 'text-1')
+
+  await tick()
+  await tick()
+  assert.equal(globalThis.__summaryCalls, 1, '班级状态没变,定时 tick 不烧 LLM')
+  assert.equal(api.aiSummary.value, 'text-1', '措辞不会无故换掉')
+
+  current.rows = [row('u1', 'completed', 70), row('u2', 'completed', 90)]
+  await tick()
+  assert.equal(globalThis.__summaryCalls, 2, '有新完成者就刷新')
+  teardown()
+}
+
+// 分数晚于 status 落地:同一批人、只有分数补上,也算新东西
+{
+  const { current, tick } = setup({ rows: [row('u1', 'completed', null)] })
+  await tick()
+  assert.equal(globalThis.__summaryCalls, 1)
+  current.rows = [row('u1', 'completed', 76)]
+  await tick()
+  assert.equal(globalThis.__summaryCalls, 2, '分数回填后重算')
+  teardown()
+}
+
+// ---------------- 失败不记指纹 → 下次 tick 自动重试 ----------------
+{
+  let attempt = 0
+  const { api, tick } = setup({
+    rows: [row('u1', 'completed', 70)],
+    summary: () => {
+      attempt += 1
+      if (attempt === 1) throw new Error('boom')
+      return { summary: 'recovered', generatedAt: 'now', fromCache: false, llmStatus: 'ok' }
+    },
+  })
+  const originalError = console.error
+  console.error = () => {}
+  try {
+    await tick()
+    assert.equal(api.aiError.value, 'SUMMARY_FAILED')
+    assert.equal(api.aiSummary.value, '', '从未成功 → 正文仍为空,由弹框显示错误块')
+    await tick()
+  }
+  finally {
+    console.error = originalError
+  }
+  assert.equal(globalThis.__summaryCalls, 2, '失败不记指纹,下次 tick 重试')
+  assert.equal(api.aiSummary.value, 'recovered')
+  assert.equal(api.aiError.value, null)
+  teardown()
+}
+
+// ---------------- 手动刷新也更新指纹 ----------------
+{
+  const { api, tick } = setup({ rows: [row('u1', 'completed', 70)] })
+  await api.fetchClassSummary()
+  await api.refreshAISummary()
+  assert.equal(globalThis.__summaryCalls, 1, '手动点按钮照常生成')
+  await tick()
+  assert.equal(globalThis.__summaryCalls, 1, '紧接着的 tick 是 no-op,不必重置计时器')
+  teardown()
+}
+
+// ---------------- 分页隐藏时跳过,切回来立刻补 ----------------
+{
+  const listeners = {}
+  globalThis.document = {
+    hidden: true,
+    addEventListener: (type, fn) => { listeners[type] = fn },
+    removeEventListener: (type) => { delete listeners[type] },
+  }
+  globalThis.__summaryCalls = 0
+  globalThis.__list = async () => ({ summaries: [row('u1', 'completed', 70)] })
+  globalThis.__summary = async () => {
+    globalThis.__summaryCalls += 1
+    return { summary: 'x', generatedAt: 'now', fromCache: false, llmStatus: 'ok' }
+  }
+  useArticleClassSummary({
+    configId: { value: 'cfg-1' },
+    studentArray: { value: [{ userid: 'u1', name: 'u1' }] },
+  })
+  globalThis.__mounted()
+  globalThis.__watch()
+  await flush(); await flush()
+  assert.equal(globalThis.__summaryCalls, 0, '老师切走的分页不烧 LLM')
+
+  globalThis.document.hidden = false
+  listeners.visibilitychange()
+  await flush(); await flush()
+  assert.equal(globalThis.__summaryCalls, 1, '切回来立刻补一次,不必再等 60 秒')
+
+  globalThis.__unmounted()
+  assert.equal(listeners.visibilitychange, undefined, '卸载摘掉监听')
+}
+
+// ---------------- 计时器:间隔与清理 ----------------
+{
+  const originalSet = globalThis.setInterval
+  const originalClear = globalThis.clearInterval
+  const created = []
+  const cleared = []
+  globalThis.setInterval = (_fn, ms) => { created.push(ms); return { id: created.length } }
+  globalThis.clearInterval = handle => { cleared.push(handle) }
+  try {
+    const { api } = setup({ rows: [] })
+    assert.deepEqual(created, [60_000], '定时间隔 60 秒')
+    assert.ok(api.scheduleRefetch, 'WS 去抖刷新仍然对外暴露')
+    teardown()
+    assert.deepEqual(cleared, [{ id: 1 }], '卸载清掉定时器')
+  }
+  finally {
+    globalThis.setInterval = originalSet
+    globalThis.clearInterval = originalClear
+  }
+}
+
+console.log('✓ test-article-class-summary-auto: all assertions passed')

+ 106 - 0
scripts/test-article-errors.mjs

@@ -0,0 +1,106 @@
+import assert from 'node:assert/strict'
+import { readFile } from 'node:fs/promises'
+import ts from 'typescript'
+
+const sourceUrl = new URL('../src/views/Editor/EnglishSpeaking/services/articleErrors.ts', import.meta.url)
+let source = await readFile(sourceUrl, 'utf8')
+
+// `lang` 是 PPT 运行时的响应式对象;测试里换成 identity 桩,直接断言「返回的是哪个 key」,
+// 而不是断言译文 —— 译文归 test-article-reading-i18n.mjs 管,这里只管映射对不对。
+source = source
+  // 桩返回 key 本身,于是断言的是「映射到哪个 key」而不是译文。带占位符的那条例外:
+  // 它多挂一个 `{n}`,好让「段号有没有被填进去」也能被断言到。
+  .replace(
+    "import { lang } from '@/main'",
+    "const lang = new Proxy({}, { get: (_, k) => k === 'ssArticleErrDemoAudio' ? 'ssArticleErrDemoAudio:{n}' : String(k) })",
+  )
+  .replace(/import \{[^}]*\} from '\.\/articleReading'/, 'class ArticleApiError extends Error { constructor(m, s) { super(m); this.status = s } }')
+
+const compiled = ts.transpileModule(source, {
+  compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
+}).outputText
+const { friendlyArticleError, articleActiveCountFromError } = await import(`data:text/javascript,${encodeURIComponent(compiled)}`)
+
+const cases = [
+  ['Config not found', 'ssArticleErrConfigNotFound'],
+  ['Config is not an article config', 'ssArticleErrNotArticleConfig'],
+  ['Article content is empty', 'ssArticleErrContentEmpty'],
+  ['Session not found', 'ssArticleErrSessionNotFound'],
+  ['Paragraph not found', 'ssArticleErrParagraphNotFound'],
+  ['Session is abandoned', 'ssArticleSessionExpired'],
+  ['Session has ended', 'ssArticleErrSessionEnded'],
+  ['Report already generated', 'ssArticleErrReportGenerated'],
+  ['Segmentation failed', 'ssArticleSegmentFailed'],
+  ['Segmentation returned empty output', 'ssArticleSegmentFailed'],
+  ['content is required', 'ssArticleErrBadRequest'],
+  ['configId is required', 'ssArticleErrBadRequest'],
+  ['userId is required', 'ssArticleErrBadRequest'],
+  ['users is required', 'ssArticleErrBadRequest'],
+  ['users capped at 100', 'ssArticleErrBadRequest'],
+]
+
+for (const [detail, key] of cases) {
+  assert.equal(friendlyArticleError(new Error(detail)), key, `bare detail: ${detail}`)
+}
+
+// request() 万一没拆干净、或者别的 service 抛了带包装的 message,也要吃得下
+assert.equal(
+  friendlyArticleError(new Error('{"detail":"Session not found"}')),
+  'ssArticleErrSessionNotFound',
+  'raw JSON body',
+)
+assert.equal(
+  friendlyArticleError(new Error('[404] {"detail":"Config not found"}')),
+  'ssArticleErrConfigNotFound',
+  '@/services/speaking parse() 的 `[404] {...}` 形状',
+)
+
+// 409「有人在练」:人数照旧要能摘出来(弹框靠它),但它**不该**被当成错误文案 ——
+// 2026-07-24 起那是问句:handleApply 截住它去弹确认框(spec §4.3 B)。真漏到 toast 只能是
+// 别处误用,那就走通用兜底,绝不出现「暂时无法修改」这种已经不成立的话。
+assert.equal(
+  friendlyArticleError(new Error('Students are practising: 3')),
+  'ssArticleErrGeneric',
+  '409 不再映射成一句「无法修改」',
+)
+assert.equal(articleActiveCountFromError(new Error('Students are practising: 3')), 3)
+assert.equal(articleActiveCountFromError(new Error('Students are practising: 0')), 0)
+assert.equal(articleActiveCountFromError(new Error('Session not found')), null)
+assert.equal(articleActiveCountFromError(null), null)
+
+// 保存配置时整篇示范音频同步合成,失败的 detail 带着段号(`[2]` / `[1, 3]`)。
+// 段号是老师唯一能据以行动的信息,必须填进文案,不能只回一句笼统的失败。
+assert.equal(
+  friendlyArticleError(new Error('Demo audio synthesis failed: [2]')),
+  'ssArticleErrDemoAudio:2',
+  'single failing paragraph',
+)
+assert.equal(
+  friendlyArticleError(new Error('Demo audio synthesis failed: [1, 3]')),
+  'ssArticleErrDemoAudio:1、3',
+  'several failing paragraphs',
+)
+assert.equal(
+  friendlyArticleError(new Error('{"detail":"Demo audio synthesis failed: [4]"}')),
+  'ssArticleErrDemoAudio:4',
+  'wrapped in a JSON body',
+)
+
+// 兜底:绝不把原始内容漏出去
+const leaks = [
+  new Error('{"detail":"some brand new backend error"}'),
+  new Error('TypeError: Failed to fetch'),
+  new Error(''),
+  'a bare string',
+  null,
+  undefined,
+  { nope: true },
+]
+for (const cause of leaks) {
+  const out = friendlyArticleError(cause)
+  assert.equal(out, 'ssArticleErrGeneric', `fallback for ${JSON.stringify(String(cause))}`)
+  assert.ok(!out.includes('detail'), 'never leak the response body')
+  assert.ok(!out.includes('{'), 'never leak JSON')
+}
+
+console.log(`article errors: ${cases.length + 2} mappings + ${leaks.length} fallbacks OK`)

+ 26 - 0
scripts/test-article-reading-data.mjs

@@ -0,0 +1,26 @@
+import assert from 'node:assert/strict'
+import { readFile } from 'node:fs/promises'
+
+const unitId = 'SHEP-5-上-Unit2'
+const topicUrl = new URL('../src/views/Editor/EnglishSpeaking/data/topicDiscussionTasks.json', import.meta.url)
+const articleUrl = new URL('../src/views/Editor/EnglishSpeaking/data/articleReadingTasks.json', import.meta.url)
+const topics = JSON.parse(await readFile(topicUrl, 'utf8'))
+const articles = JSON.parse(await readFile(articleUrl, 'utf8'))
+
+assert.equal(topics[unitId].length, 4)
+assert.ok(topics[unitId].every(task => task.taskType === 'topic-discussion'))
+assert.equal(articles[unitId].length, 1)
+
+const article = articles[unitId][0]
+assert.equal(article.id, 'ar-unit2-zoo')
+assert.equal(article.taskType, 'article-reading')
+assert.equal(article.titleEn, 'A Day at the Zoo')
+assert.equal(article.durationMinutes, 10)
+// 标题只住在 titleEn 里,**不再重复进正文**(2026-07-26 起)——
+// 正文第一段就是第一段朗读内容,否则学生第一次录音录的是标题。
+assert.ok(!article.content.startsWith(article.titleEn), 'title must not be duplicated into content')
+assert.ok(article.content.startsWith('Last Sunday'))
+assert.ok(article.content.endsWith('I learned many things about animals.'))
+// 首尾由上面两条断言守住;这条只兜住"中段被删",故用宽松下限而非精确值——
+// 正文允许正常修订,但不该缩水到只剩几段。(enspeak 原文实测 829 字符。)
+assert.ok(article.content.length > 700, 'full demo article must not be truncated')

+ 900 - 0
scripts/test-article-reading-engine.mjs

@@ -0,0 +1,900 @@
+import assert from 'node:assert/strict'
+import { readFile } from 'node:fs/promises'
+import ts from 'typescript'
+
+const sourceUrl = new URL('../src/views/Editor/EnglishSpeaking/composables/useArticleReadingEngine.ts', import.meta.url)
+let source = await readFile(sourceUrl, 'utf8')
+
+// Vue 运行时替换为最小 shim;watch 只需支持 getter + 立即回调语义
+source = source.replace(
+  "import { ref, computed, watch, onUnmounted, toValue, type MaybeRefOrGetter } from 'vue'",
+  `
+const __watchers = []
+const ref = value => {
+  const r = { _v: value, get value() { return this._v }, set value(next) { this._v = next; __runWatchers() } }
+  return r
+}
+const computed = getter => ({ get value() { return getter() } })
+const watch = (getter, cb) => { __watchers.push({ getter, cb, last: getter() }) }
+const onUnmounted = () => {}
+const toValue = v => (typeof v === 'function' ? v() : (v && typeof v === 'object' && 'value' in v ? v.value : v))
+globalThis.__runWatchers = () => {
+  for (const w of __watchers) {
+    const next = w.getter()
+    if (next !== w.last) { const prev = w.last; w.last = next; w.cb(next, prev) }
+  }
+}
+`,
+)
+
+// 运行时依赖全部替换为注入桩;测试永远传完整 overrides,这些默认值不会被执行
+source = source
+  .replace("import { useAudioRecorder } from './useAudioRecorder'", 'const useAudioRecorder = () => globalThis.__deps.recorder')
+  .replace("import { useAudioPlayer } from './useAudioPlayer'", 'const useAudioPlayer = () => globalThis.__deps.player')
+  .replace(
+    /import \{[\s\S]*?\} from '\.\.\/services\/articleReading'/,
+    `
+const createArticleSession = (...a) => globalThis.__deps.createSession(...a)
+const getArticleReport = (...a) => globalThis.__deps.getReport(...a)
+const completeArticleSession = (...a) => globalThis.__deps.completeSession(...a)
+const getArticleDemoAudio = (...a) => globalThis.__deps.getDemoAudio(...a)
+const abandonArticleSession = (...a) => globalThis.__deps.abandonSession(...a)
+`,
+  )
+  .replace(
+    /import \{[\s\S]*?\} from '\.\.\/services\/articleErrors'/,
+    `
+const friendlyArticleError = cause => {
+  const raw = cause instanceof Error ? cause.message : String(cause ?? '')
+  return raw === 'Session not found' ? '__localized_session_not_found__' : '__localized_generic__'
+}
+const isArticleSessionGone = cause => cause?.status === 409
+`,
+  )
+  .replace(
+    "import { openArticleParagraphStream, type ArticleParagraphStream } from '../services/articleReadingStream'",
+    'const openArticleParagraphStream = (...a) => globalThis.__deps.createStream(...a)',
+  )
+  .replace(
+    "import { isFinalReportReady, pollDelayMs } from '../preview/articleReadingModel'",
+    `
+const isFinalReportReady = r => !r.paragraphs.some(p => p.status === 'generating')
+  && (r.overallStatus === 'completed' || r.overallStatus === 'failed' || r.overallStatus === null)
+const pollDelayMs = attempt => Math.min(8000, 1000 * 2 ** Math.max(0, attempt))
+`,
+  )
+
+const compiled = ts.transpileModule(source, {
+  compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
+}).outputText
+const mod = await import(`data:text/javascript,${encodeURIComponent(compiled)}`)
+
+// ---------------- fakes ----------------
+
+function makeRecorder(sampleRate = 48000) {
+  const rec = {
+    recordingDuration: { _v: 0, get value() { return this._v }, set value(v) { this._v = v; globalThis.__runWatchers() } },
+    sampleRate: { value: sampleRate },
+    onChunk: { value: null },
+    started: 0,
+    cleaned: 0,
+    async startRecording() { this.started += 1 },
+    async stopRecording() { return { wav: true, size: 1 } },
+    // 真实实现在这一步停 mediaStream track、关 AudioContext、断开 worklet、清时长 interval。
+    // 不拆的后果不是泄漏一点内存:孤儿 worklet 会继续把音频灌进下一段录音。
+    cleanup() { this.cleaned += 1 },
+  }
+  return rec
+}
+
+function makePlayer() {
+  return {
+    playingId: { value: null },
+    loadingId: { value: null },
+    errorId: { value: null },
+    plays: [],
+    // lazy-url 的 URL 解析发生在 play() 内部(与真 player 一致):示范音频那条
+    // 失败路径要经过这里,引擎的 error.value 才立得起来
+    async play(id, source) {
+      this.plays.push({ id, source })
+      this.loadingId.value = id
+      if (source.kind === 'lazy-url') {
+        try { await source.resolve(new AbortController().signal) }
+        catch {
+          this.loadingId.value = null
+          this.errorId.value = id
+          return
+        }
+      }
+      this.loadingId.value = null
+      this.playingId.value = id
+    },
+    stop() { this.playingId.value = null; this.loadingId.value = null },
+  }
+}
+
+function makeStream() {
+  const stream = {
+    chunks: [],
+    stopped: false,
+    aborted: null,
+    resolveAck: null,
+    pushChunk(c) { this.chunks.push(c) },
+    stop() { this.stopped = true; return new Promise(res => { this.resolveAck = res }) },
+    abort(reason) { this.aborted = reason },
+  }
+  return stream
+}
+
+/** 手控时钟:测试自己决定何时推进 timeout/interval。 */
+function makeClock() {
+  let nextId = 1
+  let nowMs = Date.UTC(2026, 0, 1, 0, 0, 0)
+  const timeouts = new Map()
+  const intervals = new Map()
+  return {
+    timeouts,
+    intervals,
+    now() { return nowMs },
+    /** 推进墙上时钟(秒)。不自动触发 interval —— 用 tickInterval 显式驱动。 */
+    advance(seconds) { nowMs += seconds * 1000 },
+    setTimeout(fn, delay) { const id = nextId++; timeouts.set(id, { fn, delay }); return id },
+    clearTimeout(id) { timeouts.delete(id) },
+    setInterval(fn, delay) { const id = nextId++; intervals.set(id, { fn, delay }); return id },
+    clearInterval(id) { intervals.delete(id) },
+    async runTimeouts() {
+      const pending = [...timeouts.entries()]
+      timeouts.clear()
+      for (const [, t] of pending) { t.fn(); await flush() }
+    },
+    async tickInterval(times = 1) {
+      for (let i = 0; i < times; i++) {
+        for (const [, t] of [...intervals.entries()]) t.fn()
+        await flush()
+      }
+    },
+  }
+}
+
+function makeDoc(hidden = false) {
+  return {
+    hidden,
+    listeners: {},
+    addEventListener(type, fn) { (this.listeners[type] ||= []).push(fn) },
+    removeEventListener(type, fn) {
+      this.listeners[type] = (this.listeners[type] || []).filter(f => f !== fn)
+    },
+    fire(type) { for (const fn of [...(this.listeners[type] || [])]) fn() },
+  }
+}
+
+const flush = () => new Promise(resolve => setImmediate(resolve))
+
+function paragraphs(statuses) {
+  return statuses.map((status, idx) => ({
+    idx, text: `p${idx}`, status, audioUrl: null, overallScore: null, dims: null, words: [],
+  }))
+}
+
+function reportOf(statuses, overallStatus = null, extra = {}) {
+  return {
+    sessionId: 'sess-1', title: 'T', status: 'reading', totalDurationSeconds: null,
+    // §4.4:绝对截止时刻。默认 null = 不限时,既有用例的行为因此不变。
+    expiresAt: null,
+    paragraphs: paragraphs(statuses), overall: null, overallStatus, ...extra,
+  }
+}
+
+function setup({
+  statuses = ['pending', 'pending'], reports = [],
+  showReport = true, durationMinutes = 1, expiresAt = null, userId = 'u1', abandonSession, completeSession,
+  getReport, createSession, getDemoAudio, onSessionAbandoned, doc, clock,
+} = {}) {
+  const recorder = makeRecorder()
+  const player = makePlayer()
+  const streams = []
+  const calls = { complete: 0, getReport: 0, demo: 0, abandon: 0 }
+  let reportQueue = [...reports]
+
+  const deps = {
+    recorder,
+    player,
+    document: doc ?? makeDoc(),
+    setTimeout: (...a) => clock.setTimeout(...a),
+    clearTimeout: id => clock.clearTimeout(id),
+    setInterval: (...a) => clock.setInterval(...a),
+    clearInterval: id => clock.clearInterval(id),
+    now: () => clock.now(),
+    async createSession(...a) {
+      if (createSession) return createSession(...a)
+      return reportOf(statuses, null, { expiresAt })
+    },
+    async getReport() {
+      calls.getReport += 1
+      if (getReport) return getReport()
+      return reportQueue.length > 1 ? reportQueue.shift() : (reportQueue[0] ?? reportOf(statuses, null, { expiresAt }))
+    },
+    completeSession: completeSession ?? (async () => { calls.complete += 1 }),
+    async getDemoAudio(...a) {
+      calls.demo += 1
+      if (getDemoAudio) return getDemoAudio(...a)
+      return { url: 'https://s3/demo.mp3' }
+    },
+    abandonSession: abandonSession ?? (async id => { calls.abandon += 1; return { sessionId: id, status: 'abandoned' } }),
+    createStream(opts) { const s = makeStream(); s.opts = opts; streams.push(s); return s },
+  }
+  globalThis.__deps = deps
+
+  const completed = []
+  const engine = mod.useArticleReadingEngine(
+    {
+      configId: 'c1', userId, durationMinutes, showReport,
+      onSessionCompleted: id => completed.push(id),
+      onSessionAbandoned,
+    },
+    deps,
+  )
+  return { engine, recorder, player, streams, calls, completed, deps, setReports: r => { reportQueue = [...r] } }
+}
+
+// ---- 1. stream 建立前的 PCM 不丢;handshake 用 recorder 真实 sampleRate ----
+{
+  const clock = makeClock()
+  const { engine, recorder, streams } = setup({ clock })
+  await engine.startSession()
+  await clock.runTimeouts()
+
+  const startPromise = engine.startRecording()
+  const early = new Uint8Array([7, 7]).buffer
+  recorder.onChunk.value?.(early)
+  await startPromise
+
+  assert.equal(streams.length, 1)
+  assert.equal(streams[0].opts.sampleRate, 48000, 'handshake must use the recorder sample rate')
+  assert.equal(streams[0].chunks[0], early, 'PCM produced before the stream existed must still be first')
+}
+
+// ---- 2. finishRecording:先取 WAV 再 stop;ack 后立刻前进,不等分数 ----
+{
+  const clock = makeClock()
+  const { engine, streams } = setup({ clock })
+  await engine.startSession()
+  await clock.runTimeouts()
+  await engine.startRecording()
+
+  const finishing = engine.finishRecording()
+  await flush()
+  assert.equal(streams[0].stopped, true, 'stop must be sent after the WAV blob is taken')
+  assert.equal(engine.recordingState.value, 'processing')
+
+  streams[0].resolveAck({ type: 'ack', status: 'generating', attempt: 1, truncated: false })
+  await finishing
+
+  assert.equal(engine.currentParagraphIdx.value, 1, 'must advance on ack without waiting for scores')
+  assert.ok(engine.localAudioByParagraph.value.get(0), 'own recording must be replayable immediately')
+}
+
+// ---- 3. 单段 180s 只停当前段;练习计时归零才 complete ----
+{
+  const clock = makeClock()
+  const { engine, recorder, streams, calls } = setup({ clock })
+  await engine.startSession()
+  await clock.runTimeouts()
+  await engine.startRecording()
+
+  recorder.recordingDuration.value = 180
+  await flush()
+  streams[0].resolveAck?.({ type: 'ack', status: 'generating', attempt: 1, truncated: false })
+  await flush()
+  assert.equal(calls.complete, 0, '180s paragraph cap must not complete the whole practice')
+}
+// ---- 3a. 计时器按时间戳算:后台标签页节流也不会少算(spec §9.2.3)----
+{
+  const clock = makeClock()
+  const expiresAt = new Date(clock.now() + 600_000).toISOString()  // 10 分钟后
+  const { engine } = setup({ clock, expiresAt, durationMinutes: 10 })
+  await engine.startSession()
+  await clock.runTimeouts()
+  assert.equal(engine.elapsedPracticeSeconds.value, 0, 'count-up timer starts at zero')
+
+  // 墙上时钟走了 300 秒,但 interval 只被浏览器放行了一次
+  clock.advance(300)
+  await clock.tickInterval(1)
+  assert.equal(engine.elapsedPracticeSeconds.value, 300,
+    'tick accumulation would say 1; only a timestamp reading says 300')
+}
+
+// ---- 3b. 到点:冻结在上限,且绝不发 complete ----
+{
+  const clock = makeClock()
+  const expiresAt = new Date(clock.now() + 600_000).toISOString()
+  const { engine, calls } = setup({ clock, expiresAt, durationMinutes: 10 })
+  await engine.startSession()
+  await clock.runTimeouts()
+
+  clock.advance(700)               // 越过截止时刻
+  await clock.tickInterval(1)
+  assert.equal(engine.elapsedPracticeSeconds.value, 600, 'must freeze at the cap, not overshoot')
+  assert.equal(calls.complete, 0, 'the deadline belongs to the backend; the FE never completes')
+}
+
+// ---- 3c. 到点只问一次后端,不重复 ----
+{
+  const clock = makeClock()
+  const expiresAt = new Date(clock.now() + 600_000).toISOString()
+  const { engine, calls } = setup({
+    clock, expiresAt, durationMinutes: 10,
+    getReport: async () => reportOf(['completed', 'completed'], 'completed', { expiresAt, status: 'completed' }),
+  })
+  await engine.startSession()
+  await clock.runTimeouts()
+  const before = calls.getReport
+
+  clock.advance(700)
+  await clock.tickInterval(1)
+  await clock.runTimeouts()
+  assert.equal(calls.getReport, before + 1, 'exactly one query at the local deadline')
+
+  await clock.tickInterval(3)
+  await clock.runTimeouts()
+  assert.equal(calls.getReport, before + 1, 'and only one — the interval is already stopped')
+}
+
+// ---- 3d. 没有截止时刻(不限时):不起 interval,不显示计时 ----
+{
+  const clock = makeClock()
+  const { engine } = setup({ clock, expiresAt: null })
+  await engine.startSession()
+  await clock.runTimeouts()
+  assert.equal(engine.elapsedPracticeSeconds.value, null, 'no deadline means no timer to show')
+  assert.equal(clock.intervals.size, 0, 'no interval may be started without a deadline')
+}
+
+// ---- 3e. 后端单方面结束,showReport 开:转结果页流程(spec §9.2.3 第 2 条)----
+{
+  const clock = makeClock()
+  const expiresAt = new Date(clock.now() + 600_000).toISOString()
+  const { engine, completed } = setup({
+    clock, expiresAt, durationMinutes: 10, showReport: true,
+    getReport: async () => reportOf(['completed', 'completed'], 'generating', { expiresAt, status: 'completed' }),
+  })
+  await engine.startSession()
+  await clock.runTimeouts()
+
+  clock.advance(700)
+  await clock.tickInterval(1)
+  await clock.runTimeouts()
+
+  assert.equal(engine.stage.value, 'report-loading', 'a server-side end must open the report gate')
+  assert.deepEqual(completed, ['sess-1'], 'the teacher must still get the broadcast')
+}
+
+// ---- 3f. 后端单方面结束,showReport 关:留在练习页 + 合上只读锁(§9.2.1)----
+{
+  const clock = makeClock()
+  const expiresAt = new Date(clock.now() + 600_000).toISOString()
+  const { engine } = setup({
+    clock, expiresAt, durationMinutes: 10, showReport: false,
+    getReport: async () => reportOf(['completed', 'completed'], 'completed', { expiresAt, status: 'completed' }),
+  })
+  await engine.startSession()
+  await clock.runTimeouts()
+
+  clock.advance(700)
+  await clock.tickInterval(1)
+  await clock.runTimeouts()
+
+  assert.equal(engine.stage.value, 'reading', 'hidden reports keep the student on the practice page')
+  assert.equal(engine.sessionEnded.value, true, 'the read-only lock must engage')
+  assert.equal(engine.error.value, null, 'a server-side end is not an error')
+}
+
+// ---- 3g. 本地已 complete 过:后端的结束信号不得再广播一次 ----
+{
+  const clock = makeClock()
+  const expiresAt = new Date(clock.now() + 600_000).toISOString()
+  const { engine, completed } = setup({
+    clock, expiresAt, durationMinutes: 10, showReport: true,
+    getReport: async () => reportOf(['completed', 'completed'], 'completed', { expiresAt, status: 'completed' }),
+  })
+  await engine.startSession()
+  await clock.runTimeouts()
+  await engine.completeAndShowReport()
+  await clock.runTimeouts()
+
+  clock.advance(700)
+  await clock.tickInterval(1)
+  await clock.runTimeouts()
+
+  assert.deepEqual(completed, ['sess-1'], 'exactly one completion broadcast')
+}
+
+// ---- 3h. resume 重启显示计时器,算出的是真实已用时长 ----
+{
+  const clock = makeClock()
+  // 截止时刻在 4 分钟后,配置 10 分钟 → 已经过了 6 分钟
+  const expiresAt = new Date(clock.now() + 240_000).toISOString()
+  const { engine } = setup({ clock, expiresAt, durationMinutes: 10 })
+
+  engine.resumeFromReport(reportOf(['pending', 'pending'], null, { expiresAt }))
+  assert.equal(engine.elapsedPracticeSeconds.value, 360,
+    'resume must show real elapsed time, not reset to zero')
+}
+
+// ---- 4. 后台标签页不发 GET;回到前台继续;退避不因隐藏而推进 ----
+{
+  const clock = makeClock()
+  const doc = makeDoc(true)
+  const { engine, calls } = setup({ clock, doc, statuses: ['generating'], reports: [reportOf(['generating'])] })
+  await engine.startSession()
+  await clock.runTimeouts()
+  assert.equal(calls.getReport, 0, 'hidden tab must not issue GET')
+
+  doc.hidden = false
+  doc.fire('visibilitychange')
+  await clock.runTimeouts()
+  assert.ok(calls.getReport >= 1, 'returning to foreground must resume polling')
+}
+{
+  // 退避序列 1s/2s/4s/8s 封顶
+  assert.equal(Math.min(8000, 1000 * 2 ** 0), 1000)
+  assert.equal(Math.min(8000, 1000 * 2 ** 5), 8000)
+}
+
+// ---- 5. overallStatus=null 且无 generating 段可进 report;ready 前保持 report-loading ----
+{
+  const clock = makeClock()
+  const { engine } = setup({
+    clock,
+    statuses: ['completed'],
+    reports: [reportOf(['generating'], null), reportOf(['completed'], null)],
+  })
+  await engine.startSession()
+  await clock.runTimeouts()
+
+  await engine.completeAndShowReport()
+  await clock.runTimeouts()
+  assert.equal(engine.stage.value, 'report-loading', 'must not reveal a partial report while generating')
+
+  await clock.runTimeouts()
+  assert.equal(engine.stage.value, 'report')
+}
+
+// ---- 5b. 段落全 completed 但 overallStatus='generating'(真实 complete 窗口):
+//         必须持续轮询直到 overall 落定,不得卡在 report-loading ----
+{
+  const clock = makeClock()
+  const { engine, calls } = setup({
+    clock,
+    statuses: ['completed'],
+    reports: [
+      reportOf(['completed'], 'generating'),
+      reportOf(['completed'], 'generating'),
+      reportOf(['completed'], 'completed'),
+    ],
+  })
+  await engine.startSession()
+  await clock.runTimeouts()
+
+  await engine.completeAndShowReport()
+  await clock.runTimeouts()
+  assert.equal(engine.stage.value, 'report-loading', 'overallStatus=generating must keep the loading gate closed')
+
+  const midGets = calls.getReport
+  await clock.runTimeouts()
+  assert.ok(calls.getReport > midGets, 'must keep polling while overallStatus is still generating')
+
+  await clock.runTimeouts()
+  assert.equal(engine.stage.value, 'report', 'report must appear once overallStatus settles to completed')
+}
+
+// ---- 6. 全段评完静默 complete 一次,stage 仍是 reading;随后看报告不再 POST ----
+{
+  const clock = makeClock()
+  const { engine, calls, completed } = setup({
+    clock,
+    statuses: ['completed'],
+    reports: [reportOf(['completed'], 'completed')],
+  })
+  await engine.startSession()
+  await clock.runTimeouts()
+  await engine.completeAndShowReport()
+  await clock.runTimeouts()
+
+  const afterFirst = calls.complete
+  assert.equal(afterFirst, 1, 'completion must be issued exactly once')
+  assert.equal(completed.length, 1, 'onSessionCompleted must fire once')
+
+  await engine.completeAndShowReport()
+  await clock.runTimeouts()
+  assert.equal(calls.complete, 1, 'viewing the report again must not re-POST complete')
+}
+
+// ---- 7. POST 之前抓到的 report 不得满足最终 gate ----
+{
+  const clock = makeClock()
+  const { engine, calls } = setup({
+    clock,
+    statuses: ['completed'],
+    reports: [reportOf(['completed'], null)],
+  })
+  await engine.startSession()
+  await clock.runTimeouts()
+  // 没有 generating 段就不启动轮询,所以此刻静默预热还没有机会跑
+  assert.equal(calls.complete, 0)
+  assert.equal(calls.getReport, 0, 'a report fetched before the POST cannot satisfy the final gate')
+
+  await engine.completeAndShowReport()
+  await clock.runTimeouts()
+  assert.equal(engine.stage.value, 'report')
+  assert.ok(calls.getReport >= 1, 'at least one GET must be issued after the completion POST')
+  assert.equal(calls.complete, 1, 'completion is issued exactly once')
+}
+
+// ---- 8. 取消录音:abort 流、回到 idle、不产生评分任务 ----
+{
+  const clock = makeClock()
+  const { engine, streams } = setup({ clock })
+  await engine.startSession()
+  await clock.runTimeouts()
+  await engine.startRecording()
+
+  await engine.cancelRecording()
+
+  assert.equal(streams[0].stopped, false, 'cancel must not send stop, so no scoring task is created')
+  assert.match(streams[0].aborted, /cancel/i)
+  assert.equal(engine.recordingState.value, 'idle')
+  assert.equal(engine.currentParagraphIdx.value, 0, 'cancel must not advance to the next paragraph')
+  assert.equal(engine.localAudioByParagraph.value.size, 0, 'a cancelled attempt leaves no replayable audio')
+}
+
+// ---- showReport=false:completed 的 session 不进结果页,留在练习页 ----
+{
+  const { engine } = setup({ showReport: false })
+  engine.resumeFromReport({ ...reportOf(['completed'], 'completed'), status: 'completed' })
+  assert.equal(engine.stage.value, 'reading', 'showReport 关闭时 completed 也留在 reading')
+  assert.equal(engine.sessionEnded.value, true, 'sessionEnded 仍为 true —— 只读锁靠它')
+}
+
+// ---- showReport=true:completed 的 session 照旧进结果页 ----
+{
+  const { engine } = setup({ showReport: true })
+  engine.resumeFromReport({ ...reportOf(['completed'], 'completed'), status: 'completed' })
+  assert.equal(engine.stage.value, 'report', 'showReport 打开时行为不变')
+}
+
+// ---- restartSession:先 abandon 再回起始页 ----
+{
+  const abandoned = []
+  const { engine } = setup({
+    abandonSession: async id => { abandoned.push(id); return { sessionId: id, status: 'abandoned' } },
+  })
+  engine.resumeFromReport(reportOf(['pending', 'pending']))
+  assert.equal(engine.stage.value, 'reading')
+
+  const ok = await engine.restartSession()
+  assert.equal(ok, true)
+  assert.deepEqual(abandoned, ['sess-1'], '把旧 session 送去 abandon')
+  assert.equal(engine.stage.value, 'ready', '回起始页')
+  assert.equal(engine.report.value, null, 'report 已清空')
+}
+
+// ---- restartSession 失败:不重置,返回 false,旧 session 仍然有效 ----
+{
+  const { engine } = setup({
+    abandonSession: async () => { throw new Error('network down') },
+  })
+  engine.resumeFromReport(reportOf(['pending', 'pending']))
+
+  const ok = await engine.restartSession()
+  assert.equal(ok, false)
+  assert.equal(engine.stage.value, 'reading', '失败不能把学生踢回起始页')
+  assert.equal(engine.report.value?.sessionId, 'sess-1', '旧 session 仍在')
+  assert.equal(engine.error.value, null, '失败只回 false,文案由调用方决定')
+}
+
+// ---- 录音中「重新开始」:录音器必须真的拆掉 ----
+// 只摘 onChunk 不够:mediaStream track、AudioContext、worklet、时长 interval 都还活着,
+// 孤儿 worklet 会继续把音频灌进下一段录音(上传的 WAV 与 WS 流都成了两套采集图交织)。
+{
+  const clock = makeClock()
+  const { engine, recorder, streams } = setup({ clock })
+  await engine.startSession()
+  await clock.runTimeouts()
+  await engine.startRecording()
+  assert.equal(recorder.cleaned, 0, '录音进行中,还没到拆的时候')
+
+  const ok = await engine.restartSession()
+  assert.equal(ok, true)
+  assert.equal(recorder.cleaned, 1, '重新开始必须拆掉录音器,不能只摘 onChunk')
+  assert.equal(recorder.onChunk.value, null, 'onChunk 也要摘')
+  assert.ok(streams[0].aborted, 'WS 流一并中止')
+  assert.equal(engine.stage.value, 'ready')
+}
+
+// ---- 「重新开始」成功后广播作废,老师端才会把「已完成」刷回「未开始」----
+{
+  const abandonedBroadcasts = []
+  const { engine } = setup({
+    onSessionAbandoned: id => abandonedBroadcasts.push(id),
+  })
+  engine.resumeFromReport(reportOf(['pending', 'pending']))
+  await engine.restartSession()
+  assert.deepEqual(abandonedBroadcasts, ['sess-1'], '成功作废要通知调用方去广播')
+}
+
+// ---- 作废失败不广播 ----
+{
+  const abandonedBroadcasts = []
+  const { engine } = setup({
+    abandonSession: async () => { throw new Error('network down') },
+    onSessionAbandoned: id => abandonedBroadcasts.push(id),
+  })
+  engine.resumeFromReport(reportOf(['pending', 'pending']))
+  await engine.restartSession()
+  assert.deepEqual(abandonedBroadcasts, [], '没作废成功就不能报「这一轮不算了」')
+}
+
+console.log('article reading engine: all cases passed')
+
+// ---- 错误本地化:每个出口都是本地化文案,绝不是响应体(spec §9.2.5 A1)----
+
+const LEAK = 'Session not found'
+
+// 1. startSession
+{
+  const clock = makeClock()
+  const { engine } = setup({ clock, createSession: async () => { throw new Error(LEAK) } })
+  await engine.startSession()
+  assert.equal(engine.error.value, '__localized_session_not_found__',
+    'a failed start must not show the backend detail')
+}
+
+// 5. pollOnce
+{
+  const clock = makeClock()
+  const { engine } = setup({
+    clock,
+    statuses: ['generating'],
+    getReport: async () => { throw new Error(LEAK) },
+  })
+  await engine.startSession()
+  await clock.runTimeouts()
+  assert.equal(engine.error.value, '__localized_session_not_found__')
+}
+
+// 6. playDemoAudio
+{
+  const clock = makeClock()
+  // 必须经 setup 注入:引擎把 overrides 展开进一个**新对象**,
+  // 构造之后再改外面那个 deps 是改不到它的。
+  const { engine } = setup({ clock, getDemoAudio: async () => { throw new Error(LEAK) } })
+  await engine.startSession()
+  await clock.runTimeouts()
+  await engine.playDemoAudio(0)
+  assert.equal(engine.error.value, '__localized_session_not_found__')
+}
+
+// 4. completeAndShowReport
+{
+  const clock = makeClock()
+  const { engine } = setup({ clock, completeSession: async () => { throw new Error(LEAK) } })
+  await engine.startSession()
+  await clock.runTimeouts()
+  await engine.completeAndShowReport()
+  assert.equal(engine.error.value, '__localized_session_not_found__')
+}
+
+// 2. startRecording(麦克风;不是后端 detail → 走兜底)
+{
+  const clock = makeClock()
+  const { engine, recorder } = setup({ clock })
+  await engine.startSession()
+  await clock.runTimeouts()
+  recorder.startRecording = async () => { throw new Error('NotAllowedError') }
+  await engine.startRecording()
+  assert.equal(engine.error.value, '__localized_generic__',
+    'an unmapped cause must fall back, never surface raw')
+}
+
+// 409 的分支逻辑保留(spec §9.2.5 第 4 条),同时文案也本地化
+{
+  const clock = makeClock()
+  const gone = Object.assign(new Error('Session is abandoned'), { status: 409 })
+  const { engine } = setup({ clock, completeSession: async () => { throw gone } })
+  await engine.startSession()
+  await clock.runTimeouts()
+  await engine.completeAndShowReport()
+  assert.equal(engine.sessionInvalid.value, true, '409 must still flag sessionInvalid')
+  assert.ok(!String(engine.error.value).includes('{'), 'never any JSON in user-facing copy')
+}
+
+console.log('article reading engine: error localization cases passed')
+
+// ---- 轮询失败上限(spec §9.2.5 待办 Unsure-1)----
+
+// 后端持续不应答 → 敲满上限就停手,不再每 8 秒一次敲到学生关页面
+{
+  const clock = makeClock()
+  const { engine, calls } = setup({
+    clock,
+    statuses: ['generating'],
+    getReport: async () => { throw new Error('boom') },
+  })
+  await engine.startSession()
+  for (let i = 0; i < 30; i++) await clock.runTimeouts()
+
+  assert.equal(calls.getReport, 8, 'must give up after MAX_POLL_FAILURES consecutive failures')
+  assert.equal(clock.timeouts.size, 0, 'no timer may be left armed after giving up')
+  assert.ok(engine.error.value, 'the banner must stay so the student knows why it stopped')
+}
+
+// 中途成功一次 → 失败计数归零,健康的长轮询不会被误掐
+{
+  const clock = makeClock()
+  let n = 0
+  const clockRef = clock
+  const { engine, calls } = setup({
+    clock: clockRef,
+    statuses: ['generating'],
+    getReport: async () => {
+      n += 1
+      // 前 5 次失败,第 6 次成功(仍 generating),之后继续失败
+      if (n === 6) return reportOf(['generating'])
+      throw new Error('boom')
+    },
+  })
+  await engine.startSession()
+  for (let i = 0; i < 40; i++) await clock.runTimeouts()
+
+  // 5 次失败 + 1 次成功(归零)+ 8 次失败 = 14
+  assert.equal(calls.getReport, 14, 'a successful response must reset the failure budget')
+}
+
+// 停手之后,新一轮(schedulePoll(0))要拿到新的失败预算
+{
+  const clock = makeClock()
+  const { engine, calls } = setup({
+    clock,
+    statuses: ['generating'],
+    getReport: async () => { throw new Error('boom') },
+  })
+  await engine.startSession()
+  for (let i = 0; i < 30; i++) await clock.runTimeouts()
+  assert.equal(calls.getReport, 8)
+
+  // 学生做了点什么(录完一段 / 点重试)→ 重新起一轮
+  engine.resumeFromReport(reportOf(['generating']))
+  for (let i = 0; i < 30; i++) await clock.runTimeouts()
+  assert.equal(calls.getReport, 16, 'a fresh chain must get a fresh budget, not stop on failure #1')
+}
+
+console.log('article reading engine: poll failure cap cases passed')
+
+// ---- 轮询横幅不滞留(spec §9.2.5 待办 Unsure-3)----
+
+// 失败一次 → 横幅出现;下一次成功 → 横幅收走,不留在学生眼前
+{
+  const clock = makeClock()
+  let n = 0
+  const { engine } = setup({
+    clock,
+    statuses: ['generating'],
+    getReport: async () => {
+      n += 1
+      if (n === 1) throw new Error('boom')
+      return reportOf(['generating'])
+    },
+  })
+  await engine.startSession()
+  await clock.runTimeouts()
+  assert.ok(engine.error.value, 'a transient poll failure must raise the banner')
+
+  await clock.runTimeouts()
+  assert.equal(engine.error.value, null, 'a later successful poll must take its own banner down')
+}
+
+// 但**只**收自己竖的那面:录音被拒的解释必须活过随后那次成功轮询。
+// finishRecording 的 catch 设完 error 就 schedulePoll(0)(§4.4 到期收口),
+// 无差别清 error 会让学生在唯读锁合上的同一瞬间失去「为什么录不了」的唯一说明。
+{
+  const clock = makeClock()
+  const { engine } = setup({ clock, getReport: async () => reportOf(['pending', 'pending']) })
+  await engine.startSession()
+  await clock.runTimeouts()
+
+  engine.error.value = '__recording_rejected__'
+  await clock.runTimeouts()   // 成功轮询跑一轮
+  assert.equal(engine.error.value, '__recording_rejected__',
+    'a non-poll error must survive a successful poll')
+}
+
+console.log('article reading engine: stale banner cases passed')
+
+// ---- 编辑态预览:userId 原样透传给 createSession,null 不得被改写成空串(spec §4.5)----
+{
+  const clock = makeClock()
+  const seen = []
+  const { engine } = setup({
+    clock,
+    userId: null,
+    createSession: async (configId, uid) => {
+      seen.push([configId, uid])
+      return reportOf(['pending', 'pending'])
+    },
+  })
+  await engine.startSession()
+
+  assert.deepEqual(seen, [['c1', null]],
+    'editor preview must reach the backend as null, not "" — an empty string is a student named ""')
+}
+
+{
+  const clock = makeClock()
+  const seen = []
+  const { engine } = setup({
+    clock,
+    userId: 'u9',
+    createSession: async (configId, uid) => {
+      seen.push([configId, uid])
+      return reportOf(['pending', 'pending'])
+    },
+  })
+  await engine.startSession()
+
+  assert.deepEqual(seen, [['c1', 'u9']], 'a real student id must still be forwarded verbatim')
+}
+
+// ---- 双击「开始朗读」只发一个 POST /session ----
+// 后端 get-or-create 无锁、无唯一约束:两个并发请求会双双查不到既有 session 然后各插一行。
+// 编辑态预览(userId=null)必然如此 —— `_latest_session` 对空 user 早退返回 None(spec §4.5)。
+// 多出来那一行会算进 §4.3 B 的「N 名学生正在练习中」直到过期,还会多写一笔作业记录(§9.2.4)。
+{
+  const clock = makeClock()
+  let calls = 0
+  let release
+  const gate = new Promise(resolve => { release = resolve })
+  const { engine } = setup({
+    clock,
+    userId: null,
+    createSession: async () => {
+      calls += 1
+      await gate
+      return reportOf(['pending', 'pending'])
+    },
+  })
+
+  const first = engine.startSession()
+  await flush()
+  assert.equal(engine.starting.value, true, 'POST 在途时 starting 必须为真,视图据此置灰')
+
+  const second = engine.startSession()   // 第二次点击:必须被闩早退吃掉
+  release()
+  await Promise.all([first, second])
+
+  assert.equal(calls, 1, 'double click must not create a second session')
+  assert.equal(engine.starting.value, false, '结束后闩必须放开,失败重试才点得动')
+}
+
+// ---- 开始失败后闩要放开:学生还能再点一次(承 §9.2.5 A2 的 readyError 出口)----
+{
+  const clock = makeClock()
+  let calls = 0
+  const { engine } = setup({
+    clock,
+    createSession: async () => {
+      calls += 1
+      if (calls === 1) throw new Error('Config not found')
+      return reportOf(['pending', 'pending'])
+    },
+  })
+
+  await engine.startSession()
+  assert.equal(engine.stage.value, 'ready', '失败停在起始页')
+  assert.ok(engine.error.value, '失败必须有文案')
+  assert.equal(engine.starting.value, false, 'finally 放闩,不是只在成功路径上放')
+
+  await engine.startSession()
+  assert.equal(calls, 2, '第二次点击必须真的发出去')
+  assert.equal(engine.stage.value, 'reading')
+}

+ 79 - 0
scripts/test-article-reading-i18n.mjs

@@ -0,0 +1,79 @@
+import assert from 'node:assert/strict'
+import { readFile, readdir } from 'node:fs/promises'
+import path from 'node:path'
+
+const root = new URL('../', import.meta.url)
+const read = p => readFile(new URL(p, root), 'utf8')
+
+const locales = {}
+for (const loc of ['cn', 'en', 'hk']) {
+  locales[loc] = JSON.parse(await read(`src/views/lang/${loc}.json`))
+}
+
+// 扫描源码中真正引用到的 ssArticle* 键,而不是维护一张会过期的手写清单
+const scanRoots = [
+  'src/views/Editor/EnglishSpeaking',
+  'src/views/Student/components/ArticleReadingClassPanel',
+  'src/views/components/element/FrameElement',
+  'src/views/Editor/CanvasTool',
+  'src/components/CollapsibleToolbar',
+  'src/views/Student',
+]
+
+async function collectFiles(dir) {
+  const out = []
+  let entries
+  try {
+    entries = await readdir(new URL(dir + '/', root), { withFileTypes: true })
+  } catch {
+    return out
+  }
+  for (const entry of entries) {
+    const child = path.posix.join(dir, entry.name)
+    if (entry.isDirectory()) out.push(...await collectFiles(child))
+    else if (/\.(vue|ts)$/.test(entry.name)) out.push(child)
+  }
+  return out
+}
+
+const files = (await Promise.all(scanRoots.map(collectFiles))).flat()
+const used = new Set()
+for (const file of files) {
+  const source = await read(file)
+  for (const match of source.matchAll(/\blang\.(ssArticle[A-Za-z0-9]*)/g)) used.add(match[1])
+}
+
+assert.ok(used.size > 20, `expected many article keys in use, found ${used.size}`)
+
+// 每个被引用的键在三个语言包里都必须存在、非空、无首尾空白
+const missing = []
+for (const key of [...used].sort()) {
+  for (const loc of ['cn', 'en', 'hk']) {
+    const value = locales[loc][key]
+    if (typeof value !== 'string' || value.length === 0) {
+      missing.push(`${loc}.json missing ${key}`)
+      continue
+    }
+    assert.equal(value, value.trim(), `${loc}.json ${key} has surrounding whitespace`)
+  }
+}
+assert.deepEqual(missing, [], `unresolved article i18n keys:\n${missing.join('\n')}`)
+
+// 三个语言包的 ssArticle* 键集合必须完全一致,防止某个语言漏翻
+const keySets = Object.fromEntries(
+  Object.entries(locales).map(([loc, pack]) => [
+    loc,
+    Object.keys(pack).filter(key => key.startsWith('ssArticle')).sort(),
+  ]),
+)
+assert.deepEqual(keySets.en, keySets.cn, 'en.json article keys differ from cn.json')
+assert.deepEqual(keySets.hk, keySets.cn, 'hk.json article keys differ from cn.json')
+
+// en 与 cn 的文案不应逐字相同(除少数本就一致的符号型文案)
+const identical = keySets.cn.filter(key => locales.en[key] === locales.cn[key])
+assert.ok(
+  identical.length <= 2,
+  `en.json looks untranslated for: ${identical.join(', ')}`,
+)
+
+console.log(`article reading i18n: ${used.size} keys used, ${keySets.cn.length} defined, all three locales consistent`)

+ 52 - 0
scripts/test-article-reading-model.mjs

@@ -0,0 +1,52 @@
+import assert from 'node:assert/strict'
+import { readFile } from 'node:fs/promises'
+import ts from 'typescript'
+
+const unitId = 'SHEP-5-上-Unit2'
+const topics = JSON.parse(
+  await readFile(new URL('../src/views/Editor/EnglishSpeaking/data/topicDiscussionTasks.json', import.meta.url), 'utf8'),
+)
+const articles = JSON.parse(
+  await readFile(new URL('../src/views/Editor/EnglishSpeaking/data/articleReadingTasks.json', import.meta.url), 'utf8'),
+)
+const article = articles[unitId][0]
+
+// 模型的所有导入都是 `import type`,转译后被抹除,因此模块自足、可直接在 Node 里跑。
+const sourceUrl = new URL('../src/views/Editor/EnglishSpeaking/preview/articleReadingModel.ts', import.meta.url)
+const source = await readFile(sourceUrl, 'utf8')
+const compiled = ts.transpileModule(source, {
+  compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2020 },
+}).outputText
+const mod = await import(`data:text/javascript,${encodeURIComponent(compiled)}#${Math.random()}`)
+
+const empty = mod.buildEmptyArticleConfig('五年级')
+assert.deepEqual(empty, {
+  type: 'article',
+  grade: '五年级',
+  title: '',
+  content: '',
+  practice: { mode: 'time', duration: 10 },
+  evaluation: { showReport: true },
+})
+
+const fromTask = mod.buildArticleConfig(article, '五年级')
+assert.equal(fromTask.type, 'article')
+assert.equal(fromTask.grade, '五年级')
+assert.equal(fromTask.title, 'A Day at the Zoo')
+assert.equal(fromTask.content, article.content)
+
+assert.equal(mod.scoreToStars(0), 0)
+assert.equal(mod.scoreToStars(79), 3.95)
+assert.equal(mod.scoreToStars(100), 5)
+
+const allTasks = [...topics[unitId], article]
+assert.equal(mod.filterSpeakingTasks(allTasks, 'all').length, 5)
+assert.equal(mod.filterSpeakingTasks(allTasks, 'topic-discussion').length, 4)
+assert.equal(mod.filterSpeakingTasks(allTasks, 'article-reading').length, 1)
+
+assert.equal(mod.isFinalReportReady({ paragraphs: [{ status: 'generating' }], overallStatus: null }), false)
+assert.equal(mod.isFinalReportReady({ paragraphs: [{ status: 'completed' }], overallStatus: null }), true)
+assert.equal(mod.isFinalReportReady({ paragraphs: [{ status: 'failed' }], overallStatus: 'failed' }), true)
+
+assert.equal(mod.pollDelayMs(0), 1000)
+assert.equal(mod.pollDelayMs(5), 8000)

+ 137 - 0
scripts/test-article-reading-routing.mjs

@@ -0,0 +1,137 @@
+import assert from 'node:assert/strict'
+import { readFile } from 'node:fs/promises'
+
+const read = path => readFile(new URL(`../${path}`, import.meta.url), 'utf8')
+
+const frameEditor = await read('src/views/components/element/FrameElement/index.vue')
+const frameBase = await read('src/views/components/element/FrameElement/BaseFrameElement.vue')
+const operate = await read('src/views/Editor/Canvas/Operate/index.vue')
+const canvasTool = await read('src/views/Editor/CanvasTool/index2.vue')
+const canvas = await read('src/views/Editor/Canvas/index.vue')
+const toolbars = await Promise.all([
+  read('src/components/CollapsibleToolbar/index.vue'),
+  read('src/components/CollapsibleToolbar/index2.vue'),
+  read('src/components/CollapsibleToolbar/index22.vue'),
+])
+
+// 编辑态与只读/学生态都必须把 772 路由到文章预览
+assert.match(frameEditor, /ArticleReadingPreview/)
+assert.match(frameEditor, /isArticleReadingTool/)
+assert.match(frameBase, /ArticleReadingPreview/)
+assert.match(frameBase, /isArticleReadingTool/)
+assert.match(frameBase, /ARTICLE_READING_TOOL_TYPE/)
+
+// 锁定、重置、滚轮放行
+assert.match(operate, /isArticleReadingTool/)
+assert.match(canvasTool, /ARTICLE_READING_TOOL_TYPE/)
+assert.match(canvasTool, /articleStore\.requestResetPreview/)
+assert.match(canvas, /article-reading-preview/)
+
+// 三个工具栏名称映射
+for (const toolbar of toolbars) {
+  assert.match(toolbar, /ARTICLE_READING_TOOL_TYPE/)
+  assert.match(toolbar, /ssArticleReadingTool/)
+}
+
+// 编号只能来自命名常量:代码里不得出现 772 字面量(注释中说明编号是允许的)
+function stripComments(source) {
+  return source
+    .replace(/<!--[\s\S]*?-->/g, '')
+    .replace(/\/\*[\s\S]*?\*\//g, '')
+    .replace(/(^|[^:])\/\/.*$/gm, '$1')
+}
+
+const codeFiles = {
+  frameEditor, frameBase, operate, canvasTool, canvas,
+  toolbar0: toolbars[0], toolbar1: toolbars[1], toolbar2: toolbars[2],
+}
+for (const [name, source] of Object.entries(codeFiles)) {
+  assert.doesNotMatch(
+    stripComments(source),
+    /\b772\b/,
+    `${name} must reference ARTICLE_READING_TOOL_TYPE, not the literal 772`,
+  )
+}
+
+// 话题讨论与句子跟读的既有编号不得被改写
+assert.match(frameBase, /77: 'ssEnglishSpeakingTool'/)
+assert.match(frameBase, /81: 'ssH5Page'/)
+
+// 文章分支不得回落到话题讨论组件
+const articleBranch = frameEditor.slice(frameEditor.indexOf('ArticleReadingPreview'))
+assert.doesNotMatch(articleBranch.slice(0, 200), /TopicDiscussionPreview/)
+
+// 老师端 Student runtime 按工具类型分流
+const studentView = await read('src/views/Student/index.vue')
+const articleClassComposable = await read(
+  'src/views/Student/components/ArticleReadingClassPanel/useArticleClassSummary.ts',
+)
+
+assert.match(studentView, /ArticleReadingClassPanel/)
+assert.match(studentView, /ARTICLE_READING_TOOL_TYPE/)
+assert.match(studentView, /TOPIC_DISCUSSION_TOOL_TYPE/)
+assert.match(articleClassComposable, /listArticleSessionsByConfig/)
+assert.match(articleClassComposable, /generateArticleClassSummary/)
+
+// 作业记录必须带真实工具号,不能写死 77
+assert.match(studentView, /atool: String\(activityToolType\)/)
+assert.doesNotMatch(studentView, /atool: '77'/)
+
+// 通用结果弹窗/提交按钮对文章与话题一视同仁地抑制
+assert.doesNotMatch(studentView, /currentSlideToolType !== 77/)
+assert.match(studentView, /articleReadingPanelRef\.value\?\.scheduleRefetch/)
+
+// 三个语言包都要有工具名与班级面板文案
+for (const loc of ['cn', 'en', 'hk']) {
+  const pack = JSON.parse(await read(`src/views/lang/${loc}.json`))
+  for (const key of [
+    'ssArticleReadingTool', 'ssArticleReading', 'ssArticleClassSummary',
+    'ssArticleFilterCompleted', 'ssArticleFilterReading',
+  ]) {
+    assert.ok(pack[key], `${loc}.json is missing ${key}`)
+  }
+}
+
+// ---- 编辑态老师预览不进统计(spec §4.5):三道门都要在 ArticleReadingPreview 里 ----
+const articlePreview = await read('src/views/Editor/EnglishSpeaking/preview/ArticleReadingPreview.vue')
+
+// 门 1:runtime 判定不能只看 userid —— 编辑器 URL 里也有 userid。
+// 身份判据用项目既有的 type(Student/index.vue:625:老师端 1 / 学生端 2)。
+assert.match(articlePreview, /isStudentRuntime/)
+assert.match(articlePreview, /params\.get\('type'\)/)
+assert.match(articlePreview, /role === '2'/)
+
+// 门 2:编辑态把 null 传给引擎,不是空串
+assert.match(articlePreview, /sessionUserId/)
+
+// 门 3:resume 探针与进度广播只在学生态跑
+assert.match(articlePreview, /if \(!isStudentRuntime\.value\) return/)
+
+// 反向:不得再出现「只要有 userid 就当学生」的旧判据
+assert.doesNotMatch(articlePreview, /startDisabled = computed\(\(\) => !hasArticle\.value \|\| !runtimeUserId\.value\)/)
+
+// 反向:不得拿 mode 当身份 —— mode=student 是视图,老师上课时也在这个视图里,
+// 用它会把上课页的老师当成学生(编辑态那个 bug 换个地方复发),还得再去和
+// App.vue 的 localStorage viewMode 对账。type 是身份,与渲染哪个视图无关。
+assert.doesNotMatch(articlePreview, /'student'/)
+assert.doesNotMatch(articlePreview, /currentViewMode/)
+assert.doesNotMatch(await read('src/App.vue'), /currentViewMode/)
+
+// ---- 报告的词级形状必须跟住后端契约(spec §6):段落下面是**扁平的 words**。
+// 后端 c62d35a「flatten scoring/report to paragraph→words」把句层删了,前端的类型和
+// 取值路径没跟着改,于是任何一张已完成的段落卡片一展开就 `paragraph.sentences` 是
+// undefined、`.flatMap` 抛异常。类型说了谎,所以 type-check 也发现不了 —— 只能在这里钉。
+const articleTypes = await read('src/types/articleReading.ts')
+assert.match(articleTypes, /words: ArticleWordResult\[\]/)
+assert.doesNotMatch(articleTypes, /sentences/)
+assert.doesNotMatch(articleTypes, /ArticleSentenceResult/)
+
+const paragraphCard = await read('src/views/Editor/EnglishSpeaking/preview/ArticleParagraphCard.vue')
+assert.match(paragraphCard, /props\.paragraph\.words/)
+assert.doesNotMatch(paragraphCard, /flattenParagraphWords/)
+assert.doesNotMatch(
+  await read('src/views/Editor/EnglishSpeaking/preview/articleReadingModel.ts'),
+  /sentences/,
+)
+
+console.log('article reading routing: all assertions passed')

+ 114 - 0
scripts/test-article-reading-stream.mjs

@@ -0,0 +1,114 @@
+import assert from 'node:assert/strict'
+import { readFile } from 'node:fs/promises'
+import ts from 'typescript'
+
+const sourceUrl = new URL('../src/views/Editor/EnglishSpeaking/services/articleReadingStream.ts', import.meta.url)
+let source = await readFile(sourceUrl, 'utf8')
+
+source = source.replace(
+  "import { buildArticleWsUrl } from './speakingApiConfig'",
+  "const buildArticleWsUrl = path => `wss://example.test/api/speaking/article${path}`",
+)
+
+const compiled = ts.transpileModule(source, {
+  compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
+}).outputText
+
+class FakeWebSocket {
+  static CONNECTING = 0
+  static OPEN = 1
+  static CLOSING = 2
+  static CLOSED = 3
+  static instances = []
+
+  readyState = FakeWebSocket.CONNECTING
+  binaryType = ''
+  sent = []
+  onopen = null
+  onmessage = null
+  onerror = null
+  onclose = null
+
+  constructor(url) {
+    this.url = url
+    FakeWebSocket.instances.push(this)
+  }
+
+  send(payload) {
+    this.sent.push(payload)
+  }
+
+  close() {
+    this.readyState = FakeWebSocket.CLOSED
+    this.onclose?.({})
+  }
+}
+
+globalThis.WebSocket = FakeWebSocket
+
+const mod = await import(`data:text/javascript,${encodeURIComponent(compiled)}`)
+
+// ---- 1. 正常时序:start → PCM → stop → ack ----
+// chunk 先于 open 抵达,必须排队而不是丢弃(录音器早于 socket 就绪是常态)
+const stream = mod.openArticleParagraphStream({
+  sessionId: 's1',
+  paragraphIdx: 2,
+  sampleRate: 48000,
+})
+const firstChunk = new Uint8Array([1, 2, 3, 4]).buffer
+stream.pushChunk(firstChunk)
+const ackPromise = stream.stop()
+
+const ws = FakeWebSocket.instances[0]
+assert.equal(ws.url, 'wss://example.test/api/speaking/article/session/s1/paragraph/2/stream')
+ws.readyState = FakeWebSocket.OPEN
+ws.onopen()
+
+assert.deepEqual(JSON.parse(ws.sent[0]), {
+  type: 'start', sampleRate: 48000, bits: 16, channels: 1,
+})
+assert.equal(ws.sent[1], firstChunk)
+assert.deepEqual(JSON.parse(ws.sent[2]), { type: 'stop' })
+
+ws.onmessage({ data: JSON.stringify({ type: 'ack', status: 'generating', attempt: 1, truncated: false }) })
+assert.deepEqual(await ackPromise, {
+  type: 'ack', status: 'generating', attempt: 1, truncated: false,
+})
+
+// ---- 2. error frame 以其 message reject ----
+const errStream = mod.openArticleParagraphStream({ sessionId: 's2', paragraphIdx: 0, sampleRate: 16000 })
+const errAck = errStream.stop()
+const errWs = FakeWebSocket.instances[1]
+errWs.readyState = FakeWebSocket.OPEN
+errWs.onopen()
+errWs.onmessage({ data: JSON.stringify({ type: 'error', message: 'assessment failed' }) })
+await assert.rejects(errAck, /assessment failed/)
+
+// ---- 3. abort() 关闭连接并 reject 未决的 stop ----
+const abortStream = mod.openArticleParagraphStream({ sessionId: 's3', paragraphIdx: 1, sampleRate: 16000 })
+const abortWs = FakeWebSocket.instances[2]
+abortWs.readyState = FakeWebSocket.OPEN
+abortWs.onopen()
+const abortAck = abortStream.stop()
+abortStream.abort('user left the paragraph')
+assert.equal(abortWs.readyState, FakeWebSocket.CLOSED)
+await assert.rejects(abortAck, /user left the paragraph/)
+
+// ---- 4. 连接在 ack 之前关闭必须 reject,不能静默挂起 ----
+const deadStream = mod.openArticleParagraphStream({ sessionId: 's4', paragraphIdx: 0, sampleRate: 16000 })
+const deadWs = FakeWebSocket.instances[3]
+deadWs.readyState = FakeWebSocket.OPEN
+deadWs.onopen()
+const deadAck = deadStream.stop()
+deadWs.close()
+await assert.rejects(deadAck, /closed before acknowledgement/)
+
+// ---- 5. stop 之后再推 PCM 是编程错误 ----
+const lateStream = mod.openArticleParagraphStream({ sessionId: 's5', paragraphIdx: 0, sampleRate: 16000 })
+const lateWs = FakeWebSocket.instances[4]
+lateWs.readyState = FakeWebSocket.OPEN
+lateWs.onopen()
+const lateAck = lateStream.stop()
+assert.throws(() => lateStream.pushChunk(new Uint8Array([9]).buffer), /Cannot push PCM after stop/)
+lateWs.onmessage({ data: JSON.stringify({ type: 'ack', status: 'generating', attempt: 1, truncated: false }) })
+await lateAck

+ 116 - 0
scripts/test-article-recorder-sample-rate.mjs

@@ -0,0 +1,116 @@
+/**
+ * useAudioRecorder 的采样率协商。
+ *
+ * 钉的是一件事:**上报的永远是实际拿到的采样率,不是我们要的那个**。
+ * 这条不变量一破,后端就会按 16kHz 去解 48kHz 的数据 —— 回放慢三倍、评分全错,
+ * 而整条链路没有任何一层会报错。三种浏览器行为都要走一遍。
+ */
+import assert from 'node:assert/strict'
+import { readFile } from 'node:fs/promises'
+import ts from 'typescript'
+
+const sourceUrl = new URL(
+  '../src/views/Editor/EnglishSpeaking/composables/useAudioRecorder.ts',
+  import.meta.url,
+)
+let source = await readFile(sourceUrl, 'utf8')
+
+// Vue runtime → 本地 shim(同 test-article-reading-engine.mjs 的做法)
+source = source.replace(
+  "import { ref, onUnmounted } from 'vue'",
+  `const ref = v => ({ value: v })
+   const onUnmounted = () => {}`,
+)
+
+const compiled = ts.transpileModule(source, {
+  compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
+}).outputText
+
+const mod = await import(`data:text/javascript,${encodeURIComponent(compiled)}`)
+
+// ---- 浏览器环境替身 ----
+
+const HARDWARE_RATE = 48000
+
+function installEnvironment({ audioContextFactory }) {
+  const track = {
+    label: 'fake mic', enabled: true, muted: false, readyState: 'live',
+    getSettings: () => ({}), stop() { this.readyState = 'ended' },
+  }
+  // Node 22 的 globalThis.navigator 只有 getter,赋值会抛 —— 用 defineProperty 盖掉
+  Object.defineProperty(globalThis, 'navigator', {
+    configurable: true,
+    value: {
+      mediaDevices: { getUserMedia: async () => ({ getTracks: () => [track], getAudioTracks: () => [track] }) },
+      permissions: { query: async () => { throw new Error('unsupported') } },
+    },
+  })
+  globalThis.AudioContext = audioContextFactory
+  globalThis.AudioWorkletNode = class {
+    constructor() { this.port = { onmessage: null, postMessage() {} } }
+    connect() {}
+    disconnect() {}
+  }
+  globalThis.DOMException = globalThis.DOMException ?? class extends Error {}
+}
+
+/** 造一个 AudioContext 类,`resolveRate` 决定它对 sampleRate 提示的反应。 */
+function makeAudioContextClass(resolveRate) {
+  return class FakeAudioContext {
+    static requested = []
+    constructor(options) {
+      FakeAudioContext.requested.push(options?.sampleRate ?? null)
+      this.sampleRate = resolveRate(options?.sampleRate)   // 可以抛
+      this.state = 'running'
+      this.destination = {}
+      this.audioWorklet = { addModule: async () => {} }
+    }
+    createMediaStreamSource() { return { connect() {} } }
+    createAnalyser() {
+      return { fftSize: 0, frequencyBinCount: 256, connect() {}, getByteFrequencyData() {} }
+    }
+    async resume() { this.state = 'running' }
+    async close() { this.state = 'closed' }
+  }
+}
+
+async function startAndReadRate(AudioContextClass) {
+  installEnvironment({ audioContextFactory: AudioContextClass })
+  const recorder = mod.useAudioRecorder()
+  await recorder.startRecording()
+  const rate = recorder.sampleRate.value
+  recorder.cleanup()
+  return rate
+}
+
+// ---- 1. 浏览器支持:拿到 16kHz ----
+{
+  const Ctx = makeAudioContextClass(hint => hint ?? HARDWARE_RATE)
+  const rate = await startAndReadRate(Ctx)
+  assert.equal(rate, 16000, '支持自定义采样率时应该拿到 16kHz')
+  assert.deepEqual(Ctx.requested, [16000], '应该只构造一次,并且带着 16000 的提示')
+}
+
+// ---- 2. 浏览器拒绝(旧 iOS Safari):退回硬件默认,录音照常 ----
+{
+  const Ctx = makeAudioContextClass(hint => {
+    if (hint) throw new Error('NotSupportedError: sample rate not supported')
+    return HARDWARE_RATE
+  })
+  const rate = await startAndReadRate(Ctx)
+  assert.equal(rate, HARDWARE_RATE, '构造抛出时必须退回硬件默认,而不是整个录音失败')
+  assert.deepEqual(Ctx.requested, [16000, null], '先试 16000,抛了再试不带提示的')
+}
+
+// ---- 3. 浏览器**默默无视**提示:上报真值,不是我们要的值 ----
+// 1 是「照办」、2 是「明着拒绝」,这条是第三种行为:收下提示、返回硬件值、不抛。
+// 前两条都覆盖不到它,而它正是最坏的那种失败 —— 没有异常、没有日志,只是后端
+// 从此按 16kHz 去解 48kHz 的数据。
+{
+  const Ctx = makeAudioContextClass(() => HARDWARE_RATE)   // 提示照收,返回值不变
+  const rate = await startAndReadRate(Ctx)
+  assert.equal(rate, HARDWARE_RATE, '浏览器无视提示时必须上报实际采样率')
+  assert.notEqual(rate, 16000)
+}
+
+console.log('✓ test-article-recorder-sample-rate: 3 cases passed')

+ 126 - 0
scripts/test-dialogue-stream-fallback.mjs

@@ -0,0 +1,126 @@
+import assert from 'node:assert/strict'
+import { readFile } from 'node:fs/promises'
+import ts from 'typescript'
+
+const sourceUrl = new URL('../src/views/Editor/EnglishSpeaking/composables/useDialogueEngine.ts', import.meta.url)
+let source = await readFile(sourceUrl, 'utf8')
+
+source = source
+  .replace(
+    "import { ref, reactive, computed, onUnmounted } from 'vue'",
+    `
+const ref = value => ({ value })
+const reactive = value => value
+const computed = getter => ({ get value() { return getter() } })
+const onUnmounted = () => {}
+`,
+  )
+  .replace(
+    "import { createDialogueApi, DialogueApiError } from '../services/llmService'",
+    `
+const createDialogueApi = () => globalThis.__dialogueApi
+class DialogueApiError extends Error {
+  constructor(message, status) {
+    super(message)
+    this.status = status
+  }
+}
+`,
+  )
+  .replace(
+    "import { buildSpeakingWsUrl } from '../services/speakingApiConfig'",
+    "const buildSpeakingWsUrl = () => 'wss://example.test/api/speaking/dialogue/speak-stream'",
+  )
+
+const compiled = ts.transpileModule(source, {
+  compilerOptions: {
+    module: ts.ModuleKind.ESNext,
+    target: ts.ScriptTarget.ES2022,
+  },
+}).outputText
+
+class FakeWebSocket {
+  static CONNECTING = 0
+  static OPEN = 1
+  static CLOSING = 2
+  static CLOSED = 3
+  static instances = []
+
+  readyState = FakeWebSocket.CONNECTING
+  binaryType = ''
+  sent = []
+  onopen = null
+  onmessage = null
+  onerror = null
+  onclose = null
+
+  constructor(url) {
+    this.url = url
+    FakeWebSocket.instances.push(this)
+  }
+
+  send(payload) {
+    this.sent.push(payload)
+  }
+
+  close() {
+    this.readyState = FakeWebSocket.CLOSED
+    this.onclose?.({})
+  }
+}
+
+globalThis.WebSocket = FakeWebSocket
+
+const speakCalls = []
+globalThis.__dialogueApi = {
+  async *speak(sessionId, audioBlob, signal, turnId) {
+    speakCalls.push({ sessionId, audioBlob, signal, turnId })
+    yield { type: 'transcript', text: 'hello' }
+    yield { type: 'token', text: 'Hi.' }
+    yield { type: 'done', isComplete: false }
+  },
+  generateGreeting() {
+    throw new Error('not used')
+  },
+  getReport() {
+    throw new Error('not used')
+  },
+  completeSession() {
+    throw new Error('not used')
+  },
+}
+
+const mod = await import(`data:text/javascript,${encodeURIComponent(compiled)}`)
+const engine = mod.useDialogueEngine()
+engine.attachSession({ sessionId: 'session-1', totalRounds: 3 })
+
+const ctl = engine.beginStudentStream({ sampleRate: 16000, bits: 16, channels: 1 })
+assert.ok(ctl)
+
+const ws = FakeWebSocket.instances[0]
+ws.readyState = FakeWebSocket.CLOSED
+ws.onerror?.({})
+ws.onclose?.({})
+
+const audioBlob = new Blob([new Uint8Array([1, 2, 3, 4])], { type: 'audio/webm' })
+ctl.commit(audioBlob)
+await new Promise(resolve => setTimeout(resolve, 0))
+
+// WS 失败时不再自动降级 HTTP——api.speak 不应被自动调用。
+assert.equal(speakCalls.length, 0, 'auto fallback to /speak should not happen')
+
+// 应当只 push 一条 student 消息(已 splice 掉 ai 占位),处于 error 状态等待用户点重试。
+assert.equal(engine.messages.value.length, 1)
+const studentMsg = engine.messages.value[0]
+assert.equal(studentMsg.role, 'student')
+assert.equal(studentMsg.status, 'error')
+assert.equal(studentMsg.recovery, 'retry')
+assert.equal(studentMsg.audioBlob, audioBlob, 'audioBlob preserved for retry')
+assert.equal(studentMsg.turnId, ctl.turnId)
+
+// 用户点重试 → engine.retryMessage 走 sendStudentMessage → /speak(HTTP)。
+await engine.retryMessage(studentMsg.id)
+assert.equal(speakCalls.length, 1, 'retry should invoke /speak once')
+assert.equal(speakCalls[0].sessionId, 'session-1')
+assert.equal(speakCalls[0].turnId, ctl.turnId, 'retry reuses original turnId for idempotency')
+assert.equal(speakCalls[0].audioBlob, audioBlob)

+ 79 - 0
scripts/test-speaking-api-config.mjs

@@ -0,0 +1,79 @@
+import assert from 'node:assert/strict'
+import { readFile } from 'node:fs/promises'
+import ts from 'typescript'
+
+const sourceUrl = new URL('../src/views/Editor/EnglishSpeaking/services/speakingApiConfig.ts', import.meta.url)
+const source = await readFile(sourceUrl, 'utf8')
+
+async function loadConfig(env = {}) {
+  const compiled = ts.transpileModule(source.replaceAll('import.meta.env', JSON.stringify(env)), {
+    compilerOptions: {
+      module: ts.ModuleKind.ESNext,
+      target: ts.ScriptTarget.ES2020,
+    },
+  }).outputText
+
+  return import(`data:text/javascript,${encodeURIComponent(compiled)}#${Math.random()}`)
+}
+
+const defaults = await loadConfig()
+
+assert.equal(
+  defaults.FALLBACK_SPEAKING_API_HOST,
+  'https://ppt-english-speaking-api.cocorobo.cn',
+)
+
+assert.equal(
+  defaults.SPEAKING_DIALOGUE_API_BASE_URL,
+  'https://ppt-english-speaking-api.cocorobo.cn/api/speaking/dialogue',
+)
+
+assert.equal(
+  defaults.SPEAKING_CONFIG_API_BASE_URL,
+  'https://ppt-english-speaking-api.cocorobo.cn/api/speaking/config',
+)
+
+assert.equal(
+  defaults.SPEAKING_TRANSPORT,
+  'websocket',
+)
+
+assert.equal(
+  defaults.buildSpeakingWsUrl('/speak-stream'),
+  'wss://ppt-english-speaking-api.cocorobo.cn/api/speaking/dialogue/speak-stream',
+)
+
+const custom = await loadConfig({
+  VITE_SPEAKING_API_HOST: 'https://example.com/',
+  VITE_SPEAKING_TRANSPORT: 'http',
+})
+
+assert.equal(
+  custom.SPEAKING_DIALOGUE_API_BASE_URL,
+  'https://example.com/api/speaking/dialogue',
+)
+
+assert.equal(
+  custom.SPEAKING_CONFIG_API_BASE_URL,
+  'https://example.com/api/speaking/config',
+)
+
+assert.equal(
+  custom.SPEAKING_TRANSPORT,
+  'http',
+)
+
+assert.equal(
+  defaults.SPEAKING_ARTICLE_API_BASE_URL,
+  'https://ppt-english-speaking-api.cocorobo.cn/api/speaking/article',
+)
+
+assert.equal(
+  defaults.buildArticleWsUrl('/session/s1/paragraph/0/stream'),
+  'wss://ppt-english-speaking-api.cocorobo.cn/api/speaking/article/session/s1/paragraph/0/stream',
+)
+
+assert.equal(
+  custom.SPEAKING_ARTICLE_API_BASE_URL,
+  'https://example.com/api/speaking/article',
+)

+ 16 - 13
src/App.vue

@@ -154,15 +154,15 @@ onMounted(async () => {
 })
 
 // 应用注销时向 localStorage 中记录下本次 indexedDB 的数据库ID,用于之后清除数据库
-window.addEventListener('beforeunload', () => {
-  const discardedDB = localStorage.getItem(LOCALSTORAGE_KEY_DISCARDED_DB)
-  const discardedDBList: string[] = discardedDB ? JSON.parse(discardedDB) : []
+// window.addEventListener('beforeunload', () => {
+//   const discardedDB = localStorage.getItem(LOCALSTORAGE_KEY_DISCARDED_DB)
+//   const discardedDBList: string[] = discardedDB ? JSON.parse(discardedDB) : []
 
-  discardedDBList.push(databaseId.value)
+//   discardedDBList.push(databaseId.value)
 
-  const newDiscardedDB = JSON.stringify(discardedDBList)
-  localStorage.setItem(LOCALSTORAGE_KEY_DISCARDED_DB, newDiscardedDB)
-})
+//   const newDiscardedDB = JSON.stringify(discardedDBList)
+//   localStorage.setItem(LOCALSTORAGE_KEY_DISCARDED_DB, newDiscardedDB)
+// })
 </script>
 
 <style lang="scss">
@@ -188,24 +188,27 @@ window.addEventListener('beforeunload', () => {
 .image-preview__toolbar button {
   padding: 8px 12px;
   border: none;
-  background: linear-gradient(180deg, #3a8bff 0%, #2f80ed 100%);
+  // background: linear-gradient(180deg, #ff9300 0%, #2f80ed 100%);
+  background: #ff9300;
   color: #fff;
   border-radius: 10px;
   cursor: pointer;
   transition: transform 0.15s ease, box-shadow 0.2s ease, background 0.2s ease;
-  box-shadow: 0 2px 8px rgba(47, 128, 237, 0.3);
+  // box-shadow: 0 2px 8px rgba(47, 128, 237, 0.3);
 }
 .image-preview__toolbar button:hover {
   transform: translateY(-1px);
-  box-shadow: 0 6px 16px rgba(47, 128, 237, 0.35);
+  // box-shadow: 0 6px 16px rgba(47, 128, 237, 0.35);
 }
 .image-preview__toolbar button:active {
   transform: translateY(0);
-  box-shadow: 0 2px 8px rgba(47, 128, 237, 0.28);
-  background: linear-gradient(180deg, #2f80ed 0%, #1b6dde 100%);
+  // box-shadow: 0 2px 8px rgba(47, 128, 237, 0.28);
+  // background: linear-gradient(180deg, #2f80ed 0%, #1b6dde 100%);
+  background: #ff9300;
 }
 .image-preview__toolbar button:focus-visible {
-  outline: 2px solid rgba(47, 128, 237, 0.6);
+  // outline: 2px solid rgba(47, 128, 237, 0.6);
+  outline: none;
   outline-offset: 2px;
 }
 .image-preview__stage {

BIN
src/assets/img/tool_photo.png


BIN
src/assets/img/tool_vote.png


+ 5 - 2
src/assets/styles/prosemirror.scss

@@ -20,9 +20,12 @@
     font-size: 0;
     padding: 0;
   }
-  p:first-child {
+  p:first-child:has(br) {
+    font-size: 0;
+  }
+  p:first-child br {
     margin-top: 0;
-    // font-size: 0;
+    font-size: 0;
   }
 
   ul, ol, li {

File diff suppressed because it is too large
+ 703 - 146
src/components/CollapsibleToolbar/componets/aiChat.vue


+ 181 - 0
src/components/CollapsibleToolbar/componets/aiWeb.vue

@@ -0,0 +1,181 @@
+<template>
+  <div v-if="visible" class="aiweb-modal-overlay" @click.self="closeModal">
+    <div class="aiweb-modal">
+      <div class="aiweb-modal-header">
+        <span class="aiweb-modal-title">{{ lang.ssWebEditor }}</span>
+        <button class="aiweb-modal-close" @click="closeModal">
+          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+            <path d="M18 6L6 18"></path>
+            <path d="M6 6l12 12"></path>
+          </svg>
+        </button>
+      </div>
+      <div class="aiweb-modal-body">
+        <iframe :src="iframeUrl" frameborder="0" class="aiweb-iframe" ref="iframeRef"></iframe>
+      </div>
+      <div class="aiweb-modal-footer">
+        <button class="aiweb-btn aiweb-btn-close" @click="closeModal">{{ lang.ssClose }}</button>
+        <button class="aiweb-btn aiweb-btn-confirm" @click="handleConfirm" :disabled="isLoading">
+          {{ isLoading ? lang.ssCreating : lang.ssConfirmAdd }}
+        </button>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { computed, ref } from 'vue'
+import { lang } from '@/main'
+
+const props = defineProps<{
+  visible: boolean
+  webId: string
+  isLoading: boolean
+  tx: string
+}>()
+
+const emit = defineEmits<{
+  (e: 'close'): void
+  (e: 'add', webCode: string): void
+}>()
+
+const iframeUrl = computed(() => {
+  let url = 'https://beta.app.cocorobo.cn'
+  if (window.location.href.includes('beta')) {
+    url = 'https://beta.app.cocorobo.cn'
+  }
+  else if (lang.lang === 'cn') {
+    url = 'https://app.cocorobo.cn'
+  }
+  else if (lang.lang === 'hk') {
+    url = 'https://app.cocorobo.hk'
+  }
+  else if (lang.lang === 'en') {
+    url = 'https://app.cocorobo.com'
+  }
+  if (!props.webId) return ''
+  return `${url}/#/web?id=${props.webId}&create=false&isPPT=true${props.tx}`
+})
+
+const closeModal = () => {
+  emit('close')
+}
+
+const iframeRef = ref<HTMLIFrameElement>()
+
+const handleConfirm = () => {
+  const webCode = iframeRef.value?.contentWindow?.ReturnWebCode() as string || ''
+  console.log(webCode)
+  if (!webCode) return
+  console.log('确定按钮被点击')
+  emit('add', webCode)
+}
+</script>
+
+<style lang="scss" scoped>
+.aiweb-modal-overlay {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  background-color: rgba(0, 0, 0, 0.5);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  z-index: 1000;
+}
+
+.aiweb-modal {
+  width: 90%;
+  max-width: 1200px;
+  height: 90vh;
+  background-color: #fff;
+  border-radius: 8px;
+  overflow: hidden;
+  box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
+}
+
+.aiweb-modal-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 16px 20px;
+  border-bottom: 1px solid #eee;
+  background-color: #f8f9fa;
+}
+
+.aiweb-modal-title {
+  font-size: 16px;
+  font-weight: 600;
+  color: #333;
+}
+
+.aiweb-modal-close {
+  background: none;
+  border: none;
+  cursor: pointer;
+  padding: 8px;
+  color: #666;
+  transition: color 0.2s;
+
+  &:hover {
+    color: #333;
+  }
+
+  svg {
+    width: 20px;
+    height: 20px;
+  }
+}
+
+.aiweb-modal-body {
+  height: calc(100% - 140px);
+}
+
+.aiweb-modal-footer {
+  display: flex;
+  justify-content: flex-end;
+  gap: 12px;
+  padding: 16px 20px;
+  border-top: 1px solid #eee;
+  background-color: #f8f9fa;
+}
+
+.aiweb-btn {
+  padding: 8px 20px;
+  border-radius: 4px;
+  font-size: 14px;
+  font-weight: 500;
+  cursor: pointer;
+  transition: all 0.2s;
+  border: 1px solid transparent;
+}
+
+.aiweb-btn-close {
+  background-color: #fff;
+  border-color: #d9d9d9;
+  color: #666;
+
+  &:hover {
+    background-color: #f5f5f5;
+    border-color: #bfbfbf;
+  }
+}
+
+.aiweb-btn-confirm {
+  background-color: #1890ff;
+  border-color: #1890ff;
+  color: #fff;
+
+  &:hover {
+    background-color: #40a9ff;
+    border-color: #40a9ff;
+  }
+}
+
+.aiweb-iframe {
+  width: 100%;
+  height: 100%;
+}
+</style>

+ 7 - 1
src/components/CollapsibleToolbar/index.vue

@@ -131,6 +131,7 @@ import useCreateElement from '@/hooks/useCreateElement'
 import useSlideHandler from '@/hooks/useSlideHandler'
 import { useSlidesStore } from '@/store'
 import { lang } from '@/main'
+import { ARTICLE_READING_TOOL_TYPE } from '@/configs/englishSpeakingTools'
 
 interface ContentItem {
   tool?: number
@@ -283,9 +284,14 @@ const getTypeLabel = (type?: number) => {
     15: lang.ssQATest,
     72: lang.ssAiApp,
     73: lang.ssHPage,
+    81: lang.ssHPage,
     74: lang.ssVideo,
-    75: lang.ssBiliVideo,
+    75: lang.lang == 'cn' ? lang.ssBiliVideo : lang.ssYouTube,
     76: lang.ssCreative,
+    77: lang.ssEnglishSpeakingTool,
+    [ARTICLE_READING_TOOL_TYPE]: lang.ssArticleReadingTool,
+    78: lang.ssVote,
+    79: lang.ssPhoto,
   }
   return typeMap[type || 0] || lang.ssUnknown
 }

File diff suppressed because it is too large
+ 673 - 131
src/components/CollapsibleToolbar/index2.vue


+ 1651 - 0
src/components/CollapsibleToolbar/index22.vue

@@ -0,0 +1,1651 @@
+<template>
+  <div class="collapsible-toolbar" :class="{ collapsed: isCollapsed }">
+    <div class="toolbar-content" v-show="!isCollapsed">
+      <div class="sidebar-content">
+        <div class="sidebar-item" :class="{ active: activeSubmenu === 'cocoai' }" @click="toggleSubmenu('cocoai')">
+          <svg class="item-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+            <path d="M12 2L2 7l10 5 10-5-10-5z"></path>
+            <path d="M2 17l10 5 10-5"></path>
+            <path d="M2 12l10 5 10-5"></path>
+          </svg>
+          <span class="item-label">Coco AI</span>
+        </div>
+        <div class="sidebar-item" :class="{ active: activeSubmenu === 'page' }" @click="toggleSubmenu('page')">
+          <svg class="item-icon" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
+            <g id="Component 1">
+              <path id="Vector"
+                d="M12.8332 1.83398H5.49984C5.01361 1.83398 4.54729 2.02714 4.20347 2.37096C3.85966 2.71477 3.6665 3.18109 3.6665 3.66732V18.334C3.6665 18.8202 3.85966 19.2865 4.20347 19.6303C4.54729 19.9742 5.01361 20.1673 5.49984 20.1673H16.4998C16.9861 20.1673 17.4524 19.9742 17.7962 19.6303C18.14 19.2865 18.3332 18.8202 18.3332 18.334V7.33398L12.8332 1.83398Z"
+                stroke="currentColor" stroke-width="1.83333" />
+              <path id="Vector_2" d="M12.8335 1.83398V7.33398H18.3335" stroke="currentColor" stroke-width="1.83333" />
+            </g>
+          </svg>
+          <span class="item-label">{{ lang.ssPage }}</span>
+        </div>
+        <div class="sidebar-item" :class="{ active: activeSubmenu === 'interactive' }"
+          @click="toggleSubmenu('interactive')">
+          <svg class="item-icon" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
+            <g id="Frame">
+              <path id="Vector"
+                d="M4.44727 14.6738V14.5449L5.84805 13.041H6.02422L9.43164 16.2766V7.46797L9.4875 7.41211H11.9969L12.0098 7.425V13.5137H16.7234L16.8781 13.6684V18.9707H18.5969V12.7789L17.6086 11.7949H13.5996V6.62578L12.7531 5.7793H8.73125L7.8418 6.66875V12.6844L6.60859 11.4512H5.23789L2.85742 13.9863V15.2324L6.35508 18.9707H8.60234L4.44727 14.6738Z"
+                fill="currentColor" />
+              <path id="Vector_2"
+                d="M2.49219 2.79297V10.5273H6.40234V8.76562H4.25391V4.55469H17.7461V8.76562H15.0391V10.5273H19.4219V2.79297H2.49219Z"
+                fill="currentColor" />
+            </g>
+          </svg>
+          <span class="item-label">{{ lang.ssInteract }}</span>
+        </div>
+        <div class="sidebar-item" :class="{ active: activeSubmenu === 'aiapp' }" @click="toggleSubmenu('aiapp')">
+          <svg class="item-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+            <rect x="3" y="3" width="7" height="7" />
+            <rect x="14" y="3" width="7" height="7" />
+            <rect x="14" y="14" width="7" height="7" />
+            <rect x="3" y="14" width="7" height="7" />
+          </svg>
+          <span class="item-label">{{ lang.ssAiApp }}</span>
+        </div>
+        <div class="sidebar-item" :class="{ active: activeSubmenu === 'h5page' }" @click="toggleSubmenu('h5page')">
+          <svg class="item-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+            <circle cx="12" cy="12" r="10" />
+            <path d="M2 12h20" />
+            <path d="M12 2a15.3 15.3 0 014 10 15.3 15.3 0 01-4 10 15.3 15.3 0 01-4-10 15.3 15.3 0 014-10z" />
+          </svg>
+          <span class="item-label">{{ lang.ssInteractiveWebpage }}</span>
+        </div>
+        <!-- <div class="sidebar-item" @click="handleToolClick('video')">
+          <svg class="item-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+            <rect x="3" y="4" width="18" height="16" rx="2" ry="2" />
+            <polygon points="10 9 16 12 10 15 10 9" />
+          </svg>
+          <span class="item-label">{{ lang.ssVideo }}</span>
+        </div> -->
+        <!-- <div class="sidebar-item" @click="handleToolClick('creative')">
+          <svg class="item-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+            <circle cx="12" cy="12" r="10" />
+            <line x1="12" y1="8" x2="12" y2="16" />
+            <line x1="8" y1="12" x2="16" y2="12" />
+          </svg>
+          <span class="item-label">{{ lang.ssCreative }}</span>
+        </div> -->
+        <div class="sidebar-item" :class="{ active: activeSubmenu === 'multimedia' }"
+          @click="toggleSubmenu('multimedia')">
+          <svg class="item-icon" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
+            <g id="Component 1">
+              <path id="Vector"
+                d="M20.1668 17.4167C20.1668 17.9029 19.9737 18.3692 19.6299 18.713C19.286 19.0568 18.8197 19.25 18.3335 19.25H3.66683C3.1806 19.25 2.71428 19.0568 2.37047 18.713C2.02665 18.3692 1.8335 17.9029 1.8335 17.4167V4.58333C1.8335 4.0971 2.02665 3.63079 2.37047 3.28697C2.71428 2.94315 3.1806 2.75 3.66683 2.75H8.25016L10.0835 5.5H18.3335C18.8197 5.5 19.286 5.69315 19.6299 6.03697C19.9737 6.38079 20.1668 6.8471 20.1668 7.33333V17.4167Z"
+                stroke="currentColor" stroke-width="1.83333" />
+            </g>
+          </svg>
+          <span class="item-label">{{ lang.ssMultimedia }}</span>
+        </div>
+        <div class="sidebar-item" :class="{ active: activeSubmenu === 'english' }"
+          @click="toggleSubmenu('english')">
+          <svg class="item-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+            <path d="M12 1a3 3 0 00-3 3v8a3 3 0 006 0V4a3 3 0 00-3-3z"></path><path d="M19 10v2a7 7 0 01-14 0v-2"></path><line x1="12" y1="19" x2="12" y2="23"></line><line x1="8" y1="23" x2="16" y2="23"></line>
+          </svg>
+          <span class="item-label">{{ lang.ssEnglish }}</span>
+        </div>
+      </div>
+    </div>
+    <div class="submenu" :class="{ visible: activeSubmenu === 'cocoai' }">
+      <div class="submenu-title" style="margin-bottom: 0;">
+        <div class="title">Coco AI</div>
+        <div class="close-icon" @click="toggleSubmenu('cocoai')">
+          <svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
+            <g id="Component 3">
+              <g id="Component 1">
+                <path id="Vector" d="M16 18L12 14L16 10" stroke="#9CA3AF" stroke-width="1.33333" />
+              </g>
+            </g>
+          </svg>
+        </div>
+      </div>
+      <div class="submenu-content">
+        <AiChat :userid="props.userid" />
+      </div>
+    </div>
+    <div class="submenu" :class="{ visible: activeSubmenu === 'page' }">
+      <div class="submenu-title">
+        <div class="title">{{ lang.ssAddTemplatePage }}</div>
+        <div class="close-icon" @click="toggleSubmenu('page')">
+          <svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
+            <g id="Component 3">
+              <g id="Component 1">
+                <path id="Vector" d="M16 18L12 14L16 10" stroke="#9CA3AF" stroke-width="1.33333" />
+              </g>
+            </g>
+          </svg>
+        </div>
+      </div>
+      <div class="submenu-item-box2">
+        <div class="submenu-item" @click="handleToolClick('titlepage')">
+          <div class="submenu-icon">
+            <svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
+              <g id="Component 1">
+                <path id="Vector" d="M8 14V8H40V14M18 40H30M24 8V40" stroke="currentColor" stroke-width="4" />
+              </g>
+            </svg>
+          </div>
+          <span class="submenu-label">{{ lang.ssTitlePage }}</span>
+        </div>
+        <div class="submenu-item" @click="handleToolClick('ImagePage')">
+          <div class="submenu-icon">
+            <svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
+              <g id="Component 1">
+                <path id="Vector"
+                  d="M38 6H10C7.79086 6 6 7.79086 6 10V38C6 40.2091 7.79086 42 10 42H38C40.2091 42 42 40.2091 42 38V10C42 7.79086 40.2091 6 38 6Z"
+                  stroke="currentColor" stroke-width="4" />
+                <path id="Vector_2"
+                  d="M17 20C18.6569 20 20 18.6569 20 17C20 15.3431 18.6569 14 17 14C15.3431 14 14 15.3431 14 17C14 18.6569 15.3431 20 17 20Z"
+                  stroke="currentColor" stroke-width="4" />
+                <path id="Vector_3" d="M42 30L32 20L10 42" stroke="currentColor" stroke-width="4" />
+              </g>
+            </svg>
+          </div>
+          <span class="submenu-label">{{ lang.ssImagePage }}</span>
+        </div>
+        <div class="submenu-item" @click="handleToolClick('ContentPage')">
+          <div class="submenu-icon">
+            <svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
+              <g id="Component 1">
+                <path id="Vector"
+                  d="M28 4H12C10.9391 4 9.92172 4.42143 9.17157 5.17157C8.42143 5.92172 8 6.93913 8 8V40C8 41.0609 8.42143 42.0783 9.17157 42.8284C9.92172 43.5786 10.9391 44 12 44H36C37.0609 44 38.0783 43.5786 38.8284 42.8284C39.5786 42.0783 40 41.0609 40 40V16L28 4Z"
+                  stroke="currentColor" stroke-width="4" />
+                <path id="Vector_3" d="M32 26H16" stroke="currentColor" stroke-width="4" />
+                <path id="Vector_4" d="M32 34H16" stroke="currentColor" stroke-width="4" />
+              </g>
+            </svg>
+          </div>
+          <span class="submenu-label">{{ lang.ssContentPage }}</span>
+        </div>
+        <div class="submenu-item" @click="handleToolClick('ImageTextPage')">
+          <div class="submenu-icon">
+            <svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
+              <g id="Component 1">
+                <path id="Vector"
+                  d="M18 6H8C6.89543 6 6 6.89543 6 8V40C6 41.1046 6.89543 42 8 42H18C19.1046 42 20 41.1046 20 40V8C20 6.89543 19.1046 6 18 6Z"
+                  stroke="currentColor" stroke-width="4" />
+                <path id="Vector_2"
+                  d="M40 6H30C28.8954 6 28 6.89543 28 8V40C28 41.1046 28.8954 42 30 42H40C41.1046 42 42 41.1046 42 40V8C42 6.89543 41.1046 6 40 6Z"
+                  stroke="currentColor" stroke-width="4" />
+              </g>
+            </svg>
+          </div>
+          <span class="submenu-label">{{ lang.ssImageTextPage }}</span>
+        </div>
+      </div>
+      <FileInput accept="application/vnd.openxmlformats-officedocument.presentationml.presentation"
+        @change="handleFileUpload">
+        <div class="submenu-upload">
+          <div class="submenu-icon">
+            <svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
+              <g id="Component 1">
+                <path id="Vector"
+                  d="M24.5 17.5V22.1667C24.5 22.7855 24.2542 23.379 23.8166 23.8166C23.379 24.2542 22.7855 24.5 22.1667 24.5H5.83333C5.21449 24.5 4.621 24.2542 4.18342 23.8166C3.74583 23.379 3.5 22.7855 3.5 22.1667V17.5"
+                  stroke="currentColor" stroke-width="2.33333" />
+                <path id="Vector_2" d="M19.8334 9.33333L14.0001 3.5L8.16675 9.33333" stroke="currentColor"
+                  stroke-width="2.33333" />
+                <path id="Vector_3" d="M14 3.5V17.5" stroke="currentColor" stroke-width="2.33333" />
+              </g>
+            </svg>
+          </div>
+          <span class="submenu-label">{{ lang.ssUploadPPT }}</span>
+        </div>
+      </FileInput>
+    </div>
+    <div class="submenu" :class="{ visible: activeSubmenu === 'interactive' }">
+      <div class="submenu-title">
+        <div class="title">{{ lang.ssAddInteractiveTool }}</div>
+        <div class="close-icon" @click="toggleSubmenu('interactive')">
+          <svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
+            <g id="Component 3">
+              <g id="Component 1">
+                <path id="Vector" d="M16 18L12 14L16 10" stroke="#9CA3AF" stroke-width="1.33333" />
+              </g>
+            </g>
+          </svg>
+        </div>
+      </div>
+      <transition name="fade" mode="out-in">
+        <div class="submenu-panel" v-if="!hoveredTool">
+          <svg key="svg" width="120" height="80" viewBox="0 0 120 80" fill="none" xmlns="http://www.w3.org/2000/svg">
+            <g id="Component 1">
+              <path id="Vector"
+                d="M102 15H18C13.5817 15 10 18.5817 10 23V57C10 61.4183 13.5817 65 18 65H102C106.418 65 110 61.4183 110 57V23C110 18.5817 106.418 15 102 15Z"
+                fill="#FFFAF0" stroke="#FF9300" stroke-width="2" />
+              <path id="Vector_2"
+                d="M30 34C32.2091 34 34 32.2091 34 30C34 27.7909 32.2091 26 30 26C27.7909 26 26 27.7909 26 30C26 32.2091 27.7909 34 30 34Z"
+                fill="#FF9300" />
+              <path id="Vector_3"
+                d="M97 27H43C41.3431 27 40 28.3431 40 30C40 31.6569 41.3431 33 43 33H97C98.6569 33 100 31.6569 100 30C100 28.3431 98.6569 27 97 27Z"
+                fill="#FFD9A8" />
+              <path id="Vector_4"
+                d="M30 49C32.2091 49 34 47.2091 34 45C34 42.7909 32.2091 41 30 41C27.7909 41 26 42.7909 26 45C26 47.2091 27.7909 49 30 49Z"
+                fill="#D1D5DB" />
+              <path id="Vector_5"
+                d="M82 42H43C41.3431 42 40 43.3431 40 45C40 46.6569 41.3431 48 43 48H82C83.6569 48 85 46.6569 85 45C85 43.3431 83.6569 42 82 42Z"
+                fill="#E5E7EB" />
+            </g>
+          </svg>
+          <div class="detail">{{ lang.ssSelectToolCreateInteractive }}</div>
+        </div>
+        <img class="submenu-img" v-else-if="hoveredTool === 'qa'" key="qa" :src="toolAnswer" alt="">
+        <img class="submenu-img" v-else-if="hoveredTool === 'choice'" key="choice" :src="toolChoice" alt="">
+        <img class="submenu-img" v-else-if="hoveredTool === 'vote'" key="vote" :src="toolVote" alt="">
+        <img class="submenu-img" v-else-if="hoveredTool === 'photo'" key="photo" :src="toolPhoto" alt="">
+      </transition>
+      <div class="submenu-item-box">
+        <div class="submenu-item" @click="handleToolClick('choice')" @mouseenter="hoveredTool = 'choice'"
+          @mouseleave="hoveredTool = null">
+          <svg class="submenu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+            <circle cx="12" cy="12" r="10" />
+            <path d="M12 16v-4m0-4h.01" />
+          </svg>
+          <span class="submenu-label">{{ lang.ssChoiceQ }}</span>
+        </div>
+        <div class="submenu-item" @click="handleToolClick('qa')" @mouseenter="hoveredTool = 'qa'"
+          @mouseleave="hoveredTool = null">
+          <svg class="submenu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+            <path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" />
+          </svg>
+          <span class="submenu-label">{{ lang.ssQandA }}</span>
+        </div>
+        <div class="submenu-item" @click="handleToolClick('vote')" @mouseenter="hoveredTool = 'vote'"
+          @mouseleave="hoveredTool = null">
+          <svg class="submenu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+              <polyline points="9 11 12 14 22 4"></polyline>
+              <path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"></path>
+          </svg>
+          <span class="submenu-label">{{ lang.ssVote }}</span>
+        </div>
+        <div class="submenu-item" @click="handleToolClick('photo')" @mouseenter="hoveredTool = 'photo'"
+          @mouseleave="hoveredTool = null">
+          <svg class="submenu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+              <path d="M23 19a2 2 0 01-2 2H3a2 2 0 01-2-2V8a2 2 0 012-2h4l2-3h6l2 3h4a2 2 0 012 2z"></path>
+              <circle cx="12" cy="13" r="4"></circle>
+          </svg>
+          <span class="submenu-label">{{ lang.ssPhoto }}</span>
+        </div>
+        <div class="submenu-item" @click="handleToolClick('creative')" @mouseenter="hoveredTool = null"
+          @mouseleave="hoveredTool = null">
+          <svg class="submenu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+            <circle cx="12" cy="12" r="10"/>
+            <line x1="12" y1="8" x2="12" y2="16"/>
+            <line x1="8" y1="12" x2="16" y2="12"/>
+          </svg>
+          <span class="submenu-label">{{ lang.ssCreative }}</span>
+        </div>
+      </div>
+    </div>
+    <div class="submenu" :class="{ visible: activeSubmenu === 'aiapp' }">
+      <div class="submenu-title">
+        <div class="title">{{ lang.ssAddAIApp }}</div>
+        <div class="close-icon" @click="toggleSubmenu('aiapp')">
+          <svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
+            <g id="Component 3">
+              <g id="Component 1">
+                <path id="Vector" d="M16 18L12 14L16 10" stroke="#9CA3AF" stroke-width="1.33333" />
+              </g>
+            </g>
+          </svg>
+        </div>
+      </div>
+      <div class="submenu-item-box2">
+        <div class="submenu-item" @click="handleToolClick('aiapp')">
+          <div class="submenu-icon">
+            <svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
+              <g id="Component 1">
+                <path id="Vector"
+                  d="M18 6H8C6.89543 6 6 6.89543 6 8V18C6 19.1046 6.89543 20 8 20H18C19.1046 20 20 19.1046 20 18V8C20 6.89543 19.1046 6 18 6Z"
+                  stroke="currentColor" stroke-width="4" />
+                <path id="Vector_2"
+                  d="M40 6H30C28.8954 6 28 6.89543 28 8V18C28 19.1046 28.8954 20 30 20H40C41.1046 20 42 19.1046 42 18V8C42 6.89543 41.1046 6 40 6Z"
+                  stroke="currentColor" stroke-width="4" />
+                <path id="Vector_3"
+                  d="M40 28H30C28.8954 28 28 28.8954 28 30V40C28 41.1046 28.8954 42 30 42H40C41.1046 42 42 41.1046 42 40V30C42 28.8954 41.1046 28 40 28Z"
+                  stroke="currentColor" stroke-width="4" />
+                <path id="Vector_4"
+                  d="M18 28H8C6.89543 28 6 28.8954 6 30V40C6 41.1046 6.89543 42 8 42H18C19.1046 42 20 41.1046 20 40V30C20 28.8954 19.1046 28 18 28Z"
+                  stroke="currentColor" stroke-width="4" />
+              </g>
+            </svg>
+          </div>
+          <span class="submenu-label">{{ lang.ssAppCenter }}</span>
+        </div>
+        <div class="submenu-item" @click="handleToolClick('createApp')">
+          <div class="submenu-icon">
+            <svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
+              <g id="Component 1">
+                <path id="Vector"
+                  d="M24 44C35.0457 44 44 35.0457 44 24C44 12.9543 35.0457 4 24 4C12.9543 4 4 12.9543 4 24C4 35.0457 12.9543 44 24 44Z"
+                  stroke="currentColor" stroke-width="4" />
+                <path id="Vector_2" d="M24 16V32" stroke="currentColor" stroke-width="4" />
+                <path id="Vector_3" d="M16 24H32" stroke="currentColor" stroke-width="4" />
+              </g>
+            </svg>
+          </div>
+          <span class="submenu-label">{{ lang.ssCreateApp }}</span>
+        </div>
+      </div>
+    </div>
+    <div class="submenu" :class="{ visible: activeSubmenu === 'h5page' }">
+      <div class="submenu-title">
+        <div class="title">{{ lang.ssAddInteractiveWebpage }}</div>
+        <div class="close-icon" @click="toggleSubmenu('h5page')">
+          <svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
+            <g id="Component 3">
+              <g id="Component 1">
+                <path id="Vector" d="M16 18L12 14L16 10" stroke="#9CA3AF" stroke-width="1.33333" />
+              </g>
+            </g>
+          </svg>
+        </div>
+      </div>
+      <div class="submenu-item-box2">
+        <!-- <div class="submenu-item">
+          <div class="submenu-icon">
+            <svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
+              <g id="Component 1">
+                <path id="Vector"
+                  d="M40 6H8C5.79086 6 4 7.79086 4 10V30C4 32.2091 5.79086 34 8 34H40C42.2091 34 44 32.2091 44 30V10C44 7.79086 42.2091 6 40 6Z"
+                  stroke="currentColor" stroke-width="4" />
+                <path id="Vector_2" d="M4 14H44" stroke="currentColor" stroke-width="4" />
+                <path id="Vector_3"
+                  d="M10 11C10.5523 11 11 10.5523 11 10C11 9.44772 10.5523 9 10 9C9.44772 9 9 9.44772 9 10C9 10.5523 9.44772 11 10 11Z"
+                  fill="currentColor" stroke="currentColor" stroke-width="4" />
+                <path id="Vector_4"
+                  d="M14 11C14.5523 11 15 10.5523 15 10C15 9.44772 14.5523 9 14 9C13.4477 9 13 9.44772 13 10C13 10.5523 13.4477 11 14 11Z"
+                  fill="currentColor" stroke="currentColor" stroke-width="4" />
+                <path id="Vector_5"
+                  d="M18 11C18.5523 11 19 10.5523 19 10C19 9.44772 18.5523 9 18 9C17.4477 9 17 9.44772 17 10C17 10.5523 17.4477 11 18 11Z"
+                  fill="currentColor" stroke="currentColor" stroke-width="4" />
+                <path id="Vector_6" d="M16 22H32M16 28H26" stroke="currentColor" stroke-width="4" />
+              </g>
+            </svg>
+          </div>
+          <span class="submenu-label">{{ lang.ssWebpageCenter }}</span>
+        </div> -->
+        <div class="submenu-item" @click="handleToolClick('uploadWebpage')">
+          <div class="submenu-icon">
+            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+              <circle cx="12" cy="12" r="10"></circle>
+              <path d="M2 12h20"></path>
+              <path d="M12 2a15.3 15.3 0 014 10 15.3 15.3 0 01-4 10 15.3 15.3 0 01-4-10 15.3 15.3 0 014-10z"></path>
+              <path d="M16 8l-4 4-4-4" stroke-width="1.5"></path>
+            </svg>
+          </div>
+          <span class="submenu-label">{{ lang.ssUploadWebpageLink }}</span>
+        </div>
+        <!-- <div class="submenu-item">
+          <div class="submenu-icon">
+            <svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
+              <g id="Component 1">
+                <path id="Vector"
+                  d="M42 30V38C42 39.0609 41.5786 40.0783 40.8284 40.8284C40.0783 41.5786 39.0609 42 38 42H10C8.93913 42 7.92172 41.5786 7.17157 40.8284C6.42143 40.0783 6 39.0609 6 38V30"
+                  stroke="currentColor" stroke-width="4" />
+                <path id="Vector_2" d="M34 16L24 6L14 16" stroke="currentColor" stroke-width="4" />
+                <path id="Vector_3" d="M24 6V30" stroke="currentColor" stroke-width="4" />
+              </g>
+            </svg>
+          </div>
+          <span class="submenu-label">{{ lang.ssUploadWebpage }}</span>
+        </div> -->
+        <div class="submenu-item" @click="handleToolClick('createWebpage')">
+          <div class="submenu-icon">
+            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+              <rect x="3" y="3" width="18" height="14" rx="2"></rect>
+              <path d="M12 8v6"></path>
+              <path d="M9 11h6"></path>
+            </svg>
+          </div>
+          <span class="submenu-label">{{ lang.ssNewWebpage }}</span>
+        </div>
+        <!-- <div class="submenu-item">
+          <div class="submenu-icon">
+            <svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
+              <g id="Component 1">
+                <path id="Vector"
+                  d="M24 44C35.0457 44 44 35.0457 44 24C44 12.9543 35.0457 4 24 4C12.9543 4 4 12.9543 4 24C4 35.0457 12.9543 44 24 44Z"
+                  stroke="currentColor" stroke-width="4" />
+                <path id="Vector_2" d="M4 24H44" stroke="currentColor" stroke-width="4" />
+                <path id="Vector_3"
+                  d="M24 4C29.0026 9.47671 31.8455 16.5841 32 24C31.8455 31.4159 29.0026 38.5233 24 44C18.9974 38.5233 16.1545 31.4159 16 24C16.1545 16.5841 18.9974 9.47671 24 4Z"
+                  stroke="currentColor" stroke-width="4" />
+                <path id="Vector_4" d="M32 16L24 24L16 16" stroke="currentColor" stroke-width="3" />
+              </g>
+            </svg>
+          </div>
+          <span class="submenu-label">{{ lang.ssCrawlWebpage }}</span>
+        </div> -->
+      </div>
+    </div>
+    <div class="submenu" :class="{ visible: activeSubmenu === 'uploadWebpage' }">
+      <div class="submenu-title">
+        <div class="title">{{ lang.ssUploadWebpageLink }}</div>
+        <div class="close-icon" @click="activeSubmenu = 'h5page'">
+          <svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
+            <g id="Component 3">
+              <g id="Component 1">
+                <path id="Vector" d="M16 18L12 14L16 10" stroke="#9CA3AF" stroke-width="1.33333" />
+              </g>
+            </g>
+          </svg>
+        </div>
+      </div>
+      <div class="line_box">
+        <div class="webpage-link-container">
+          <h3 class="webpage-link-title">{{ lang.ssWebpageLink }}</h3>
+          <input type="text" class="webpage-link-input" :placeholder="lang.ssEnterCompleteUrl" v-model="webpageUrl"
+            @input="handleUrlInput" />
+          <button class="webpage-link-button"
+            :class="{ 'loading': isLoading, 'error': isValidUrl === false, 'disabled': isValidUrl === null || isLoading }"
+            :disabled="isValidUrl === null || isLoading || isValidUrl === false" @click="uploadWebpageLink">
+            {{ isLoading ? lang.ssUploading : isValidUrl === null ? lang.ssWaitingForInput : isValidUrl === false ?
+              lang.ssInvalidUrl : lang.ssStartUpload }}
+          </button>
+        </div>
+      </div>
+    </div>
+    <div class="submenu" :class="{ visible: activeSubmenu === 'multimedia' }">
+      <div class="submenu-title">
+        <div class="title">{{ lang.ssAddMultimedia }}</div>
+        <div class="close-icon" @click="toggleSubmenu('multimedia')">
+          <svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
+            <g id="Component 3">
+              <g id="Component 1">
+                <path id="Vector" d="M16 18L12 14L16 10" stroke="#9CA3AF" stroke-width="1.33333" />
+              </g>
+            </g>
+          </svg>
+        </div>
+      </div>
+      <div class="submenu-item-box2">
+        <div class="submenu-item" @click="handleToolClick('video')">
+          <div class="submenu-icon">
+            <svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
+              <g id="Component 1">
+                <path id="Vector"
+                  d="M38 8H10C7.79086 8 6 9.79086 6 12V36C6 38.2091 7.79086 40 10 40H38C40.2091 40 42 38.2091 42 36V12C42 9.79086 40.2091 8 38 8Z"
+                  stroke="currentColor" stroke-width="4" />
+                <path id="Vector_2" d="M20 18L32 24L20 30V18Z" stroke="currentColor" stroke-width="4" />
+              </g>
+            </svg>
+          </div>
+          <span class="submenu-label">{{ lang.ssVideo }}</span>
+        </div>
+        <!-- <div class="submenu-item">
+          <div class="submenu-icon">
+            <svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
+              <g id="Component 1">
+                <path id="Vector" d="M18 36V10L42 6V32" stroke="currentColor" stroke-width="4" />
+                <path id="Vector_2"
+                  d="M12 42C15.3137 42 18 39.3137 18 36C18 32.6863 15.3137 30 12 30C8.68629 30 6 32.6863 6 36C6 39.3137 8.68629 42 12 42Z"
+                  stroke="currentColor" stroke-width="4" />
+                <path id="Vector_3"
+                  d="M36 38C39.3137 38 42 35.3137 42 32C42 28.6863 39.3137 26 36 26C32.6863 26 30 28.6863 30 32C30 35.3137 32.6863 38 36 38Z"
+                  stroke="currentColor" stroke-width="4" />
+              </g>
+            </svg>
+          </div>
+          <span class="submenu-label">{{ lang.ssAudio }}</span>
+        </div>
+        <div class="submenu-item">
+          <div class="submenu-icon">
+            <svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
+              <g id="Component 1">
+                <path id="Vector"
+                  d="M28 4H12C10.9391 4 9.92172 4.42143 9.17157 5.17157C8.42143 5.92172 8 6.93913 8 8V40C8 41.0609 8.42143 42.0783 9.17157 42.8284C9.92172 43.5786 10.9391 44 12 44H36C37.0609 44 38.0783 43.5786 38.8284 42.8284C39.5786 42.0783 40 41.0609 40 40V16L28 4Z"
+                  stroke="currentColor" stroke-width="4" />
+                <path id="Vector_2" d="M28 4V16H40" stroke="currentColor" stroke-width="4" />
+                <path id="Vector_3" d="M16 26H32" stroke="currentColor" stroke-width="4" />
+                <path id="Vector_4" d="M16 34H28" stroke="currentColor" stroke-width="4" />
+              </g>
+            </svg>
+          </div>
+          <span class="submenu-label">{{ lang.ssDocument }}</span>
+        </div>
+        <div class="submenu-item">
+          <div class="submenu-icon">
+            <svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
+              <g id="Component 1">
+                <path id="Vector"
+                  d="M18 8H10C7.79086 8 6 9.79086 6 12V32C6 34.2091 7.79086 36 10 36H18C20.2091 36 22 34.2091 22 32V12C22 9.79086 20.2091 8 18 8Z"
+                  stroke="currentColor" stroke-width="4" />
+                <path id="Vector_2"
+                  d="M38 8H30C27.7909 8 26 9.79086 26 12V32C26 34.2091 27.7909 36 30 36H38C40.2091 36 42 34.2091 42 32V12C42 9.79086 40.2091 8 38 8Z"
+                  stroke="currentColor" stroke-width="4" />
+                <path id="Vector_3" d="M14 18V26" stroke="currentColor" stroke-width="4" />
+                <path id="Vector_4" d="M34 18V26" stroke="currentColor" stroke-width="4" />
+              </g>
+            </svg>
+          </div>
+          <span class="submenu-label">{{ lang.ssDocumentSet }}</span>
+        </div> -->
+      </div>
+    </div>
+    <div class="submenu submenu-english" :class="{ visible: activeSubmenu === 'english' }">
+      <SpeakingPanel />
+    </div>
+
+    <div v-if="exporting" class="parsing-modal">
+      <div class="parsing-content">
+        <div class="loading-spinner" v-if="exporting"></div>
+        <div class="success-icon" v-if="!exporting">
+          <svg width="26" height="26" viewBox="0 0 26 26" fill="none" xmlns="http://www.w3.org/2000/svg">
+            <g id="Component 1">
+              <path id="Vector" d="M5.41675 14.084L9.75008 18.4173L20.5834 7.58398" stroke="#FF9300"
+                stroke-width="2.16667" stroke-linecap="round" stroke-linejoin="round" />
+            </g>
+          </svg>
+
+        </div>
+        <h3>{{ exporting ? lang.ssParsing : lang.ssExportCompleted }}</h3>
+        <p v-if="exporting">{{ lang.ssParsingFile }}{{ currentFileName }}</p>
+        <p v-if="!exporting">{{ lang.ssParsingCompleted }}</p>
+        <button class="close-btn2" @click="handleParsingClose">
+          {{ exporting ? lang.ssClose : lang.ssComplete }}
+        </button>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { ref, watch } from 'vue'
+import { storeToRefs } from 'pinia'
+import useCreateElement from '@/hooks/useCreateElement'
+import useSlideHandler from '@/hooks/useSlideHandler'
+import { useSlidesStore } from '@/store'
+import { useSpeakingStore } from '@/store/speaking'
+import FileInput from '@/components/FileInput.vue'
+import AiChat from './componets/aiChat.vue'
+import SpeakingPanel from '@/views/Editor/EnglishSpeaking/SpeakingPanel.vue'
+import { lang } from '@/main'
+import { ARTICLE_READING_TOOL_TYPE } from '@/configs/englishSpeakingTools'
+import toolChoice from '@/assets/img/tool_choice.jpeg'
+import toolAnswer from '@/assets/img/tool_answer.png'
+import toolVote from '@/assets/img/tool_vote.png'
+import toolPhoto from '@/assets/img/tool_photo.png'
+
+
+
+interface ContentItem {
+  tool?: number
+  title?: string
+  url?: string
+  id?: string
+}
+
+
+
+const props = withDefaults(defineProps<{
+  defaultCollapsed?: boolean
+  userid?: string | null
+}>(), {
+  defaultCollapsed: false,
+  userid: null,
+})
+
+const emit = defineEmits<{
+  (e: 'toggle', collapsed: boolean): void
+}>()
+
+const isCollapsed = ref(props.defaultCollapsed)
+const activeSubmenu = ref<string | null>(null)
+const contentList = ref<ContentItem[]>([])
+const hoveredTool = ref<string | null>(null)
+const webpageUrl = ref('')
+const isLoading = ref(false)
+const isValidUrl = ref<boolean | null>(null) // null: 未输入, true: 有效, false: 无效
+
+const slidesStore = useSlidesStore()
+const { currentSlide } = storeToRefs(slidesStore)
+
+const { createFrameElement } = useCreateElement()
+const { createSlide, createSlideByTemplate } = useSlideHandler()
+
+
+
+import _ from 'lodash'
+
+const handleUrlInput = _.debounce(() => {
+  const url = webpageUrl.value.trim()
+  if (!url) {
+    isValidUrl.value = null
+  }
+  else {
+    // 简化的URL格式验证,支持各种复杂URL
+    const urlRegex = /^https?:\/\/.+/
+    isValidUrl.value = urlRegex.test(url)
+  }
+  console.log('URL输入:', webpageUrl.value, '验证结果:', isValidUrl.value)
+}, 300)
+const uploadWebpageLink = async () => {
+  if (!webpageUrl.value || isValidUrl.value !== true) {
+    // 可以添加提示信息
+    return
+  }
+
+  isLoading.value = true
+
+  // 模拟上传过程
+  // isLoading.value = false
+  // 上传成功后创建iframe元素
+
+  const isValid = await new Promise((resolve) => {
+    // 创建隐藏iframe
+    const iframe = document.createElement('iframe')
+    iframe.style.display = 'none'
+    iframe.src = webpageUrl.value
+    let finished = false
+    const timeout = setTimeout(() => {
+      if (finished) return
+      finished = true
+      // 超时,移除iframe,进入XHR判断
+      document.body.removeChild(iframe)
+      // 用XHR判断
+      const xhr = new XMLHttpRequest()
+      xhr.open('GET', iframe.src, true)
+      xhr.onreadystatechange = function() {
+        if (xhr.readyState === 4) {
+          if (xhr.status === 200) {
+            resolve(true)
+          }
+          else {
+            // 再试一次 getFile
+            getFile(iframe.src as any).then(res => {
+              if (res && res.data && res.data !== 1) {
+                resolve(true)
+              }
+              else {
+                resolve(false)
+              }
+            }).catch(() => {
+              resolve(false)
+            })
+          }
+        }
+      }.bind(this)
+      xhr.onerror = function() {
+        // 再试一次 getFile
+        getFile(iframe.src as any).then(res => {
+          if (res && res.data && res.data !== 1) {
+            resolve(true)
+          }
+          else {
+            resolve(false)
+          }
+        }).catch(() => {
+          resolve(false)
+        })
+      }.bind(this)
+      xhr.send()
+    }, 5000)
+
+    iframe.onload = function() {
+      if (finished) return
+      finished = true
+      clearTimeout(timeout)
+      try {
+        // 尝试访问contentWindow.document
+        const doc = iframe?.contentWindow?.document
+        document.body.removeChild(iframe)
+        resolve(true)
+      }
+      catch (e) {
+        // 跨域或其他异常,移除iframe,进入XHR判断
+        document.body.removeChild(iframe)
+        const xhr = new XMLHttpRequest()
+        xhr.open('GET', iframe.src, true)
+        xhr.onreadystatechange = function() {
+          if (xhr.readyState === 4) {
+            if (xhr.status === 200) {
+              resolve(true)
+            }
+            else {
+              // 再试一次 getFile
+              getFile(iframe.src as any).then(res => {
+                if (res && res.data && res.data !== 1) {
+                  resolve(true)
+                }
+                else {
+                  resolve(false)
+                }
+              }).catch(() => {
+                resolve(false)
+              })
+            }
+          }
+        }.bind(this)
+        xhr.onerror = function() {
+          // 再试一次 getFile
+          getFile(iframe.src as any).then(res => {
+            if (res && res.data && res.data !== 1) {
+              resolve(true)
+            }
+            else {
+              resolve(false)
+            }
+          }).catch(() => {
+            resolve(false)
+          })
+        }.bind(this)
+        xhr.send()
+      }
+    }
+    iframe.onerror = function() {
+      if (finished) return
+      finished = true
+      clearTimeout(timeout)
+      document.body.removeChild(iframe)
+      // iframe加载失败,进入XHR判断
+      const xhr = new XMLHttpRequest()
+      xhr.open('GET', iframe.src, true)
+      xhr.onreadystatechange = function() {
+        if (xhr.readyState === 4) {
+          if (xhr.status === 200) {
+            resolve(true)
+          }
+          else {
+            // 再试一次 getFile
+            getFile(iframe.src as any).then(res => {
+              if (res && res.data && res.data !== 1) {
+                resolve(true)
+              }
+              else {
+                resolve(false)
+              }
+            }).catch(() => {
+              resolve(false)
+            })
+          }
+        }
+      }.bind(this)
+      xhr.onerror = function() {
+        // 再试一次 getFile
+        getFile(iframe.src as any).then(res => {
+          if (res && res.data && res.data !== 1) {
+            resolve(true)
+          }
+          else {
+            resolve(false)
+          }
+        }).catch(() => {
+          resolve(false)
+        })
+      }.bind(this)
+      xhr.send()
+    }
+    document.body.appendChild(iframe)
+  })
+
+  if (!isValid) {
+    message.error(lang.ssCocoLinkTip)
+    isLoading.value = false
+    return
+  }
+  isLoading.value = false
+
+  createSlide()
+  createFrameElement(webpageUrl.value, 73) // 假设15是网页工具的类型
+  // 清空输入框和验证状态
+  webpageUrl.value = ''
+  isValidUrl.value = null
+}
+
+const toggleCollapse = () => {
+  isCollapsed.value = !isCollapsed.value
+  emit('toggle', isCollapsed.value)
+}
+
+const toggleSubmenu = (menu: string) => {
+  if (activeSubmenu.value === menu) {
+    activeSubmenu.value = null
+  }
+  else {
+    activeSubmenu.value = menu
+  }
+}
+
+// 监听"打开口语配置面板"信号 → 自动展开 english 子菜单
+const speakingStore = useSpeakingStore()
+watch(() => speakingStore.openConfigSignal, (val, old) => {
+  if (val !== old) {
+    if (isCollapsed.value) {
+      isCollapsed.value = false
+      emit('toggle', false)
+    }
+    activeSubmenu.value = 'english'
+  }
+})
+
+import titlePage from './page/TitlePage.json'
+import ImagePage from './page/ImagePage.json'
+import ContentPage from './page/ContentPage.json'
+import ImageTextPage from './page/ImageTextPage.json'
+
+const handleToolClick = _.debounce((tool: string) => {
+  interface ParentWindowWithToolList extends Window {
+    addTool?: (id: number) => void;
+    openVideoUploadDialog?: () => void;
+    openApplicationCenter?: () => void;
+  }
+  const parentWindow = window.parent as ParentWindowWithToolList
+  console.log('点击工具:', tool)
+  if (tool === 'h5page') {
+    parentWindow?.addTool?.(73)
+  }
+  else if (tool === 'aiapp') {
+    parentWindow?.addTool?.(72)
+  }
+  else if (tool === 'createApp') {
+    if (lang.lang === 'cn') {
+      window.open('https://cloud.cocorobo.cn/admin.html?type=cocoflow1', '_blank')
+    }
+    else if (lang.lang === 'en') {
+      window.open('https://cloud.cocorobo.com/admin.html?type=cocoflow1', '_blank')
+    }
+    else if (lang.lang === 'hk') {
+      window.open('https://cloud.cocorobo.hk/admin.html?type=cocoflow1', '_blank')
+    }
+  }
+  else if (tool === 'video') {
+    parentWindow?.openVideoUploadDialog?.()
+  }
+  else if (tool === 'creative') {
+    parentWindow?.openApplicationCenter?.()
+  }
+  else if (tool === 'choice') {
+    parentWindow?.addTool?.(45)
+  }
+  else if (tool === 'vote') {
+    parentWindow?.addTool?.(78)
+  }
+  else if (tool === 'photo') {
+    parentWindow?.addTool?.(79)
+  } 
+  else if (tool === 'qa') {
+    parentWindow?.addTool?.(15)
+  }
+  else if (tool === 'titlepage') {
+    createSlideByTemplate(titlePage)
+  }
+  else if (tool === 'ImagePage') {
+    createSlideByTemplate(ImagePage)
+  }
+  else if (tool === 'ContentPage') {
+    createSlideByTemplate(ContentPage)
+  }
+  else if (tool === 'ImageTextPage') {
+    createSlideByTemplate(ImageTextPage)
+  }
+  else if (tool === 'createWebpage') {
+    if (lang.lang === 'cn') {
+      window.open('https://cloud.cocorobo.cn/admin.html?type=cocoflow3', '_blank')
+    }
+    else if (lang.lang === 'en') {
+      window.open('https://cloud.cocorobo.com/admin.html?type=cocoflow3', '_blank')
+    }
+    else if (lang.lang === 'hk') {
+      window.open('https://cloud.cocorobo.hk/admin.html?type=cocoflow3', '_blank')
+    }
+  }
+  else if (tool === 'uploadWebpage') {
+    activeSubmenu.value = 'uploadWebpage'
+  }
+}, 300)
+
+const loadContentList = () => {
+  try {
+    interface ParentWindowWithToolList extends Window {
+      pptToolList?: ContentItem[]
+    }
+    const parentWindow = window.parent as ParentWindowWithToolList
+    contentList.value = parentWindow?.pptToolList || []
+  }
+  catch (error) {
+    console.error('加载内容列表失败:', error)
+    contentList.value = []
+  }
+}
+
+const insertContent = (item: ContentItem) => {
+  if (!item.tool || !item.url) return
+  createFrameElement(item.url, item.tool)
+}
+
+const addContent = (data: ContentItem, type: number) => {
+  // contentList.value.push(data)
+  if (type === 2) {
+    const elements = currentSlide.value?.elements || []
+    const frameElement = elements.find((el: any) => el.type === 'frame' && (el.toolType === 45 || el.toolType === 15 || el.toolType === 78 || el.toolType === 79))
+    if (frameElement) {
+      slidesStore.updateElement({
+        id: frameElement.id,
+        props: { url: data.url, toolType: data.tool }
+      })
+    }
+  }
+  else {
+    createSlide()
+    insertContent(data)
+  }
+}
+
+Object.assign(window, { addContent, loadContentList })
+// window.loadContentList = loadContentList
+// window.addContent = addContent
+
+const previewVideo = (item: ContentItem) => {
+  interface ParentWindowWithToolList extends Window {
+    previewVideo?: (item: ContentItem) => void;
+  }
+  const parentWindow = window.parent as ParentWindowWithToolList
+  parentWindow?.previewVideo?.(item)
+}
+
+const editContent = (item: ContentItem) => {
+  interface ParentWindowWithToolList extends Window {
+    toolBtn?: (action: number, id: string) => void;
+  }
+  const parentWindow = window.parent as ParentWindowWithToolList
+  parentWindow?.toolBtn?.(0, item.id || '')
+}
+
+const copyContent = (item: ContentItem) => {
+  interface ParentWindowWithToolList extends Window {
+    toolBtn?: (action: number, id: string) => void;
+  }
+  const parentWindow = window.parent as ParentWindowWithToolList
+  parentWindow?.toolBtn?.(1, item.id || '')
+}
+
+const deleteContent = (item: ContentItem) => {
+  interface ParentWindowWithToolList extends Window {
+    toolBtn?: (action: number, id: string) => void;
+  }
+  const parentWindow = window.parent as ParentWindowWithToolList
+  parentWindow?.toolBtn?.(2, item.id || '')
+}
+
+const getTypeLabel = (type?: number) => {
+  const typeMap: Record<number, string> = {
+    45: lang.ssChoiceQ,
+    15: lang.ssQATest,
+    72: lang.ssAiApp,
+    73: lang.ssHPage,
+    81: lang.ssHPage,
+    74: lang.ssVideo,
+    75: lang.lang == 'cn' ? lang.ssBiliVideo : lang.ssYouTube,
+    76: lang.ssCreative,
+    77: lang.ssEnglishSpeakingTool,
+    [ARTICLE_READING_TOOL_TYPE]: lang.ssArticleReadingTool,
+    78: lang.ssVote,
+    79: lang.ssPhoto,
+  }
+  return typeMap[type || 0] || lang.ssUnknown
+}
+
+const getTypeClass = (type?: number) => {
+  const classMap: Record<number, string> = {
+    45: 'type-choice',
+    15: 'type-question',
+    72: 'type-ai',
+    73: 'type-h5',
+    74: 'type-video',
+    75: 'type-bilibili',
+    76: 'type-app-center'
+  }
+  return classMap[type || 0] || 'type-default'
+}
+
+import useImport from '@/hooks/useImport'
+import message from '@/utils/message'
+const { importPPTXFile, exporting, getFile } = useImport()
+const currentFileName = ref('')
+const parsingStatus = ref<'parsing' | 'success'>('parsing')
+const parsingAbortController = ref<AbortController | null>(null)
+
+const handleFileUpload = async (files: FileList) => {
+  if (!files || files.length === 0) return
+
+  const file = files[0]
+  currentFileName.value = file.name
+
+  try {
+    // 创建AbortController用于取消操作
+    parsingAbortController.value = new AbortController()
+    const signal = parsingAbortController.value.signal
+
+    // 调用importPPTXFile并传入signal
+    await importPPTXFile(files, { signal })
+  }
+  catch (error) {
+    if (error instanceof DOMException && error.name === 'AbortError') {
+      console.log(lang.ssFileParseCancelled)
+    }
+    else {
+      console.error(lang.ssFileParseFailed, error)
+      message.error(lang.ssFileParseFailedRetry)
+    }
+  }
+}
+
+const handleParsingClose = () => {
+  if (exporting.value && parsingAbortController.value) {
+    parsingAbortController.value.abort()
+    exporting.value = false
+    parsingAbortController.value = null
+    // message.info(lang.ssParseCancelled)
+  }
+  else if (!exporting.value) {
+    emit('close')
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.collapsible-toolbar {
+  width: 100%;
+  height: 100%;
+  display: flex;
+  background: #fff;
+  border-right: 1px solid #e5e7eb;
+  transition: width 0.3s ease;
+}
+
+.toolbar-content {
+  flex: 1;
+  overflow: hidden;
+  padding: 16px 8px;
+}
+
+.sidebar-content {
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+  width: 84px;
+  position: relative;
+}
+
+.sidebar-item {
+  width: 84px;
+  padding: 12px 8px;
+  border-radius: 12px;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  gap: 6px;
+  cursor: pointer;
+  transition: all 0.2s;
+  position: relative;
+
+  &:hover {
+    background: #f3f4f6;
+  }
+
+  &:active {
+    background: #e5e7eb;
+  }
+
+  &.active {
+    background: #fff5e5;
+    box-shadow: 0 2px 8px rgba(40, 92, 245, 0.15);
+  }
+
+  &.active::after {
+    content: '';
+    position: absolute;
+    left: -8px;
+    top: 50%;
+    transform: translateY(-50%);
+    width: 4px;
+    height: 32px;
+    background: #FF9300;
+    border-radius: 0 2px 2px 0;
+  }
+}
+
+.item-icon {
+  width: 22px;
+  height: 22px;
+  flex-shrink: 0;
+  color: #6b7280;
+}
+
+.sidebar-item:hover .item-icon,
+.sidebar-item.active .item-icon {
+  color: #FF9300;
+}
+
+.item-label {
+  font-size: 11px;
+  font-weight: 500;
+  color: #6b7280;
+  text-align: center;
+}
+
+.sidebar-item:hover .item-label,
+.sidebar-item.active .item-label {
+  color: #FF9300;
+  font-weight: 600;
+}
+
+.submenu {
+  width: 0;
+  min-width: 0;
+  overflow: hidden;
+  // transition: all 0.3s ease;
+  background: #fff;
+  border-radius: 0 12px 12px 0;
+  z-index: 100;
+
+  &.visible {
+    width: 420px;
+    // padding: 16px;
+    border-left: 1px solid #E5E7EB;
+  }
+
+  @media screen and (max-width: 1920px) {
+    &.visible {
+      width: 360px;
+    }
+  }
+
+  @media screen and (max-width: 1440px) {
+    &.visible {
+      width: 320px;
+    }
+  }
+
+  &.submenu-english {
+    display: flex;
+    flex-direction: column;
+  }
+}
+
+.submenu-title {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  border-bottom: 1px solid #f0f0f0;
+  margin-bottom: 20px;
+  width: 100%;
+  box-sizing: border-box;
+  padding: 12px 15px;
+
+  .title {
+    font-size: 14px;
+    font-weight: 600;
+    color: #333;
+  }
+
+  .close-icon {
+    width: 28px;
+    height: 28px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    border-radius: 8px;
+    cursor: pointer;
+    transition: all 0.2s ease;
+
+    &:hover {
+      background-color: #f3f4f6;
+
+      svg {
+        stroke: #6b7280;
+      }
+    }
+
+    svg {
+      width: 25px;
+      height: 25px;
+      stroke: #9ca3af;
+      transition: all 0.2s ease;
+    }
+  }
+}
+
+.submenu-content {
+  width: 100%;
+  height: calc(100% - 50px);
+  box-sizing: border-box;
+  overflow: hidden;
+}
+
+
+.submenu-img {
+  width: calc(100% - 30px);
+  margin: 0 auto;
+  height: 130px;
+  display: flex;
+  object-fit: cover;
+  border-radius: 10px;
+  margin-bottom: 20px;
+}
+
+.submenu-panel {
+  width: calc(100% - 30px);
+  height: 130px;
+  margin: 0 auto;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  padding: 15px 0;
+  margin-bottom: 20px;
+  background: #FFFAF0;
+  border-radius: 10px;
+  box-sizing: border-box;
+
+  svg,
+  img {
+    width: 120px;
+    height: auto;
+    object-fit: cover;
+  }
+
+  .detail {
+    font-size: 12px;
+    font-weight: 500;
+    color: #6B7280;
+    text-align: center;
+    margin-top: 5px;
+  }
+}
+
+/* 淡入淡出过渡效果 */
+.fade-enter-active,
+.fade-leave-active {
+  transition: opacity 0.3s ease, transform 0.3s ease;
+}
+
+.fade-enter-from,
+.fade-leave-to {
+  opacity: 0;
+  transform: scale(0.95);
+}
+
+.fade-enter-to,
+.fade-leave-from {
+  opacity: 1;
+  transform: scale(1);
+}
+
+.submenu-item-box {
+  display: grid;
+  grid-template-columns: repeat(2, 1fr);
+  gap: 12px;
+  padding: 0 15px;
+
+  .submenu-item {
+    display: flex;
+    align-items: center;
+    gap: 12px;
+    padding: 12px;
+    cursor: pointer;
+    transition: all 0.2s ease;
+    font-size: 14px;
+    color: #333;
+    border-radius: 12px;
+    background: #fff;
+    border: 1px solid #e5e7eb;
+
+    &:hover {
+      background-color: #fffdfa;
+      border-color: #fffdfa;
+      color: #FF9300;
+      transform: translateY(-1px);
+      box-shadow: 0 2px 8px rgba(40, 92, 245, 0.1);
+    }
+
+    &:active {
+      background-color: #fffdfa;
+      transform: translateY(0);
+    }
+  }
+
+  .submenu-icon {
+    width: 20px;
+    height: 20px;
+    flex-shrink: 0;
+    color: #6b7280;
+    background: #fffaf0;
+    border-radius: 10px;
+    padding: 8px;
+    border: 1px solid #e5e7eb;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+  }
+
+  .submenu-item:hover .submenu-icon {
+    color: #FF9300;
+    border-color: #FF9300;
+    box-shadow: 0 0 0 2px rgba(255, 147, 0, 0.1);
+  }
+
+  .submenu-label {
+    font-size: 14px;
+    font-weight: 500;
+    color: #333;
+    flex: 1;
+  }
+
+  .submenu-item:hover .submenu-label {
+    color: #FF9300;
+    font-weight: 600;
+  }
+}
+
+.submenu-item-box2 {
+  display: grid;
+  grid-template-columns: repeat(2, 1fr);
+  gap: 12px;
+  padding: 0 15px;
+
+  .submenu-item {
+    display: flex;
+    align-items: center;
+    gap: 12px;
+    padding: 12px;
+    cursor: pointer;
+    transition: all 0.2s ease;
+    font-size: 14px;
+    color: #333;
+    border-radius: 12px;
+    border: 1px solid #e5e7eb;
+    flex-direction: column;
+    background: #fafbfc;
+
+    &:hover {
+      background-color: #fffdfa;
+      border-color: #fffdfa;
+      color: #FF9300;
+      transform: translateY(-1px);
+      box-shadow: 0 2px 8px rgba(40, 92, 245, 0.1);
+    }
+
+    &:active {
+      background-color: #fffdfa;
+      transform: translateY(0);
+    }
+  }
+
+  .submenu-icon {
+    flex-shrink: 0;
+    color: #6b7280;
+    background: #fff;
+    border-radius: 10px;
+    padding: 25px;
+    border: 1px solid #e5e7eb;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    width: 95%;
+
+    svg {
+      width: 40px;
+      height: 40px;
+      color: #d7dbe0;
+    }
+  }
+
+  .submenu-item:hover .submenu-icon {
+    svg {
+      color: #FF9300;
+    }
+
+    border-color: #FF9300;
+    box-shadow: 0 0 0 2px rgba(255, 147, 0, 0.1);
+  }
+
+  .submenu-label {
+    font-size: 14px;
+    font-weight: 600;
+    color: #333;
+    flex: 1;
+  }
+
+  .submenu-item:hover .submenu-label {
+    color: #FF9300;
+    font-weight: 600;
+  }
+}
+
+.submenu-upload {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  padding: 12px;
+  cursor: pointer;
+  transition: all 0.2s ease;
+  font-size: 14px;
+  color: #333;
+  border-radius: 12px;
+  border: 1px solid #e5e7eb;
+  background: #fafbfc;
+  width: calc(100% - 30px);
+  margin: 10px auto 0;
+  justify-content: center;
+
+  .submenu-icon {
+    width: 20px;
+    height: 20px;
+
+    svg {
+      color: #d7dbe0;
+      width: 20px;
+      height: 20px;
+    }
+  }
+
+  .submenu-label {
+    font-size: 14px;
+    font-weight: 600;
+    color: #333;
+
+  }
+
+  &:hover {
+    background-color: #fffdfa;
+    border-color: #fffdfa;
+    color: #FF9300;
+    transform: translateY(-1px);
+    box-shadow: 0 2px 8px rgba(40, 92, 245, 0.1);
+
+
+    .submenu-icon {
+
+      svg {
+        color: #FF9300;
+      }
+    }
+
+    .submenu-label {
+      color: #FF9300;
+    }
+  }
+}
+
+
+.parsing-modal {
+  position: fixed;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  background: rgba(0, 0, 0, 0.5);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  z-index: 1000;
+
+  .parsing-content {
+    background: white;
+    border-radius: 12px;
+    padding: 24px;
+    width: 400px;
+    text-align: center;
+    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
+
+    .loading-spinner {
+      width: 48px;
+      height: 48px;
+      border: 4px solid #f0f0f0;
+      border-top: 4px solid #FF9300;
+      border-radius: 50%;
+      margin: 0 auto 20px;
+      animation: spin 1s linear infinite;
+    }
+
+    .success-icon {
+      width: 48px;
+      height: 48px;
+      margin: 0 auto 20px;
+      background: #FFFAF0;
+      border-radius: 5px;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      color: white;
+      font-size: 24px;
+      font-weight: bold;
+    }
+
+    h3 {
+      font-size: 20px;
+      font-weight: 600;
+      color: #333;
+      margin: 0 0 12px;
+    }
+
+    p {
+      font-size: 14px;
+      color: #666;
+      margin: 0 0 24px;
+    }
+
+    .close-btn2 {
+      background: #FF9300;
+      color: white;
+      border: none;
+      border-radius: 8px;
+      padding: 12px 24px;
+      font-size: 14px;
+      font-weight: 500;
+      cursor: pointer;
+      width: 100%;
+      transition: all 0.3s;
+
+      &:hover {
+        background: #e68a00;
+      }
+    }
+  }
+
+  @keyframes spin {
+    0% {
+      transform: rotate(0deg);
+    }
+
+    100% {
+      transform: rotate(360deg);
+    }
+  }
+
+  .close-btn {
+    width: 32px;
+    height: 32px;
+    border: none;
+    background: none;
+    cursor: pointer;
+    color: #999;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    border-radius: 4px;
+    transition: all 0.2s;
+
+    &:hover {
+      background: #f0f0f0;
+      color: #666;
+    }
+
+    svg {
+      width: 16px;
+      height: 16px;
+    }
+  }
+}
+
+.line_box {
+  .webpage-link-container {
+    padding: 20px;
+    // text-align: center;
+
+    .webpage-link-title {
+      margin: 0 0 16px;
+      font-size: 14px;
+      font-weight: 600;
+      color: #333;
+    }
+
+    .webpage-link-input {
+      width: 100%;
+      padding: 12px 12px;
+      border: 1px solid #d9d9d9;
+      border-radius: 8px;
+      font-size: 14px;
+      margin-bottom: 16px;
+      transition: all 0.3s;
+      box-sizing: border-box;
+
+      &:focus {
+        outline: none;
+        border-color: #FF9300;
+        background: #fff8f0;
+      }
+    }
+
+    .webpage-link-button {
+      text-align: center;
+      padding: 10px 24px;
+      border: none;
+      border-radius: 4px;
+      font-size: 14px;
+      font-weight: 500;
+      cursor: pointer;
+      transition: all 0.3s;
+      background-color: #FF9300;
+      color: white;
+      margin: 0 auto;
+      display: block;
+
+      &:hover:not(:disabled) {
+        background-color: #e68a00;
+      }
+
+      &:disabled {
+        opacity: 0.5;
+        cursor: not-allowed;
+      }
+
+      &.loading {
+        cursor: not-allowed;
+        opacity: 0.8;
+      }
+
+      &.error {
+        background-color: #ff4d4f;
+        color: white;
+
+        &:hover:not(:disabled) {
+          background-color: #ff7875;
+        }
+      }
+    }
+  }
+}
+</style>

+ 1 - 0
src/components/ColorButton.vue

@@ -33,6 +33,7 @@ defineProps<{
 .content {
   width: 100%;
   height: 100%;
+  padding: 0;
 }
 .color-btn-icon {
   width: 32px;

+ 143 - 0
src/components/Contextmenu2/MenuContent.vue

@@ -0,0 +1,143 @@
+<template>
+  <ul class="menu-content" 
+    :style="{width: lang.lang === 'en' ? '160px' : '120px'}"
+  >
+    <template v-for="(menu, index) in menus" :key="menu.text || index">
+      <li
+        v-if="!menu.hide"
+        class="menu-item"
+        @click.stop="handleClickMenuItem(menu)"
+        :class="{'divider': menu.divider, 'disable': menu.disable}"
+      >
+        <div 
+          class="menu-item-content" 
+          :class="{
+            'has-children': menu.children,
+            'has-handler': menu.handler,
+          }" 
+          v-if="!menu.divider"
+        >
+          <span class="text">{{menu.text}}</span>
+          <span class="sub-text" v-if="menu.subText && !menu.children">{{menu.subText}}</span>
+
+          <menu-content 
+            class="sub-menu"
+            :menus="menu.children" 
+            v-if="menu.children && menu.children.length"
+            :handleClickMenuItem="handleClickMenuItem" 
+          />
+        </div>
+      </li>
+    </template>
+  </ul>
+</template>
+
+<script lang="ts" setup>
+import type { ContextmenuItem } from './types'
+import { lang } from '@/main'
+defineProps<{
+  menus: ContextmenuItem[]
+  handleClickMenuItem: (item: ContextmenuItem) => void
+}>()
+</script>
+
+<style lang="scss" scoped>
+$menuWidth: 180px;
+$menuHeight: 30px;
+$subMenuWidth: 120px;
+
+.menu-content {
+  width: $menuWidth;
+  padding: 8px;
+  // background: #fff;
+  background: #fff;
+  box-shadow: $boxShadow;
+  border-radius: 10px;
+  list-style: none;
+  margin: 0;
+}
+.menu-item {
+  // padding: 0 20px;
+  color: #555;
+  font-size: 14px;
+  transition: all $transitionDelayFast;
+  white-space: nowrap;
+  height: 40px;
+  line-height: 40px;
+  // background-color: #fff;
+  background-color: #fff;
+  cursor: pointer;
+  border-radius: 10px;
+
+  &:not(.disable):hover > .menu-item-content > .sub-menu {
+    display: block;
+  }
+
+  &:not(.disable):hover > .has-children.has-handler::after {
+    transform: scale(1);
+  }
+
+  &:hover:not(.disable) {
+    // background-color: rgba($color: $themeColor, $alpha: .2);
+    background-color: rgba($color: #ff9300, $alpha: .2);
+  }
+
+  &.divider {
+    height: 1px;
+    overflow: hidden;
+    margin: 5px;
+    background-color: #e5e5e5;
+    line-height: 0;
+    padding: 0;
+  }
+
+  &.disable {
+    color: #b1b1b1;
+    cursor: no-drop;
+  }
+}
+.menu-item-content {
+  display: flex;
+  align-items: center;
+  // justify-content: space-between;
+  justify-content: center;
+  position: relative;
+
+  &.has-children::before {
+    content: '';
+    display: inline-block;
+    width: 8px;
+    height: 8px;
+    border-width: 1px;
+    border-style: solid;
+    border-color: #666 #666 transparent transparent;
+    position: absolute;
+    right: 0;
+    top: 50%;
+    transform: translateY(-50%) rotate(45deg);
+  }
+  &.has-children.has-handler::after {
+    content: '';
+    display: inline-block;
+    width: 1px;
+    height: 24px;
+    background-color: rgba($color: #fff, $alpha: .3);
+    position: absolute;
+    right: 18px;
+    top: 3px;
+    transform: scale(0);
+    transition: transform $transitionDelay;
+  }
+
+  .sub-text {
+    opacity: 0.6;
+  }
+  .sub-menu {
+    width: $subMenuWidth;
+    position: absolute;
+    display: none;
+    left: 112%;
+    top: -6px;
+  }
+}
+</style>

+ 80 - 0
src/components/Contextmenu2/index.vue

@@ -0,0 +1,80 @@
+<template>
+  <div 
+    class="mask"
+    @contextmenu.prevent="removeContextmenu()"
+    @mousedown.left="removeContextmenu()"
+  ></div>
+
+  <div 
+    class="contextmenu"
+    :style="{
+      left: style.left + 'px',
+      top: style.top + 'px',
+    }"
+    @contextmenu.prevent
+  >
+    <MenuContent 
+      :menus="menus"
+      :handleClickMenuItem="handleClickMenuItem" 
+    />
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { computed } from 'vue'
+import type { ContextmenuItem, Axis } from './types'
+
+import MenuContent from './MenuContent.vue'
+
+const props = defineProps<{
+  axis: Axis
+  el: HTMLElement
+  menus: ContextmenuItem[]
+  removeContextmenu: () => void
+}>()
+
+const style = computed(() => {
+  const MENU_WIDTH = 180
+  const MENU_HEIGHT = 30
+  const DIVIDER_HEIGHT = 11
+  const PADDING = 5
+
+  const { x, y } = props.axis
+  const menuCount = props.menus.filter(menu => !(menu.divider || menu.hide)).length
+  const dividerCount = props.menus.filter(menu => menu.divider).length
+
+  const menuWidth = MENU_WIDTH
+  const menuHeight = menuCount * MENU_HEIGHT + dividerCount * DIVIDER_HEIGHT + PADDING * 2
+
+  const screenWidth = document.body.clientWidth
+  const screenHeight = document.body.clientHeight
+
+  return {
+    left: screenWidth <= x + menuWidth ? x - menuWidth : x,
+    top: screenHeight <= y + menuHeight ? y - menuHeight : y,
+  }
+})
+
+const handleClickMenuItem = (item: ContextmenuItem) => {
+  if (item.disable) return
+  if (item.children && !item.handler) return
+  if (item.handler) item.handler(props.el)
+  props.removeContextmenu()
+}
+</script>
+
+<style lang="scss">
+.mask {
+  position: fixed;
+  left: 0;
+  top: 0;
+  width: 100vw;
+  height: 100vh;
+  z-index: 9998;
+}
+.contextmenu {
+  position: fixed;
+  z-index: 9999;
+  user-select: none;
+}
+</style>

+ 14 - 0
src/components/Contextmenu2/types.ts

@@ -0,0 +1,14 @@
+export interface ContextmenuItem {
+  text?: string
+  subText?: string
+  divider?: boolean
+  disable?: boolean
+  hide?: boolean
+  children?: ContextmenuItem[]
+  handler?: (el: HTMLElement) => void
+}
+
+export interface Axis {
+  x: number
+  y: number
+}

+ 42 - 23
src/components/CreateCourseDialog.vue

@@ -12,18 +12,6 @@
       <h2>{{ lang.ssCreateCourse }}</h2>
       <p class="subtitle">{{ lang.ssCreateCourseSubtitle }}</p>
       <div class="options-grid">
-        <div class="option-card disabled">
-          <div class="option-icon">
-            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
-              <path d="M12 2L2 7l10 5 10-5-10-5z" />
-              <path d="M2 17l10 5 10-5" />
-              <path d="M2 12l10 5 10-5" />
-            </svg>
-          </div>
-          <h3>{{ lang.ssCreateFromAI }}</h3>
-          <p>{{ lang.ssAIGenerateContent }}</p>
-          <div class="coming-soon">{{ lang.ssComingSoon }}</div>
-        </div>
         <FileInput accept="application/vnd.openxmlformats-officedocument.presentationml.presentation"
           @change="handleFileUpload">
           <div class="option-card">
@@ -38,34 +26,46 @@
             <p>{{ lang.ssUploadPPTFile }}</p>
           </div>
         </FileInput>
-        <div class="option-card disabled">
+        <div class="option-card" @click="handleOptionClick('blank')">
           <div class="option-icon">
             <svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
               <g id="Component 1">
                 <path id="Vector"
-                  d="M3.5 10.5007L14 2.33398L24.5 10.5007V23.334C24.5 23.9528 24.2542 24.5463 23.8166 24.9839C23.379 25.4215 22.7855 25.6673 22.1667 25.6673H5.83333C5.21449 25.6673 4.621 25.4215 4.18342 24.9839C3.74583 24.5463 3.5 23.9528 3.5 23.334V10.5007Z"
+                  d="M16.3332 2.33398H6.99984C6.381 2.33398 5.78751 2.57982 5.34992 3.0174C4.91234 3.45499 4.6665 4.04848 4.6665 4.66732V23.334C4.6665 23.9528 4.91234 24.5463 5.34992 24.9839C5.78751 25.4215 6.381 25.6673 6.99984 25.6673H20.9998C21.6187 25.6673 22.2122 25.4215 22.6498 24.9839C23.0873 24.5463 23.3332 23.9528 23.3332 23.334V9.33398L16.3332 2.33398Z"
                   stroke="currentColor" stroke-width="2.33333" />
-                <path id="Vector_2" d="M10.5 25.6667V14H17.5V25.6667" stroke="currentColor" stroke-width="2.33333" />
+                <path id="Vector_2" d="M16.3335 2.33398V9.33398H23.3335" stroke="currentColor" stroke-width="2.33333" />
               </g>
             </svg>
           </div>
-          <h3>{{ lang.ssImportFromLibrary }}</h3>
-          <p>{{ lang.ssSelectExistingContent }}</p>
+          <h3>{{ lang.ssCreateBlank }}</h3>
+          <p>{{ lang.ssStartFromScratch }}</p>
+        </div>
+        <div class="option-card disabled">
+          <div class="option-icon">
+            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+              <path d="M12 2L2 7l10 5 10-5-10-5z" />
+              <path d="M2 17l10 5 10-5" />
+              <path d="M2 12l10 5 10-5" />
+            </svg>
+          </div>
+          <h3>{{ lang.ssCreateFromAI }}</h3>
+          <p>{{ lang.ssAIGenerateContent }}</p>
           <div class="coming-soon">{{ lang.ssComingSoon }}</div>
         </div>
-        <div class="option-card" @click="handleOptionClick('blank')">
+        <div class="option-card disabled">
           <div class="option-icon">
             <svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
               <g id="Component 1">
                 <path id="Vector"
-                  d="M16.3332 2.33398H6.99984C6.381 2.33398 5.78751 2.57982 5.34992 3.0174C4.91234 3.45499 4.6665 4.04848 4.6665 4.66732V23.334C4.6665 23.9528 4.91234 24.5463 5.34992 24.9839C5.78751 25.4215 6.381 25.6673 6.99984 25.6673H20.9998C21.6187 25.6673 22.2122 25.4215 22.6498 24.9839C23.0873 24.5463 23.3332 23.9528 23.3332 23.334V9.33398L16.3332 2.33398Z"
+                  d="M3.5 10.5007L14 2.33398L24.5 10.5007V23.334C24.5 23.9528 24.2542 24.5463 23.8166 24.9839C23.379 25.4215 22.7855 25.6673 22.1667 25.6673H5.83333C5.21449 25.6673 4.621 25.4215 4.18342 24.9839C3.74583 24.5463 3.5 23.9528 3.5 23.334V10.5007Z"
                   stroke="currentColor" stroke-width="2.33333" />
-                <path id="Vector_2" d="M16.3335 2.33398V9.33398H23.3335" stroke="currentColor" stroke-width="2.33333" />
+                <path id="Vector_2" d="M10.5 25.6667V14H17.5V25.6667" stroke="currentColor" stroke-width="2.33333" />
               </g>
             </svg>
           </div>
-          <h3>{{ lang.ssCreateBlank }}</h3>
-          <p>{{ lang.ssStartFromScratch }}</p>
+          <h3>{{ lang.ssImportFromLibrary }}</h3>
+          <p>{{ lang.ssSelectExistingContent }}</p>
+          <div class="coming-soon">{{ lang.ssComingSoon }}</div>
         </div>
       </div>
     </div>
@@ -100,6 +100,8 @@ import useImport from '@/hooks/useImport'
 import FileInput from '@/components/FileInput.vue'
 import message from '@/utils/message'
 import { lang } from '@/main'
+import { showConfirmDialog } from '@/utils/confirmDialog'
+import { useMainStore } from '@/store'
 
 const emit = defineEmits<{
   (e: 'close'): void
@@ -108,6 +110,8 @@ const emit = defineEmits<{
 }>()
 
 const { importPPTXFile, exporting } = useImport()
+const mainStore = useMainStore()
+const { setRunningState } = mainStore
 const currentFileName = ref('')
 const parsingStatus = ref<'parsing' | 'success'>('parsing')
 const parsingAbortController = ref<AbortController | null>(null)
@@ -137,7 +141,22 @@ const handleFileUpload = async (files: FileList) => {
     const signal = parsingAbortController.value.signal
 
     // 调用importPPTXFile并传入signal
-    await importPPTXFile(files, { signal, onclose: () => {emit('setTitle', file.name.replace(/\.[^/.]+$/, '')); emit('close')} })
+    await importPPTXFile(files, { signal, onclose: () => {
+      emit('setTitle', file.name.replace(/\.[^/.]+$/, '')); emit('close')
+      showConfirmDialog({
+        title: lang.ssUploadSuccess,
+        content: lang.ssBatchInteractiveWebConfirm,
+        confirmText: lang.ssBatchInteractiveWebConfirmDesc,
+        cancelText: lang.ssBatchInteractiveWebConfirmDesc2,
+        width: 400,
+        onConfirm: () => {
+          setRunningState(true)
+        },
+        onCancel: () => {
+          console.log('取消删除')
+        },
+      })
+    } })
   }
   catch (error) {
     if (error instanceof DOMException && error.name === 'AbortError') {

+ 27 - 2
src/components/FileInput.vue

@@ -1,5 +1,13 @@
 <template>
-  <div class="file-input" @click="handleClick()">
+  <div 
+    class="file-input" 
+    :class="{ 'drag-over': isDragOver }"
+    @click="handleClick()"
+    @dragenter.prevent="handleDragEnter"
+    @dragover.prevent="handleDragOver"
+    @dragleave.prevent="handleDragLeave"
+    @drop.prevent="handleDrop"
+  >
     <slot></slot>
     <input 
       class="input"
@@ -14,7 +22,7 @@
 </template>
 
 <script lang="ts" setup>
-import { useTemplateRef } from 'vue'
+import { ref, useTemplateRef } from 'vue'
 
 withDefaults(defineProps<{
   accept?: string
@@ -27,6 +35,7 @@ const emit = defineEmits<{
 }>()
 
 const inputRef = useTemplateRef<HTMLInputElement>('inputRef')
+const isDragOver = ref(false)
 
 const handleClick = () => {
   if (!inputRef.value) return
@@ -37,6 +46,22 @@ const handleChange = (e: Event) => {
   const files = (e.target as HTMLInputElement).files
   if (files) emit('change', files)
 }
+const handleDragEnter = () => {
+  isDragOver.value = true
+}
+const handleDragOver = () => {
+  isDragOver.value = true
+}
+const handleDragLeave = () => {
+  isDragOver.value = false
+}
+const handleDrop = (e: DragEvent) => {
+  isDragOver.value = false
+  const files = e.dataTransfer?.files
+  if (files && files.length > 0) {
+    emit('change', files)
+  }
+}
 </script>
 
 <style lang="scss" scoped>

+ 6 - 4
src/components/Switch.vue

@@ -31,14 +31,16 @@ const handleChange = () => {
 
 <style lang="scss" scoped>
 .switch {
-  height: 20px;
-  display: inline-block;
+  // height: 20px;
+  // display: inline-block;
+  display: flex;
+  align-items: center;
   cursor: pointer;
 
   &:not(.disabled).active {
     .switch-core {
-      border-color: $themeColor;
-      background-color: $themeColor;
+      border-color: #f6c82b;
+      background-color: #f6c82b;
 
       &::after {
         left: 100%;

+ 38 - 0
src/components/TextColorButton2.vue

@@ -0,0 +1,38 @@
+<template>
+  <Button class="text-color-btn">
+    <slot></slot>
+    <div class="text-color-block">
+      <div class="text-color-block-content" :style="{ backgroundColor: color }"></div>
+    </div>
+  </Button>
+</template>
+
+<script lang="ts" setup>
+import Button from './Button.vue'
+
+defineProps<{
+  color: string
+}>()
+</script>
+
+<style lang="scss" scoped>
+.text-color-btn {
+  width: 100%;
+  display: flex !important;
+  flex-direction: column;
+  justify-content: center;
+  align-items: center;
+  padding: 0 15px;
+}
+.text-color-block {
+  width: 17px;
+  height: 4px;
+  margin-top: 1px;
+  background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAQAAAAECAYAAACp8Z5+AAAAAXNSR0IArs4c6QAAACdJREFUGFdjfPbs2X8GBgYGSUlJEMXAiCHw//9/sIrnz59DVKALAADNxxVfaiODNQAAAABJRU5ErkJggg==);
+
+  .text-color-block-content {
+    width: 100%;
+    height: 100%;
+  }
+}
+</style>

+ 17 - 0
src/configs/englishSpeakingTools.ts

@@ -0,0 +1,17 @@
+/**
+ * 口语活动的画布工具编号(唯一来源)。
+ *
+ * 772 刻意跳出 72–81 这段顺序区间:82 是该区间的下一个空位,可能被并行开发的
+ * 其他功能占用。所有判定必须走这里的常量与函数,不得写字面量或范围比较。
+ */
+export const TOPIC_DISCUSSION_TOOL_TYPE = 77 as const
+export const SENTENCE_READING_TOOL_TYPE = 80 as const
+export const ARTICLE_READING_TOOL_TYPE = 772 as const
+
+export function isArticleReadingTool(type: unknown): boolean {
+  return Number(type) === ARTICLE_READING_TOOL_TYPE
+}
+
+export function isTopicDiscussionTool(type: unknown): boolean {
+  return Number(type) === TOPIC_DISCUSSION_TOOL_TYPE
+}

+ 1 - 0
src/configs/hotkey.ts

@@ -13,6 +13,7 @@ export const enum KEYS {
   O = 'O',
   R = 'R',
   T = 'T',
+  V = 'V',
   MINUS = '-',
   EQUAL = '=',
   DIGIT_0 = '0',

+ 1 - 0
src/hooks/useCopyAndPasteElement.ts

@@ -35,6 +35,7 @@ export default () => {
 
   // 尝试将剪贴板元素数据解密后进行粘贴
   const pasteElement = () => {
+    console.log('pasteElement')
     readClipboard().then(text => {
       pasteTextClipboardData(text)
     }).catch(err => message.warning(err))

+ 2 - 1
src/hooks/useCreateElement.ts

@@ -318,7 +318,7 @@ export default () => {
    * 创建网页元素
    * @param url 网页链接地址
    */
-  const createFrameElement = (url: string, type: number) => {
+  const createFrameElement = (url: string, type: number, id?: string) => {
     // 检查当前幻灯片是否已经包含网页元素
     const { currentSlide } = storeToRefs(useSlidesStore())
     const hasWebpage = currentSlide.value?.elements?.some(element => element.type === 'frame')
@@ -334,6 +334,7 @@ export default () => {
     createElement({
       type: 'frame',
       id: nanoid(10),
+      webId: id || '',
       url,
       width: width,
       height: height,

+ 12 - 1
src/hooks/useGlobalHotkey.ts

@@ -44,7 +44,7 @@ export default () => {
   const { combineElements, uncombineElements } = useCombineElement()
   const { deleteElement } = useDeleteElement()
   const { lockElement } = useLockElement()
-  const { copyElement, cutElement, quickCopyElement } = useCopyAndPasteElement()
+  const { copyElement, cutElement, quickCopyElement, pasteElement } = useCopyAndPasteElement()
   const { selectAllElements } = useSelectElement()
   const { moveElement } = useMoveElement()
   const { orderElement } = useOrderElement()
@@ -62,6 +62,12 @@ export default () => {
     else if (thumbnailsFocus.value) cutSlide()
   }
 
+  const paste = () => {
+    console.log('paste')
+    
+    if (editorAreaFocus.value) pasteElement()
+  }
+
   const quickCopy = () => {
     if (activeElementIdList.value.length) quickCopyElement()
     else if (thumbnailsFocus.value) copyAndPasteSlide()
@@ -186,6 +192,11 @@ export default () => {
       e.preventDefault()
       cut()
     }
+    // if (ctrlOrMetaKeyActive && key === KEYS.V) {
+    //   // if (disableHotkeys.value) return
+    //   e.preventDefault()
+    //   paste()
+    // }
     if (ctrlOrMetaKeyActive && key === KEYS.D) {
       if (disableHotkeys.value) return
       e.preventDefault()

+ 87 - 32
src/hooks/useImport.ts

@@ -15,6 +15,29 @@ import { showConfirmDialog } from '@/utils/confirmDialog'
 import { EMFJS, WMFJS } from 'rtf.js'
 import * as UTIF from 'utif2'
 
+/**
+ * 获取 PPT 文件的基本信息
+ */
+const getPPTInfo = async (file: File): Promise<{ pageCount: number; filename: string }> => {
+  return new Promise((resolve, reject) => {
+    const reader = new FileReader()
+    reader.onload = async (e) => {
+      try {
+        const json = await parse(e.target!.result as ArrayBuffer)
+        resolve({
+          pageCount: json.slides.length,
+          filename: file.name
+        })
+      }
+      catch (error) {
+        reject(error)
+      }
+    }
+    reader.onerror = reject
+    reader.readAsArrayBuffer(file)
+  })
+}
+
 import type {
   Slide,
   TableCellStyle,
@@ -50,9 +73,9 @@ const getStyle = (htmlString: string) => {
   // 2. 解析 HTML 字符串为文档对象
   const doc = parser.parseFromString(htmlString, 'text/html')
   // 3. 获取 p 元素
-  const firstElem = doc.querySelector('p, ul, ol, table');
+  const firstElem = doc.querySelector('p, ul, ol, table')
   // 读取该元素的 allstyle 属性
-  const styleAttr = firstElem?.getAttribute('allstyle');
+  const styleAttr = firstElem?.getAttribute('allstyle')
   console.log(styleAttr) // 输出完整的 style 字符串
   return styleAttr || ''
 
@@ -384,29 +407,41 @@ export default () => {
   }
 
   const calculateRotatedPosition = (
-    x: number,
-    y: number,
-    w: number,
-    h: number,
-    ox: number,
-    oy: number,
-    k: number,
+    x: number, // 原外层容器左上角 X
+    y: number, // 原外层容器左上角 Y
+    w: number, // 原外层容器宽度
+    h: number, // 原外层容器高度
+    ox: number, // 子元素在原容器内左上角 X
+    oy: number, // 子元素在原容器内左上角 Y
+    childW: number, // 子元素宽度
+    childH: number, // 子元素高度
+    k: number // 顺时针旋转角度(度)
   ) => {
     const radians = k * (Math.PI / 180)
+    const cos = Math.cos(radians)
+    const sin = Math.sin(radians)
 
+    // 原容器中心点(全局坐标)
     const containerCenterX = x + w / 2
     const containerCenterY = y + h / 2
 
-    const relativeX = ox - w / 2
-    const relativeY = oy - h / 2
+    // 子元素中心相对于容器中心的偏移
+    const childCenterInContainerX = (ox + childW / 2) - w / 2
+    const childCenterInContainerY = (oy + childH / 2) - h / 2
 
-    const rotatedX = relativeX * Math.cos(radians) + relativeY * Math.sin(radians)
-    const rotatedY = -relativeX * Math.sin(radians) + relativeY * Math.cos(radians)
+    // 屏幕坐标系下的顺时针旋转
+    const rotatedOffsetX = childCenterInContainerX * cos - childCenterInContainerY * sin
+    const rotatedOffsetY = childCenterInContainerX * sin + childCenterInContainerY * cos
 
-    const graphicX = containerCenterX + rotatedX
-    const graphicY = containerCenterY + rotatedY
+    // 子元素中心的新全局坐标
+    const newCenterX = containerCenterX + rotatedOffsetX
+    const newCenterY = containerCenterY + rotatedOffsetY
 
-    return { x: graphicX, y: graphicY }
+    // 反推新左上角
+    return {
+      x: newCenterX - childW / 2,
+      y: newCenterY - childH / 2
+    }
   }
 
   /**
@@ -878,7 +913,8 @@ export default () => {
           width = parseFloat(parts[2])
           height = parseFloat(parts[3])
         }
-      } else {
+      }
+      else {
         const svgWidth = tempSvg.getAttribute('width')
         const svgHeight = tempSvg.getAttribute('height')
         if (svgWidth && svgHeight) {
@@ -886,7 +922,8 @@ export default () => {
           height = parseFloat(svgHeight)
         }
       }
-    } catch (e) {
+    }
+    catch (e) {
       console.warn('Failed to get dimensions from SVG, using default', e)
     }
 
@@ -948,7 +985,8 @@ export default () => {
           'image/png'
         )
       })
-    } finally {
+    }
+    finally {
       URL.revokeObjectURL(url)
     }
   }
@@ -1605,13 +1643,13 @@ export default () => {
     }
   */
 
-  const importPPTXFile = (files: FileList, options?: { cover?: boolean; fixedViewport?: boolean; signal?: AbortSignal, onclose?: () => void }) => {
+  const importPPTXFile = (files: FileList, options?: { cover?: boolean; fixedViewport?: boolean; signal?: AbortSignal, onclose?: () => void, startTimer?: () => void, confirmOnclose?: () => void }) => {
     console.log('导入', files)
     const defaultOptions = {
       cover: false,
       fixedViewport: false,
     }
-    const { cover, fixedViewport, signal, onclose } = { ...defaultOptions, ...options }
+    const { cover, fixedViewport, signal, onclose, startTimer, confirmOnclose } = { ...defaultOptions, ...options }
 
     let isNone = false
     if (slides.value.length === 1 && slides.value[0].elements.length === 0) {
@@ -1623,7 +1661,7 @@ export default () => {
 
     exporting.value = true // 假设 exporting 是一个全局 ref
     imgExporting.value = true // 假设 imgExporting 是一个全局 ref
-
+    startTimer?.()
     // 预加载形状库(用于后续形状匹配)
     const shapeList: ShapePoolItem[] = []
     for (const item of SHAPE_LIST) {
@@ -1678,6 +1716,7 @@ export default () => {
             exporting.value = false
             imgExporting.value = false
             onclose?.()
+            confirmOnclose?.()
             return Promise.reject(new Error('用户取消导入'))
           }
         }
@@ -1694,10 +1733,9 @@ export default () => {
 
       // 收集当前幻灯片内所有上传任务
       const uploadTasks: Promise<void>[] = []
-      
       // 遍历每一张幻灯片
       for (const item of json.slides) {
-        let type = "", value = "";
+        let type = '', value = ''
         if (item.fill) {
           // ----- 解析背景 -----
           type = item.fill.type
@@ -1785,7 +1823,7 @@ export default () => {
                 defaultColor: theme.value.fontColor,
                 content: convertFontSizePtToPx(el.content, ratio, el.autoFit),
                 style: getStyle(convertFontSizePtToPx(el.content, ratio, el.autoFit)),
-                lineHeight: 1.5,
+                lineHeight: 1.2,
                 align: vAlignMap[el.vAlign] || 'middle',
                 outline: {
                   color: el.borderColor,
@@ -1986,8 +2024,8 @@ export default () => {
             // ---------- 形状 ----------
             else if (el.type === 'shape') {
               if (el.shapType === 'line' || /Connector/.test(el.shapType)) {
-                //el.isFlipH = el.isFlipV = false;
-                //el.rotate = 0;
+                // el.isFlipH = el.isFlipV = false;
+                // el.rotate = 0;
                 //   // 线条元素(单独处理)
                 //   const lineElement = parseLineElement(el, ratio)
                 //   slide.elements.push(lineElement)
@@ -2013,8 +2051,23 @@ export default () => {
                 : undefined
 
               const pattern: string | undefined = el.fill?.type === 'image' ? el.fill.value.picBase64 : undefined
-              const fill = el.fill?.type === 'color' ? el.fill.value : 'none'
-              const style = getStyle(convertFontSizePtToPx(el.content, ratio, el.autoFit)) + (el.pathBBox?.pWidth ? ';width:' + (el.pathBBox?.pWidth * ratio) + 'px;height:' + (el.pathBBox?.pHeight * ratio) + 'px;' : '') // 设置字体的样式等,这里由于不支持的样式在里面会过滤
+              const fill = el.fill?.type === 'color' ? el.fill.value : el.fill || 'none'
+              //const style = getStyle(convertFontSizePtToPx(el.content, ratio, el.autoFit)) + (el.pathBBox?.pWidth ? ';width:' + (el.pathBBox?.pWidth * ratio) + 'px;height:' + (el.pathBBox?.pHeight * ratio) + 'px;' : '') // 设置字体的样式等,这里由于不支持的样式在里面会过滤
+              const baseStyle = getStyle(convertFontSizePtToPx(el.content, ratio, el.autoFit));
+              const isVertical =
+                (typeof el.content === 'string' && el.content.includes('writing-mode: vertical-rl')) ||
+                baseStyle.includes('writing-mode: vertical-rl');
+
+              let extraStyle = '';
+              if (el.pathBBox?.pWidth) {
+                const w = el.pathBBox.pWidth * ratio;
+                const h = el.pathBBox.pHeight * ratio;
+                extraStyle = isVertical
+                  ? `;width:${h}px;height:${w}px;`
+                  : `;width:${w}px;height:${h}px;`;
+              }
+
+              const style = baseStyle + extraStyle;
               const element: PPTShapeElement = {
                 type: 'shape',
                 id: nanoid(10),
@@ -2100,7 +2153,7 @@ export default () => {
               }
 
               if (element.path) slide.elements.push(element)
-              //}
+              // }
             }
 
             // ---------- 表格 ----------
@@ -2271,7 +2324,7 @@ export default () => {
                 if (el.rotate) {
                   const { x, y } = calculateRotatedPosition(
                     originLeft, originTop, originWidth, originHeight,
-                    _el.left, _el.top, el.rotate
+                    _el.left, _el.top, _el.width, _el.height, el.rotate
                   )
                   left = x
                   top = y
@@ -2284,6 +2337,7 @@ export default () => {
                 }
                 if (el.isFlipH && 'isFlipH' in element) element.isFlipH = true
                 if (el.isFlipV && 'isFlipV' in element) element.isFlipV = true
+                if (el.rotate) element.rotate = (_el.rotate || 0) + el.rotate
 
                 return element
               })
@@ -2487,6 +2541,7 @@ export default () => {
     getFile,
     getFile2,
     dataToFile,
-    uploadFileToS3
+    uploadFileToS3,
+    getPPTInfo
   }
 }

+ 46 - 1
src/hooks/usePasteEvent.ts

@@ -4,6 +4,50 @@ import { useMainStore } from '@/store'
 import { getImageDataURL } from '@/utils/image'
 import usePasteTextClipboardData from './usePasteTextClipboardData'
 import useCreateElement from './useCreateElement'
+/**
+     * 上传 File 到 S3,返回公开访问的 URL
+     */
+const uploadFileToS3 = (file: File): Promise<string> => {
+  return new Promise((resolve, reject) => {
+    if (typeof window === 'undefined' || !window.AWS) {
+      reject(new Error('AWS SDK not available'))
+      return
+    }
+
+    const credentials = {
+      accessKeyId: 'AKIATLPEDU37QV5CHLMH',
+      secretAccessKey: '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',
+    }
+    window.AWS.config.update(credentials)
+    window.AWS.config.region = 'cn-northwest-1'
+
+    const bucket = new window.AWS.S3({
+      params: { Bucket: 'ccrb' }, httpOptions: {
+        timeout: 600000 // 10分钟超时
+      }
+    })
+    const ext = file.name.split('.').pop() || 'bin'
+    const key = `${file.name.split('.')[0]}_${Date.now()}.${ext}`
+
+    const params = {
+      Key: 'pptto/' + key,
+      ContentType: file.type,
+      Body: file,
+      ACL: 'public-read',
+    }
+    const options = {
+      partSize: 5 * 1024 * 1024, // 2GB 分片,可酌情调小
+      queueSize: 2,
+      leavePartsOnError: true,
+    }
+
+    bucket
+      .upload(params, options)
+      .promise()
+      .then(data => resolve(data.Location))
+      .catch(err => reject(err))
+  })
+}
 
 export default () => {
   const { editorAreaFocus, thumbnailsFocus, disableHotkeys } = storeToRefs(useMainStore())
@@ -13,7 +57,8 @@ export default () => {
 
   // 粘贴图片到幻灯片元素
   const pasteImageFile = (imageFile: File) => {
-    getImageDataURL(imageFile).then(dataURL => createImageElement(dataURL))
+    // getImageDataURL(imageFile).then(dataURL => createImageElement(dataURL))
+    uploadFileToS3(imageFile).then(url => createImageElement(url))
   }
 
   /**

+ 1 - 0
src/i18n/lang.ts

@@ -3,6 +3,7 @@ import cn from '@/views/lang/cn.json'
 import hk from '@/views/lang/hk.json'
 
 const href = window.location.href.toLowerCase()
+// export const lang = en
 export const lang =
   href.includes('cocorobo.com') ? en
     : href.includes('cocorobo.hk') ? hk

+ 64 - 0
src/plugins/directive/contextmenu2.ts

@@ -0,0 +1,64 @@
+import { type Directive, type DirectiveBinding, createVNode, render } from 'vue'
+import ContextmenuComponent from '@/components/Contextmenu2/index.vue'
+
+const CTX_CONTEXTMENU_HANDLER = 'CTX_CONTEXTMENU_HANDLER'
+
+interface CustomHTMLElement extends HTMLElement {
+  [CTX_CONTEXTMENU_HANDLER]?: (event: MouseEvent) => void
+} 
+
+const contextmenuListener = (el: HTMLElement, event: MouseEvent, binding: DirectiveBinding) => {
+  event.stopPropagation()
+  event.preventDefault()
+
+  const menus = binding.value(el)
+  if (!menus) return
+
+  let container: HTMLDivElement | null = null
+
+  // 移除右键菜单并取消相关的事件监听
+  const removeContextmenu = () => {
+    if (container) {
+      document.body.removeChild(container)
+      container = null
+    }
+    el.classList.remove('contextmenu-active')
+    document.body.removeEventListener('scroll', removeContextmenu)  
+    window.removeEventListener('resize', removeContextmenu)
+  }
+
+  // 创建自定义菜单
+  const options = {
+    axis: { x: event.x, y: event.y },
+    el,
+    menus,
+    removeContextmenu,
+  }
+  container = document.createElement('div')
+  const vm = createVNode(ContextmenuComponent, options, null)
+  render(vm, container)
+  document.body.appendChild(container)
+
+  // 为目标节点添加菜单激活状态的className
+  el.classList.add('contextmenu-active')
+
+  // 页面变化时移除菜单
+  document.body.addEventListener('scroll', removeContextmenu)
+  window.addEventListener('resize', removeContextmenu)
+}
+
+const ContextmenuDirective: Directive = {
+  mounted(el: CustomHTMLElement, binding) {
+    el[CTX_CONTEXTMENU_HANDLER] = (event: MouseEvent) => contextmenuListener(el, event, binding)
+    el.addEventListener('contextmenu', el[CTX_CONTEXTMENU_HANDLER])
+  },
+
+  unmounted(el: CustomHTMLElement) {
+    if (el && el[CTX_CONTEXTMENU_HANDLER]) {
+      el.removeEventListener('contextmenu', el[CTX_CONTEXTMENU_HANDLER])
+      delete el[CTX_CONTEXTMENU_HANDLER]
+    }
+  },
+}
+
+export default ContextmenuDirective

+ 2 - 0
src/plugins/directive/index.ts

@@ -1,12 +1,14 @@
 import type { App } from 'vue'
 
 import Contextmenu from './contextmenu'
+import Contextmenu2 from './contextmenu2'
 import ClickOutside from './clickOutside'
 import Tooltip from './tooltip'
 
 export default {
   install(app: App) {
     app.directive('contextmenu', Contextmenu)
+    app.directive('contextmenu2', Contextmenu2)
     app.directive('click-outside', ClickOutside)
     app.directive('tooltip', Tooltip)
   }

+ 4 - 0
src/plugins/icon.ts

@@ -131,6 +131,8 @@ import {
   LoadingFour, // 引入loadingIcon
   UpTwo,
   Refresh,
+  ThumbsUp,
+  CastScreen
 } from '@icon-park/vue-next'
 
 export interface Icons {
@@ -267,6 +269,8 @@ export const icons: Icons = {
   IconLoading: LoadingFour, // 添加loadingIcon
   UpTwo: UpTwo,
   IconRefresh: Refresh,
+  IconThumbsUp: ThumbsUp,
+  IconCastScreen: CastScreen,
 }
 
 export default {

+ 1 - 1
src/services/config.ts

@@ -17,7 +17,7 @@ instance.interceptors.request.use(
     // 确保每个请求都带上cookie
     config.withCredentials = true
 
-    if (url.includes('https://gpt4.cocorobo.cn') || url.includes('https://appapi.cocorobo.cn') || url.includes('https://ccrb.s3.cn-northwest-1.amazonaws.com.cn')) {
+    if (url.includes('https://yjsredis.cocorobo.cn/') || url.includes('https://gpt4.cocorobo.cn') || url.includes('https://appapi.cocorobo.cn') || url.includes('https://ccrb.s3.cn-northwest-1.amazonaws.com.cn')) {
       config.withCredentials = false 
     }
 

+ 39 - 3
src/services/course.ts

@@ -1,8 +1,8 @@
 import axios from './config'
 
 export const API_URL = 'https://pbl.cocorobo.cn/api/pbl/'
-export const yweb_socket = 'wss://yjs.cocorobo.cn'
-// export const yweb_socket = 'wss://yrs.cocorobo.cn'
+// export const yweb_socket = 'wss://yjs.cocorobo.cn'
+export const yweb_socket = 'wss://yrs.cocorobo.cn'
 
 /**
  * 获取课程详情
@@ -199,6 +199,17 @@ export const getWorkPageId = (params: any): Promise<any> => {
 }
 
 
+/**
+ * 
+ * 点赞功能
+ * @param any wid 作业id lid 用户id t 1点赞 2评论 c 评论内容
+ * @returns Promise<any>
+ */
+
+export const likeWork = (params: any): Promise<any> => {
+  return axios.post(`${API_URL}insertComment`, [params])
+}
+
 /**
  * 
  * 获取年级
@@ -210,6 +221,27 @@ export const getClassById = (params: any): Promise<any> => {
   return axios.get(`${API_URL}getClassById`, { params: params })
 }
 
+/**
+ * 
+ * 查询操作记录
+ * @param any id VARCHAR(3000), type VARCHAR(36), `index` int
+ * @returns Promise<any>
+ */
+
+export const selectPPTOp = (params: any): Promise<any> => {
+  return axios.post(`${API_URL}selectPPTOp`, [params])
+}
+
+/**
+ * 
+ * 新增操作记录
+ * @param any id VARCHAR(3000), type VARCHAR(36), `index` INT, `json` LONGTEXT
+ * @returns Promise<any>
+ */
+
+export const addPPTOp = (params: any): Promise<any> => {
+  return axios.post(`${API_URL}addPPTOp`, [params])
+}
 
 
 export default {
@@ -229,6 +261,10 @@ export default {
   getAgentData,
   clearDialogue,
   getWorkDetail,
-  getWorkPageId
+  getWorkPageId,
+  likeWork,
+  getClassById,
+  selectPPTOp,
+  addPPTOp,
 }
 

+ 111 - 0
src/services/speaking.ts

@@ -0,0 +1,111 @@
+import type { ArticleReadingConfig } from '@/types/articleReading'
+import type { TopicDiscussionConfig } from '@/types/englishSpeaking'
+import { SPEAKING_CONFIG_API_BASE_URL, SPEAKING_DIALOGUE_API_BASE_URL } from '@/views/Editor/EnglishSpeaking/services/speakingApiConfig'
+
+const API_BASE = SPEAKING_CONFIG_API_BASE_URL
+
+/** 话题讨论与文章朗读共用同一套 config endpoint,靠 config.type 判别。 */
+export type SpeakingActivityConfig = TopicDiscussionConfig | ArticleReadingConfig
+
+export interface SpeakingConfigRecord<T extends SpeakingActivityConfig = SpeakingActivityConfig> {
+  id: string
+  config: T
+  ownerUid?: string | null
+}
+
+async function parse<T>(res: Response): Promise<T> {
+  if (!res.ok) {
+    const detail = await res.text().catch(() => '')
+    throw new Error(`[${res.status}] ${detail || res.statusText}`)
+  }
+  return res.json() as Promise<T>
+}
+
+/** 新建口语配置(话题讨论或文章朗读) */
+export async function createSpeakingConfig<T extends SpeakingActivityConfig>(
+  config: T,
+  ownerUid?: string | null,
+): Promise<SpeakingConfigRecord<T>> {
+  const res = await fetch(API_BASE, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({ config, ownerUid: ownerUid ?? null }),
+  })
+  return parse<SpeakingConfigRecord<T>>(res)
+}
+
+/** 读取口语配置 */
+export async function getSpeakingConfig<T extends SpeakingActivityConfig = SpeakingActivityConfig>(
+  id: string,
+): Promise<SpeakingConfigRecord<T>> {
+  const res = await fetch(`${API_BASE}/${encodeURIComponent(id)}`)
+  return parse<SpeakingConfigRecord<T>>(res)
+}
+
+/** 更新口语配置 */
+export async function updateSpeakingConfig<T extends SpeakingActivityConfig>(
+  id: string,
+  config: T,
+): Promise<SpeakingConfigRecord<T>> {
+  const res = await fetch(`${API_BASE}/${encodeURIComponent(id)}`, {
+    method: 'PUT',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({ config }),
+  })
+  return parse<SpeakingConfigRecord<T>>(res)
+}
+
+const DIALOGUE_BASE = SPEAKING_DIALOGUE_API_BASE_URL
+
+export interface ClassSessionSummary {
+  userId: string
+  sessionId: string
+  status: 'active' | 'completed' | 'abandoned'
+  overallStatus: 'ready' | 'generating' | 'failed' | null
+  currentRound: number
+  totalRounds: number
+  overallScore: number | null
+  dimensions: Record<string, number>
+  topHighlights: string[]
+  topImprovements: string[]
+  createdAt: string | null
+  completedAt: string | null
+}
+
+export interface ListSessionsByConfigResponse {
+  summaries: ClassSessionSummary[]
+}
+
+export async function listSpeakingSessionsByConfig(
+  configId: string,
+  userIds: string[],
+): Promise<ListSessionsByConfigResponse> {
+  const res = await fetch(`${DIALOGUE_BASE}/sessions/by-config`, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    credentials: 'include',
+    body: JSON.stringify({ configId, userIds }),
+  })
+  return parse<ListSessionsByConfigResponse>(res)
+}
+
+export interface ClassSummaryResponse {
+  bullets: [string, string, string]
+  generatedAt: string
+  fromCache: boolean
+  llmStatus: 'ok' | 'fallback'
+}
+
+export async function generateClassSummary(
+  configId: string,
+  userIds: string[],
+  locale: 'zh' | 'en' | 'hk',
+): Promise<ClassSummaryResponse> {
+  const res = await fetch(`${DIALOGUE_BASE}/sessions/by-config/summary`, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    credentials: 'include',
+    body: JSON.stringify({ configId, userIds, locale }),
+  })
+  return parse<ClassSummaryResponse>(res)
+}

+ 60 - 0
src/store/articleReading.ts

@@ -0,0 +1,60 @@
+import { defineStore } from 'pinia'
+import { reactive, ref } from 'vue'
+import type { ArticleReadingConfig, ArticleReadingTask } from '@/types/articleReading'
+import type { ArticlePreviewStage } from '@/views/Editor/EnglishSpeaking/composables/useArticleReadingEngine'
+import { buildArticleConfig, buildEmptyArticleConfig } from '@/views/Editor/EnglishSpeaking/preview/articleReadingModel'
+
+/** 只持有文章朗读的表单状态,绝不混入话题讨论字段。 */
+export const useArticleReadingStore = defineStore('articleReading', () => {
+  const config = reactive<ArticleReadingConfig>(buildEmptyArticleConfig('五年级'))
+  const activeConfigId = ref('')
+  const openConfigSignal = ref(0)
+  const resetSignal = ref(0)
+  // 预览当前阶段(由 ArticleReadingPreview 同步)。CanvasTool 据此决定「重置预览」显不显示
+  // —— 与话题讨论的 speakingStore.previewState 同一套做法:停在起始页时没什么可重置的。
+  const previewStage = ref<ArticlePreviewStage>('ready')
+
+  function replaceConfig(next: ArticleReadingConfig) {
+    Object.assign(config, next)
+  }
+
+  function prefillFromTask(task: ArticleReadingTask, grade: string) {
+    replaceConfig(buildArticleConfig(task, grade))
+  }
+
+  function resetConfigEmpty(grade: string) {
+    replaceConfig(buildEmptyArticleConfig(grade))
+  }
+
+  function setActiveConfigId(configId: string) {
+    activeConfigId.value = configId
+  }
+
+  function setPreviewStage(stage: ArticlePreviewStage) {
+    previewStage.value = stage
+  }
+
+  function openConfigPanel(configId: string) {
+    activeConfigId.value = configId
+    openConfigSignal.value += 1
+  }
+
+  function requestResetPreview() {
+    resetSignal.value += 1
+  }
+
+  return {
+    config,
+    activeConfigId,
+    openConfigSignal,
+    resetSignal,
+    previewStage,
+    replaceConfig,
+    prefillFromTask,
+    resetConfigEmpty,
+    setActiveConfigId,
+    setPreviewStage,
+    openConfigPanel,
+    requestResetPreview,
+  }
+})

+ 29 - 1
src/store/main.ts

@@ -1,4 +1,4 @@
-import { customAlphabet } from 'nanoid'
+import { customAlphabet } from 'nanoid'
 import { defineStore } from 'pinia'
 import { ToolbarStates } from '@/types/toolbar'
 import type { CreatingElement, ShapeFormatPainter, TextFormatPainter } from '@/types/edit'
@@ -37,6 +37,12 @@ export interface MainState {
   showNotesPanel: boolean
   showMarkupPanel: boolean
   showAIPPTDialog: boolean
+  childPageVisible: boolean
+  childPageToolsArray: any[]
+  childPageIndex: number
+  userid: string
+  courseTitle: string
+  running: boolean
 }
 
 const nanoid = customAlphabet('0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz')
@@ -73,6 +79,12 @@ export const useMainStore = defineStore('main', {
     showNotesPanel: false, // 打开批注面板
     showMarkupPanel: false, // 打开类型标注面板
     showAIPPTDialog: false, // 打开AIPPT创建窗口
+    childPageVisible: false, // 打开带选页
+    childPageToolsArray: [], // 带选页工具数组
+    childPageIndex: 0, // 带选页当前页面索引
+    userid: '', // 当前用户ID
+    courseTitle: '', // 课程标题
+    running: false, // 是否正在运行AI任务
   }),
 
   getters: {
@@ -206,5 +218,21 @@ export const useMainStore = defineStore('main', {
     setAIPPTDialogState(show: boolean) {
       this.showAIPPTDialog = show
     },
+    setChildPageState(show: boolean, tools: any[], pageIndex: number) {
+      const slidesStore = useSlidesStore()
+      slidesStore.panAllToolsArray()
+      this.childPageVisible = show
+      this.childPageToolsArray = tools
+      this.childPageIndex = pageIndex
+    },
+    setUserid(userid: string) {
+      this.userid = userid
+    },
+    setCourseTitle(courseTitle: string) {
+      this.courseTitle = courseTitle
+    },
+    setRunningState(state: boolean) {
+      this.running = state
+    }
   },
 })

+ 11 - 0
src/store/slides.ts

@@ -189,6 +189,7 @@ export const useSlidesStore = defineStore('slides', {
     },
   
     updateSlideIndex(index: number) {
+      this.panAllToolsArray()
       this.slideIndex = index
     },
   
@@ -230,5 +231,15 @@ export const useSlidesStore = defineStore('slides', {
       })
       this.slides[slideIndex].elements = (elements as PPTElement[])
     },
+    panAllToolsArray() {
+      this.slides.forEach(slide => {
+        if (slide.toolsArray) {
+          const allToolsSet = slide.toolsArray.every(t => t.isSet)
+          if (allToolsSet) {
+            slide.toolsArray = []
+          }
+        }
+      })
+    },
   },
 })

+ 168 - 0
src/store/speaking.ts

@@ -0,0 +1,168 @@
+import { defineStore } from 'pinia'
+import type {
+  TopicDiscussionConfig,
+  PracticeMode,
+  ScoreMode,
+  EvalDimensions,
+  PreviewDialogueState,
+} from '@/types/englishSpeaking'
+
+const DEFAULT_CONFIG: TopicDiscussionConfig = {
+  topic: 'How I get to school',
+  grade: 'grade5-1',
+  selectedRole: 'tom',
+  learningGoals: {
+    vocabulary: ['on foot', 'by bus', 'by bike', 'by car', 'by underground', 'bus stop', 'near', 'far from'],
+    sentences: [
+      'How do you come to school?',
+      'I come to school by bus.',
+      'I come to school on foot.',
+      'Do you live near school?',
+      'I live far from school.',
+      'How long does it take?',
+    ],
+  },
+  practice: {
+    mode: 'time',
+    rounds: 2,
+    duration: 5,
+    showEnglish: true,
+    taskHint: true,
+    stutterHint: true,
+  },
+  evaluation: {
+    showReport: true,
+    dimensions: {
+      accuracy: true,
+      fluency: true,
+      completeness: true,
+      rhythm: true,
+    },
+    scoreMode: 'letter',
+  },
+}
+
+// 手动创建用:结构与 DEFAULT_CONFIG 完全一致,按需手动调整为空值
+const EMPTY_CONFIG: TopicDiscussionConfig = {
+  topic: '',
+  grade: 'grade5-1',
+  selectedRole: 'tom',
+  learningGoals: {
+    vocabulary: [],
+    sentences: [],
+  },
+  practice: {
+    mode: 'time',
+    rounds: 60,
+    duration: 1,
+    showEnglish: true,
+    taskHint: true,
+    stutterHint: true,
+  },
+  evaluation: {
+    showReport: true,
+    dimensions: {
+      accuracy: true,
+      fluency: true,
+      completeness: true,
+      rhythm: true,
+    },
+    scoreMode: 'letter',
+  },
+}
+
+export const useSpeakingStore = defineStore('speaking', {
+  state: () => ({
+    config: { ...DEFAULT_CONFIG } as TopicDiscussionConfig,
+    // 话题讨论预览当前阶段(由 TopicDiscussionPreview 同步)
+    previewState: 'ready' as PreviewDialogueState,
+    // 请求重置预览的递增信号(由 CanvasTool 触发 → TopicDiscussionPreview 监听)
+    resetSignal: 0,
+    // 请求打开配置面板的递增信号(由画布点击 77 型 frame 触发 → CollapsibleToolbar + SpeakingPanel 监听)
+    openConfigSignal: 0,
+  }),
+
+  actions: {
+    // 重置为默认配置
+    resetConfig() {
+      this.config = JSON.parse(JSON.stringify(DEFAULT_CONFIG))
+    },
+
+    // 重置为空白配置(手动创建用)
+    resetConfigEmpty() {
+      this.config = JSON.parse(JSON.stringify(EMPTY_CONFIG))
+    },
+
+    setPreviewState(state: PreviewDialogueState) {
+      this.previewState = state
+    },
+    requestResetPreview() {
+      this.resetSignal += 1
+    },
+    openConfigPanel() {
+      this.openConfigSignal += 1
+    },
+
+    // 用推荐卡片数据预填充
+    prefillFromTask(topic: string, vocabulary: string[], sentences: string[]) {
+      this.resetConfig()
+      this.config.topic = topic
+      this.config.learningGoals.vocabulary = [...vocabulary]
+      this.config.learningGoals.sentences = [...sentences]
+    },
+
+    // 话题
+    updateTopic(topic: string) {
+      this.config.topic = topic
+    },
+
+    // 年级
+    updateGrade(grade: string) {
+      this.config.grade = grade
+    },
+
+    // 学习目标 - 词汇
+    addVocabulary(word: string) {
+      this.config.learningGoals.vocabulary.push(word)
+    },
+    removeVocabulary(index: number) {
+      this.config.learningGoals.vocabulary.splice(index, 1)
+    },
+
+    // 学习目标 - 句型
+    addSentence(sentence: string) {
+      this.config.learningGoals.sentences.push(sentence)
+    },
+    removeSentence(index: number) {
+      this.config.learningGoals.sentences.splice(index, 1)
+    },
+    updateSentence(index: number, sentence: string) {
+      this.config.learningGoals.sentences[index] = sentence
+    },
+
+    // 练习设置
+    updatePracticeMode(mode: PracticeMode) {
+      this.config.practice.mode = mode
+    },
+    updateRounds(rounds: number) {
+      this.config.practice.rounds = rounds
+    },
+    updateDuration(duration: number) {
+      this.config.practice.duration = duration
+    },
+    togglePracticeSetting(key: 'showEnglish' | 'taskHint' | 'stutterHint') {
+      this.config.practice[key] = !this.config.practice[key]
+    },
+
+    // 评估设置
+    toggleShowReport() {
+      this.config.evaluation.showReport = !this.config.evaluation.showReport
+    },
+    toggleEvalDimension(dimension: keyof EvalDimensions) {
+      this.config.evaluation.dimensions[dimension] = !this.config.evaluation.dimensions[dimension]
+    },
+    updateScoreMode(scoreMode: ScoreMode) {
+      this.config.evaluation.scoreMode = scoreMode
+    },
+  },
+})

+ 5 - 4
src/tools/aiChat.ts

@@ -50,7 +50,7 @@ export const chat_no_stream = (msg: string, agentId: string, userId: string, lan
         : language === 'hk'
           ? 'Traditional Chinese'
           : 'Chinese'
-      } ${msg} ${language === 'hk' ? '請用繁體中文回复' : language === 'en' ? 'Please reply in English' : '用中文回复'}`,
+      } ${msg} ${language === 'hk' ? '請用繁體中文回复' : language === 'en' ? 'Please reply in English' : '用中文回复'}`,
       uid: uuidv4(),
       stream: false,
       model: agentData?.modelType || 'open-doubao',
@@ -103,7 +103,8 @@ export const chat_stream = async (
   language: string,
   onMessage: (event: { type: 'message' | 'close' | 'error' | 'messageEnd'; data: string }) => void,
   session_name?: string,
-  file_ids?: Array<string>
+  file_ids?: Array<string>,
+  model?: string
 ): Promise<{ abort: () => void }> => {
   const agentData = await getAgentModel(agentId)
   const params: ChatParams = {
@@ -115,10 +116,10 @@ export const chat_stream = async (
       : language === 'hk'
         ? 'Traditional Chinese'
         : 'Chinese'
-    } ${msg}  ${language === 'hk' ? '請用繁體中文回复' : language === 'en' ? 'Please reply in English' : '用中文回复'}`,
+    } ${msg}  ${language === 'hk' ? '請用繁體中文回复' : language === 'en' ? 'Please reply in English' : '用中文回复'}`,
     uid: uuidv4(),
     stream: true,
-    model: agentData?.modelType || 'open-doubao',
+    model: model || agentData?.modelType || 'open-doubao',
     userId: userId,
     tts_language: getTtsLanguage(language),
     session_name: session_name || uuidv4()

+ 112 - 0
src/types/articleReading.ts

@@ -0,0 +1,112 @@
+/**
+ * 文章朗读契约(对齐后端 spec §6)。
+ *
+ * 班级两个 DTO 的可空字段是 required-nullable 而非 optional:后端
+ * _collect_class_rows 对每个学生都发全部键,not_started 行发 null。
+ */
+export type ArticlePracticeMode = 'time' | 'unlimited'
+/**
+ * `abandoned` 是「重新开始」的标记(后端 spec §4.2)。「哪个 session」类查询一律排除它,
+ * 所以 resume 探针与 get-or-create 都不会返回它 —— 但 `GET /report` 原样回 `session.status`,
+ * 拿着旧 sessionId 直接查就会拿到,契约里必须写全三个值。
+ */
+export type ArticleSessionStatus = 'reading' | 'completed' | 'abandoned'
+export type ArticleParagraphStatus = 'pending' | 'generating' | 'completed' | 'failed'
+export type ArticleOverallStatus = 'generating' | 'completed' | 'failed' | null
+export type ArticleWordStatus = 'good' | 'warn' | 'bad' | 'misread' | 'omission' | 'insertion'
+
+export interface ArticleReadingConfig {
+  type: 'article'
+  grade: string
+  title: string
+  content: string
+  practice: { mode: ArticlePracticeMode; duration: number }
+  evaluation: { showReport: boolean }
+}
+
+export interface ArticleReadingTask {
+  id: string
+  taskType: 'article-reading'
+  titleZh: string
+  titleEn: string
+  subtitle: string
+  difficulty: number
+  count: 1
+  unit: '篇'
+  durationMinutes: number
+  content: string
+}
+
+export interface ArticleScoreDimensions {
+  accuracy: number
+  fluency: number
+  completeness: number
+  prosody: number
+}
+
+export interface ArticleWordResult {
+  word: string
+  status: ArticleWordStatus
+  accuracy: number | null
+}
+
+export interface ArticleParagraphResult {
+  idx: number
+  text: string
+  status: ArticleParagraphStatus
+  audioUrl: string | null
+  overallScore: number | null
+  dims: ArticleScoreDimensions | null
+  /** 段内扁平词级,按对齐顺序(spec §6)。句层已在后端 c62d35a 删除,别再加回来。 */
+  words: ArticleWordResult[]
+}
+
+export interface ArticleOverallResult {
+  aiComment: string
+  highlights: string[]
+  suggestions: string[]
+  overallScore: number
+  dims: ArticleScoreDimensions
+}
+
+export interface ArticleReadingReport {
+  sessionId: string
+  title: string
+  status: ArticleSessionStatus
+  totalDurationSeconds: number | null
+  /**
+   * 绝对截止时刻(ISO8601,后端 spec §4.4);null = 不限时。
+   * **只用来显示计时、以及决定本地时钟什么时候去问一次后端** —— 到了没由后端说了算。
+   */
+  expiresAt: string | null
+  paragraphs: ArticleParagraphResult[]
+  overall: ArticleOverallResult | null
+  overallStatus: ArticleOverallStatus
+}
+
+export interface ArticleStreamAck {
+  type: 'ack'
+  status: 'generating'
+  attempt: number
+  truncated: boolean
+}
+
+export interface ArticleClassUser {
+  userId: string
+  name: string
+}
+
+export interface ArticleClassSessionSummary extends ArticleClassUser {
+  status: 'completed' | 'reading' | 'not_started'
+  sessionId: string | null
+  overallScore: number | null
+  dims: ArticleScoreDimensions | null
+  completedAt: string | null
+}
+
+export interface ArticleClassSummaryResponse {
+  summary: string
+  generatedAt: string
+  fromCache: boolean
+  llmStatus: 'ok' | 'fallback'
+}

+ 348 - 0
src/types/englishSpeaking.ts

@@ -0,0 +1,348 @@
+import type { ArticleReadingTask } from '@/types/articleReading'
+
+// 练习方式
+export type PracticeMode = 'rounds' | 'time' | 'free'
+
+// 评分方式
+export type ScoreMode = 'numeric' | 'letter' | 'comment_only'
+
+// AI 角色
+export interface Role {
+  id: string
+  name: string
+  avatar: string
+  avatarUrl?: string
+  identity: string
+  personality: string
+  difficulty: number
+  isRecommended?: boolean
+}
+
+// 评估维度
+export interface EvalDimensions {
+  accuracy: boolean
+  fluency: boolean
+  completeness: boolean
+  rhythm: boolean
+}
+
+// 练习设置
+export interface PracticeSettings {
+  mode: PracticeMode
+  rounds: number
+  duration: number // 时长(分钟)
+  showEnglish: boolean
+  taskHint: boolean
+  stutterHint: boolean
+}
+
+// 评估设置
+export interface EvaluationSettings {
+  showReport: boolean
+  dimensions: EvalDimensions
+  scoreMode: ScoreMode
+}
+
+// 学习目标
+export interface LearningGoals {
+  vocabulary: string[]
+  sentences: string[]
+}
+
+// 话题讨论配置
+export interface TopicDiscussionConfig {
+  topic: string
+  grade: string
+  selectedRole: string
+  learningGoals: LearningGoals
+  practice: PracticeSettings
+  evaluation: EvaluationSettings
+}
+
+// 推荐任务卡片
+export interface TopicDiscussionTask {
+  id: string
+  taskType: 'topic-discussion'
+  titleZh: string
+  titleEn: string
+  subtitle: string
+  difficulty: 1 | 2 | 3
+  rounds: number
+  durationMinutes: number
+  vocabulary: string[]
+  sentences: string[]
+  note: string
+}
+
+// 教材单元
+export interface CurriculumUnit {
+  id: string
+  number: string
+  title: string
+  description: string
+  vocabulary: string[]
+  sentences: string[]
+}
+
+// 教材年级
+export interface CurriculumGrade {
+  id: string
+  name: string
+  semester: string
+  units: CurriculumUnit[]
+}
+
+// 教材
+export interface CurriculumTextbook {
+  id: string
+  name: string
+  grades: CurriculumGrade[]
+}
+
+// 教材数据根
+export interface CurriculumData {
+  textbooks: CurriculumTextbook[]
+}
+
+// 创建方式
+export type CreationMode = 'smart' | 'ai' | 'manual'
+
+// 推荐卡片的判别联合:按 taskType 区分,不把文章数据伪装成话题讨论
+export type SpeakingRecommendationTask = TopicDiscussionTask | ArticleReadingTask
+
+// 页面层级
+export type PageMode = 'layer1' | 'layer2' | 'config' | 'config-article-reading'
+
+// 练习类型
+export type ExerciseType = 'speaking' | 'listening' | 'reading' | 'writing'
+
+// ==================== 预览组件类型定义 ====================
+
+// 评分等级
+export type ScoreLevel = 'excellent' | 'good' | 'fair' | 'needsWork'
+
+// 评分展示方式(复用配置侧的 ScoreMode)
+export type ScoreDisplayMode = ScoreMode
+
+// 对话统计数据
+export interface DialogueStatistics {
+  totalRounds: number
+  averageScore: number
+  highestScore: number
+  highestRound: number
+  grammarErrors: number
+  excellentExpressions: number
+  totalDuration: number // 秒
+}
+
+// 下次挑战推荐
+export interface NextChallenge {
+  difficulty?: string
+  unlockedTopic?: string
+  suggestedMode?: string
+}
+
+// 单句评价
+export interface SentenceEvaluation {
+  id: string
+  round: number
+  role: 'ai' | 'student'
+  content: string
+  contentZh?: string
+  audioUrl?: string
+  audioDuration?: number
+  score?: number
+  pronunciation?: {
+    accuracy: number
+    intonation: number
+    stress: number
+    fluency: number
+  }
+  feedback?: {
+    comment: string
+    betterExpression: string
+  }
+}
+
+// 整体评价报告
+export interface OverallEvaluation {
+  overallScore: number
+  scoreLevel: ScoreLevel
+  percentile: number
+  dimensions: {
+    fluency: number
+    interaction: number
+    vocabulary: number
+    grammar: number
+  }
+  aiComment: string
+  highlights: string[]
+  improvements: string[]
+  nextChallenge: NextChallenge
+  statistics: DialogueStatistics
+  sentenceEvaluations: SentenceEvaluation[]
+}
+
+// 预览用 AI 角色(扩展配置侧的 Role)
+export interface PreviewAIRole {
+  id: string
+  name: string
+  avatar: string
+  identity: string
+  personality: string
+  speakingStyle: 'formal' | 'casual' | 'playful'
+  speed: 'slow' | 'normal' | 'fast'
+  isCustom: boolean
+  isRecommended?: boolean
+}
+
+// 消息状态
+export type MessageStatus = 'loading' | 'done' | 'error'
+
+// 对话消息
+export interface PreviewChatMessage {
+  id: string
+  role: 'ai' | 'student'
+  content: string
+  timestamp: Date
+  status?: MessageStatus
+  error?: string
+  turnId?: string                                     // set when message is created via begin/commit
+  recovery?: 'retry' | 'rerecord' | 'restart'        // set on error; drives the button matrix
+  audioBlob?: Blob
+  audioUrl?: string
+  audioDuration?: number   // NEW: 秒,由后端 transcript 事件 / sessions-latest 写入
+  evaluation?: {
+    dimensions: {
+      accuracy: 'excellent' | 'good' | 'improve'
+      fluency: 'excellent' | 'good' | 'improve'
+      completeness: 'excellent' | 'good' | 'improve'
+      rhythm: 'excellent' | 'good' | 'improve'
+    }
+    suggestion: string
+    betterExpression?: string
+    suggestedWords?: string[]
+    wordAnalysis?: {
+      word: string
+      status: 'correct' | 'improvable'
+      userPronunciation?: string
+      standardPronunciation?: string
+      tip?: string
+    }[]
+  }
+}
+
+// ==================== 对话引擎类型定义 ====================
+
+// SSE 事件类型
+export type SSEEvent =
+  | { type: 'transcript'; text: string; audioDuration?: number | null }
+  | { type: 'token'; text: string }
+  | { type: 'done'; isComplete: boolean }
+  | { type: 'error'; message: string }
+
+// 对话会话配置
+export interface SessionConfig {
+  topic: string
+  grade: string
+  roleId: string
+  totalRounds: number
+  /** 时长(分钟)。与 totalRounds 是并存的两个结束条件,后端据此计算 expiresAt */
+  durationMinutes: number
+  vocabulary?: string[]
+  sentences?: string[]
+  configId?: string | null
+  userId?: string | null
+}
+
+// 对话会话信息 (createSession 返回)
+export interface SessionInfo {
+  sessionId: string
+  totalRounds: number
+  currentRound: number
+  expiresAt: string | null
+}
+
+// 开场白生成结果 (generateGreeting 返回)
+export interface GreetingInfo {
+  aiMessage: string
+}
+
+export interface HistoricalDialogueMessage {
+  id: string
+  round: number
+  role: 'ai' | 'student'
+  content: string
+  audioUrl?: string | null
+  audioDuration?: number | null    // NEW: 秒,来自后端 sessions/latest
+  clientTurnId?: string | null
+}
+
+// 用于启动 chat view 的最小 session 信息(父组件 createSession 后传给 DialogueChatView)
+// expiresAt 由后端基于 createSession 时传入的 durationMinutes 计算下发 —— 后端权威,
+// 关页面/刷新/换设备重进都接续同一个截止时刻,确保倒计时不会因任何原因暂停。
+export interface SessionStartInfo {
+  sessionId: string
+  expiresAt: string | null
+  currentRound?: number
+  messages?: HistoricalDialogueMessage[]
+}
+
+export interface LatestSessionInfo extends SessionStartInfo {
+  status: 'active' | 'completed' | 'abandoned'
+  totalRounds: number
+  currentRound: number
+  createdAt: string | null
+  completedAt: string | null
+  messages: HistoricalDialogueMessage[]
+}
+
+export interface LatestSessionResponse {
+  session: LatestSessionInfo | null
+}
+
+// 对话报告
+export type DialogueReportStatus = 'evaluating' | 'ready' | 'failed' | 'incomplete'
+
+export interface DialogueReport {
+  status: DialogueReportStatus
+  evaluation: OverallEvaluation
+}
+
+export interface TaskHint {
+  practice_level: string
+  conversation_topic: string
+  current_question: string
+  example_sentences: {
+    english: string
+    chinese: string
+  }[]
+  key_vocabulary: {
+    word: string
+    meaning: string
+  }[]
+}
+
+// 对话 API 接口
+export interface DialogueAPI {
+  createSession(config: SessionConfig): Promise<SessionInfo>
+  getLatestSession(configId: string, userId: string): Promise<LatestSessionResponse>
+  completeSession(sessionId: string): Promise<void>
+  /** Throws DOMException('AbortError') on signal abort; throws DialogueApiError on non-OK HTTP. */
+  generateGreeting(sessionId: string, turnId: string, signal?: AbortSignal): Promise<GreetingInfo>
+  generateTaskHint(sessionId: string): Promise<TaskHint>
+  speak(sessionId: string, audioBlob: Blob, signal: AbortSignal, turnId: string): AsyncGenerator<SSEEvent>
+  getReport(sessionId: string): Promise<DialogueReport>
+}
+
+// 徽章成就
+export interface BadgeAchievement {
+  id: string
+  name: string
+  nameEn: string
+  icon: string
+  description: string
+}
+
+// 预览对话状态
+export type PreviewDialogueState = 'checking-history' | 'ready' | 'chatting' | 'completed'

+ 3 - 1
src/types/slides.ts

@@ -651,6 +651,7 @@ export interface PPTFrameElement extends PPTBaseElement {
   isHTML?: boolean,
   toolType?: number,
   isDone?: boolean,
+  webId?: string,
 }
 
 export type PPTElement = PPTTextElement | PPTImageElement | PPTShapeElement | PPTLineElement | PPTChartElement | PPTTableElement | PPTLatexElement | PPTVideoElement | PPTAudioElement | PPTFrameElement
@@ -761,7 +762,8 @@ export interface Slide {
   animations?: PPTAnimation[]
   turningMode?: TurningMode
   sectionTag?: SectionTag
-  type?: SlideType
+  type?: SlideType,
+  toolsArray?: Array<any>
 }
 
 /**

+ 22 - 0
src/utils/clipboard.ts

@@ -1,5 +1,26 @@
 import Clipboard from 'clipboard'
 import { decrypt } from '@/utils/crypto'
+import { lang } from '@/main'
+
+// 全局隐藏的 input 元素,用于保持页面焦点
+let clipboardFocusInput: HTMLInputElement | null = null
+
+// 初始化隐藏的 input 元素以保持焦点
+export const initClipboardFocus = () => {
+  if (clipboardFocusInput) return clipboardFocusInput
+  
+  clipboardFocusInput = document.createElement('input')
+  clipboardFocusInput.style.cssText = 'position:fixed;left:-9999px;top:-9999px;opacity:0;pointer-events:none;'
+  // clipboardFocusInput.style.cssText = 'position:fixed;left:-0;top:-0;'
+  // clipboardFocusInput.setAttribute('readonly', 'readonly')
+  clipboardFocusInput.setAttribute('tabindex', '-1')
+  document.querySelector('.viewport-wrapper')?.appendChild(clipboardFocusInput)
+  
+  // 尝试聚焦
+  clipboardFocusInput.focus()
+  readClipboard()
+  return clipboardFocusInput
+}
 
 /**
  * 复制文本到剪贴板
@@ -27,6 +48,7 @@ export const copyText = (text: string) => {
   })
 }
 
+// 读取剪贴板
 // 读取剪贴板
 export const readClipboard = (): Promise<string> => {
   return new Promise((resolve, reject) => {

+ 162 - 0
src/utils/components/messageInstruction.vue

@@ -0,0 +1,162 @@
+<template>
+  <div class="messageInstruction">
+    <div class="messageItem" :class="item.type" v-for="item in messageList" :key="item.id">
+      <div class="icon">
+        <svg v-if="['success'].includes(item.type)" viewBox="0 0 1024 1024"><path d="M512 85.333333c235.648 0 426.666667 191.018667 426.666667 426.666667s-191.018667 426.666667-426.666667 426.666667S85.333333 747.648 85.333333 512 276.352 85.333333 512 85.333333z m-74.965333 550.4L346.453333 545.152a42.666667 42.666667 0 1 0-60.330666 60.330667l120.704 120.704a42.666667 42.666667 0 0 0 60.330666 0l301.653334-301.696a42.666667 42.666667 0 1 0-60.288-60.330667l-271.530667 271.488z" fill="#67C239"></path></svg>
+        <svg v-if="['pptMessage'].includes(item.type)" viewBox="0 0 1024 1024" ><path d="M512 85.333c-234.667 0-426.667 192-426.667 426.667s192 426.667 426.667 426.667 426.667-192 426.667-426.667S746.667 85.333 512 85.333z m21.333 640h-42.666c-12.8 0-21.334-8.533-21.334-21.333v-42.667c0-12.8 8.534-21.333 21.334-21.333h42.666c12.8 0 21.334 8.533 21.334 21.333V704c0 12.8-8.534 21.333-21.334 21.333zM512 554.667c-25.6 0-42.667-17.067-42.667-42.667V341.333c0-25.6 17.067-42.666 42.667-42.666s42.667 17.066 42.667 42.666V512c0 25.6-17.067 42.667-42.667 42.667z" fill="#FCCF00"></path></svg>
+        <!-- <svg v-if="['pptMessage'].includes(item.type)" viewBox="0 0 1024 1024"><path d="M512 28.444444C244.906667 28.444444 28.444444 244.906667 28.444444 512s216.462222 483.555556 483.555556 483.555556 483.555556-216.462222 483.555556-483.555556S779.093333 28.444444 512 28.444444z m30.225067 755.5584A30.190933 30.190933 0 0 1 512 814.222222a30.190933 30.190933 0 0 1-30.225067-30.225066A30.190933 30.190933 0 0 1 512 753.777778a30.190933 30.190933 0 0 1 30.225067 30.225066z m0-120.888888A30.190933 30.190933 0 0 1 512 693.327644a30.190933 30.190933 0 0 1-30.225067-30.225066V240.008533A30.190933 30.190933 0 0 1 512 209.772089a30.190933 30.190933 0 0 1 30.225067 30.225067v423.105422z" fill="#FCCF00"></path></svg> -->
+      </div>
+      <div class="message" v-html="item.message"></div>
+      <div class="close" @click="close(item.id)" v-if="item.showClose">
+        <svg viewBox="0 0 1025 1024"><path d="M997.553471 154.252491 639.804427 512.001535 997.553471 869.751602l0 0c16.34988 16.374439 26.450623 38.948897 26.450623 63.898376 0 49.899981-40.450041 90.350022-90.351045 90.350022-24.949479 0-47.549519-10.100743-63.897353-26.475181l0 0L512.003581 639.775775 154.255561 997.525842l0 0c-16.34988 16.374439-38.948897 26.475181-63.899399 26.475181-49.901004 0-90.350022-40.450041-90.350022-90.350022 0-24.950502 10.099719-47.523938 26.449599-63.898376l0 0 357.750067-357.750067L26.454716 154.252491l0 0c-16.34988-16.350903-26.449599-38.949921-26.449599-63.900423 0-49.899981 40.449018-90.348999 90.350022-90.348999 24.950502 0 47.550543 10.099719 63.899399 26.474158l0 0 357.748021 357.749044L869.754672 26.477228l0 0c16.348857-16.374439 38.948897-26.474158 63.897353-26.474158 49.901004 0 90.351045 40.449018 90.351045 90.348999C1024.00307 115.301547 1013.902327 137.901588 997.553471 154.252491L997.553471 154.252491z" fill="#1A1A1A"></path></svg>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { onMounted, ref } from 'vue'
+
+const messageList = ref([])
+
+const success = (data) => {
+  if (typeof data === 'string') {
+    let _id = Date.now()
+    messageList.value.push({
+      id: _id,
+      type: 'success',
+      message: data,
+      showClose: data.showClose || false
+    })
+    setTimeout(() => {
+      messageList.value = messageList.value.filter(item => item.id !== _id)
+    }, 3000)
+  } else if (typeof data === 'object' && data.message && data.duration) {
+    let _id = Date.now()
+    messageList.value.push({
+      id: _id,
+      type: 'success',
+      message: data.message,
+      showClose: data.showClose || false
+    })
+    setTimeout(() => {
+      messageList.value = messageList.value.filter(item => item.id !== _id)
+    }, data.duration)
+  }
+}
+
+const pptMessage = (data) => {
+  if (typeof data === 'string') {
+    let _id = Date.now()
+    messageList.value.push({
+      id: _id,
+      type: 'pptMessage',
+      message: data,
+      showClose: data.showClose || true
+    })
+    setTimeout(() => {
+      messageList.value = messageList.value.filter(item => item.id !== _id)
+    }, 3000)
+  } else if (typeof data === 'object' && data.message && data.duration) {
+    let _id = Date.now()
+    messageList.value.push({
+      id: _id,
+      type: 'pptMessage',
+      message: data.message,
+      showClose: data.showClose || true
+    })
+    setTimeout(() => {
+      messageList.value = messageList.value.filter(item => item.id !== _id)
+    }, data.duration)
+  }
+}
+
+const close = (id) => {
+  messageList.value = messageList.value.filter(item => item.id !== id)
+}
+defineExpose({
+  success,
+  pptMessage,
+  close
+})
+</script>
+
+<style lang="scss" scoped>
+.messageInstruction {
+  position: fixed;
+  top: 80px;
+  left: 50%;
+  transform: translate(-50%, 0%);
+  width: auto;
+  height: auto;
+  z-index: 9999;
+  display: flex;
+  flex-direction: column;
+  gap: .5rem;
+}
+
+.messageItem{
+  padding: 15px 20px;
+  border-radius: .2rem;
+  font-size: 1rem;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  background: #FFFFFF;
+  box-shadow: 0 0 .2rem rgba(0, 0, 0, 0.1);
+  transition: .3s;
+}
+
+.messageItem>.icon{
+  width: 1.3rem;
+  height: 1.3rem;
+  margin-right: .8rem;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  //margin-top: -.1rem;
+  line-height: 1rem;
+}
+
+.messageItem>.icon>svg{
+  width: 100%;
+  height: 100%;
+}
+
+.messageItem>.close{
+  width: .6rem;
+  height: .6rem;
+  margin-left: 1.5rem;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-top: .3rem;
+}
+
+.messageItem>.close>svg{
+  width: 100%;
+  height: 100%;
+  cursor: pointer;
+}
+
+.messageItem>.message{
+  flex: 1;
+  max-width: 50vh;
+}
+
+.messageItem {
+  animation: slideDownFadeIn 0.3s ease-out;
+}
+
+@keyframes slideDownFadeIn {
+  from {
+    opacity: 0;
+    transform: translateY(-20px);
+  }
+  to {
+    opacity: 1;
+    transform: translateY(0);
+  }
+}
+
+</style>

+ 4 - 0
src/utils/confirmDialog.ts

@@ -8,6 +8,8 @@ interface ConfirmDialogOptions {
   confirmText?: string
   cancelText?: string
   width?: number
+  onConfirm?: () => void
+  onCancel?: () => void
 }
 
 export function showConfirmDialog(options: ConfirmDialogOptions): Promise<boolean> {
@@ -27,6 +29,7 @@ export function showConfirmDialog(options: ConfirmDialogOptions): Promise<boolea
         visible.value = false
         setTimeout(() => {
           app.unmount()
+          options.onConfirm?.()
           document.body.removeChild(container)
           resolve(true)
         }, 300)
@@ -35,6 +38,7 @@ export function showConfirmDialog(options: ConfirmDialogOptions): Promise<boolea
         visible.value = false
         setTimeout(() => {
           app.unmount()
+          options.onCancel?.()
           document.body.removeChild(container)
           resolve(false)
         }, 300)

+ 137 - 18
src/utils/prosemirror/schema/marks.ts

@@ -1,6 +1,7 @@
 import { marks } from 'prosemirror-schema-basic'
 import type { MarkSpec } from 'prosemirror-model'
 
+// ===== 原有定义 =====
 const subscript: MarkSpec = {
   excludes: 'subscript',
   parseDOM: [
@@ -10,7 +11,7 @@ const subscript: MarkSpec = {
       getAttrs: value => value === 'sub' && null
     },
   ],
-  toDOM: () => ['sub',  0],
+  toDOM: () => ['sub', 0],
 }
 
 const superscript: MarkSpec = {
@@ -56,9 +57,7 @@ const underline: MarkSpec = {
 }
 
 const forecolor: MarkSpec = {
-  attrs: {
-    color: {},
-  },
+  attrs: { color: {} },
   inline: true,
   group: 'inline',
   parseDOM: [
@@ -76,9 +75,7 @@ const forecolor: MarkSpec = {
 }
 
 const backcolor: MarkSpec = {
-  attrs: {
-    backcolor: {},
-  },
+  attrs: { backcolor: {} },
   inline: true,
   group: 'inline',
   parseDOM: [
@@ -96,9 +93,7 @@ const backcolor: MarkSpec = {
 }
 
 const fontsize: MarkSpec = {
-  attrs: {
-    fontsize: {},
-  },
+  attrs: { fontsize: {} },
   inline: true,
   group: 'inline',
   parseDOM: [
@@ -116,16 +111,14 @@ const fontsize: MarkSpec = {
 }
 
 const fontname: MarkSpec = {
-  attrs: {
-    fontname: {},
-  },
+  attrs: { fontname: {} },
   inline: true,
   group: 'inline',
   parseDOM: [
     {
       style: 'font-family',
       getAttrs: fontname => {
-        return { fontname: fontname && typeof fontname === 'string' ? fontname.replace(/[\"\']/g, '') : '' }
+        return { fontname: fontname && typeof fontname === 'string' ? fontname.replace(/["']/g, '') : '' }
       }
     },
   ],
@@ -158,9 +151,7 @@ const link: MarkSpec = {
 }
 
 const mark: MarkSpec = {
-  attrs: {
-    index: { default: null },
-  },
+  attrs: { index: { default: null } },
   parseDOM: [
     {
       tag: 'mark',
@@ -173,14 +164,135 @@ const mark: MarkSpec = {
   toDOM: node => ['mark', { 'data-index': node.attrs.index }, 0],
 }
 
+// ===== 新增 marks =====
+
+// 通用 font-weight(如 100, 200, bold, bolder 等)
+const fontWeight: MarkSpec = {
+  attrs: { fontWeight: {} },
+  inline: true,
+  group: 'inline',
+  parseDOM: [
+    {
+      style: 'font-weight',
+      getAttrs: value => value ? { fontWeight: value } : {}
+    }
+  ],
+  toDOM: mark => {
+    const { fontWeight } = mark.attrs
+    let style = ''
+    if (fontWeight) style += `font-weight: ${fontWeight};`
+    return ['span', { style }, 0]
+  }
+}
+
+// 通用 font-style(如 italic, oblique, normal)
+const fontStyle: MarkSpec = {
+  attrs: { fontStyle: {} },
+  inline: true,
+  group: 'inline',
+  parseDOM: [
+    {
+      style: 'font-style',
+      getAttrs: value => value ? { fontStyle: value } : {}
+    }
+  ],
+  toDOM: mark => {
+    const { fontStyle } = mark.attrs
+    let style = ''
+    if (fontStyle) style += `font-style: ${fontStyle};`
+    return ['span', { style }, 0]
+  }
+}
+
+// 通用 text-decoration(可包含 underline, overline, line-through 等组合)
+const textDecoration: MarkSpec = {
+  attrs: { textDecoration: {} },
+  inline: true,
+  group: 'inline',
+  parseDOM: [
+    {
+      style: 'text-decoration',
+      getAttrs: value => value ? { textDecoration: value } : {}
+    },
+    {
+      style: 'text-decoration-line',
+      getAttrs: value => value ? { textDecoration: value } : {}
+    }
+  ],
+  toDOM: mark => {
+    const { textDecoration } = mark.attrs
+    let style = ''
+    if (textDecoration) style += `text-decoration: ${textDecoration};`
+    return ['span', { style }, 0]
+  }
+}
+
+// 通用 vertical-align(支持 baseline, sub, super, top, middle, bottom, 长度值等)
+const verticalAlign: MarkSpec = {
+  attrs: { verticalAlign: {} },
+  inline: true,
+  group: 'inline',
+  parseDOM: [
+    {
+      style: 'vertical-align',
+      getAttrs: value => value ? { verticalAlign: value } : {}
+    }
+  ],
+  toDOM: mark => {
+    const { verticalAlign } = mark.attrs
+    let style = ''
+    if (verticalAlign) style += `vertical-align: ${verticalAlign};`
+    return ['span', { style }, 0]
+  }
+}
+
+// letter-spacing
+const letterSpacing: MarkSpec = {
+  attrs: { letterSpacing: {} },
+  inline: true,
+  group: 'inline',
+  parseDOM: [
+    {
+      style: 'letter-spacing',
+      getAttrs: value => value ? { letterSpacing: value } : {}
+    }
+  ],
+  toDOM: mark => {
+    const { letterSpacing } = mark.attrs
+    let style = ''
+    if (letterSpacing) style += `letter-spacing: ${letterSpacing};`
+    return ['span', { style }, 0]
+  }
+}
+
+// text-shadow
+const textShadow: MarkSpec = {
+  attrs: { textShadow: {} },
+  inline: true,
+  group: 'inline',
+  parseDOM: [
+    {
+      style: 'text-shadow',
+      getAttrs: value => value ? { textShadow: value } : {}
+    }
+  ],
+  toDOM: mark => {
+    const { textShadow } = mark.attrs
+    let style = ''
+    if (textShadow) style += `text-shadow: ${textShadow};`
+    return ['span', { style }, 0]
+  }
+}
+
+// 从 prosemirror-schema-basic 获取的默认 marks
 const { em, strong, code } = marks
 
 export default {
   em,
   strong,
+  code,
   fontsize,
   fontname,
-  code,
   forecolor,
   backcolor,
   subscript,
@@ -189,4 +301,11 @@ export default {
   underline,
   link,
   mark,
+  // 新增
+  fontWeight,
+  fontStyle,
+  textDecoration,
+  verticalAlign,
+  letterSpacing,
+  textShadow,
 }

+ 111 - 52
src/utils/prosemirror/schema/nodes.ts

@@ -115,7 +115,16 @@ const listItem: NodeSpec = {
     paddingRight: { default: '' },
     paddingBottom: { default: '' },
     paddingLeft: { default: '' },
-    whiteSpace: { default: 'normal' }, // 新增 white-space 属性
+    whiteSpace: { default: 'normal' },
+    writingMode: { default: '' },
+    // 新增字体相关属性
+    fontWeight: { default: '' },
+    fontStyle: { default: '' },
+    textDecoration: { default: '' },
+    textDecorationLine: { default: '' },
+    verticalAlign: { default: '' },
+    letterSpacing: { default: '' },
+    textShadow: { default: '' },
   },
   content: 'paragraph block*',
   group: 'block',
@@ -138,7 +147,17 @@ const listItem: NodeSpec = {
         const paddingRight = style.paddingRight || '';
         const paddingBottom = style.paddingBottom || '';
         const paddingLeft = style.paddingLeft || '';
-        const whiteSpace = style.whiteSpace || 'normal'; // 读取 white-space
+        const whiteSpace = style.whiteSpace || 'normal';
+        const writingMode = style.writingMode || '';
+
+        // 读取新增属性
+        const fontWeight = style.fontWeight || '';
+        const fontStyle = style.fontStyle || '';
+        const textDecoration = style.textDecoration || '';
+        const textDecorationLine = style.textDecorationLine || '';
+        const verticalAlign = style.verticalAlign || '';
+        const letterSpacing = style.letterSpacing || '';
+        const textShadow = style.textShadow || '';
 
         return {
           textAlign,
@@ -153,7 +172,15 @@ const listItem: NodeSpec = {
           paddingRight,
           paddingBottom,
           paddingLeft,
-          whiteSpace, // 返回 whiteSpace
+          whiteSpace,
+          writingMode,
+          fontWeight,
+          fontStyle,
+          textDecoration,
+          textDecorationLine,
+          verticalAlign,
+          letterSpacing,
+          textShadow,
         };
       },
     },
@@ -172,7 +199,15 @@ const listItem: NodeSpec = {
       paddingRight,
       paddingBottom,
       paddingLeft,
-      whiteSpace, // 获取 whiteSpace
+      whiteSpace,
+      writingMode,
+      fontWeight,
+      fontStyle,
+      textDecoration,
+      textDecorationLine,
+      verticalAlign,
+      letterSpacing,
+      textShadow,
     } = node.attrs;
 
     let style = '';
@@ -189,13 +224,12 @@ const listItem: NodeSpec = {
     if (marginTop) style += `margin-top: ${marginTop};`;
     if (marginBottom) style += `margin-bottom: ${marginBottom};`;
     if (marginLeft) {
-      // 解析数值和单位
       const str = String(marginLeft).trim();
       const match = str.match(/^([+-]?\d*\.?\d+)(px|pt|em|rem|%|vw|vh)?$/i);
       if (match) {
         let num = parseFloat(match[1]);
         const unit = match[2] || 'px';
-        const absNum = Math.abs(num); // 负数转正
+        const absNum = Math.abs(num);
         const val = absNum + unit;
         style += `margin-left: max(min(0px, 100% - ${val}), 0px);`;
       } else {
@@ -206,35 +240,43 @@ const listItem: NodeSpec = {
     if (lineHeight) {
       let finalValue;
       const str = String(lineHeight).trim();
-      // 匹配纯数字(整数或小数,可带负号)
       if (/^-?\d+(\.\d+)?$/.test(str)) {
         finalValue = parseFloat(str) * 1.2;
       } else {
-        // 带单位或其他非纯数字内容,直接使用原值
         finalValue = lineHeight;
       }
       style += `line-height: ${finalValue};`;
     }
+    else{
+      style += `line-height: 1.2;`;
+    }
     if (paddingTop) style += `padding-top: ${paddingTop};`;
     if (paddingRight) style += `padding-right: ${paddingRight};`;
     if (paddingBottom) style += `padding-bottom: ${paddingBottom};`;
     if (paddingLeft) style += `padding-left: ${paddingLeft};`;
     if (whiteSpace && whiteSpace !== 'normal') {
-      style += `white-space: ${whiteSpace};`; // 添加 white-space
+      style += `white-space: ${whiteSpace};`;
+    }
+    if (writingMode) {
+      style += `writing-mode: ${writingMode};`;
     }
 
-    //const attrs: { style?: string } = {};
-    //if (style) attrs.style = style;
+    // 新增字体相关样式输出
+    if (fontWeight) style += `font-weight: ${fontWeight};`;
+    if (fontStyle) style += `font-style: ${fontStyle};`;
+    if (textDecoration) style += `text-decoration: ${textDecoration};`;
+    if (textDecorationLine) style += `text-decoration-line: ${textDecorationLine};`;
+    if (verticalAlign) style += `vertical-align: ${verticalAlign};`;
+    if (letterSpacing) style += `letter-spacing: ${letterSpacing};`;
+    if (textShadow) style += `text-shadow: ${textShadow};`;
 
     let isEmpty = false;
     const firstChild = node.content.firstChild;
     if (firstChild && firstChild.type.name === 'paragraph') {
-      // 段落无任何子节点(包括 text 和 inline 节点)
       if (firstChild.content.size === 0) {
         isEmpty = true;
       }
     }
-    // 如果整个 li 的内容长度为 0 也可以作为判断
     if (node.content.size === 0) isEmpty = true;
 
     const attrs: { style?: string; class?: string } = {};
@@ -246,7 +288,7 @@ const listItem: NodeSpec = {
 };
 
 const paragraph: NodeSpec = {
-  whitespace: "pre", // 此属性控制 ProseMirror 内部空格处理,与 CSS white-space 无关,保留不变
+  whitespace: "pre",
   attrs: {
     textAlign: { default: '' },
     textAlignLast: { default: '' },
@@ -261,7 +303,16 @@ const paragraph: NodeSpec = {
     paddingRight: { default: '' },
     paddingBottom: { default: '' },
     paddingLeft: { default: '' },
-    whiteSpace: { default: 'normal' }, // 新增 white-space 属性
+    whiteSpace: { default: 'normal' },
+    writingMode: { default: '' },
+    // 新增字体相关属性
+    fontWeight: { default: '' },
+    fontStyle: { default: '' },
+    textDecoration: { default: '' },
+    textDecorationLine: { default: '' },
+    verticalAlign: { default: '' },
+    letterSpacing: { default: '' },
+    textShadow: { default: '' },
   },
   content: 'inline*',
   group: 'block',
@@ -284,7 +335,17 @@ const paragraph: NodeSpec = {
         const paddingRight = style.paddingRight || '';
         const paddingBottom = style.paddingBottom || '';
         const paddingLeft = style.paddingLeft || '';
-        const whiteSpace = style.whiteSpace || 'normal'; // 读取 white-space
+        const whiteSpace = style.whiteSpace || 'normal';
+        const writingMode = style.writingMode || '';
+
+        // 读取新增属性
+        const fontWeight = style.fontWeight || '';
+        const fontStyle = style.fontStyle || '';
+        const textDecoration = style.textDecoration || '';
+        const textDecorationLine = style.textDecorationLine || '';
+        const verticalAlign = style.verticalAlign || '';
+        const letterSpacing = style.letterSpacing || '';
+        const textShadow = style.textShadow || '';
 
         return {
           textAlign,
@@ -299,7 +360,15 @@ const paragraph: NodeSpec = {
           paddingRight,
           paddingBottom,
           paddingLeft,
-          whiteSpace, // 返回 whiteSpace
+          whiteSpace,
+          writingMode,
+          fontWeight,
+          fontStyle,
+          textDecoration,
+          textDecorationLine,
+          verticalAlign,
+          letterSpacing,
+          textShadow,
         };
       },
     },
@@ -326,7 +395,15 @@ const paragraph: NodeSpec = {
       paddingRight,
       paddingBottom,
       paddingLeft,
-      whiteSpace, // 获取 whiteSpace
+      whiteSpace,
+      writingMode,
+      fontWeight,
+      fontStyle,
+      textDecoration,
+      textDecorationLine,
+      verticalAlign,
+      letterSpacing,
+      textShadow,
     } = node.attrs;
 
     let style = '';
@@ -342,60 +419,42 @@ const paragraph: NodeSpec = {
     }
     if (marginTop) style += `margin-top: ${marginTop};`;
     if (marginBottom) style += `margin-bottom: ${marginBottom};`;
-    /*
-    if (marginLeft) {
-      // 解析数值和单位
-      const str = String(marginLeft).trim();
-      const match = str.match(/^([+-]?\d*\.?\d+)(px|pt|em|rem|%|vw|vh)?$/i);
-      if (match) {
-        let num = parseFloat(match[1]);
-        const unit = match[2] || 'px';
-        const absNum = Math.abs(num); // 负数转正
-        const val = absNum + unit;
-        style += `margin-left: max(min(0px, 100% - ${val}), 0px);`;
-      } else {
-        style += `margin-left: ${marginLeft};`;
-      }
-    }
-    */
     if (marginLeft) style += `margin-left: ${marginLeft};`;
     if (marginRight) style += `margin-right: ${marginRight};`;
     if (lineHeight) {
       let finalValue;
       const str = String(lineHeight).trim();
-      // 匹配纯数字(整数或小数,可带负号)
       if (/^-?\d+(\.\d+)?$/.test(str)) {
         finalValue = parseFloat(str) * 1.2;
       } else {
-        // 带单位或其他非纯数字内容,直接使用原值
         finalValue = lineHeight;
       }
       style += `line-height: ${finalValue};`;
     }
+    else{
+      style += `line-height: 1.2;`;
+    }
     if (paddingTop) style += `padding-top: ${paddingTop};`;
     if (paddingRight) style += `padding-right: ${paddingRight};`;
     if (paddingBottom) style += `padding-bottom: ${paddingBottom};`;
     if (paddingLeft) style += `padding-left: ${paddingLeft};`;
     if (whiteSpace && whiteSpace !== 'normal') {
-      style += `white-space: ${whiteSpace};`; // 添加 white-space
+      style += `white-space: ${whiteSpace};`;
     }
+    if (writingMode) {
+      style += `writing-mode: ${writingMode};`;
+    }
+
+    // 新增字体相关样式输出
+    if (fontWeight) style += `font-weight: ${fontWeight};`;
+    if (fontStyle) style += `font-style: ${fontStyle};`;
+    if (textDecoration) style += `text-decoration: ${textDecoration};`;
+    if (textDecorationLine) style += `text-decoration-line: ${textDecorationLine};`;
+    if (verticalAlign) style += `vertical-align: ${verticalAlign};`;
+    if (letterSpacing) style += `letter-spacing: ${letterSpacing};`;
+    if (textShadow) style += `text-shadow: ${textShadow};`;
 
     const attrs: { style?: string; class?: string } = {};
-/*
-    let isEmpty = false;
-    const firstChild = node.content.firstChild;
-    if (firstChild) {
-      // 段落无任何子节点(包括 text 和 inline 节点)
-      if (firstChild.content.size === 0) {
-        isEmpty = true;
-      }
-    }
-    
-    // 如果整个 li 的内容长度为 0 也可以作为判断
-    if (node.content.size === 0) isEmpty = true;
-    if (style) attrs.style = style;
-    //if (isEmpty) attrs.class = 'empty';
-*/
     if (style) attrs.style = style;
     return ['p', attrs, 0];
   },

+ 479 - 0
src/views/Editor/Canvas/ChildPageDisplay.vue

@@ -0,0 +1,479 @@
+<template>
+  <div class="child-page-display">
+    <div v-if="loading" class="loading-overlay">
+      <div class="loading-spinner"></div>
+      <span class="loading-text">{{ lang.ssAiWait }}</span>
+    </div>
+    
+    <div class="child-page-header">
+      <div class="header-left">
+        <span class="badge">{{ lang.ssWaitSelection }}</span>
+        <span class="hint">{{ formatHint(lang.ssWaitSelectionHint, toolsArray.length) }}</span>
+      </div>
+      <div class="header-right">
+        <button v-for="(tool, index) in toolsArray" :key="tool.tool_id" class="candidate-tag"
+          :class="{ active: selectedToolIndex === index }" @click="selectTool(index)">
+          {{ lang.ssCandidate }} {{ fillDigit(index + 1, 2) }} {{ tool.tool_name }}
+        </button>
+      </div>
+    </div>
+
+    <div class="child-page-body" v-if="toolsJson?.url === toolsArray[selectedToolIndex]?.url">
+      <div class="question-area">
+        <template v-for="(tool, index) in toolsArray" :key="tool.tool_id">
+          <iframe
+          v-if="index === selectedToolIndex && tool.url"
+          :src="tool.url + '&isSet=2' + '&timestamp=' + new Date().getTime()"
+          frameborder="0"></iframe>
+        </template>
+      </div>
+
+      <div class="side-panel" v-if="!toolsJson.isSet">
+         <button class="side-btn primary" @click="saveTool(toolsJson.url || '', currentToolType, selectedToolIndex)">{{ lang.ssKeep }}</button>
+        <button class="side-btn secondary" @click="discardTool(selectedToolIndex)">{{ lang.ssDiscard }}</button>
+        <div class="divider"></div>
+        <button class="side-btn link" @click="keepAllTools">{{ lang.ssKeepAll }}</button>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { ref, onMounted, watch, computed } from 'vue'
+import { storeToRefs } from 'pinia'
+import { useMainStore } from '@/store'
+import { fillDigit } from '@/utils/common'
+import { lang } from '@/main'
+import { useSlidesStore } from '@/store/slides'
+import { chat_no_stream, chat_no_stream2 } from '@/tools/aiChat'
+
+const slidesStore = useSlidesStore()
+const mainStore = useMainStore()
+const { childPageToolsArray, userid, courseTitle, childPageIndex } = storeToRefs(mainStore)
+const { setChildPageState } = mainStore
+const { currentSlide, slideIndex } = storeToRefs(slidesStore)
+
+const toolsArray = childPageToolsArray.value
+const selectedToolIndex = ref(0)
+const agentid2 = ref('f86aa63c-b7b7-4d03-9b37-b59f116d36f3')// 生成内容
+const loading = ref(true)
+
+
+const toolsJson = computed(() => {
+  return toolsArray[selectedToolIndex.value] || {}
+})
+
+const formatHint = (str: string, count: number) => {
+  return str.replace('{0}', String(count))
+}
+
+const selectTool = (index: number) => {
+  selectedToolIndex.value = index
+  console.log('👶 选中工具:', toolsArray[index])
+}
+
+onMounted(() => {
+  console.log('👶 带选页打开,toolsArray:', toolsArray)
+  const toolsToCreate = toolsArray.filter(t => !t.url)
+  if (toolsToCreate.length === 0) {
+    loading.value = false
+    // return
+  }
+  
+  // const createPromises = toolsToCreate.map((tool) => {
+  //   const originalIndex = toolsArray.findIndex(t => t.tool_id === tool.tool_id)
+  //   return createTool(toolsArray, tool, originalIndex)
+  // })
+  
+  // await Promise.all(createPromises)
+  // loading.value = false
+})
+
+watch(childPageToolsArray, (newVal) => {
+  // async 
+  console.log('👶 带选页 toolsArray 更新:', newVal)
+  const toolsToCreate = newVal.filter(t => !t.url)
+  if (toolsToCreate.length === 0) {
+    loading.value = false
+    // return
+  }
+  
+  // loading.value = true
+  // const createPromises = toolsToCreate.map((tool) => {
+  //   const originalIndex = newVal.findIndex(t => t.tool_id === tool.tool_id)
+  //   return createTool(newVal, tool, originalIndex)
+  // })
+  
+  // await Promise.all(createPromises)
+  // loading.value = false
+}, { immediate: true, deep: true })
+
+const createTool = async (array: any[], tool: any, index: number) => {
+  let action = ''
+  let type = 0
+  if (tool.tool_id === 'choice') {
+    action = lang.ssAiChatQuickAction1
+    type = 45
+  }
+  else if (tool.tool_id === 'qa') {
+    action = lang.ssAiChatQuickAction2
+    type = 15
+  }
+  else if (tool.tool_id === 'poll') {
+    action = lang.ssAiChatQuickAction6
+    type = 78
+  }
+  else if (tool.tool_id === 'photo') {
+    action = lang.ssAiChatQuickAction7
+    type = 79
+  }
+  
+  const textContents = currentSlide.value?.elements
+    .filter((element: any) => element.type === 'text' || (element.type === 'shape' && element.text && element.text.content))
+    .map((textElement: any) => {
+      if (textElement.type === 'shape') {
+        const tempElement = document.createElement('div')
+        tempElement.innerHTML = textElement.text.content
+        return tempElement.textContent || tempElement.innerText || ''
+      }
+      const tempElement = document.createElement('div')
+      tempElement.innerHTML = textElement.content
+      return tempElement.textContent || tempElement.innerText || ''
+    })
+    .filter(content => content.trim() !== '') || []
+  
+  console.log('textContents', textContents)
+  const prompt = `
+      #课程标题(course_title): ${courseTitle.value || ''}
+      #当前页面内容(current_page_content): ${textContents.length > 0 ? textContents.join('\n') : '无文本内容'}
+      #query: ${action}
+      `
+  const result = chat_no_stream(prompt, agentid2.value, userid.value || '', lang.lang)
+  console.log(result)
+  const content = await result.promise
+  console.log('👶 创建工具:', content)
+
+  let jsonFormat = ''
+  if (tool.tool_id === 'choice') {
+    jsonFormat = `{"testCount":1,"testTitle":"","testJson":[{"id":"7de1fdb4-bec3-4324-8986-4623f838e3d7","type":"2","teststitle":"1+1?","checkList":["1","2","3"],"timuList":[],"answer":[1],"userAnswer":[],"explanation":"解析"}]}`
+  }
+  else if (tool.tool_id === 'qa') {
+    jsonFormat = `{"answerQ":"问题","answer":"","fileList":[],"imageList":[],"evaluationCriteria":"评价标准"}`
+  }
+  else if (tool.tool_id === 'poll') {
+    jsonFormat = `{"testCount":1,"testTitle":"","testJson":[{"id":"7de1fdb4-bec3-4324-8986-4623f838e3d7","type":"2","teststitle":"1+1?","checkList":["1","2","3"],"timuList":[],"answer":[],"userAnswer":[]}]}`
+  }
+  else if (tool.tool_id === 'photo') {
+    jsonFormat = `{"answerQ":"问题","answer":"","fileList":[]}`
+  }
+  
+  const res = await chat_no_stream2([{
+    role: 'user',
+    content: `这是用户输入的内容:"${content}",根据用户输入的内容,生成json。输出一个json格式的回复,格式如下:${jsonFormat}。`,
+  }], { type: 'json_object' })
+  
+  console.log(`${tool.tool_name}`, JSON.parse(res))
+  
+  const pageId = await setPageId(type, res)
+  const baseUrl = setUrl()
+  const url = `${baseUrl}/pbl-teacher-table/dist/workPage.html#/setWorkPage?id=${pageId}&type=${type}&userid=${userid.value || ''}`
+  array[index].url = url
+  setTool()
+}
+
+const setTool = () => {
+  const slide = currentSlide.value
+  slidesStore.updateSlide({ toolsArray: toolsArray }, slide.id)
+  console.log(slide)
+
+  // 判断所有工具是否都已保留
+  const allToolsSet = toolsArray.every(t => t.isSet)
+  if (allToolsSet) {
+    const slide = currentSlide.value
+    slidesStore.updateSlide({ toolsArray: [] }, slide.id) 
+    setChildPageState(false, [], slideIndex.value || 0)
+  }
+}
+
+const setUrl = () => {
+  let url = 'https://beta.pbl.cocorobo.cn'
+  if (window.location.href.includes('beta')) {
+    url = 'https://beta.pbl.cocorobo.cn/'
+  }
+  else if (lang.lang === 'cn') {
+    url = 'https://pbl.cocorobo.cn/'
+  }
+  else if (lang.lang === 'hk') {
+    url = 'https://pbl.cocorobo.hk/'
+  }
+  else if (lang.lang === 'en') {
+    url = 'https://pbl.cocorobo.com/'
+  }
+  return url
+}
+
+import { getWorkPageId } from '@/services/course'
+const setPageId = async (tool: any, json: any) => {
+  const res = await getWorkPageId({
+    userid: userid.value || '',
+    type: tool,
+    json: json
+  })
+  return res[0][0].id
+}
+
+import useCreateElement from '@/hooks/useCreateElement'
+import useSlideHandler from '@/hooks/useSlideHandler'
+const { createSlide } = useSlideHandler()
+const { createFrameElement } = useCreateElement()
+
+// 切换页面
+const changeSlideIndex = (index: number) => {
+  mainStore.setActiveElementIdList([])
+
+  if (slideIndex.value === index) return
+  slidesStore.updateSlideIndex(index)
+}
+// 保留工具
+const saveTool = (url: string, type: any, index: number) => {
+  console.log('保留工具')
+  
+  createSlide()
+  createFrameElement(url, type)
+  changeSlideIndex(childPageIndex.value)
+  toolsArray[index].isSet = true
+  setTool()
+  setTimeout(() => {
+    changeSlideIndex(childPageIndex.value + 1)
+    setChildPageState(false, [], slideIndex.value || 0)
+  }, 100)
+  console.log('保留工具', toolsArray)
+}
+
+// 放弃工具
+const discardTool = (index: number) => {
+  toolsArray[index].isSet = true
+  setTool()
+  console.log('放弃工具', toolsArray)
+}
+
+// 保留所有工具
+const keepAllTools = () => {
+  toolsArray.forEach(tool => {
+    if (!tool.isSet) {
+      createSlide()
+      createFrameElement(tool.url, getToolType(tool.tool_id))
+      tool.isSet = true
+    }
+  })
+  changeSlideIndex(childPageIndex.value)
+  setTool()
+  setTimeout(() => {
+    changeSlideIndex(childPageIndex.value + 1)
+    setChildPageState(false, [], slideIndex.value || 0)
+  }, 100)
+}
+
+// 判断工具type
+const toolTypeMap: Record<string, number> = {
+  'choice': 45,
+  'qa': 15,
+  'poll': 78,
+  'photo': 79
+}
+
+const currentToolType = computed(() => {
+  const toolId = toolsArray[selectedToolIndex.value]?.tool_id
+  return toolId ? toolTypeMap[toolId] || 0 : 0
+})
+
+const getToolType = (toolId: string) => {
+  return toolTypeMap[toolId] || 0
+}
+</script>
+
+<style lang="scss" scoped>
+.child-page-display {
+  width: 100%;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  background-color: #fffbeb;
+  position: absolute;
+  top: 0;
+  left: 0;
+  border-radius: 3px;
+  overflow: hidden;
+}
+
+.child-page-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 7px 20px;
+  background: linear-gradient(180deg, #fcd34d 0%, #f59e0b 100%);
+
+  .header-left {
+    display: flex;
+    align-items: center;
+    gap: 10px;
+  }
+
+  .badge {
+    background-color: #d97706;
+    color: white;
+    padding: 4px 12px;
+    border-radius: 4px;
+    font-size: 12px;
+    font-weight: 600;
+  }
+
+  .hint {
+    color: #78350f;
+    font-size: 14px;
+  }
+
+  .header-right {
+    display: flex;
+    gap: 8px;
+    background: #d97706;
+    padding: 5px 10px;
+    border-radius: 30px;
+  }
+
+  .candidate-tag {
+    background-color: #d97706;
+    color: white;
+    padding: 4px 12px;
+    border-radius: 4px;
+    font-size: 12px;
+    font-weight: 500;
+    border: none;
+    cursor: pointer;
+    transition: all 0.2s;
+    border-radius: 30px;
+
+    &:hover {
+      background-color: #b45309;
+    }
+
+    &.active {
+      background-color: white;
+      color: #d97706;
+    }
+  }
+}
+
+.child-page-body {
+  flex: 1;
+  display: flex;
+  padding: 20px;
+  gap: 20px;
+  overflow: auto;
+}
+
+.question-area {
+  flex: 1;
+  background-color: white;
+  border: 2px solid #f59e0b;
+  border-radius: 8px;
+  padding: 20px;
+  box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
+
+  iframe {
+    width: 100%;
+    height: 100%;
+    border: none;
+  }
+}
+
+.loading-overlay {
+  position: absolute;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  background-color: rgba(255, 251, 235, 0.95);
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  align-items: center;
+  gap: 16px;
+  z-index: 100;
+}
+
+.loading-spinner {
+  width: 48px;
+  height: 48px;
+  border: 4px solid #f59e0b;
+  border-top-color: transparent;
+  border-radius: 50%;
+  animation: spin 1s linear infinite;
+}
+
+.loading-text {
+  color: #d97706;
+  font-size: 16px;
+  font-weight: 500;
+}
+
+@keyframes spin {
+  to {
+    transform: rotate(360deg);
+  }
+}
+
+.side-panel {
+  width: 90px;
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+
+  .side-btn {
+    padding: 12px 20px;
+    border: none;
+    border-radius: 8px;
+    font-size: 14px;
+    font-weight: 500;
+    cursor: pointer;
+    transition: all 0.2s;
+
+    &.primary {
+      background-color: #f59e0b;
+      color: white;
+
+      &:hover {
+        background-color: #d97706;
+      }
+    }
+
+    &.secondary {
+      background-color: white;
+      color: #6b7280;
+      border: 1px solid #e5e7eb;
+
+      &:hover {
+        border-color: #f59e0b;
+        color: #f59e0b;
+      }
+    }
+
+    &.link {
+      background-color: transparent;
+      color: #f59e0b;
+      text-align: left;
+      padding-left: 0;
+
+      &:hover {
+        text-decoration: underline;
+      }
+    }
+  }
+
+  .divider {
+    height: 1px;
+    background-color: #e5e7eb;
+    margin: 4px 0;
+  }
+}
+</style>

+ 11 - 2
src/views/Editor/Canvas/EditableElement.vue

@@ -17,7 +17,7 @@
 </template>
 
 <script lang="ts" setup>
-import { computed } from 'vue'
+import { computed, onMounted } from 'vue'
 import { storeToRefs } from 'pinia'
 import { ElementTypes, type PPTElement } from '@/types/slides'
 import type { ContextmenuItem } from '@/components/Contextmenu/types'
@@ -30,7 +30,7 @@ import useAlignElementToCanvas from '@/hooks/useAlignElementToCanvas'
 import useCopyAndPasteElement from '@/hooks/useCopyAndPasteElement'
 import useSelectElement from '@/hooks/useSelectElement'
 import useHistorySnapshot from '@/hooks/useHistorySnapshot'
-import { useSlidesStore } from '@/store'
+import { useSlidesStore, useMainStore } from '@/store'
 import message from '@/utils/message'
 import { lang } from '@/main'
 
@@ -46,6 +46,15 @@ import LatexElement from '@/views/components/element/LatexElement/index.vue'
 import VideoElement from '@/views/components/element/VideoElement/index.vue'
 import AudioElement from '@/views/components/element/AudioElement/index.vue'
 import FrameElement from '@/views/components/element/FrameElement/index.vue'
+import { initClipboardFocus } from '@/utils/clipboard'
+
+const mainStore = useMainStore()
+
+onMounted(() => {
+  console.log('ed,onMounted')
+  initClipboardFocus()
+  mainStore.setDisableHotkeysState(false)
+})
 
 const props = defineProps<{
   elementInfo: PPTElement

+ 10 - 1
src/views/Editor/Canvas/Operate/index.vue

@@ -10,7 +10,7 @@
     }"
   >
     <component
-      v-if="isSelected"
+      v-if="isSelected && !isLockedInteractiveWidget"
       :is="currentOperateComponent"
       :elementInfo="elementInfo"
       :handlerVisible="!elementInfo.lock && (isActiveGroupElement || !isMultiSelect)"
@@ -51,6 +51,7 @@ import {
   type PPTChartElement,
 } from '@/types/slides'
 import type { OperateLineHandlers, OperateResizeHandlers } from '@/types/edit'
+import { isArticleReadingTool, isTopicDiscussionTool } from '@/configs/englishSpeakingTools'
 
 import ImageElementOperate from './ImageElementOperate.vue'
 import TextElementOperate from './TextElementOperate.vue'
@@ -103,6 +104,14 @@ const elementIndexListInAnimation = computed(() => {
 
 const rotate = computed(() => 'rotate' in props.elementInfo ? props.elementInfo.rotate : 0)
 const height = computed(() => 'height' in props.elementInfo ? props.elementInfo.height : 0)
+
+// 英语口语预览(FRAME + toolType 77 / 772)产品设计上占满整页不可变形,
+// 强制隐藏 resize / rotate 把手,用户仍可选中+删除+开配置面板,但看不到任何"拖动感"。
+const isLockedInteractiveWidget = computed(() => {
+  if (props.elementInfo.type !== ElementTypes.FRAME) return false
+  const toolType = (props.elementInfo as any).toolType
+  return isTopicDiscussionTool(toolType) || isArticleReadingTool(toolType)
+})
 </script>
 
 <style lang="scss" scoped>

+ 15 - 0
src/views/Editor/Canvas/index.vue

@@ -94,6 +94,13 @@
 
     <div class="drag-mask" v-if="spaceKeyState"></div>
 
+    <ChildPageDisplay :style="{
+        width: viewportStyles.width * canvasScale + 'px',
+        height: viewportStyles.height * canvasScale + 'px',
+        left: viewportStyles.left + 'px',
+        top: viewportStyles.top + 'px',
+      }" v-if="childPageVisible" />
+
     <Ruler :viewportStyles="viewportStyles" :elementList="elementList" v-if="showRuler" />
 
     <Modal
@@ -159,6 +166,7 @@ import Operate from './Operate/index.vue'
 import LinkDialog from './LinkDialog.vue'
 import WebpageLinkEditDialog from './WebpageLinkEditDialog.vue'
 import Modal from '@/components/Modal.vue'
+import ChildPageDisplay from './ChildPageDisplay.vue'
 import api from '@/services/course'
 import useImport from '@/hooks/useImport'
 import message from '@/utils/message'
@@ -194,6 +202,7 @@ const {
 } = storeToRefs(mainStore)
 const { currentSlide } = storeToRefs(useSlidesStore())
 const { ctrlKeyState, spaceKeyState } = storeToRefs(useKeyboardStore())
+const { childPageVisible } = storeToRefs(mainStore)
 
 const viewportRef = useTemplateRef<HTMLElement>('viewportRef')
 const alignmentLines = ref<AlignmentLineProps[]>([])
@@ -384,6 +393,12 @@ const throttleScaleCanvas = throttle(scaleCanvas, 100, { leading: true, trailing
 const throttleUpdateSlideIndex = throttle(updateSlideIndex, 300, { leading: true, trailing: false })
 
 const handleMousewheelCanvas = (e: WheelEvent) => {
+// type 77 / 772(英语口语预览)这类可交互 widget:如果 wheel 来自它内部的某个 scroll 容器(比如
+  // 报告的 .report-scroll、对话的 .chat-area、文章的段落列表),让内部 scroll 接管,canvas 不介入。
+  // 如果不这样做,下面的 preventDefault 会把浏览器默认滚动行为一并抑制,内部永远滚不动。
+  const targetEl = e.target instanceof Element ? e.target : null
+  if (targetEl?.closest('.topic-discussion-preview, .article-reading-preview')) return
+
   e.preventDefault()
 
   // 按住Ctrl键时:缩放画布

+ 6 - 2
src/views/Editor/CanvasTool/WebpageInput.vue

@@ -103,9 +103,13 @@ const getTypeLabel = (type: number) => {
     15: lang.ssQATest,
     72: lang.ssAiApp,
     73: lang.ssHPage,
+    81: lang.ssHPage,
     74: lang.ssVideo,
-    75: lang.ssBiliVideo,
-    76: lang.ssCreative
+    75: lang.lang == 'cn' ? lang.ssBiliVideo : lang.ssYouTube,
+    76: lang.ssCreative,
+    77: lang.ssEnglishSpeakingTool,
+    78: lang.ssVote,
+    79: lang.ssPhoto,
   }
   return typeMap[type] || lang.ssUnknown
 }

+ 264 - 5
src/views/Editor/CanvasTool/index2.vue

@@ -1,6 +1,8 @@
 <template>
   <div class="canvas-tool">
     <div class="left-handler">
+      <IconBack class="handler-item" :class="{ 'disable': !canUndo }" v-tooltip="lang.ssUndoTip" @click="undo()"  v-if="!isFrame" />
+      <IconNext class="handler-item" :class="{ 'disable': !canRedo }" v-tooltip="lang.ssRedoTip" @click="redo()"  v-if="!isFrame" />
       <Popover trigger="click" v-model:value="toolVisible" style="height: 100%;display: flex;align-items: center;"
         :offset="10" v-if="hasInteractiveTool">
         <template #content>
@@ -17,6 +19,20 @@
             </svg>
             <span>{{ lang.ssQandA }}</span>
           </div>
+          <div class="popover-item" @click="editContent(78)" v-if="frametype != 78">
+            <svg width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+                <polyline points="9 11 12 14 22 4"></polyline>
+                <path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"></path>
+            </svg>
+            <span>{{ lang.ssVote }}</span>
+          </div>
+          <div class="popover-item" @click="editContent(79)" v-if="frametype != 79">
+            <svg width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+                <path d="M23 19a2 2 0 01-2 2H3a2 2 0 01-2-2V8a2 2 0 012-2h4l2-3h6l2 3h4a2 2 0 012 2z"></path>
+                <circle cx="12" cy="13" r="4"></circle>
+            </svg>
+            <span>{{ lang.ssPhoto }}</span>
+          </div>
         </template>
         <div class="handler-item" :class="{ active: toolVisible }">
           <span class="svg-icon">
@@ -29,6 +45,14 @@
               stroke-width="2">
               <path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" />
             </svg>
+            <svg v-if="frametype == 78" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+                <polyline points="9 11 12 14 22 4"></polyline>
+                <path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"></path>
+            </svg>
+            <svg v-if="frametype == 79" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+                <path d="M23 19a2 2 0 01-2 2H3a2 2 0 01-2-2V8a2 2 0 012-2h4l2-3h6l2 3h4a2 2 0 012 2z"></path>
+                <circle cx="12" cy="13" r="4"></circle>
+            </svg>
           </span>
           <span>{{ iframeLabel }}</span>
           <svg t="1776672009773" class="xia-icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg"
@@ -104,6 +128,87 @@
           <span>{{ lang.ssShape }}</span>
         </div>
       </Popover>
+      <RichTextBase  v-if="handleElement?.type == 'text'"/>
+      <div style="display: flex; align-items: center;" v-if="handleElement?.type == 'shape'">
+        <Select 
+          style="flex: 1;" 
+          :value="fillType" 
+          @update:value="value => updateFillType(value as 'fill' | 'gradient' | 'pattern')"
+          :options="[
+            { label: lang.ssSolidFill, value: 'fill' },
+            { label: lang.ssGradFill, value: 'gradient' },
+            // { label: lang.ssImgFill, value: 'pattern' },
+          ]"
+        />
+        <Popover trigger="click" v-if="fillType === 'fill'" style="flex: 1;">
+          <template #content>
+            <ColorPicker
+              :modelValue="fill"
+              @update:modelValue="value => updateFill(value)"
+            />
+          </template>
+          <ColorButton style="width: 100px;" :color="fill" />
+        </Popover>
+        <Select 
+          style="flex: 1;" 
+          :value="gradient.type" 
+          @update:value="value => updateGradient({ type: value as GradientType })"
+          v-else-if="fillType === 'gradient'"
+          :options="[
+            { label: lang.ssLinearGrad, value: 'linear' },
+            { label: lang.ssRadialGrad, value: 'radial' },
+          ]"
+        />
+      </div>
+
+      <template v-if="fillType === 'gradient' && handleElement?.type == 'shape'">
+        <div class="row">
+          <GradientBar
+            :value="gradient.colors"
+            :index="currentGradientIndex"
+            @update:value="value => updateGradient({ colors: value })"
+            @update:index="index => currentGradientIndex = index"
+          />
+        </div>
+        <div class="row">
+          <div style="width: 40%;">{{ lang.ssCurColorBlock }}</div>
+          <Popover trigger="click" style="width: 60%;">
+            <template #content>
+              <ColorPicker
+                :modelValue="gradient.colors[currentGradientIndex].color"
+                @update:modelValue="value => updateGradientColors(value)"
+              />
+            </template>
+            <ColorButton :color="gradient.colors[currentGradientIndex].color" />
+          </Popover>
+        </div>
+        <div class="row" v-if="gradient.type === 'linear'">
+          <div style="width: 40%;">{{ lang.ssGradAngle }}</div>
+          <Slider
+            style="width: 60%;"
+            :min="0"
+            :max="360"
+            :step="15"
+            :value="gradient.rotate"
+            @update:value="value => updateGradient({ rotate: value as number })" 
+          />
+        </div>
+      </template>
+      <ElementFlip  v-if="handleElement?.type == 'shape'"/>
+
+      <!-- 英语口语工具:重置预览 -->
+      <div
+        v-if="canResetSpeakingPreview"
+        class="handler-item reset-preview-btn"
+        @click="handleResetSpeakingPreview"
+      >
+        <svg width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor"
+          stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+          <polyline points="23 4 23 10 17 10" />
+          <path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
+        </svg>
+        <span>重置预览</span>
+      </div>
     </div>
 
 
@@ -130,9 +235,16 @@
 </template>
 
 <script lang="ts" setup>
-import { ref, computed } from 'vue'
+import { ref, computed, watch } from 'vue'
 import { storeToRefs } from 'pinia'
 import { useMainStore, useSnapshotStore, useSlidesStore } from '@/store'
+import { useSpeakingStore } from '@/store/speaking'
+import { useArticleReadingStore } from '@/store/articleReading'
+import {
+  ARTICLE_READING_TOOL_TYPE,
+  isArticleReadingTool,
+  isTopicDiscussionTool,
+} from '@/configs/englishSpeakingTools'
 import { getImageDataURL } from '@/utils/image'
 import useImport from '@/hooks/useImport'
 import type { ShapePoolItem } from '@/configs/shapes'
@@ -156,11 +268,49 @@ import Popover from '@/components/Popover.vue'
 import PopoverMenuItem from '@/components/PopoverMenuItem.vue'
 import Button from '@/components/Button.vue'
 
+import RichTextBase from '@/views/Editor/Toolbar/common/RichTextBase2.vue'
+import Select from '@/components/Select.vue'
+import ColorPicker from '@/components/ColorPicker/index.vue'
+import GradientBar from '@/components/GradientBar.vue'
+import Slider from '@/components/Slider.vue'
+import ColorButton from '@/components/ColorButton.vue'
+import ElementFlip from '@/views/Editor/Toolbar/common/ElementFlip2.vue'
+
+
 const mainStore = useMainStore()
 const slidesStore = useSlidesStore()
+const speakingStore = useSpeakingStore()
+const articleStore = useArticleReadingStore()
 const { creatingElement, creatingCustomShape, showSelectPanel, showSearchPanel, showNotesPanel } = storeToRefs(mainStore)
 const { canUndo, canRedo } = storeToRefs(useSnapshotStore())
 const { currentSlide } = storeToRefs(slidesStore)
+const { previewState } = storeToRefs(speakingStore)
+const { previewStage: articlePreviewStage } = storeToRefs(articleStore)
+
+// 当前 slide 是否包含英语口语工具(toolType=77)
+const hasEnglishSpeakingTool = computed(() => {
+  const elements = currentSlide.value?.elements || []
+  return elements.some((el: any) => el.type === 'frame' && isTopicDiscussionTool(el.toolType))
+})
+
+// 文章朗读(toolType=772)
+const hasArticleReadingTool = computed(() => {
+  const elements = currentSlide.value?.elements || []
+  return elements.some((el: any) => el.type === 'frame' && isArticleReadingTool(el.toolType))
+})
+
+// 「重置预览」两种题型同一条规则:元素在当前页 **且** 预览已经离开起始页。
+// 文章朗读原来是「元素在就一直显示」,与话题讨论不一致 —— 停在起始页时那个按钮没有任何
+// 可重置的东西,按下去只是原地重载一次。两边的 ready 都由各自的预览组件同步进 store。
+const canResetSpeakingPreview = computed(
+  () => (hasEnglishSpeakingTool.value && previewState.value !== 'ready')
+    || (hasArticleReadingTool.value && articlePreviewStage.value !== 'ready')
+)
+
+const handleResetSpeakingPreview = () => {
+  if (hasArticleReadingTool.value) articleStore.requestResetPreview()
+  if (hasEnglishSpeakingTool.value) speakingStore.requestResetPreview()
+}
 
 const getInitialViewMode = () => {
   const urlParams = new URLSearchParams(window.location.search)
@@ -179,7 +329,7 @@ const viewMode = computed(() => getInitialViewMode())
 
 const hasInteractiveTool = computed(() => {
   const elements = currentSlide.value?.elements || []
-  return elements.some((el: any) => el.type === 'frame' && (el.toolType === 45 || el.toolType === 15))
+  return elements.some((el: any) => el.type === 'frame' && (el.toolType === 45 || el.toolType === 15 || el.toolType === 78 || el.toolType === 79))
 })
 
 const iframeLabel = computed(() => {
@@ -198,9 +348,14 @@ const getTypeLabel = (type: number) => {
     15: lang.ssQATest,
     72: lang.ssAiApp,
     73: lang.ssHPage,
+    81: lang.ssHPage,
     74: lang.ssVideo,
-    75: lang.ssBiliVideo,
-    76: lang.ssCreative
+    75: lang.lang == 'cn' ? lang.ssBiliVideo : lang.ssYouTube,
+    76: lang.ssCreative,
+    77: lang.ssEnglishSpeakingTool,
+    [ARTICLE_READING_TOOL_TYPE]: lang.ssArticleReadingTool,
+    78: lang.ssVote,
+    79: lang.ssPhoto,
   }
   return typeMap[type] || lang.ssUnknown
 }
@@ -396,12 +551,95 @@ const editContent = (toolType: number) => {
     }
     const parentWindow = window.parent as ParentWindowWithToolList
     const elements = currentSlide.value?.elements || []
-    const frameElement = elements.find((el: any) => el.type === 'frame' && (el.toolType === 45 || el.toolType === 15))
+    const frameElement = elements.find((el: any) => el.type === 'frame' && (el.toolType === 45 || el.toolType === 15 || el.toolType === 78 || el.toolType === 79))
     parentWindow?.toolBtn2?.(0, frameElement?.url || '', toolType)
     toolVisible.value = false
   })
 }
 
+import type { GradientType, PPTShapeElement, Gradient, ShapeText } from '@/types/slides'
+const { handleElement, handleElementId } = storeToRefs(useMainStore())
+const fillType = ref('fill')
+const fill = ref<string>('#000')
+const textAlign = ref('middle')
+const pattern = ref('')
+import emitter, { EmitterEvents } from '@/utils/emitter'
+const { addHistorySnapshot } = useHistorySnapshot()
+const currentGradientIndex = ref(0)
+
+watch(handleElementId, () => {
+  currentGradientIndex.value = 0
+})
+
+const updateElement = (props: Partial<PPTShapeElement>) => {
+  slidesStore.updateElement({ id: handleElementId.value, props })
+  addHistorySnapshot()
+}
+
+const gradient = ref<Gradient>({
+  type: 'linear', 
+  rotate: 0,
+  colors: [
+    { pos: 0, color: '#fff' },
+    { pos: 100, color: '#fff' },
+  ],
+})
+
+watch(handleElement, () => {
+  if (!handleElement.value || handleElement.value.type !== 'shape') return
+
+  fill.value = handleElement.value.fill || '#fff'
+  const defaultGradientColor = [
+    { pos: 0, color: fill.value },
+    { pos: 100, color: '#fff' },
+  ]
+  gradient.value = handleElement.value.gradient || { type: 'linear', rotate: 0, colors: defaultGradientColor }
+  pattern.value = handleElement.value.pattern || ''
+  fillType.value = (handleElement.value.pattern !== undefined) ? 'pattern' : (handleElement.value.gradient ? 'gradient' : 'fill')
+  textAlign.value = handleElement.value?.text?.align || 'middle'
+
+  if (handleElement.value.text?.content) {
+    emitter.emit(EmitterEvents.SYNC_RICH_TEXT_ATTRS_TO_STORE)
+  }
+}, { deep: true, immediate: true })
+
+// 设置填充类型:渐变、纯色
+const updateFillType = (type: 'gradient' | 'fill' | 'pattern') => {
+  console.log('设置填充类型:', type)
+  if (type === 'fill') {
+    slidesStore.removeElementProps({ id: handleElementId.value, propName: ['gradient', 'pattern'] })
+    addHistorySnapshot()
+  }
+  else if (type === 'gradient') {
+    currentGradientIndex.value = 0
+    slidesStore.removeElementProps({ id: handleElementId.value, propName: 'pattern' })
+    updateElement({ gradient: gradient.value })
+  }
+  // else if (type === 'pattern') {
+  //   slidesStore.removeElementProps({ id: handleElementId.value, propName: 'gradient' })
+  //   updateElement({ pattern: '' })
+  // }
+}
+
+// 设置填充色
+const updateFill = (value: string) => {
+  updateElement({ fill: value })
+}
+
+// 设置渐变填充
+const updateGradient = (gradientProps: Partial<Gradient>) => {
+  if (!gradient.value) return
+  const _gradient = { ...gradient.value, ...gradientProps }
+  updateElement({ gradient: _gradient })
+}
+const updateGradientColors = (color: string) => {
+  const colors = gradient.value.colors.map((item, index) => {
+    if (index === currentGradientIndex.value) return { ...item, color }
+    return item
+  })
+  updateGradient({ colors })
+}
+
 </script>
 
 <style lang="scss" scoped>
@@ -481,6 +719,20 @@ const editContent = (toolType: number) => {
   }
 }
 
+.reset-preview-btn {
+  gap: 5px;
+  color: #6b7280;
+
+  svg {
+    width: 14px;
+    height: 14px;
+  }
+
+  &:hover {
+    color: #f97316;
+  }
+}
+
 .left-handler {
   display: flex;
   align-items: center;
@@ -624,4 +876,11 @@ const editContent = (toolType: number) => {
     }
   }
 }
+
+.row {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  width: 180px;
+}
 </style>

+ 186 - 0
src/views/Editor/EnglishSpeaking/SpeakingPanel.vue

@@ -0,0 +1,186 @@
+<!--
+  SpeakingPanel — 英语口语练习的主面板容器组件
+
+  职责:
+  1. 管理三层页面导航状态(layer1 → layer2 → config)
+  2. 渲染面包屑导航,支持任意层级回退
+  3. 根据 pageMode 切换展示不同的子页面组件
+
+  页面流转:
+  layer1 (首页:选课本/年级/单元) → layer2 (口语练习列表) → config (话题讨论配置)
+-->
+<template>
+  <div class="speaking-panel">
+    <!-- 面包屑导航:根据当前 pageMode 展示不同层级的路径 -->
+    <div class="panel-header">
+      <div class="breadcrumb">
+        <!-- layer1: 仅显示标题,无回退链接 -->
+        <template v-if="pageMode === 'layer1'">
+          <span class="breadcrumb-title">{{ lang.ssEnglishSubject }}</span>
+        </template>
+        <template v-else>
+          <button class="breadcrumb-link" @click="goHome">{{ lang.ssEnglishSubject }}</button>
+          <svg class="breadcrumb-arrow" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+            <path d="M9 18l6-6-6-6" />
+          </svg>
+          <!-- config 层:显示完整三级面包屑 英语 > 口语 > 话题讨论 / 文章朗读 -->
+          <template v-if="pageMode === 'config' || pageMode === 'config-article-reading'">
+            <button class="breadcrumb-link" @click="goLayer2">{{ lang.ssSpeaking }}</button>
+            <svg class="breadcrumb-arrow" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+              <path d="M9 18l6-6-6-6" />
+            </svg>
+            <span class="breadcrumb-current">
+              {{ pageMode === 'config-article-reading' ? lang.ssArticleReading : lang.ssTopicDiscussion }}
+            </span>
+          </template>
+          <!-- layer2:显示两级面包屑 英语 > 口语 -->
+          <template v-else>
+            <span class="breadcrumb-current">{{ lang.ssSpeaking }}</span>
+          </template>
+        </template>
+      </div>
+    </div>
+
+    <!-- 内容区域:根据 pageMode 切换子页面 -->
+    <div class="panel-body">
+      <Layer1Home
+        v-if="pageMode === 'layer1'"
+        :selectedTextbook="selectedTextbook"
+        :selectedGrade="selectedGrade"
+        :selectedUnit="selectedUnit"
+        @update:selectedTextbook="selectedTextbook = $event"
+        @update:selectedGrade="selectedGrade = $event"
+        @update:selectedUnit="selectedUnit = $event"
+        @selectExerciseType="handleSelectExerciseType"
+      />
+      <Layer2Speaking
+        v-else-if="pageMode === 'layer2'"
+        :unitId="selectedUnit"
+        @openConfig="handleOpenConfig"
+      />
+      <TopicDiscussionConfig
+        v-else-if="pageMode === 'config'"
+        @back="goLayer2"
+      />
+      <ArticlePracticeConfig
+        v-else-if="pageMode === 'config-article-reading'"
+        @back="goLayer2"
+      />
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { ref, computed, watch } from 'vue'
+import { lang } from '@/main'
+import type { PageMode, ExerciseType } from '@/types/englishSpeaking'
+import { useSpeakingStore } from '@/store/speaking'
+import { useArticleReadingStore } from '@/store/articleReading'
+import Layer1Home from './layers/Layer1Home.vue'
+import Layer2Speaking from './layers/Layer2Speaking.vue'
+import TopicDiscussionConfig from './configs/TopicDiscussionConfig.vue'
+import ArticlePracticeConfig from './configs/ArticlePracticeConfig.vue'
+
+/** 当前页面层级:layer1(首页) → layer2(口语列表) → config(配置页) */
+const pageMode = ref<PageMode>('layer1')
+
+const speakingStore = useSpeakingStore()
+const articleStore = useArticleReadingStore()
+
+// 课本筛选条件的默认值(上海英语·五年级上·Unit2),供开发/演示用
+const selectedTextbook = ref('shep')
+const selectedUnit = ref('SHEP-5-上-Unit2')
+// selectedGrade 持久化到 store(用于发送到后端 createSession 渲染 system_prompt)
+const selectedGrade = computed({
+  get: () => speakingStore.config.grade,
+  set: (val: string) => speakingStore.updateGrade(val),
+})
+
+/** Layer1 选择练习类型后的回调,目前仅 speaking 类型会触发页面跳转 */
+const handleSelectExerciseType = (type: ExerciseType) => {
+  if (type === 'speaking') {
+    pageMode.value = 'layer2'
+  }
+}
+
+/** Layer2 点击某个口语话题后,进入配置页 */
+const handleOpenConfig = () => {
+  pageMode.value = 'config'
+}
+
+// ---- 面包屑导航回退 ----
+const goHome = () => {
+  pageMode.value = 'layer1'
+}
+
+const goLayer2 = () => {
+  pageMode.value = 'layer2'
+}
+
+// 点击画布上的 77 型 frame 会递增 openConfigSignal → 强制切到配置页
+watch(() => speakingStore.openConfigSignal, (val, old) => {
+  if (val !== old) pageMode.value = 'config'
+})
+
+// 772 型 frame 走各自的信号,两条配置页互不干扰
+watch(() => articleStore.openConfigSignal, (val, old) => {
+  if (val !== old) pageMode.value = 'config-article-reading'
+})
+</script>
+
+<style lang="scss" scoped>
+.speaking-panel {
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+  background: #fff;
+}
+
+.panel-header {
+  padding: 12px 16px;
+  border-bottom: 1px solid rgba(0, 0, 0, 0.06);
+  flex-shrink: 0;
+}
+
+.breadcrumb {
+  display: flex;
+  align-items: center;
+  gap: 4px;
+  font-size: 13px;
+}
+
+.breadcrumb-title {
+  font-size: 15px;
+  font-weight: 600;
+  color: #111;
+}
+
+.breadcrumb-link {
+  background: none;
+  border: none;
+  padding: 0;
+  cursor: pointer;
+  color: #9ca3af;
+  font-size: 13px;
+  transition: color 0.2s;
+
+  &:hover {
+    color: #6b7280;
+  }
+}
+
+.breadcrumb-arrow {
+  color: #d1d5db;
+  flex-shrink: 0;
+}
+
+.breadcrumb-current {
+  font-weight: 600;
+  color: #111;
+}
+
+.panel-body {
+  flex: 1;
+  overflow-y: auto;
+}
+</style>

+ 239 - 0
src/views/Editor/EnglishSpeaking/components/AIGenerationForm.vue

@@ -0,0 +1,239 @@
+<template>
+  <div class="ai-generation-form">
+    <div class="form-section">
+      <label class="form-label">描述你想要的练习</label>
+      <textarea
+        v-model="description"
+        class="form-textarea"
+        placeholder="例如:我想让学生练习用英语描述上学路线..."
+        rows="4"
+      />
+    </div>
+
+    <div class="form-section">
+      <label class="form-label">任务类型</label>
+      <div class="task-type-selector">
+        <select v-model="taskType" class="form-select">
+          <option value="topic-discussion">话题讨论</option>
+          <option value="scenario-task">情境任务</option>
+          <option value="casual-chat">轻松闲聊</option>
+          <option value="mock-interview">模拟访谈</option>
+        </select>
+        <svg class="select-arrow" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+          <path d="M6 9l6 6 6-6" />
+        </svg>
+      </div>
+    </div>
+
+    <div class="form-section">
+      <label class="form-label">参考资料(可选)</label>
+      <div class="knowledge-upload">
+        <div class="upload-placeholder">
+          <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+            <path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
+            <polyline points="17 8 12 3 7 8" />
+            <line x1="12" y1="3" x2="12" y2="15" />
+          </svg>
+          <span>上传文件或粘贴链接</span>
+        </div>
+      </div>
+    </div>
+
+    <div class="form-actions">
+      <button class="btn-generate" :disabled="!description.trim()" @click="handleGenerate">
+        <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+          <path d="M12 2l2.4 7.2L22 12l-7.6 2.8L12 22l-2.4-7.2L2 12l7.6-2.8z" />
+        </svg>
+        <span>生成练习</span>
+      </button>
+    </div>
+
+    <!-- 功能开发中提示 -->
+    <div v-if="showDevelopingTip" class="developing-tip">
+      <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+        <circle cx="12" cy="12" r="10" />
+        <line x1="12" y1="8" x2="12" y2="12" />
+        <line x1="12" y1="16" x2="12.01" y2="16" />
+      </svg>
+      <span>{{ lang.ssFunctionDeveloping }}</span>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { ref } from 'vue'
+import { lang } from '@/main'
+
+const description = ref('')
+const taskType = ref('topic-discussion')
+const showDevelopingTip = ref(false)
+
+const handleGenerate = () => {
+  // 仅 UI 壳子,显示"功能开发中"提示
+  showDevelopingTip.value = true
+  setTimeout(() => {
+    showDevelopingTip.value = false
+  }, 3000)
+}
+</script>
+
+<style lang="scss" scoped>
+.ai-generation-form {
+  display: flex;
+  flex-direction: column;
+  gap: 16px;
+}
+
+.form-section {
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+}
+
+.form-label {
+  font-size: 13px;
+  font-weight: 500;
+  color: #374151;
+}
+
+.form-textarea {
+  width: 100%;
+  padding: 10px 12px;
+  border: 1px solid rgba(0, 0, 0, 0.08);
+  border-radius: 12px;
+  background: #f9fafb;
+  font-size: 13px;
+  color: #111827;
+  line-height: 1.5;
+  resize: vertical;
+  transition: all 0.2s;
+  font-family: inherit;
+  box-sizing: border-box;
+
+  &:focus {
+    outline: none;
+    border-color: #f97316;
+    background: #fff;
+    box-shadow: 0 0 0 2px rgba(249, 115, 22, 0.1);
+  }
+
+  &::placeholder {
+    color: #9ca3af;
+  }
+}
+
+.task-type-selector {
+  position: relative;
+}
+
+.form-select {
+  width: 100%;
+  appearance: none;
+  padding: 8px 32px 8px 12px;
+  border: 1px solid rgba(0, 0, 0, 0.08);
+  border-radius: 12px;
+  background: #f9fafb;
+  font-size: 13px;
+  color: #111827;
+  cursor: pointer;
+  transition: all 0.2s;
+
+  &:focus {
+    outline: none;
+    border-color: #f97316;
+    background: #fff;
+    box-shadow: 0 0 0 2px rgba(249, 115, 22, 0.1);
+  }
+}
+
+.select-arrow {
+  position: absolute;
+  right: 10px;
+  top: 50%;
+  transform: translateY(-50%);
+  color: #9ca3af;
+  pointer-events: none;
+}
+
+.knowledge-upload {
+  border: 1px dashed rgba(0, 0, 0, 0.12);
+  border-radius: 12px;
+  padding: 16px;
+  cursor: pointer;
+  transition: all 0.2s;
+
+  &:hover {
+    border-color: #f97316;
+    background: #fff7ed;
+  }
+}
+
+.upload-placeholder {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 8px;
+  color: #9ca3af;
+  font-size: 12px;
+
+  svg {
+    color: #d1d5db;
+  }
+}
+
+.form-actions {
+  display: flex;
+  justify-content: flex-end;
+}
+
+.btn-generate {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  padding: 8px 20px;
+  border: none;
+  border-radius: 12px;
+  background: linear-gradient(135deg, #f97316, #ef4444);
+  color: #fff;
+  font-size: 13px;
+  font-weight: 500;
+  cursor: pointer;
+  transition: all 0.2s;
+
+  &:hover:not(:disabled) {
+    opacity: 0.9;
+    transform: translateY(-1px);
+    box-shadow: 0 4px 12px rgba(249, 115, 22, 0.3);
+  }
+
+  &:disabled {
+    opacity: 0.5;
+    cursor: not-allowed;
+  }
+}
+
+.developing-tip {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: 6px;
+  padding: 10px 16px;
+  border-radius: 12px;
+  background: #fffbeb;
+  border: 1px solid #fde68a;
+  color: #92400e;
+  font-size: 13px;
+  animation: fadeIn 0.3s ease-out;
+}
+
+@keyframes fadeIn {
+  from {
+    opacity: 0;
+    transform: translateY(-4px);
+  }
+  to {
+    opacity: 1;
+    transform: translateY(0);
+  }
+}
+</style>

+ 346 - 0
src/views/Editor/EnglishSpeaking/components/ArticleFormatDialog.vue

@@ -0,0 +1,346 @@
+<template>
+  <div v-if="visible" class="modal-mask">
+    <div
+      ref="dialogRef"
+      class="format-dialog scale-in"
+      role="dialog"
+      aria-modal="true"
+      aria-labelledby="article-format-dialog-title"
+      aria-describedby="article-format-dialog-desc"
+      tabindex="-1"
+      @keydown="handleKeydown"
+    >
+      <!-- 覆盖确认 / 应用守卫的进行中态:整块大转圈,按钮排消失(还原 enspeak :461-480) -->
+      <div v-if="loading && mode !== 'paste'" class="dialog-loading">
+        <svg class="spinner spinner-lg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
+          <path d="M12 3a9 9 0 1 0 9 9" />
+        </svg>
+        <div id="article-format-dialog-title" class="dialog-title">{{ lang.ssArticleAutoFormatting }}</div>
+        <div id="article-format-dialog-desc" class="dialog-hint">{{ lang.ssArticleAutoFormattingHint }}</div>
+      </div>
+
+      <template v-else>
+        <div class="dialog-body">
+          <div id="article-format-dialog-title" class="dialog-title">{{ title }}</div>
+          <div id="article-format-dialog-desc" class="dialog-message">{{ bodyText }}</div>
+        </div>
+
+        <div class="dialog-actions">
+          <button class="dialog-btn btn-secondary" :disabled="loading" @click="emit('cancel')">
+            {{ cancelLabel }}
+          </button>
+          <button class="dialog-btn btn-primary" :disabled="loading" @click="emit('confirm')">
+            <!-- 粘贴弹窗的进行中态:确认键内联小转圈(还原 enspeak :443-450) -->
+            <svg
+              v-if="loading"
+              class="spinner spinner-sm"
+              viewBox="0 0 24 24"
+              fill="none"
+              stroke="currentColor"
+              stroke-width="2.5"
+              stroke-linecap="round"
+            >
+              <path d="M12 3a9 9 0 1 0 9 9" />
+            </svg>
+            {{ loading ? lang.ssArticleAutoFormatting : confirmLabel }}
+          </button>
+        </div>
+      </template>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
+import { lang } from '@/main'
+
+const props = defineProps<{
+  visible: boolean
+  mode: 'paste' | 'overwrite' | 'guard'
+  loading: boolean
+}>()
+
+const emit = defineEmits<{
+  (e: 'confirm'): void
+  (e: 'cancel'): void
+}>()
+
+// 焦点陷阱 / 键盘行为:与同目录 TaskHintModal.vue 同一套实现,两个弹框统一风格
+const dialogRef = ref<HTMLElement | null>(null)
+const previouslyFocusedElement = ref<HTMLElement | null>(null)
+
+const focusableSelector = [
+  'a[href]',
+  'button:not([disabled])',
+  'textarea:not([disabled])',
+  'input:not([disabled])',
+  'select:not([disabled])',
+  '[tabindex]:not([tabindex="-1"])',
+].join(',')
+
+const getFocusableElements = () => {
+  const dialog = dialogRef.value
+  if (!dialog) return []
+
+  return Array.from(dialog.querySelectorAll<HTMLElement>(focusableSelector)).filter((el) => {
+    if (el.hasAttribute('disabled') || el.getAttribute('aria-hidden') === 'true') return false
+    return el.offsetParent !== null || el === document.activeElement
+  })
+}
+
+const restorePreviousFocus = () => {
+  const element = previouslyFocusedElement.value
+  previouslyFocusedElement.value = null
+
+  if (element && document.contains(element)) {
+    element.focus()
+  }
+}
+
+const focusDialog = async () => {
+  previouslyFocusedElement.value = document.activeElement instanceof HTMLElement
+    ? document.activeElement
+    : null
+
+  await nextTick()
+  dialogRef.value?.focus()
+}
+
+const containTabFocus = (event: KeyboardEvent) => {
+  const dialog = dialogRef.value
+  if (!dialog) return
+
+  const focusableElements = getFocusableElements()
+  if (!focusableElements.length) {
+    event.preventDefault()
+    dialog.focus()
+    return
+  }
+
+  const firstElement = focusableElements[0]
+  const lastElement = focusableElements[focusableElements.length - 1]
+  const activeElement = document.activeElement
+
+  if (event.shiftKey) {
+    if (activeElement === dialog || activeElement === firstElement || !dialog.contains(activeElement)) {
+      event.preventDefault()
+      lastElement.focus()
+    }
+    return
+  }
+
+  if (activeElement === dialog) {
+    event.preventDefault()
+    firstElement.focus()
+    return
+  }
+
+  if (activeElement === lastElement) {
+    event.preventDefault()
+    firstElement.focus()
+  }
+}
+
+const handleKeydown = (event: KeyboardEvent) => {
+  if (event.key === 'Escape') {
+    if (props.loading) return // 进行中态 Escape 失效,与取消按钮 :disabled="loading" 一致
+    event.preventDefault()
+    emit('cancel')
+    return
+  }
+
+  if (event.key === 'Tab') {
+    containTabFocus(event)
+  }
+}
+
+watch(
+  () => props.visible,
+  (visible) => {
+    if (visible) {
+      void focusDialog()
+      return
+    }
+
+    restorePreviousFocus()
+  },
+  { flush: 'post', immediate: true },
+)
+
+onBeforeUnmount(() => {
+  restorePreviousFocus()
+})
+
+// 文案按 mode 分档。必须使用静态key访问(避免动态下标)
+// 扫描脚本依赖正则表达式检测源码中的key引用
+const title = computed(() => {
+  if (props.mode === 'paste') return lang.ssArticlePasteFormatTitle as string
+  if (props.mode === 'overwrite') return lang.ssArticleOverwriteFormatTitle as string
+  return lang.ssArticleGuardTitle as string
+})
+
+const bodyText = computed(() => {
+  if (props.mode === 'paste') return lang.ssArticlePasteFormatMessage as string
+  if (props.mode === 'overwrite') return lang.ssArticleOverwriteFormatMessage as string
+  return lang.ssArticleGuardMessage as string
+})
+
+const cancelLabel = computed(() =>
+  (props.mode === 'guard' ? lang.ssArticleGuardManual : lang.ssArticleFormatCancel) as string
+)
+
+const confirmLabel = computed(() =>
+  (props.mode === 'guard' ? lang.ssArticleAutoFormat : lang.ssArticleFormatConfirm) as string
+)
+</script>
+
+<style lang="scss" scoped>
+/* 值抄自同仓 TaskHintModal.vue(scoped 无法跨组件复用,故复制)= enspeak bg-black/30 + inset-0 + z-50 */
+.modal-mask {
+  position: fixed;
+  inset: 0;
+  z-index: 50;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 16px;
+  background: rgba(0, 0, 0, 0.3);
+  backdrop-filter: blur(2px);
+}
+
+/* enspeak: bg-white rounded-xl shadow-2xl w-[380px] p-6 */
+.format-dialog {
+  width: 380px;
+  max-width: 100%;
+  padding: 24px;
+  background: #fff;
+  border-radius: 16px;
+  box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15);
+  box-sizing: border-box;
+}
+
+/* enspeak: animate-in fade-in zoom-in duration-200 */
+.scale-in {
+  animation: scale-in 0.18s ease-out;
+}
+
+@keyframes scale-in {
+  from {
+    opacity: 0;
+    transform: scale(0.96);
+  }
+
+  to {
+    opacity: 1;
+    transform: scale(1);
+  }
+}
+
+.dialog-body {
+  text-align: center;
+  margin-bottom: 24px;
+}
+
+/* enspeak: text-[15px] font-semibold text-gray-800 mb-2 */
+.dialog-title {
+  font-size: 15px;
+  font-weight: 600;
+  color: #1f2937;
+  margin-bottom: 8px;
+}
+
+/* enspeak: text-[13px] text-gray-600 */
+.dialog-message {
+  font-size: 13px;
+  line-height: 1.6;
+  color: #4b5563;
+}
+
+.dialog-loading {
+  text-align: center;
+  padding: 8px 0;
+}
+
+/* enspeak: text-[13px] text-gray-500 */
+.dialog-hint {
+  font-size: 13px;
+  color: #6b7280;
+}
+
+/* enspeak: flex gap-3 */
+.dialog-actions {
+  display: flex;
+  gap: 12px;
+}
+
+/* enspeak: flex-1 h-10 rounded-lg text-[13px] font-medium */
+.dialog-btn {
+  flex: 1;
+  height: 40px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: 6px;
+  border-radius: 8px;
+  font-size: 13px;
+  font-weight: 500;
+  cursor: pointer;
+  transition: background 0.2s;
+
+  &:disabled {
+    cursor: not-allowed;
+  }
+}
+
+/* enspeak: border-gray-200 text-gray-700 hover:bg-gray-50 disabled:opacity-50 */
+.btn-secondary {
+  border: 1px solid #e5e7eb;
+  background: #fff;
+  color: #374151;
+
+  &:hover:not(:disabled) {
+    background: #f9fafb;
+  }
+
+  &:disabled {
+    opacity: 0.5;
+  }
+}
+
+/* enspeak: bg-orange-500 text-white hover:bg-orange-600 */
+.btn-primary {
+  border: none;
+  background: #f97316;
+  color: #fff;
+
+  &:hover:not(:disabled) {
+    background: #ea580c;
+  }
+}
+
+.spinner {
+  animation: spin 0.8s linear infinite;
+  flex-shrink: 0;
+}
+
+/* enspeak: LoaderIcon w-4 h-4 */
+.spinner-sm {
+  width: 16px;
+  height: 16px;
+}
+
+/* enspeak: LoaderIcon w-8 h-8 text-orange-500 mx-auto mb-4 */
+.spinner-lg {
+  width: 32px;
+  height: 32px;
+  color: #f97316;
+  display: block;
+  margin: 0 auto 16px;
+}
+
+@keyframes spin {
+  to {
+    transform: rotate(360deg);
+  }
+}
+</style>

+ 123 - 0
src/views/Editor/EnglishSpeaking/components/CreationModeSwitch.vue

@@ -0,0 +1,123 @@
+<template>
+  <div class="creation-mode-switch">
+    <span class="mode-title">
+      {{ currentModeLabel }}
+    </span>
+    <div class="mode-actions">
+      <!-- 当前是 smart 模式时,显示 AI生成 和 手动创建 按钮 -->
+      <template v-if="modelValue === 'smart'">
+        <!-- AI生成按钮(暂时隐藏,保留以便后续启用)
+        <button class="mode-btn" @click="$emit('update:modelValue', 'ai')">
+          <svg class="mode-btn-icon sparkle" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+            <path d="M12 2l2.4 7.2L22 12l-7.6 2.8L12 22l-2.4-7.2L2 12l7.6-2.8z" />
+          </svg>
+          <span>AI生成</span>
+        </button>
+        -->
+        <button class="mode-btn" @click="$emit('update:modelValue', 'manual')">
+          <svg class="mode-btn-icon clipboard" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+            <path d="M16 4h2a2 2 0 012 2v14a2 2 0 01-2 2H6a2 2 0 01-2-2V6a2 2 0 012-2h2" />
+            <rect x="8" y="2" width="8" height="4" rx="1" ry="1" />
+          </svg>
+          <span>手动创建</span>
+        </button>
+      </template>
+      <!-- 当前是 ai 或 manual 模式时,显示 返回推荐 按钮 -->
+      <template v-else>
+        <button class="mode-btn mode-btn--active" @click="$emit('update:modelValue', 'smart')">
+          <svg class="mode-btn-icon sparkle" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+            <path d="M12 2l2.4 7.2L22 12l-7.6 2.8L12 22l-2.4-7.2L2 12l7.6-2.8z" />
+          </svg>
+          <span>{{ lang.ssBackToRecommend }}</span>
+        </button>
+      </template>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { computed } from 'vue'
+import { lang } from '@/main'
+import type { CreationMode } from '@/types/englishSpeaking'
+
+const props = defineProps<{
+  modelValue: CreationMode
+}>()
+
+defineEmits<{
+  (e: 'update:modelValue', value: CreationMode): void
+}>()
+
+const currentModeLabel = computed(() => {
+  switch (props.modelValue) {
+    case 'ai': return 'AI 生成'
+    case 'manual': return lang.ssManualCreate
+    default: return lang.ssSmartRecommend
+  }
+})
+</script>
+
+<style lang="scss" scoped>
+.creation-mode-switch {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+}
+
+.mode-title {
+  font-size: 14px;
+  font-weight: 600;
+  color: #1f2937;
+}
+
+.mode-actions {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
+.mode-btn {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  padding: 6px 14px;
+  border-radius: 12px;
+  border: 1px solid rgba(0, 0, 0, 0.08);
+  background: transparent;
+  font-size: 13px;
+  color: #4b5563;
+  cursor: pointer;
+  transition: all 0.2s;
+
+  &:hover {
+    border-color: #d1d5db;
+    background: #f9fafb;
+  }
+
+  &--active {
+    border-color: #fb923c;
+    background: #fff7ed;
+    color: #ea580c;
+
+    &:hover {
+      opacity: 0.8;
+    }
+
+    .sparkle {
+      color: #ea580c;
+    }
+  }
+}
+
+.mode-btn-icon {
+  flex-shrink: 0;
+
+  &.sparkle {
+    color: #f97316;
+  }
+
+  &.clipboard {
+    color: #f97316;
+  }
+}
+</style>

+ 116 - 0
src/views/Editor/EnglishSpeaking/components/CurriculumSelector.vue

@@ -0,0 +1,116 @@
+<template>
+  <div class="curriculum-selector">
+    <!-- 教材 -->
+    <div class="select-wrapper">
+      <select
+        :value="selectedTextbook"
+        @change="$emit('update:selectedTextbook', ($event.target as HTMLSelectElement).value)"
+      >
+        <option v-for="tb in textbooks" :key="tb.id" :value="tb.id">{{ tb.name }}</option>
+      </select>
+      <svg class="select-arrow" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6"/></svg>
+    </div>
+
+    <!-- 年级 -->
+    <div class="select-wrapper">
+      <select
+        :value="selectedGrade"
+        @change="$emit('update:selectedGrade', ($event.target as HTMLSelectElement).value)"
+      >
+        <option v-for="g in grades" :key="g.id" :value="g.id">{{ g.name }}{{ g.semester }}</option>
+      </select>
+      <svg class="select-arrow" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6"/></svg>
+    </div>
+
+    <!-- 单元 -->
+    <div class="select-wrapper">
+      <select
+        :value="selectedUnit"
+        @change="$emit('update:selectedUnit', ($event.target as HTMLSelectElement).value)"
+      >
+        <option v-for="u in units" :key="u.id" :value="u.id">{{ u.number }}</option>
+      </select>
+      <svg class="select-arrow" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6"/></svg>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { computed } from 'vue'
+import curriculumData from '../data/curriculum.json'
+import type { CurriculumData } from '@/types/englishSpeaking'
+
+const props = defineProps<{
+  selectedTextbook: string
+  selectedGrade: string
+  selectedUnit: string
+}>()
+
+defineEmits<{
+  (e: 'update:selectedTextbook', value: string): void
+  (e: 'update:selectedGrade', value: string): void
+  (e: 'update:selectedUnit', value: string): void
+}>()
+
+const data = curriculumData as CurriculumData
+
+const textbooks = computed(() => data.textbooks)
+
+const grades = computed(() => {
+  const tb = data.textbooks.find(t => t.id === props.selectedTextbook)
+  return tb?.grades || []
+})
+
+const units = computed(() => {
+  const tb = data.textbooks.find(t => t.id === props.selectedTextbook)
+  const grade = tb?.grades.find(g => g.id === props.selectedGrade)
+  return grade?.units || []
+})
+</script>
+
+<style lang="scss" scoped>
+.curriculum-selector {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+}
+
+.select-wrapper {
+  position: relative;
+  flex: 1;
+
+  select {
+    width: 100%;
+    appearance: none;
+    background: rgba(249, 250, 251, 0.8);
+    border: 1px solid rgba(229, 231, 235, 0.8);
+    border-radius: 12px;
+    padding: 8px 28px 8px 12px;
+    font-size: 13px;
+    color: #1f2937;
+    cursor: pointer;
+    transition: all 0.2s;
+
+    &:hover {
+      background: #fff;
+      border-color: #d1d5db;
+    }
+
+    &:focus {
+      outline: none;
+      border-color: #fb923c;
+      box-shadow: 0 0 0 2px rgba(251, 146, 60, 0.1);
+      background: #fff;
+    }
+  }
+}
+
+.select-arrow {
+  position: absolute;
+  right: 10px;
+  top: 50%;
+  transform: translateY(-50%);
+  color: #9ca3af;
+  pointer-events: none;
+}
+</style>

+ 145 - 0
src/views/Editor/EnglishSpeaking/components/ExerciseTypeGrid.vue

@@ -0,0 +1,145 @@
+<template>
+  <div class="exercise-type-section">
+    <label class="section-label">{{ lang.ssAddExercise }}</label>
+    <div class="type-grid">
+      <button
+        v-for="item in exerciseTypes"
+        :key="item.id"
+        class="type-card"
+        :class="[`type-card--${item.id}`, { disabled: item.id !== 'speaking' }]"
+        @click="handleClick(item.id)"
+      >
+        <div class="type-icon" v-html="item.icon"></div>
+        <span class="type-name">{{ item.label }}</span>
+        <span v-if="item.id !== 'speaking'" class="coming-soon">{{ lang.ssComingSoon }}</span>
+      </button>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { lang } from '@/main'
+import type { ExerciseType } from '@/types/englishSpeaking'
+
+const emit = defineEmits<{
+  (e: 'select', type: ExerciseType): void
+}>()
+
+const exerciseTypes: { id: ExerciseType; label: string; icon: string }[] = [
+  {
+    id: 'speaking',
+    label: lang.ssSpeaking as string,
+    icon: '<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 1a3 3 0 00-3 3v8a3 3 0 006 0V4a3 3 0 00-3-3z"/><path d="M19 10v2a7 7 0 01-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>',
+  },
+  {
+    id: 'listening',
+    label: lang.ssListening as string,
+    icon: '<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 18v-6a9 9 0 0118 0v6"/><path d="M21 19a2 2 0 01-2 2h-1a2 2 0 01-2-2v-3a2 2 0 012-2h3zM3 19a2 2 0 002 2h1a2 2 0 002-2v-3a2 2 0 00-2-2H3z"/></svg>',
+  },
+  {
+    id: 'reading',
+    label: lang.ssReading as string,
+    icon: '<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 3h6a4 4 0 014 4v14a3 3 0 00-3-3H2z"/><path d="M22 3h-6a4 4 0 00-4 4v14a3 3 0 013-3h7z"/></svg>',
+  },
+  {
+    id: 'writing',
+    label: lang.ssWriting as string,
+    icon: '<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>',
+  },
+]
+
+const handleClick = (type: ExerciseType) => {
+  if (type === 'speaking') {
+    emit('select', type)
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.section-label {
+  display: block;
+  font-size: 14px;
+  font-weight: 500;
+  color: #4b5563;
+  margin-bottom: 16px;
+}
+
+.type-grid {
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  gap: 14px;
+}
+
+.type-card {
+  position: relative;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  gap: 10px;
+  padding: 16px 12px;
+  border-radius: 16px;
+  border: 1px solid rgba(0, 0, 0, 0.06);
+  background: #fff;
+  cursor: pointer;
+  transition: all 0.3s;
+
+  &:hover:not(.disabled) {
+    transform: translateY(-2px);
+    box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
+  }
+
+  &.disabled {
+    opacity: 0.5;
+    cursor: not-allowed;
+  }
+
+  &--speaking {
+    border-color: rgba(251, 146, 60, 0.15);
+    background: linear-gradient(135deg, rgba(255, 237, 213, 0.3), #fff);
+    &:hover { border-color: #fb923c; box-shadow: 0 8px 24px rgba(251, 146, 60, 0.15); }
+    .type-icon { color: #f97316; }
+  }
+
+  &--listening {
+    border-color: rgba(59, 130, 246, 0.15);
+    background: linear-gradient(135deg, rgba(219, 234, 254, 0.3), #fff);
+    .type-icon { color: #3b82f6; }
+  }
+
+  &--reading {
+    border-color: rgba(34, 197, 94, 0.15);
+    background: linear-gradient(135deg, rgba(220, 252, 231, 0.3), #fff);
+    .type-icon { color: #22c55e; }
+  }
+
+  &--writing {
+    border-color: rgba(168, 85, 247, 0.15);
+    background: linear-gradient(135deg, rgba(243, 232, 255, 0.3), #fff);
+    .type-icon { color: #a855f7; }
+  }
+}
+
+.type-icon {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.type-name {
+  font-size: 13px;
+  font-weight: 600;
+  color: #374151;
+}
+
+.coming-soon {
+  position: absolute;
+  top: 6px;
+  right: 6px;
+  font-size: 10px;
+  color: #9ca3af;
+  background: #f3f4f6;
+  padding: 2px 6px;
+  border-radius: 6px;
+}
+</style>

+ 136 - 0
src/views/Editor/EnglishSpeaking/components/RecommendCard.vue

@@ -0,0 +1,136 @@
+<template>
+  <div class="recommend-card" @click="$emit('select')">
+    <div class="card-content">
+      <h4 class="card-title">{{ task.titleZh }}</h4>
+      <p class="card-subtitle">{{ task.subtitle }}</p>
+      <p class="card-meta">
+        <span>{{ typeLabel }}</span>
+        <span class="meta-dot">·</span>
+        <span>{{ countLabel }}</span>
+        <span class="meta-dot">·</span>
+        <span>{{ difficultyLabel }}</span>
+        <span class="meta-dot">·</span>
+        <span>~{{ task.durationMinutes }}{{ lang.ssMinutesUnit }}</span>
+      </p>
+    </div>
+    <svg class="card-arrow" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+      <path d="M9 18l6-6-6-6" />
+    </svg>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { computed } from 'vue'
+import { lang } from '@/main'
+import type { SpeakingRecommendationTask } from '@/types/englishSpeaking'
+
+const props = defineProps<{
+  task: SpeakingRecommendationTask
+}>()
+
+defineEmits<{
+  (e: 'select'): void
+}>()
+
+// 按 taskType 分支渲染:文章卡显示「1 篇」,话题卡显示「N 轮」,不把文章塞进话题形状
+const typeLabel = computed(() =>
+  props.task.taskType === 'article-reading'
+    ? (lang.ssArticleReading as string)
+    : (lang.ssTopicDiscussion as string),
+)
+
+const countLabel = computed(() =>
+  props.task.taskType === 'article-reading'
+    ? `${props.task.count} ${props.task.unit}`
+    : `${props.task.rounds}${lang.ssRoundsUnit}`,
+)
+
+const difficultyLabel = computed(() => {
+  const labels: Record<number, string> = {
+    1: lang.ssDifficultyBeginner as string,
+    2: lang.ssDifficultyIntermediate as string,
+    3: lang.ssDifficultyAdvanced as string,
+  }
+  return labels[props.task.difficulty] || ''
+})
+</script>
+
+<style lang="scss" scoped>
+.recommend-card {
+  position: relative;
+  display: flex;
+  align-items: flex-start;
+  background: #fff;
+  border: 1px solid rgba(0, 0, 0, 0.06);
+  border-radius: 16px;
+  padding: 14px;
+  cursor: pointer;
+  transition: all 0.2s;
+
+  &:hover {
+    border-color: rgba(0, 0, 0, 0.12);
+    box-shadow: 0 2px 16px rgba(0, 0, 0, 0.08);
+
+    .card-arrow {
+      color: #f97316;
+    }
+
+    .card-title {
+      color: #ea580c;
+    }
+  }
+}
+
+.card-content {
+  flex: 1;
+  min-width: 0;
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+}
+
+.card-title {
+  font-size: 13px;
+  font-weight: 500;
+  color: #111827;
+  line-height: 1.4;
+  transition: color 0.2s;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.card-subtitle {
+  font-size: 12px;
+  color: #6b7280;
+  line-height: 1.5;
+  margin: 0;
+  display: -webkit-box;
+  -webkit-line-clamp: 2;
+  -webkit-box-orient: vertical;
+  overflow: hidden;
+}
+
+.card-meta {
+  font-size: 11px;
+  color: #9ca3af;
+  margin: 0;
+  display: flex;
+  align-items: center;
+  flex-wrap: wrap;
+  gap: 0;
+}
+
+.meta-dot {
+  margin: 0 4px;
+}
+
+.card-arrow {
+  position: absolute;
+  top: 14px;
+  right: 14px;
+  color: #d1d5db;
+  flex-shrink: 0;
+  transition: color 0.2s;
+}
+</style>

+ 62 - 0
src/views/Editor/EnglishSpeaking/components/UnitInfoCard.vue

@@ -0,0 +1,62 @@
+<template>
+  <div v-if="unit" class="unit-info-card">
+    <div class="unit-icon">📚</div>
+    <div class="unit-content">
+      <h3 class="unit-title">{{ unit.title }}</h3>
+      <p class="unit-desc">{{ unit.description }}</p>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import type { CurriculumUnit } from '@/types/englishSpeaking'
+
+defineProps<{
+  unit: CurriculumUnit | undefined
+}>()
+</script>
+
+<style lang="scss" scoped>
+.unit-info-card {
+  display: flex;
+  align-items: flex-start;
+  gap: 12px;
+  background: linear-gradient(135deg, rgba(251, 191, 36, 0.06), #fff);
+  border-radius: 16px;
+  padding: 14px;
+  border: 1px solid rgba(251, 191, 36, 0.15);
+  box-shadow: 0 1px 3px rgba(251, 191, 36, 0.08);
+}
+
+.unit-icon {
+  width: 36px;
+  height: 36px;
+  border-radius: 12px;
+  background: linear-gradient(135deg, #fbbf24, #f97316);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  flex-shrink: 0;
+  font-size: 16px;
+  box-shadow: 0 2px 6px rgba(251, 191, 36, 0.3);
+}
+
+.unit-content {
+  flex: 1;
+  min-width: 0;
+}
+
+.unit-title {
+  font-size: 13px;
+  font-weight: 600;
+  color: #111827;
+  margin: 0 0 4px 0;
+}
+
+.unit-desc {
+  font-size: 12px;
+  color: #4b5563;
+  line-height: 1.5;
+  margin: 0;
+}
+</style>

+ 45 - 0
src/views/Editor/EnglishSpeaking/composables/articleAutoFormatRules.ts

@@ -0,0 +1,45 @@
+/**
+ * 配置端自动分段的纯规则(spec §3.2 / §3.2.1)。
+ *
+ * 本文件**零依赖**——不 import vue、不 import 服务,
+ * 这样 scripts/test-article-auto-format.mjs 可以直接转译执行,不需要任何 shim。
+ */
+
+/** 单段最大词数;超过则「应用配置」硬守卫拦截(spec §3.2 阈值汇总)。 */
+export const MAX_PARAGRAPH_WORDS = 300
+
+/** 粘贴触发弹窗的词数下限,严格大于才提示(spec §3.2 step 1)。 */
+export const PASTE_PROMPT_MIN_WORDS = 50
+
+export function countWords(text: string): number {
+  const trimmed = text.trim()
+  return trimmed ? trimmed.split(/\s+/).length : 0
+}
+
+/** 与后端 split_paragraphs 同规则:`\n+` 切段 + 段内空白归一 + 丢空段,再以 `\n\n` 接。 */
+export function normalizeParagraphs(text: string): string {
+  return text
+    .split(/\n+/)
+    .map(chunk => chunk.trim().split(/\s+/).join(' '))
+    .filter(chunk => chunk.length > 0)
+    .join('\n\n')
+}
+
+/** 是否存在超过上限的段落。提示文案不点名段号,故只回布尔(spec §3.2 step 4)。 */
+export function hasOverlongParagraph(text: string): boolean {
+  return normalizeParagraphs(text)
+    .split('\n\n')
+    .some(paragraph => countWords(paragraph) > MAX_PARAGRAPH_WORDS)
+}
+
+/**
+ * 空文本框粘贴长文时提示(spec §3.2 step 1)。条件就是 spec 写的两条,没有第三条:
+ * 原内容 trim 后为空,且新内容超过 50 词。
+ *
+ * 不再看新内容里有没有 `\n\n`。带空行 ≠ 分得适合朗读——一篇三段、每段 400 词的文章
+ * 完全带空行,而每段就是一个录音单位,正是本功能要拆的东西。
+ * 老师从网页/Word 复制的正文几乎必然带空行,加那条守卫等于让主要入口永不触发。
+ */
+export function shouldPromptOnPaste(prev: string, next: string): boolean {
+  return prev.trim() === '' && countWords(next) > PASTE_PROMPT_MIN_WORDS
+}

+ 117 - 0
src/views/Editor/EnglishSpeaking/composables/useArticleAutoFormat.ts

@@ -0,0 +1,117 @@
+/**
+ * 配置端自动分段的弹框状态机(spec §3.2 / §3.2.1)。
+ *
+ * 三种触发共用同一个 `POST /article/segment`(整篇,非单段):
+ * ① 空文本框粘贴长文 → mode='paste'
+ * ② 点「自动分段」按钮 → mode='overwrite'
+ * ③ 应用配置撞上 300 词硬守卫 → mode='guard'
+ */
+import { onScopeDispose, ref, type Ref } from 'vue'
+import { lang } from '@/main'
+import message from '@/utils/message'
+import { segmentArticle } from '../services/articleReading'
+import {
+  hasOverlongParagraph,
+  normalizeParagraphs,
+  shouldPromptOnPaste,
+} from './articleAutoFormatRules'
+
+export type FormatDialogMode = 'paste' | 'overwrite' | 'guard'
+
+export function useArticleAutoFormat(content: Ref<string>) {
+  const dialogVisible = ref(false)
+  const dialogMode = ref<FormatDialogMode>('paste')
+  const isFormatting = ref(false)
+
+  // ArticlePracticeConfig 是 v-else-if'd(真会卸载)。晚到的分段响应不能在作用域销毁后
+  // 还去写 content(它是外部传入、直连 pinia store 的 computed)。
+  let disposed = false
+  onScopeDispose(() => {
+    disposed = true
+  })
+
+  /**
+   * textarea 的 @input。恒写 store —— 取消弹窗即「保留原始粘贴」。
+   * 上一次的内容直接取 content.value:此刻还没写入,它就是输入前的值。
+   * 不缓存到局部变量,否则 store.replaceConfig 载入已有配置后会陈旧。
+   */
+  function onContentInput(next: string) {
+    const prev = content.value
+    content.value = next
+    if (shouldPromptOnPaste(prev, next)) {
+      dialogMode.value = 'paste'
+      dialogVisible.value = true
+    }
+  }
+
+  /** 「自动分段」按钮:先弹覆盖确认,确认才调后端。 */
+  function openOverwrite() {
+    if (!content.value.trim() || isFormatting.value) return
+    dialogMode.value = 'overwrite'
+    dialogVisible.value = true
+  }
+
+  /**
+   * 「应用配置」前置校验:归一化回写 + 300 词硬守卫。
+   * @returns true = 放行提交;false = 已开守卫弹框,调用方必须中止提交
+   */
+  function guardBeforeApply(): boolean {
+    if (isFormatting.value) return false // 请求进行中:不放行提交,也不误开/误改弹框状态
+    const normalized = normalizeParagraphs(content.value)
+    content.value = normalized
+    if (!hasOverlongParagraph(normalized)) return true
+    dialogMode.value = 'guard'
+    dialogVisible.value = true
+    return false
+  }
+
+  /** 三种弹框的确认键共用;整篇送后端,结果原样归一化后回填。 */
+  async function confirmDialog() {
+    if (isFormatting.value) return // 防重复(承 enspeak runAutoFormat 早退)
+    const source = content.value.trim()
+    if (!source) {
+      // 可达路径:老师清空文本框后点确认。不能静默无反应,必须关框(否则弹框卡住)。
+      dialogVisible.value = false
+      return
+    }
+
+    isFormatting.value = true
+    try {
+      const { content: segmented } = await segmentArticle(source)
+      if (disposed) return // 组件已卸载:作用域已销毁,响应作废,不再写 store
+      if (content.value.trim() !== source) {
+        // 30 秒等待期间老师又改了文本:新内容优先,只关框,绝不覆盖
+        dialogVisible.value = false
+        return
+      }
+      content.value = normalizeParagraphs(segmented)
+      dialogVisible.value = false
+    }
+    catch (err: unknown) {
+      if (disposed) return
+      console.error('[article-reading] auto format failed:', err)
+      message.error(lang.ssArticleSegmentFailed as string)
+      // 守卫框保持打开:老师可原地重试或改选「手动修改」。其余两种关掉即可。
+      if (dialogMode.value !== 'guard') dialogVisible.value = false
+    }
+    finally {
+      if (!disposed) isFormatting.value = false
+    }
+  }
+
+  function cancelDialog() {
+    if (isFormatting.value) return
+    dialogVisible.value = false
+  }
+
+  return {
+    dialogVisible,
+    dialogMode,
+    isFormatting,
+    onContentInput,
+    openOverwrite,
+    guardBeforeApply,
+    confirmDialog,
+    cancelDialog,
+  }
+}

+ 827 - 0
src/views/Editor/EnglishSpeaking/composables/useArticleReadingEngine.ts

@@ -0,0 +1,827 @@
+import { ref, computed, watch, onUnmounted, toValue, type MaybeRefOrGetter } from 'vue'
+import { useAudioRecorder } from './useAudioRecorder'
+import { useAudioPlayer } from './useAudioPlayer'
+import {
+  createArticleSession,
+  getArticleReport,
+  completeArticleSession,
+  getArticleDemoAudio,
+  abandonArticleSession,
+} from '../services/articleReading'
+import { friendlyArticleError, isArticleSessionGone } from '../services/articleErrors'
+import { openArticleParagraphStream, type ArticleParagraphStream } from '../services/articleReadingStream'
+import { isFinalReportReady, pollDelayMs } from '../preview/articleReadingModel'
+import type { ArticleReadingReport } from '@/types/articleReading'
+
+export type ArticlePreviewStage = 'ready' | 'reading' | 'report-loading' | 'report'
+/**
+ * 三态,**没有原型的 `done`**(enspeak `ArticleReadingView.tsx` 有第四态「完成 ✓」)。
+ *
+ * 原型的 `done` 是 mock 的产物:它录完当场出分,所以能停在「完成」上让学生看一眼。
+ * 真实管线是异步的(决策 A,spec §9)—— ack 一到 `advanceAfterAck` 就把 recordingState
+ * 打回 'idle' 并跳去下一段,分数稍后由轮询回填。`done` 因此没有任何可以停留的时刻。
+ * 别照着原型把它加回来:加回来也永远不会被赋值。
+ */
+export type ArticleRecordingState = 'idle' | 'recording' | 'processing'
+
+/** 单段录音硬上限(spec §5.1);到点只停当前段,不结束整篇。 */
+const MAX_PARAGRAPH_SECONDS = 180
+
+/**
+ * 轮询**连续失败**几次后放弃(对齐话题讨论有上限的做法,`useDialogueEngine.ts:269`)。
+ *
+ * 计的是连续失败而不是总轮询次数:段落还在 `generating` 时的健康轮询走的是同一个
+ * `pollAttempt`,拿总次数封顶会把正常等分数的学生一起掐掉。收到任何一次成功响应就归零 ——
+ * 响应本身就是后端还活着的证据。
+ *
+ * 退避封顶 8s,所以 8 次连续失败 ≈ 0+1+2+4+8+8+8 ≈ 31 秒后停手。停手不是无声的:
+ * error 横幅留在原地,report-loading 门另有手动重试按钮,学生有路可走。
+ */
+const MAX_POLL_FAILURES = 8
+
+export interface ArticleReadingEngineOptions {
+  configId: MaybeRefOrGetter<string>
+  /** null = 编辑态老师预览(spec §4.5)。engine 只负责原样转发,判定在 ArticleReadingPreview。 */
+  userId: MaybeRefOrGetter<string | null>
+  durationMinutes: MaybeRefOrGetter<number>
+  showReport: MaybeRefOrGetter<boolean>
+  onSessionStarted?: (sessionId: string) => void
+  onSessionCompleted?: (sessionId: string) => void
+  /** 「重新开始」成功作废旧 session(spec §4.2)。老师端据此把「已完成」刷回「未开始」。 */
+  onSessionAbandoned?: (sessionId: string) => void
+}
+
+export interface ArticleReadingEngineDeps {
+  createSession: typeof createArticleSession
+  getReport: typeof getArticleReport
+  completeSession: typeof completeArticleSession
+  getDemoAudio: typeof getArticleDemoAudio
+  abandonSession: typeof abandonArticleSession
+  createStream: typeof openArticleParagraphStream
+  recorder: ReturnType<typeof useAudioRecorder>
+  player: ReturnType<typeof useAudioPlayer>
+  document: Pick<Document, 'hidden' | 'addEventListener' | 'removeEventListener'>
+  setTimeout: typeof window.setTimeout
+  clearTimeout: typeof window.clearTimeout
+  setInterval: typeof window.setInterval
+  clearInterval: typeof window.clearInterval
+  /** 墙上时钟。计时器改按时间戳算之后(spec §9.2.3),测试必须能控制「现在几点」。 */
+  now: () => number
+}
+
+export function useArticleReadingEngine(
+  options: ArticleReadingEngineOptions,
+  overrides: Partial<ArticleReadingEngineDeps> = {},
+) {
+  const deps: ArticleReadingEngineDeps = {
+    createSession: createArticleSession,
+    getReport: getArticleReport,
+    completeSession: completeArticleSession,
+    getDemoAudio: getArticleDemoAudio,
+    abandonSession: abandonArticleSession,
+    createStream: openArticleParagraphStream,
+    recorder: overrides.recorder ?? useAudioRecorder(),
+    player: overrides.player ?? useAudioPlayer(),
+    document: globalThis.document,
+    setTimeout: ((...args: Parameters<typeof setTimeout>) => setTimeout(...args)) as typeof window.setTimeout,
+    clearTimeout: ((id?: number) => clearTimeout(id)) as typeof window.clearTimeout,
+    setInterval: ((...args: Parameters<typeof setInterval>) => setInterval(...args)) as typeof window.setInterval,
+    clearInterval: ((id?: number) => clearInterval(id)) as typeof window.clearInterval,
+    now: () => Date.now(),
+    ...overrides,
+  }
+  const recorder = deps.recorder
+  const player = deps.player
+
+  const stage = ref<ArticlePreviewStage>('ready')
+  /** `POST /session` 在途。视图据此置灰「开始朗读」;闩本身在 startSession 里,按钮只是它的显示。 */
+  const starting = ref(false)
+  const report = ref<ArticleReadingReport | null>(null)
+  const currentParagraphIdx = ref(0)
+  const recordingState = ref<ArticleRecordingState>('idle')
+  /** 已用练习秒数:仅计时模式下计数,非计时模式恒为 null(没有计时器)。到上限后冻结,不再累加。 */
+  const elapsedPracticeSeconds = ref<number | null>(null)
+  const localAudioByParagraph = ref(new Map<number, Blob>())
+  const error = ref<string | null>(null)
+  /**
+   * 后端说这一轮已经作废(409,spec §4.2)。重试必然再失败,视图应改提示「请重新开始」。
+   * 与 error 分开:error 是给人看的文案,这面旗是给视图分支用的。
+   */
+  const sessionInvalid = ref(false)
+
+  const recordedParagraphs = new Set<number>()
+  const demoUrlCache = new Map<string, string>()
+
+  let activeStream: ArticleParagraphStream | null = null
+  let startingRecording = false
+  let finishingRecording = false
+  let pollTimer: number | null = null
+  let pollAttempt = 0
+  /** 连续失败次数(成功即归零)。到 MAX_POLL_FAILURES 就停手,不再无限敲后端。 */
+  let pollFailures = 0
+  /**
+   * 当前那面横幅是不是轮询自己竖起来的。
+   *
+   * 只有它该被下一次成功轮询收走 —— 麦克风、录音被拒、示范音频那些错误不能碰。
+   * 尤其是录音被拒(`Session has ended`):那条 catch 随后就 `schedulePoll(0)`,
+   * 若在 acceptReport 里无差别清 error,学生会在唯读锁合上的同一瞬间失去
+   * 「为什么我录不了」的唯一解释。
+   */
+  let pollErrorShown = false
+  let pollInFlight = false
+  let practiceTimer: number | null = null
+  let reportAbort: AbortController | null = null
+  let disposed = false
+  let waitingForVisible = false
+
+  let completionPromise: Promise<void> | null = null
+  let completionNotified = false
+  let completionSucceeded = false
+  let hasFetchedAfterComplete = false
+
+  /**
+   * 已录完的段落 = 后端已知有结果(generating/completed/failed),或本地刚 ack 过。
+   * 本地集合让最后一段 ack 后按钮立刻可用,不必等下一轮 GET。绝不本地伪造分数。
+   */
+  const canViewReport = computed(() => {
+    const current = report.value
+    if (!current || current.paragraphs.length === 0) return false
+    return current.paragraphs.every(
+      paragraph => paragraph.status !== 'pending' || recordedParagraphs.has(paragraph.idx),
+    )
+  })
+
+  /** 这一轮是否已结束。showReport 关闭时,视图据此把练习页转为只读(spec §9.2.1)。 */
+  const sessionEnded = computed(() => report.value?.status === 'completed')
+
+  /**
+   * 整篇总评已经开始生成(`overallStatus` 非 null = generating / completed / failed)。
+   *
+   * 后端 2026-07-27 起在这之后**拒绝重录**(409 `Report already generated`):放行不是多烧一次
+   * LLM,而是作废一份学生/老师可能已经看到的报告。视图据此提前锁掉录音,否则那颗按钮按下去
+   * 必然报错。判据是 `overallStatus` 而不是 `sessionEnded` —— 静默预热把 status 翻成
+   * completed 的那一刻总评还没开始,那时重录仍然允许。
+   */
+  const overallStarted = computed(() => (report.value?.overallStatus ?? null) !== null)
+
+  // ---------------- session ----------------
+
+  async function startSession() {
+    /**
+     * 双击「开始朗读」= 两个 `POST /session`,而后端的 get-or-create 既没有锁也没有唯一约束
+     * (`article_service.py` `get_or_create_session`):两个并发请求会双双查不到既有 session,
+     * 然后各插一行。编辑态老师预览(`userId=null`)更是**必然**两行 —— `_latest_session`
+     * 对空 user 早退返回 None 是设计如此(spec §4.5)。
+     *
+     * 多出来的那一行不是无害的垃圾:它一路算进 §4.3 B 的「N 名学生正在练习中」直到
+     * `expires_at` 把它扫掉,老师明明只有一个学生在读却被问「2 名学生正在练习中」;
+     * `onSessionStarted` 也会跑两次,宿主的作业表因此多一笔(§9.2.4)。
+     *
+     * 闩放在这里而不是只置灰按钮:按钮只挡得住鼠标,挡不住程序化调用(承 §3.2.1
+     * `guardBeforeApply` 早退同一条理由)。对齐话题讨论的 `sessionCreating`
+     * (`TopicDiscussionPreview.vue:33`)。
+     */
+    if (starting.value) return
+    starting.value = true
+    error.value = null
+    sessionInvalid.value = false
+    // 每一轮都从干净的完成态起步。reset() 也清这四个,但 startSession 不该依赖
+    // 「上一次一定 reset 过、且没有在途的 POST 在 reset 之后把它们设回来」
+    completionPromise = null
+    completionNotified = false
+    completionSucceeded = false
+    hasFetchedAfterComplete = false
+    try {
+      const next = await deps.createSession(toValue(options.configId), toValue(options.userId))
+      report.value = next
+      stage.value = 'reading'
+      currentParagraphIdx.value = firstPendingIdx(next)
+      options.onSessionStarted?.(next.sessionId)
+      startPracticeTimer()
+      if (next.paragraphs.some(paragraph => paragraph.status === 'generating')) schedulePoll(0)
+    } catch (cause) {
+      error.value = friendlyArticleError(cause)
+    } finally {
+      // 只在这里放闩。reset() **故意不清它** —— 请求还在飞的时候把闩打开,
+      // 第二次点击照样能发出第二个 POST,等于没修。
+      starting.value = false
+    }
+  }
+
+  function firstPendingIdx(current: ArticleReadingReport): number {
+    const pending = current.paragraphs.find(paragraph => paragraph.status === 'pending')
+    return pending ? pending.idx : 0
+  }
+
+  /**
+   * 刷新页面 resume:用后端最新 session 的 report 直接水合引擎,跳过 ready 起始页。
+   * reading → 回到朗读视图;completed → 回到结果页(overall 未就绪则走一次性 loading 门轮询)。
+   * 不触发 onSessionStarted(那是真正开始时记作业用);presence 广播由调用方负责。
+   */
+  function resumeFromReport(next: ArticleReadingReport) {
+    error.value = null
+    sessionInvalid.value = false
+    report.value = next
+
+    if (next.status === 'completed') {
+      // 已完成:complete 不必再 POST;标记成已完成态,防止 preheat 重发或重复通知
+      completionPromise = Promise.resolve()
+      completionSucceeded = true
+      completionNotified = true
+      hasFetchedAfterComplete = false
+      if (!toValue(options.showReport)) {
+        // 关闭「展示学习报告」:完成也不进结果页,留在练习页(只读锁由 sessionEnded 触发,spec §9.2.1)
+        stage.value = 'reading'
+        currentParagraphIdx.value = firstPendingIdx(next)
+        return
+      }
+      if (isFinalReportReady(next)) {
+        stage.value = 'report'
+      } else {
+        // 段落已落定但整篇总评仍 generating → 走一次性 loading 门 + 退避轮询
+        stage.value = 'report-loading'
+        schedulePoll(0)
+      }
+      return
+    }
+
+    // reading:回到朗读视图。计时器改按时间戳算之后,resume 重启是安全的 ——
+    // 它从 expiresAt 反推起点,算出的是真实已用时长,不会「刷新即重置」。
+    stage.value = 'reading'
+    currentParagraphIdx.value = firstPendingIdx(next)
+    startPracticeTimer()
+    if (next.paragraphs.some(paragraph => paragraph.status === 'generating')) schedulePoll(0)
+  }
+
+  // ---------------- recording ----------------
+
+  async function startRecording() {
+    /**
+     * `recordingState` 要等 getUserMedia + addModule 落地才翻成 'recording',那期间
+     * 录音按钮是亮的、idle 那一栏也还在 —— 少了 startingRecording 这道闩,连点两下
+     * 就会**建起两套采集图**:两个 worklet 的 onmessage 往同一个 pcmChunks 里塞,
+     * 采样数翻倍且两路交错,本地回放听起来就是「慢一半 + 刺啦声」;`onChunk` 同样被
+     * 两路调用,上行 WS 与后端存的 WAV 一并被污染。第二次调用还会覆盖 activeStream,
+     * 把第一条 WS 连同它的 attempt 一起漏在半空。
+     */
+    if (!report.value || recordingState.value === 'recording' || startingRecording) return
+    startingRecording = true
+    try {
+      await beginRecording()
+    }
+    finally {
+      startingRecording = false
+    }
+  }
+
+  async function beginRecording() {
+    if (!report.value) return
+    error.value = null
+    // 录音器先于 socket 就绪吐 chunk 是常态,先缓在本地,建流后补推,开头不能丢
+    const beforeStream: ArrayBuffer[] = []
+    recorder.onChunk.value = chunk => {
+      if (activeStream) activeStream.pushChunk(chunk)
+      else beforeStream.push(chunk)
+    }
+
+    try {
+      await recorder.startRecording()
+    } catch (cause) {
+      recorder.onChunk.value = null
+      error.value = friendlyArticleError(cause)
+      return
+    }
+
+    activeStream = deps.createStream({
+      sessionId: report.value.sessionId,
+      paragraphIdx: currentParagraphIdx.value,
+      sampleRate: recorder.sampleRate.value,
+    })
+    for (const chunk of beforeStream) activeStream.pushChunk(chunk)
+    beforeStream.length = 0
+    recordingState.value = 'recording'
+  }
+
+  async function finishRecording() {
+    if (!activeStream || finishingRecording) return
+    finishingRecording = true
+    recordingState.value = 'processing'
+    const paragraphIdx = currentParagraphIdx.value
+    const stream = activeStream
+    const sessionId = report.value?.sessionId
+    /**
+     * 两个 await 期间「重新开始」都可能跑过 reset(),把引擎带到另一轮。
+     * 之后任何回写都是污染:blob 会落进刚清空的 Map(新一轮那段带着「已录制」徽章
+     * 和一个播放上一次录音的回放按钮),activeStream/recordingState 则会踩掉新一轮。
+     */
+    const stillCurrent = () => report.value?.sessionId === sessionId
+
+    try {
+      const wav = await recorder.stopRecording()
+      if (!stillCurrent()) {
+        stream.abort('Article session reset while finishing')
+        return
+      }
+      activeStream = null
+      await stream.stop()
+      if (!stillCurrent()) return
+      localAudioByParagraph.value.set(paragraphIdx, wav)
+      recordedParagraphs.add(paragraphIdx)
+      advanceAfterAck(paragraphIdx)
+      schedulePoll(0)
+    }
+    catch (cause) {
+      stream.abort('Article recording attempt failed')
+      if (!stillCurrent()) return
+      activeStream = null
+      if (isArticleSessionGone(cause)) sessionInvalid.value = true
+      error.value = friendlyArticleError(cause)
+      recordingState.value = 'idle'
+      // 失败可能是「后端说这一轮已到期」(409 Session has ended,§4.4)。补一次查询,
+      // 让 status: completed 经 acceptReport 落地 —— 否则学生要等到下一次主动动作
+      // 才会发现自己已经结束了。
+      schedulePoll(0)
+    }
+    finally {
+      if (stillCurrent()) recorder.onChunk.value = null
+      finishingRecording = false
+    }
+  }
+
+  /**
+   * 中途放弃本次录音:abort 掉流(后端 attempt 已 +1,旧分数保留、无任务写入),
+   * 回到 idle。不发 stop,因此不会产生评分任务。
+   */
+  async function cancelRecording() {
+    if (!activeStream || finishingRecording) return
+    const stream = activeStream
+    activeStream = null
+    stream.abort('Article recording cancelled by user')
+    try {
+      await recorder.stopRecording()
+    } catch {
+      // 录音器已经停了或从未真正启动,忽略
+    }
+    recorder.onChunk.value = null
+    recordingState.value = 'idle'
+    error.value = null
+  }
+
+  /** ack 到手即前进,不等分数(异步 UX 决策 A)。最后一段则留在原地。 */
+  function advanceAfterAck(paragraphIdx: number) {
+    recordingState.value = 'idle'
+    const current = report.value
+    if (!current) return
+    const next = current.paragraphs.find(
+      paragraph => paragraph.idx > paragraphIdx && paragraph.status === 'pending'
+        && !recordedParagraphs.has(paragraph.idx),
+    )
+    if (next) currentParagraphIdx.value = next.idx
+  }
+
+  function rerecordParagraph(idx: number) {
+    // 后端重录会重开 session 并作废整篇总评,本地完成态必须一并清掉
+    recordedParagraphs.delete(idx)
+    localAudioByParagraph.value.delete(idx)
+    completionPromise = null
+    completionNotified = false
+    completionSucceeded = false
+    hasFetchedAfterComplete = false
+    currentParagraphIdx.value = idx
+    stage.value = 'reading'
+    error.value = null
+  }
+
+  function selectParagraph(idx: number) {
+    if (recordingState.value === 'recording') return
+    currentParagraphIdx.value = idx
+  }
+
+  // ---------------- timers ----------------
+
+  // 单段 180s 安全阀:只停当前段,不结束整篇
+  watch(() => recorder.recordingDuration.value, duration => {
+    if (duration >= MAX_PARAGRAPH_SECONDS && recordingState.value === 'recording') {
+      void finishRecording()
+    }
+  })
+
+  /** report 上的截止时刻,毫秒;无截止 / 解析不出来 → null。 */
+  function deadlineMs(current: ArticleReadingReport | null): number | null {
+    if (!current?.expiresAt) return null
+    const parsed = Date.parse(current.expiresAt)
+    return Number.isNaN(parsed) ? null : parsed
+  }
+
+  /**
+   * 练习计时器:**纯展示**(spec §9.2.3)。
+   *
+   * 值从 `expiresAt` 与配置时长反推起点、按墙上时钟算,不再逐 tick 累加 —— 后台标签页
+   * 的 setInterval 会被浏览器节流(可能一分钟才跑一次),累加法切回来时显示的数字是错的。
+   * 同样因为这个算法,刷新页面 resume 时可以直接重启计时器:它会自己算出真实已用时长。
+   *
+   * 到点**什么都不做**,只停自己 + 发一次 GET /report。到期由后端判定(§4.4);
+   * 本地时钟只是触发器,不是判据:算早了后端说还没到,算晚了后端早已标好,两种都不会错。
+   */
+  function startPracticeTimer() {
+    stopPracticeTimer()
+    const expiresAtMs = deadlineMs(report.value)
+    if (expiresAtMs === null) {
+      elapsedPracticeSeconds.value = null
+      return
+    }
+    const capSeconds = Math.max(0, Math.round(toValue(options.durationMinutes) * 60))
+    if (capSeconds <= 0) {
+      elapsedPracticeSeconds.value = null
+      return
+    }
+    const startedAtMs = expiresAtMs - capSeconds * 1000
+    const tick = () => {
+      const elapsed = Math.floor((deps.now() - startedAtMs) / 1000)
+      elapsedPracticeSeconds.value = Math.min(capSeconds, Math.max(0, elapsed))
+      if (elapsed < capSeconds) return
+      stopPracticeTimer()
+      // 到点问一次。schedulePoll 复用既有的后台标签页挂起与去重逻辑;
+      // 没有 generating 段时 acceptReport 会 stopPolling(),所以确实只有这一次。
+      schedulePoll(0)
+    }
+    tick()
+    practiceTimer = deps.setInterval(tick, 1000)
+  }
+
+  /** 清掉练习页上的错误横幅。重试无从谈起的那些错(麦克风、示范音频、轮询)只能这样收走。 */
+  function dismissError() {
+    error.value = null
+    pollErrorShown = false
+  }
+
+  function stopPracticeTimer() {
+    if (practiceTimer !== null) {
+      deps.clearInterval(practiceTimer)
+      practiceTimer = null
+    }
+  }
+
+  // ---------------- completion ----------------
+
+  function completeOnce(): Promise<void> {
+    if (!report.value) return Promise.reject(new Error('Article session is not ready'))
+    if (!completionPromise) {
+      const sessionId = report.value.sessionId
+      completionPromise = deps.completeSession(sessionId).then(() => {
+        // POST 在途时学生可能已经「重新开始」。reset() 清过这些标记,这里绝不能再设回去 ——
+        // 否则下一轮开局就带着 completionNotified=true,那一轮真正完成时 onSessionCompleted
+        // 不再触发,老师端整轮都收不到刷新(复合放大「重新开始不广播」那条)。
+        if (report.value?.sessionId !== sessionId) return
+        completionSucceeded = true
+        hasFetchedAfterComplete = false
+        if (!completionNotified) {
+          completionNotified = true
+          options.onSessionCompleted?.(sessionId)
+        }
+      })
+    }
+    return completionPromise
+  }
+
+  function allParagraphsRecorded(current: ArticleReadingReport): boolean {
+    return current.paragraphs.every(paragraph =>
+      paragraph.status === 'generating'
+        || paragraph.status === 'completed'
+        || paragraph.status === 'failed',
+    )
+  }
+
+  /** 全段评完就静默 complete,让老师端列表提前刷新;不改 stage。 */
+  async function preheatIfEligible(current: ArticleReadingReport) {
+    if (!allParagraphsRecorded(current)) return
+    if (current.paragraphs.some(paragraph => paragraph.status === 'generating')) return
+    try {
+      await completeOnce()
+    } catch {
+      completionPromise = null
+    }
+  }
+
+  async function completeAndShowReport() {
+    if (recordingState.value === 'recording') await finishRecording()
+    stopPracticeTimer()
+    stage.value = 'report-loading'
+    error.value = null
+    try {
+      await completeOnce()
+      schedulePoll(0)
+    }
+    catch (cause) {
+      completionPromise = null
+      if (isArticleSessionGone(cause)) sessionInvalid.value = true
+      error.value = friendlyArticleError(cause)
+    }
+  }
+
+  /**
+   * 「重新开始」(spec §4.2):把当前 session 标作废,回起始页。
+   * 不在这里建新 session —— 学生点「开始朗读」时由 startSession 的 get-or-create 建,
+   * 那样 created_at 才等于真正的开始时刻。
+   * @returns 成功与否;失败时不重置,调用方负责提示。
+   */
+  async function restartSession(): Promise<boolean> {
+    const sessionId = report.value?.sessionId
+    if (!sessionId) return false
+    try {
+      await deps.abandonSession(sessionId)
+    }
+    catch (cause) {
+      console.error('[article-reading] abandon failed:', cause)
+      return false
+    }
+    reset()
+    // 库里已经是「未开始」了(§4.2 各处查询排除 abandoned),但老师端只在收到学生广播时
+    // 才重取;不发这一条,他会一直看着一个不再作数的「已完成 + 分数」,点进去还是作废那轮的报告。
+    options.onSessionAbandoned?.(sessionId)
+    return true
+  }
+
+  async function acceptReport(next: ArticleReadingReport) {
+    const wasReading = report.value?.status === 'reading'
+    report.value = next
+
+    /**
+     * 后端单方面把这一轮结束了(到期,spec §4.4)。这是学生结束的**唯一信号**,
+     * 而它可能来自任何一次响应 —— 到点那次查询、恢复探针、或学生自己的动作触发的轮询,
+     * 所以反应必须写在这个统一入口,不能绑在某一次调用上。
+     *
+     * 下面那句 `if (!completionSucceeded) return` 原本假定「只有我能结束 session」;
+     * 判定权搬到后端后这个前提不再成立,必须先在这里补齐本地的完成态,否则这次响应
+     * 会被那句早退直接吞掉。
+     *
+     * 放在 preheatIfEligible **之前**:preheat 走的是 completeOnce(),而这里已经把
+     * completionPromise 填好,于是它自然成为 no-op。反过来先 preheat 的话,它会先
+     * 把 completionSucceeded 置真、让下面的条件失效,还白发一次没有意义的 POST,
+     * 并因为「POST 之后还没 GET 过」再多轮询一轮。
+     */
+    if (wasReading && next.status === 'completed' && !completionSucceeded) {
+      stopPracticeTimer()
+      completionPromise = Promise.resolve()
+      completionSucceeded = true
+      // 这个响应本身就是「结束之后拿到的」—— 后端在这次查询里完成的收口
+      hasFetchedAfterComplete = true
+      if (!completionNotified) {
+        completionNotified = true
+        options.onSessionCompleted?.(next.sessionId)
+      }
+      // showReport 关闭时不进 loading 门:留在练习页,由 sessionEnded 合上只读锁(§9.2.1)
+      if (toValue(options.showReport)) stage.value = 'report-loading'
+    }
+
+    await preheatIfEligible(next)
+
+    if (!completionSucceeded) {
+      if (!next.paragraphs.some(paragraph => paragraph.status === 'generating')) stopPolling()
+      return
+    }
+
+    // POST 之前抓到的 report 不算数:必须至少有一次 POST 之后发起的 GET
+    if (!hasFetchedAfterComplete) {
+      schedulePoll(0)
+      return
+    }
+
+    if (isFinalReportReady(next)) {
+      if (stage.value === 'report-loading') stage.value = 'report'
+      stopPolling()
+    } else {
+      // 段落已全部落定,但整篇 overallStatus 仍是 'generating'(complete 触发的后台总评
+      // LLM 未完成)。此时没有 generating 段,pollOnce 的兜底重排不会触发,必须在这里继续
+      // 退避轮询,否则 report-loading 门会永久卡住等一个再也不来的 GET。
+      schedulePoll(pollAttempt + 1)
+    }
+  }
+
+  // ---------------- polling ----------------
+
+  function schedulePoll(attempt: number) {
+    if (disposed) return
+    // attempt === 0 = 重新起一轮(录完一段、点重试、到点查一次、resume)。
+    // 这是用户动作或新事件带来的,给它一份新的失败预算 —— 否则上一次敲到上限之后,
+    // 之后每一条新链都会在第一次失败时立刻再次停手。
+    if (attempt === 0) pollFailures = 0
+    pollAttempt = attempt
+    if (pollTimer !== null) deps.clearTimeout(pollTimer)
+    pollTimer = deps.setTimeout(() => {
+      pollTimer = null
+      void pollOnce()
+    }, attempt === 0 ? 0 : pollDelayMs(attempt - 1))
+  }
+
+  function stopPolling() {
+    if (pollTimer !== null) {
+      deps.clearTimeout(pollTimer)
+      pollTimer = null
+    }
+    pollAttempt = 0
+    // 不清 pollFailures:停手那一刻它正好等于上限,清了就没意义了。
+    // 由重新起一轮轮询的入口负责归零 —— 见 schedulePoll(0)。
+  }
+
+  function onVisible() {
+    deps.document.removeEventListener('visibilitychange', onVisible)
+    waitingForVisible = false
+    // 后台期间不计入重试次数,回到前台立即补一次
+    schedulePoll(0)
+  }
+
+  async function pollOnce() {
+    if (disposed || pollInFlight || !report.value) return
+
+    // 后台标签页不发 GET,挂起等 visibilitychange,且不推进退避
+    if (deps.document.hidden) {
+      if (!waitingForVisible) {
+        waitingForVisible = true
+        deps.document.addEventListener('visibilitychange', onVisible)
+      }
+      return
+    }
+
+    pollInFlight = true
+    const requestStartedAfterComplete = completionSucceeded
+    reportAbort = new AbortController()
+    try {
+      const next = await deps.getReport(report.value.sessionId, reportAbort.signal)
+      if (disposed) return
+      // 拿到响应 = 后端还活着,连续失败计数归零
+      pollFailures = 0
+      // 上一次轮询失败留下的横幅已经不成立了,收走它(只收自己竖的那面)
+      if (pollErrorShown) {
+        error.value = null
+        pollErrorShown = false
+      }
+      if (requestStartedAfterComplete) hasFetchedAfterComplete = true
+      const wasPolling = pollTimer
+      await acceptReport(next)
+      // acceptReport 未自行安排下一轮,且仍有段落在生成 → 退避重试
+      if (pollTimer === wasPolling && next.paragraphs.some(p => p.status === 'generating')) {
+        schedulePoll(pollAttempt + 1)
+      }
+    } catch (cause) {
+      if (disposed || (cause instanceof Error && cause.name === 'AbortError')) return
+      // 瞬时失败不退出 loading 门,退避后再试
+      error.value = friendlyArticleError(cause)
+      pollErrorShown = true
+      pollFailures += 1
+      if (pollFailures >= MAX_POLL_FAILURES) {
+        // 后端连续不应答,别再每 8 秒敲一次敲到学生关页面。横幅留在原地,
+        // report-loading 门那个手动重试按钮是学生的出口。
+        stopPolling()
+        return
+      }
+      schedulePoll(pollAttempt + 1)
+    } finally {
+      reportAbort = null
+      pollInFlight = false
+    }
+  }
+
+  // ---------------- audio ----------------
+
+  /**
+   * 全篇(示范 + 我的录音)共用一个 player,所以「点别的按钮」天然会 stop 掉上一段。
+   * 这里只负责另一半:**同一颗按钮再点一次 = 停**。loading 也要算进来,否则取流/
+   * 合成的那 1-3 秒里按钮按不停,只会排队再放一遍(对齐话题讨论 DialogueChatView:898)。
+   */
+  function isBusyWith(id: string): boolean {
+    return player.playingId.value === id || player.loadingId.value === id
+  }
+
+  async function playOwnAudio(idx: number) {
+    const id = `own-${idx}`
+    if (isBusyWith(id)) {
+      player.stop()
+      return
+    }
+    const localBlob = localAudioByParagraph.value.get(idx)
+    if (localBlob) {
+      await player.play(id, { kind: 'blob', blob: localBlob })
+      return
+    }
+    const url = report.value?.paragraphs.find(paragraph => paragraph.idx === idx)?.audioUrl
+    if (!url) return
+    await player.play(id, { kind: 'url', url })
+  }
+
+  async function playDemoAudio(idx: number) {
+    const id = `demo-${idx}`
+    if (isBusyWith(id)) {
+      player.stop()
+      return
+    }
+    const sessionId = report.value?.sessionId
+    if (!sessionId) return
+    const cacheKey = `${sessionId}:${idx}`
+    // 取 URL 交给 player(lazy-url),不在这儿先 await:缓存 miss 时后端同步合成要
+    // 1-3s,在外面等的话这段时间里上一段录音还在响、这颗按钮也不转圈 —— 学生看到的
+    // 就是「点了没反应,旧的还在放」。进了 play() 才有 stop() 和 loadingId。
+    await player.play(id, {
+      kind: 'lazy-url',
+      resolve: async () => {
+        const cached = demoUrlCache.get(cacheKey)
+        if (cached) return cached
+        try {
+          const res = await deps.getDemoAudio(sessionId, idx)
+          demoUrlCache.set(cacheKey, res.url)
+          return res.url
+        } catch (cause) {
+          // 横幅仍由引擎竖:player 只有 errorId,卡片上没有它的出口
+          error.value = friendlyArticleError(cause)
+          throw cause
+        }
+      },
+    })
+  }
+
+  function stopAudio() {
+    player.stop()
+  }
+
+  // ---------------- lifecycle ----------------
+
+  function reset() {
+    stopPolling()
+    pollFailures = 0
+    pollErrorShown = false
+    stopPracticeTimer()
+    reportAbort?.abort()
+    reportAbort = null
+    activeStream?.abort('Article session reset')
+    activeStream = null
+    player.stop()
+    recorder.onChunk.value = null
+    // 录音器必须真的拆掉,不能只摘 onChunk:mediaStream 的 track、AudioContext、
+    // AudioWorkletNode 和时长 interval 都还活着。留着的后果不是「泄漏一点内存」——
+    // 下一次 startRecording 会挂上第二个时长 interval(180s 单段安全阀在 ~90 真实秒就开火),
+    // 而孤儿 worklet 的 onmessage 闭包着同一个 chunk 缓冲与 onChunk ref,会继续把音频
+    // 灌进**下一段**录音,上传的 WAV 与 WS 流里都是两套采集图交织的声音。
+    // 放在 reset() 而不是 restartSession():configId 切换与老师端「应用配置」同样只走
+    // reset(),三条路一次修完;completeAndShowReport / cancelRecording 本就先过录音器。
+    recorder.cleanup()
+    recordedParagraphs.clear()
+    localAudioByParagraph.value.clear()
+    demoUrlCache.clear()
+    completionPromise = null
+    completionNotified = false
+    completionSucceeded = false
+    hasFetchedAfterComplete = false
+    finishingRecording = false
+    stage.value = 'ready'
+    report.value = null
+    currentParagraphIdx.value = 0
+    recordingState.value = 'idle'
+    elapsedPracticeSeconds.value = null
+    error.value = null
+    sessionInvalid.value = false
+    if (waitingForVisible) {
+      deps.document.removeEventListener('visibilitychange', onVisible)
+      waitingForVisible = false
+    }
+  }
+
+  function dispose() {
+    disposed = true
+    reset()
+  }
+
+  onUnmounted(() => dispose())
+
+  return {
+    stage,
+    starting,
+    report,
+    currentParagraphIdx,
+    recordingState,
+    recordingDuration: recorder.recordingDuration,
+    elapsedPracticeSeconds,
+    sessionEnded,
+    overallStarted,
+    canViewReport,
+    localAudioByParagraph,
+    error,
+    sessionInvalid,
+    startSession,
+    resumeFromReport,
+    startRecording,
+    finishRecording,
+    cancelRecording,
+    rerecordParagraph,
+    selectParagraph,
+    completeAndShowReport,
+    dismissError,
+    restartSession,
+    playOwnAudio,
+    playDemoAudio,
+    stopAudio,
+    playingId: player.playingId,
+    loadingId: player.loadingId,
+    reset,
+    dispose,
+  }
+}

+ 215 - 0
src/views/Editor/EnglishSpeaking/composables/useAudioPlayer.ts

@@ -0,0 +1,215 @@
+import { ref, onUnmounted, type Ref } from 'vue'
+import { synthesize } from '../services/speechService'
+
+export type PlaySource =
+  | { kind: 'tts'; text: string }
+  | { kind: 'blob'; blob: Blob }
+  | { kind: 'url'; url: string }
+  /**
+   * URL 要先问一次后端才知道(文章示范音频:缓存 miss 时后端同步合成 1-3s)。
+   * 解析必须发生在 play() 内部,不能由调用方先 await 再进来 —— 只有进了 play()
+   * 才算「这一次播放开始了」:前一段音频才会被 stop(),按钮才转得起圈,
+   * 中途改点别的按钮时这次解析才会被 abort。
+   */
+  | { kind: 'lazy-url'; resolve: (signal: AbortSignal) => Promise<string> }
+
+export interface AudioPlayer {
+  /** Id of the message currently playing (audio element fired `playing`). */
+  playingId: Readonly<Ref<string | null>>
+  /** Id of the message whose synthesis or play() is in flight. */
+  loadingId: Readonly<Ref<string | null>>
+  /** Id of the message whose last playback attempt failed. Sticky until next play(). */
+  errorId: Readonly<Ref<string | null>>
+
+  play(id: string, source: PlaySource): Promise<void>
+  stop(): void
+}
+
+/**
+ * Single audio playback owner for the dialogue view.
+ *
+ * Structural guarantees:
+ *  - At most one HTMLAudioElement / one in-flight synthesis at a time.
+ *    Every play() begins by aborting & pausing the prior session.
+ *  - Cached MP3 blobs (one per AI messageId) live in-memory; URLs are
+ *    revoked on view unmount.
+ *  - Errors collapse to a single per-id state. The view renders a retry
+ *    affordance; clicking the same play button re-enters play().
+ */
+export function useAudioPlayer(): AudioPlayer {
+  const playingId = ref<string | null>(null)
+  const loadingId = ref<string | null>(null)
+  const errorId = ref<string | null>(null)
+
+  // Closure-private state. Not reactive on purpose.
+  let currentAudio: HTMLAudioElement | null = null
+  let synthAbort: AbortController | null = null
+
+  /**
+   * 已经建好的 object URL,按「这段音频是什么」缓存。重复播放同一段因此既不重新
+   * 下载、也不再堆一个新的 object URL(原本每按一次播放都两样都做一遍,而 object
+   * URL 要到卸载才 revoke —— 学生把一段示范听五遍就是五次下载加五条泄漏)。
+   *
+   * key 用**解析出来的那条远端 URL**,不用 id:`own-{idx}` 这种 id 在学生重录之后
+   * 仍然是同一个,指向的却是新的 audioUrl,按 id 缓存会把上一次的录音放给他听。
+   * URL 变了就是另一个 key,这个失效模式从根上不存在。tts 没有 URL,按 `tts:${id}`。
+   */
+  const objectUrls = new Map<string, string>()
+  /** 本地录音(kind: 'blob')没有 URL 可当 key,按 Blob 身份缓存。 */
+  const blobObjectUrls = new WeakMap<Blob, string>()
+  /** 建过的全部 object URL,卸载时统一 revoke。 */
+  const createdUrls: string[] = []
+
+  function trackUrl(url: string): string {
+    createdUrls.push(url)
+    return url
+  }
+
+  function clearCurrentAudio() {
+    if (currentAudio) {
+      currentAudio.onplaying = null
+      currentAudio.onended = null
+      currentAudio.onerror = null
+      try { currentAudio.pause() } catch { /* ignore */ }
+      currentAudio = null
+    }
+  }
+
+  function failPlayback(id: string, reason: unknown) {
+    if (loadingId.value === id) loadingId.value = null
+    if (playingId.value === id) playingId.value = null
+    errorId.value = id
+    console.warn('[audio-player] playback failed:', id, reason)
+  }
+
+  async function play(id: string, source: PlaySource): Promise<void> {
+    // A fresh attempt — drop stale error.
+    errorId.value = null
+
+    // Abort any in-flight synthesis and pause any current audio.
+    stop()
+
+    loadingId.value = id
+
+    try {
+      let objectUrl: string
+
+      if (source.kind === 'blob') {
+        let cached = blobObjectUrls.get(source.blob)
+        if (!cached) {
+          cached = trackUrl(URL.createObjectURL(source.blob))
+          blobObjectUrls.set(source.blob, cached)
+        }
+        objectUrl = cached
+      }
+      else if (source.kind === 'url' || source.kind === 'lazy-url') {
+        synthAbort = new AbortController()
+        const signal = synthAbort.signal
+        const remoteUrl = source.kind === 'url' ? source.url : await source.resolve(signal)
+        // 解析期间被打断(stop() 或换了个按钮)—— 那条 URL 不再属于任何一次播放
+        if (loadingId.value !== id) return
+
+        const cached = objectUrls.get(remoteUrl)
+        if (cached) {
+          // 第二次起完全不碰网络。lazy-url 的 resolve 也已经命中引擎那层的 URL 缓存,
+          // 所以整条路上只剩一次 Map 查询。
+          synthAbort = null
+          objectUrl = cached
+        }
+        else {
+          const res = await fetch(remoteUrl, { signal })
+          synthAbort = null
+          if (loadingId.value !== id) return
+          if (!res.ok) throw new Error(`fetch audio failed: ${res.status}`)
+          objectUrl = trackUrl(URL.createObjectURL(await res.blob()))
+          objectUrls.set(remoteUrl, objectUrl)
+        }
+      }
+      else {
+        const key = `tts:${id}`
+        const cached = objectUrls.get(key)
+        if (cached) {
+          objectUrl = cached
+        }
+        else {
+          synthAbort = new AbortController()
+          const blob = await synthesize(source.text, synthAbort.signal)
+          synthAbort = null
+          // We may have been interrupted while awaiting (loadingId changed).
+          if (loadingId.value !== id) return
+          objectUrl = trackUrl(URL.createObjectURL(blob))
+          objectUrls.set(key, objectUrl)
+        }
+      }
+
+      const audio = new Audio(objectUrl)
+      currentAudio = audio
+
+      audio.onplaying = () => {
+        if (loadingId.value === id) loadingId.value = null
+        playingId.value = id
+      }
+      audio.onended = () => {
+        if (currentAudio === audio) currentAudio = null
+        if (playingId.value === id) playingId.value = null
+      }
+      audio.onerror = () => {
+        if (currentAudio === audio) currentAudio = null
+        failPlayback(id, audio.error)
+      }
+
+      try {
+        await audio.play()
+      }
+      catch (err) {
+        if (currentAudio === audio) currentAudio = null
+        // pause() during a pending play() rejects with AbortError —
+        // that is an intentional interrupt (from stop()), not a failure.
+        if (err instanceof DOMException && err.name === 'AbortError') return
+        // Most often: autoplay policy blocked the call.
+        failPlayback(id, err)
+      }
+    }
+    catch (err) {
+      // Synthesis path errors. AbortError fires when stop() was called
+      // mid-synthesis — that is a normal interrupt, not a failure.
+      synthAbort = null
+      if (err instanceof Error && err.name === 'AbortError') {
+        // loadingId was already cleared synchronously by stop(); if a newer
+        // play() has since set it again, that value belongs to that call,
+        // not this aborted one. Do nothing here.
+        return
+      }
+      failPlayback(id, err)
+    }
+  }
+
+  function stop(): void {
+    if (synthAbort) {
+      synthAbort.abort()
+      synthAbort = null
+    }
+    clearCurrentAudio()
+    loadingId.value = null
+    playingId.value = null
+    // Note: errorId is intentionally left alone here. It is only
+    // cleared at the start of a new play() attempt.
+  }
+
+  onUnmounted(() => {
+    stop()
+    for (const url of createdUrls) {
+      try { URL.revokeObjectURL(url) } catch { /* ignore */ }
+    }
+    createdUrls.length = 0
+    objectUrls.clear()
+  })
+
+  return {
+    playingId,
+    loadingId,
+    errorId,
+    play,
+    stop,
+  }
+}

+ 368 - 0
src/views/Editor/EnglishSpeaking/composables/useAudioRecorder.ts

@@ -0,0 +1,368 @@
+import { ref, onUnmounted } from 'vue'
+
+/**
+ * 诊断日志说明:本文件的 `console.debug('[recorder] ...')` 用于排查"录不到声音"类问题
+ * (mic track 状态 / AudioContext 状态 / 每 50 个 chunk 的 Float32 峰值 / 结束时整体统计)。
+ * 默认在 Chrome DevTools 不显示——浏览器 Console 的 log level filter 切到 "Verbose"
+ * (或 "All levels")即可看到。Safari / Firefox 同理。
+ */
+
+/**
+ * 想要的采样率。Azure 发音评估原生就是 16kHz —— 送 48kHz 不会让评分更准,只是把
+ * 重采样挪到别人那边做,而每一段录音的三个成本都是 3 倍:
+ *
+ *            记忆体(180s 单声道)   上行频宽      S3 存储
+ *   48kHz         16.5 MB         96 KB/s        ×3
+ *   16kHz          5.5 MB         32 KB/s        ×1
+ *
+ * 一个班 30 人同时录,上行是 2.8 MB/s 对 0.9 MB/s —— 在学校 wifi 上这一项比记忆体
+ * 更容易先出事。
+ */
+const TARGET_SAMPLE_RATE = 16000
+
+/**
+ * 优先要 16kHz,拿不到就用硬件默认。
+ *
+ * 原来这里是写死的 `new AudioContext()`,注解写着「iOS Safari 不支持自定义采样率」。
+ * 那在 Safari 14.1(2021)之前是对的。但与其去赌教室里那台 iPad 的版本号,不如让
+ * 「赌输」变成一件无所谓的事 —— **整条链路带的都是真实采样率**:WS handshake 送
+ * `recorder.sampleRate.value`(useArticleReadingEngine.ts),WAV header 写
+ * `audioContext.sampleRate`(encodeWAV),后端的评估器格式与字节上限又是从那个
+ * handshake 推出来的。所以退回 48kHz 时一切照常,只是没省到。
+ *
+ * 两种失败都要接住,而且形状不同:
+ *   ① 构造抛(NotSupportedError)→ 这里 catch,退回默认;
+ *   ② 构造成功但浏览器无视这个提示,sampleRate 仍是硬件值 → 不需要任何处理,
+ *      因为下面读的是 `audioContext.sampleRate` 而不是 TARGET_SAMPLE_RATE。
+ * 千万别把 sampleRate.value 写成常数 —— 那会让 ② 变成一个静默的谎:后端按 16kHz
+ * 解 48kHz 的数据,回放速度慢三倍,而没有任何一层会报错。
+ */
+function createAudioContext(): AudioContext {
+  try {
+    return new AudioContext({ sampleRate: TARGET_SAMPLE_RATE })
+  }
+  catch (err) {
+    console.debug('[recorder] 16kHz AudioContext rejected, using hardware default', err)
+    return new AudioContext()
+  }
+}
+
+export function useAudioRecorder() {
+  const isRecording = ref(false)
+  const permissionState = ref<PermissionState>('prompt')
+  const recordingDuration = ref(0)
+  const silenceDetected = ref(false)
+  /** 实际硬件采样率,startRecording 后可读;用于 WebSocket 上报给后端 */
+  const sampleRate = ref<number>(0)
+  /** 每收到一块 PCM chunk 时触发(外部可订阅做流式上传) */
+  const onChunk = ref<((pcm16: ArrayBuffer) => void) | null>(null)
+
+  let audioContext: AudioContext | null = null
+  let mediaStream: MediaStream | null = null
+  let workletNode: AudioWorkletNode | null = null
+  let pcmChunks: Float32Array[] = []
+  let durationTimer: ReturnType<typeof setInterval> | null = null
+  let silenceCheckTimer: ReturnType<typeof setInterval> | null = null
+  let analyser: AnalyserNode | null = null
+
+  // [DEBUG] 跨 start/stop 共享的录音诊断计数器
+  let debugChunkCount = 0
+  let debugPeakFloat = 0
+  // 墙钟起点。用它而不是 recordingDuration —— 采集图若被建了两套,那个 interval 也会
+  // 跟着翻倍,两边一起错就比不出问题。samplesSeconds / wallSeconds 明显大于 1 = 采样重复。
+  let debugStartedAt = 0
+
+  // Check permission state
+  async function checkPermission() {
+    try {
+      const status = await navigator.permissions.query({ name: 'microphone' as PermissionName })
+      permissionState.value = status.state
+      status.onchange = () => { permissionState.value = status.state }
+    } catch {
+      // permissions API may not support microphone query in all browsers
+    }
+  }
+
+  // Silence detection using AnalyserNode
+  function startSilenceDetection(source: MediaStreamAudioSourceNode) {
+    analyser = audioContext!.createAnalyser()
+    analyser.fftSize = 512
+    source.connect(analyser)
+
+    const dataArray = new Uint8Array(analyser.frequencyBinCount)
+    let silentFrames = 0
+    const SILENCE_THRESHOLD = 10
+    const FRAMES_FOR_5S = Math.ceil(5000 / 200)
+
+    silenceCheckTimer = setInterval(() => {
+      if (!analyser) return
+      analyser.getByteFrequencyData(dataArray)
+      const average = dataArray.reduce((sum, v) => sum + v, 0) / dataArray.length
+
+      if (average < SILENCE_THRESHOLD) {
+        silentFrames++
+        if (silentFrames >= FRAMES_FOR_5S) {
+          silenceDetected.value = true
+        }
+      } else {
+        silentFrames = 0
+        silenceDetected.value = false
+      }
+    }, 200)
+  }
+
+  function stopSilenceDetection() {
+    silenceDetected.value = false
+    if (silenceCheckTimer) { clearInterval(silenceCheckTimer); silenceCheckTimer = null }
+    analyser = null
+  }
+
+  async function startRecording(signal?: AbortSignal): Promise<void> {
+    pcmChunks = []
+    silenceDetected.value = false
+
+    if (signal?.aborted) {
+      throw new DOMException('Aborted', 'AbortError')
+    }
+
+    // 获取麦克风
+    try {
+      mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true })
+      permissionState.value = 'granted'
+    } catch (err: any) {
+      if (err.name === 'NotAllowedError') {
+        permissionState.value = 'denied'
+      }
+      throw err
+    }
+
+    if (signal?.aborted) {
+      // User cancelled while the permission prompt was open. Release the track.
+      mediaStream.getTracks().forEach(t => t.stop())
+      mediaStream = null
+      throw new DOMException('Aborted', 'AbortError')
+    }
+
+    // 优先 16kHz(见 createAudioContext)。sampleRate.value 一定是**实际拿到的**那个,
+    // 不是我们要的那个 —— 浏览器可以无视这个提示,而下游全部按这个值走。
+    audioContext = createAudioContext()
+    sampleRate.value = audioContext.sampleRate
+    // createMediaStreamSource 会由浏览器在 graph 层把麦克风的硬件采样率重采样到
+    // context 的采样率,带正规的低通。别改成自己抽样:无低通的降采样会混叠,而混叠
+    // 的表现是发音分数**普遍偏低** —— 没有错误、没有日志,只有「这批学生分数怎么低了」。
+    const source = audioContext.createMediaStreamSource(mediaStream)
+
+    // 诊断录音管线是否健康:mic track 状态 + AudioContext 状态
+    console.debug('[recorder] startRecording', {
+      contextState: audioContext.state,
+      requestedSampleRate: TARGET_SAMPLE_RATE,
+      contextSampleRate: audioContext.sampleRate,
+      sampleRateHonoured: audioContext.sampleRate === TARGET_SAMPLE_RATE,
+      tracks: mediaStream.getAudioTracks().map(t => ({
+        label: t.label,
+        enabled: t.enabled,
+        muted: t.muted,
+        readyState: t.readyState,
+        settings: t.getSettings?.(),
+      })),
+    })
+
+    // 有些浏览器 AudioContext 默认 suspended,必须 resume 才会 tick worklet
+    if (audioContext.state === 'suspended') {
+      await audioContext.resume()
+      console.debug('[recorder] AudioContext resumed →', audioContext.state)
+    }
+
+    // 加载 AudioWorklet processor
+    await audioContext.audioWorklet.addModule('/pcm-recorder-worklet.js')
+    workletNode = new AudioWorkletNode(audioContext, 'pcm-recorder-processor')
+
+    // 重置诊断计数器(很便宜,一直追踪;只有打印受 DevTools Verbose filter 控制)
+    debugChunkCount = 0
+    debugPeakFloat = 0
+    debugStartedAt = Date.now()
+
+    // 收集 PCM 数据;同时(可选)通过 onChunk 把 int16 字节推给外部(WebSocket 流式上传)
+    workletNode.port.onmessage = (e: MessageEvent<Float32Array>) => {
+      pcmChunks.push(e.data)
+      debugChunkCount++
+      for (let i = 0; i < e.data.length; i++) {
+        const a = Math.abs(e.data[i])
+        if (a > debugPeakFloat) debugPeakFloat = a
+      }
+      // 每 50 个 chunk 打一次(≈ 128*50/48000 ≈ 133ms 一条,避免刷屏)
+      if (debugChunkCount % 50 === 0) {
+        console.debug(`[recorder] chunks=${debugChunkCount} peak_float=${debugPeakFloat.toFixed(4)}`)
+      }
+      const cb = onChunk.value
+      if (cb) {
+        const int16 = float32ToInt16(e.data)
+        cb(int16.buffer as ArrayBuffer)
+      }
+    }
+
+    source.connect(workletNode)
+    workletNode.connect(audioContext.destination) // 需要连接才能驱动处理
+
+    // 静音检测
+    startSilenceDetection(source)
+
+    isRecording.value = true
+    recordingDuration.value = 0
+    durationTimer = setInterval(() => { recordingDuration.value++ }, 1000)
+  }
+
+  async function stopRecording(): Promise<Blob> {
+    if (!workletNode || !audioContext) {
+      throw new Error('No active recording')
+    }
+
+    // 通知 worklet 停止。onmessage 必须当场摘掉:'stop' 是投递到音频线程的异步消息,
+    // 在它生效之前音频线程还会再吐几块,那几块会落进**下一段**录音的 pcmChunks
+    // (pcmChunks 是跨 start/stop 复用的同一个闭包变量)。
+    workletNode.port.onmessage = null
+    workletNode.port.postMessage('stop')
+    workletNode.disconnect()
+    workletNode = null
+
+    // 合并 PCM chunks 并按实际硬件采样率编码 WAV(后端按 WAV header 解析)
+    const sampleRate = audioContext.sampleRate
+
+    // 合并前打一条"整段录音的最终统计"—— peak_float ≈ 0 就是录音管线产零
+    let totalSamples = 0
+    for (const c of pcmChunks) totalSamples += c.length
+    const wallSeconds = (Date.now() - debugStartedAt) / 1000
+    const samplesSeconds = totalSamples / sampleRate
+    console.debug('[recorder] stopRecording final', {
+      chunks: debugChunkCount,
+      totalSamples,
+      sampleRate,
+      duration: samplesSeconds.toFixed(2) + 's',
+      wallSeconds: wallSeconds.toFixed(2) + 's',
+      // 1.0 = 正常。≈2 就是同时跑着两套采集图,采样重复 → 回放慢一半且刺啦
+      sampleRatio: (samplesSeconds / Math.max(wallSeconds, 0.001)).toFixed(2),
+      peakFloat: debugPeakFloat.toFixed(6),
+      contextState: audioContext.state,
+      trackMuted: mediaStream?.getAudioTracks()[0]?.muted,
+      trackEnabled: mediaStream?.getAudioTracks()[0]?.enabled,
+    })
+
+    const wavBlob = encodeWAV(pcmChunks, sampleRate)
+    pcmChunks = []
+
+    cleanup()
+    return wavBlob
+  }
+
+  function cleanup() {
+    isRecording.value = false
+    recordingDuration.value = 0
+
+    if (durationTimer) { clearInterval(durationTimer); durationTimer = null }
+    stopSilenceDetection()
+
+    // stopRecording() 之外的路径(reset / 卸载 / startRecording 失败)也会走到这里,
+    // 那时 worklet 还挂着 onmessage。不摘掉它,孤儿 worklet 会继续往 pcmChunks 灌音频。
+    if (workletNode) {
+      workletNode.port.onmessage = null
+      try { workletNode.port.postMessage('stop') } catch { /* context 已关闭 */ }
+      try { workletNode.disconnect() } catch { /* 已断开 */ }
+      workletNode = null
+    }
+
+    if (audioContext) { audioContext.close().catch(() => {}); audioContext = null }
+    if (mediaStream) {
+      mediaStream.getTracks().forEach(t => t.stop())
+      mediaStream = null
+    }
+  }
+
+  checkPermission()
+
+  onUnmounted(() => {
+    cleanup()
+  })
+
+  return {
+    isRecording,
+    permissionState,
+    recordingDuration,
+    silenceDetected,
+    sampleRate,
+    onChunk,
+    startRecording,
+    stopRecording,
+    cleanup,
+  }
+}
+
+// ==================== Helpers ====================
+
+function float32ToInt16(float32: Float32Array): Int16Array {
+  const int16 = new Int16Array(float32.length)
+  for (let i = 0; i < float32.length; i++) {
+    const s = Math.max(-1, Math.min(1, float32[i]))
+    int16[i] = s < 0 ? s * 0x8000 : s * 0x7FFF
+  }
+  return int16
+}
+
+// ==================== WAV 编码 ====================
+
+function encodeWAV(chunks: Float32Array[], sampleRate: number): Blob {
+  // 计算总长度
+  let totalLength = 0
+  for (const chunk of chunks) totalLength += chunk.length
+
+  // 合并为单个 Float32Array
+  const pcm = new Float32Array(totalLength)
+  let offset = 0
+  for (const chunk of chunks) {
+    pcm.set(chunk, offset)
+    offset += chunk.length
+  }
+
+  // Float32 → Int16
+  const int16 = float32ToInt16(pcm)
+
+  // 写 WAV header + data
+  const numChannels = 1
+  const bitsPerSample = 16
+  const byteRate = sampleRate * numChannels * (bitsPerSample / 8)
+  const blockAlign = numChannels * (bitsPerSample / 8)
+  const dataSize = int16.length * (bitsPerSample / 8)
+
+  const buffer = new ArrayBuffer(44 + dataSize)
+  const view = new DataView(buffer)
+
+  // RIFF header
+  writeString(view, 0, 'RIFF')
+  view.setUint32(4, 36 + dataSize, true)
+  writeString(view, 8, 'WAVE')
+
+  // fmt chunk
+  writeString(view, 12, 'fmt ')
+  view.setUint32(16, 16, true)           // chunk size
+  view.setUint16(20, 1, true)            // PCM format
+  view.setUint16(22, numChannels, true)
+  view.setUint32(24, sampleRate, true)
+  view.setUint32(28, byteRate, true)
+  view.setUint16(32, blockAlign, true)
+  view.setUint16(34, bitsPerSample, true)
+
+  // data chunk
+  writeString(view, 36, 'data')
+  view.setUint32(40, dataSize, true)
+
+  // PCM data
+  const int16View = new Int16Array(buffer, 44)
+  int16View.set(int16)
+
+  return new Blob([buffer], { type: 'audio/wav' })
+}
+
+function writeString(view: DataView, offset: number, str: string) {
+  for (let i = 0; i < str.length; i++) {
+    view.setUint8(offset + i, str.charCodeAt(i))
+  }
+}

+ 615 - 0
src/views/Editor/EnglishSpeaking/composables/useDialogueEngine.ts

@@ -0,0 +1,615 @@
+import { ref, reactive, computed, onUnmounted } from 'vue'
+import type { PreviewChatMessage, DialogueAPI, DialogueReport, HistoricalDialogueMessage } from '@/types/englishSpeaking'
+import { createDialogueApi, DialogueApiError } from '../services/llmService'
+import { buildSpeakingWsUrl } from '../services/speakingApiConfig'
+
+export function useDialogueEngine() {
+  const messages = ref<PreviewChatMessage[]>([])
+  const sessionId = ref<string | null>(null)
+  const expiresAt = ref<string | null>(null)
+  const currentRound = ref(1)
+  const totalRounds = ref<number>(3)
+  const isComplete = ref(false)
+  const countdownSeconds = ref<number | null>(null)
+
+  const api: DialogueAPI = createDialogueApi()
+  let currentAbortController: AbortController | null = null
+  let countdownTimer: ReturnType<typeof setInterval> | null = null
+
+  const isProcessing = computed(() => messages.value.some(m => m.status === 'loading'))
+  const canRecord = computed(() => !isProcessing.value && !isComplete.value)
+  const isFinalRound = computed(() => currentRound.value >= totalRounds.value)
+
+  // ==================== Session Attach ====================
+
+  const greetingInflight = ref(false)
+  let greetingAbortController: AbortController | null = null
+
+  /** 仅附加已建好的 session 到 engine,不发任何网络请求。
+   *  时间和轮次是两个并存的结束条件,先满足谁就结束。
+   *  expiresAt 由后端在 createSession 时基于 durationMinutes 计算下发,前端只读 ——
+   *  保证关页面/刷新/换设备重进都能接续同一个截止时刻,倒计时不暂停。
+   */
+  function toPreviewMessage(message: HistoricalDialogueMessage): PreviewChatMessage {
+    return {
+      id: message.id,
+      role: message.role,
+      content: message.content,
+      timestamp: new Date(),
+      status: 'done',
+      turnId: message.clientTurnId ?? undefined,
+      audioUrl: message.audioUrl ?? undefined,
+      audioDuration: message.audioDuration ?? undefined,
+    }
+  }
+
+  function attachSession(info: {
+    sessionId: string
+    expiresAt?: string | null
+    totalRounds: number
+    currentRound?: number
+    messages?: HistoricalDialogueMessage[]
+  }) {
+    sessionId.value = info.sessionId
+    expiresAt.value = info.expiresAt ?? null
+    totalRounds.value = info.totalRounds
+    currentRound.value = info.currentRound ?? 1
+    messages.value = (info.messages ?? []).map(toPreviewMessage)
+    if (info.expiresAt) startCountdown(info.expiresAt)
+  }
+
+  /** 执行开场白生成的私有帮手:push loading 占位 AI 消息 → 请求 /greeting → 成功填内容,失败标 error。
+   *  @param turnId 本次生成的轮次 ID,用于后端幂等性查找。
+   */
+  async function _runGreeting(turnId: string) {
+    if (!sessionId.value || greetingInflight.value) return
+    greetingInflight.value = true
+    greetingAbortController = new AbortController()
+
+    const aiMsg = reactive<PreviewChatMessage>({
+      id: crypto.randomUUID(),
+      role: 'ai',
+      content: '',
+      timestamp: new Date(),
+      status: 'loading',
+      turnId,
+    })
+    messages.value.push(aiMsg)
+
+    try {
+      const { aiMessage } = await api.generateGreeting(sessionId.value, turnId, greetingAbortController.signal)
+      aiMsg.content = aiMessage
+      aiMsg.status = 'done'
+    } catch (err: unknown) {
+      if (err instanceof Error && err.name === 'AbortError') return  // 组件卸载:不改 UI
+      aiMsg.status = 'error'
+      const status = err instanceof DialogueApiError ? err.status : undefined
+      const raw = err instanceof Error ? err.message : undefined
+      aiMsg.error = friendlyErrorMessage(raw)
+      aiMsg.recovery = classifyError(raw, status, 'ai')
+    } finally {
+      greetingInflight.value = false
+      greetingAbortController = null
+    }
+  }
+
+  /** 触发开场白生成:分配新的 turnId,调用 _runGreeting。 */
+  async function generateGreeting() {
+    await _runGreeting(crypto.randomUUID())
+  }
+
+  /** 重试失败的开场白:移除 error 消息,复用失败消息的 turnId 再走一遍(后端幂等性查找)。
+   *  如果老消息没有 turnId(不应该发生,但防守性),分配新的。 */
+  async function retryGreeting() {
+    if (greetingInflight.value) return
+    const firstAi = messages.value.find(m => m.role === 'ai')
+    if (firstAi?.status !== 'error' || firstAi.recovery === 'restart') return
+    const reuseTurnId = firstAi.turnId ?? crypto.randomUUID()
+    messages.value = messages.value.filter(m => m.id !== firstAi.id)
+    await _runGreeting(reuseTurnId)
+  }
+
+  // ==================== Send Message ====================
+
+  async function sendStudentMessage(audioBlob: Blob, turnId: string) {
+    if (!sessionId.value || isProcessing.value) return
+
+    // 只 push student 占位。aiMsg 留到 transcript 事件到达再 push——避免 AI typing-bubble
+    // 抢在 student 转录文本前出现。学生 loading 状态下由 student 自己的 typing-bubble 占位。
+    const studentMsg = reactive<PreviewChatMessage>({
+      id: crypto.randomUUID(),
+      role: 'student',
+      content: '',
+      timestamp: new Date(),
+      status: 'loading',
+      audioBlob,
+      turnId,
+    })
+    messages.value.push(studentMsg)
+
+    const aiMsg = reactive<PreviewChatMessage>({
+      id: crypto.randomUUID(),
+      role: 'ai',
+      content: '',
+      timestamp: new Date(),
+      status: 'loading',
+      turnId,
+    })
+
+    currentAbortController = new AbortController()
+
+    try {
+      const generator = api.speak(sessionId.value, audioBlob, currentAbortController.signal, turnId)
+
+      for await (const event of generator) {
+        if (event.type === 'transcript') {
+          studentMsg.content = event.text
+          studentMsg.status = 'done'
+          if (event.audioDuration != null) {
+            studentMsg.audioDuration = event.audioDuration
+          }
+          if (!isFinalRound.value) {
+            messages.value.push(aiMsg)
+          }
+        } else if (event.type === 'token') {
+          aiMsg.content += event.text
+        } else if (event.type === 'done') {
+          aiMsg.status = 'done'
+          isComplete.value = event.isComplete
+
+          if (!event.isComplete) {
+            currentRound.value++
+          }
+        } else if (event.type === 'error') {
+          studentMsg.status = 'error'
+          studentMsg.error = friendlyErrorMessage(event.message)
+          studentMsg.recovery = classifyError(event.message, undefined, 'student')
+          const aiIdx = messages.value.indexOf(aiMsg)
+          if (aiIdx !== -1) messages.value.splice(aiIdx, 1)
+          return
+        }
+      }
+
+      // If student message never got transcript, mark done with fallback
+      if (studentMsg.status === 'loading') {
+        studentMsg.status = 'done'
+      }
+      if (aiMsg.status === 'loading') {
+        aiMsg.status = 'done'
+      }
+    } catch (err: any) {
+      if (err.name === 'AbortError') return
+      const status: number | undefined = err instanceof DialogueApiError ? err.status : undefined
+      if (studentMsg.status === 'loading') {
+        studentMsg.status = 'error'
+        studentMsg.error = friendlyErrorMessage(err.message)
+        studentMsg.recovery = classifyError(err.message, status, 'student')
+      } else if (aiMsg.status === 'loading') {
+        aiMsg.status = 'error'
+        aiMsg.error = friendlyErrorMessage(err.message)
+        aiMsg.recovery = classifyError(err.message, status, 'ai')
+      }
+    } finally {
+      currentAbortController = null
+    }
+  }
+
+  // ==================== Retry / Regenerate ====================
+
+  async function retryMessage(messageId: string) {
+    const msg = messages.value.find(m => m.id === messageId)
+    if (!msg || msg.status !== 'error') return
+
+    if (msg.role === 'student' && msg.audioBlob && msg.turnId) {
+      // Remove the failed student message and any subsequent AI message
+      const idx = messages.value.indexOf(msg)
+      messages.value.splice(idx)
+      await sendStudentMessage(msg.audioBlob, msg.turnId)
+    }
+  }
+
+  async function regenerateAiMessage(messageId: string) {
+    const msg = messages.value.find(m => m.id === messageId)
+    if (!msg || msg.role !== 'ai' || msg.status !== 'error') return
+
+    // Find the student message before this AI message
+    const idx = messages.value.indexOf(msg)
+    const prevStudent = messages.value.slice(0, idx).reverse().find(m => m.role === 'student')
+    if (!prevStudent?.audioBlob || !sessionId.value || !prevStudent.turnId) return
+
+    // Remove the failed AI message
+    messages.value.splice(idx, 1)
+
+    // Re-add AI placeholder and stream
+    const aiMsg = reactive<PreviewChatMessage>({
+      id: crypto.randomUUID(),
+      role: 'ai',
+      content: '',
+      timestamp: new Date(),
+      status: 'loading',
+      turnId: prevStudent.turnId,
+    })
+    messages.value.push(aiMsg)
+
+    currentAbortController = new AbortController()
+
+    try {
+      const generator = api.speak(
+        sessionId.value, prevStudent.audioBlob,
+        currentAbortController.signal, prevStudent.turnId,
+      )
+
+      for await (const event of generator) {
+        if (event.type === 'transcript') {
+          // Skip transcript on regenerate, student message already exists
+        } else if (event.type === 'token') {
+          aiMsg.content += event.text
+        } else if (event.type === 'done') {
+          aiMsg.status = 'done'
+          isComplete.value = event.isComplete
+          if (!event.isComplete) currentRound.value++
+        }
+      }
+
+      if (aiMsg.status === 'loading') aiMsg.status = 'done'
+    } catch (err: any) {
+      if (err.name === 'AbortError') return
+      aiMsg.status = 'error'
+      aiMsg.error = friendlyErrorMessage(err.message)
+      const status = err instanceof DialogueApiError ? err.status : undefined
+      aiMsg.recovery = classifyError(err.message, status, 'ai')
+    } finally {
+      currentAbortController = null
+    }
+  }
+
+  // ==================== Report ====================
+
+  async function waitForReport(sessionId: string): Promise<DialogueReport> {
+    const maxAttempts = 30
+    const intervalMs = 2000
+    for (let attempt = 1; attempt <= maxAttempts; attempt++) {
+      const report = await api.getReport(sessionId)
+      if (report.status === 'ready' || report.status === 'failed' || report.status === 'incomplete') {
+        return report
+      }
+      await new Promise(resolve => setTimeout(resolve, intervalMs))
+    }
+    throw new Error('Report generation timed out')
+  }
+
+  async function getReport(): Promise<DialogueReport> {
+    if (!sessionId.value) return Promise.reject(new Error('No session'))
+
+    const report = await waitForReport(sessionId.value!)
+    return report
+  }
+
+  async function completeSession(): Promise<void> {
+    if (!sessionId.value) return
+    await api.completeSession(sessionId.value)
+  }
+
+  // ==================== Countdown ====================
+
+  function startCountdown(expiresAtStr: string) {
+    stopCountdown()
+
+    const update = () => {
+      const remaining = Math.max(0, Math.floor((new Date(expiresAtStr).getTime() - Date.now()) / 1000))
+      countdownSeconds.value = remaining
+      if (remaining <= 0) {
+        stopCountdown()
+        isComplete.value = true
+      }
+    }
+
+    update()
+    countdownTimer = setInterval(update, 1000)
+  }
+
+  function stopCountdown() {
+    if (countdownTimer) { clearInterval(countdownTimer); countdownTimer = null }
+    countdownSeconds.value = null
+  }
+
+  // ==================== Abort ====================
+
+  function abort() {
+    currentAbortController?.abort()
+    currentAbortController = null
+  }
+
+  // ==================== Streaming Speak (WebSocket) ====================
+
+  /** 流式开始:打开 WS,返回 { pushChunk, commit, abort };占位消息在 commit() 时才 push */
+  function beginStudentStream(opts: {
+    sampleRate: number
+    bits?: number
+    channels?: number
+  }) {
+    if (!sessionId.value || isProcessing.value) return null
+
+    const turnId = crypto.randomUUID()
+
+    // No messages pushed yet. They are pushed in commit().
+    let studentMsg: PreviewChatMessage | null = null
+    let aiMsg: PreviewChatMessage | null = null
+
+    const wsUrl = buildSpeakingWsUrl('/speak-stream')
+    const ws = new WebSocket(wsUrl)
+    ws.binaryType = 'arraybuffer'
+
+    let aborted = false
+    let committed = false
+    let pendingCommitBlob: Blob | null = null
+    let openWaitTimer: ReturnType<typeof setTimeout> | null = null
+    let chunkQueue: ArrayBuffer[] = []
+    let open = false
+
+    const clearOpenWaitTimer = () => {
+      if (openWaitTimer) {
+        clearTimeout(openWaitTimer)
+        openWaitTimer = null
+      }
+    }
+
+    const pushPlaceholders = (blob: Blob) => {
+      studentMsg = reactive<PreviewChatMessage>({
+        id: crypto.randomUUID(),
+        role: 'student',
+        content: '',
+        timestamp: new Date(),
+        status: 'loading',
+        audioBlob: blob,
+        turnId,
+      })
+      messages.value.push(studentMsg)
+
+      // aiMsg 创建但暂不 push——等 transcript 事件到达后再插入聊天列表。
+      // 这样 student loading 阶段不会有 AI typing-bubble 抢先显示。
+      if (!isFinalRound.value) {
+        aiMsg = reactive<PreviewChatMessage>({
+          id: crypto.randomUUID(),
+          role: 'ai',
+          content: '',
+          timestamp: new Date(),
+          status: 'loading',
+          turnId,
+        })
+      }
+    }
+
+    const finalizeError = (raw: string) => {
+      const text = friendlyErrorMessage(raw)
+      if (studentMsg && studentMsg.status === 'loading') {
+        studentMsg.status = 'error'
+        studentMsg.error = text
+        studentMsg.recovery = classifyError(raw, undefined, 'student')
+        // 学生侧出错时把 ai 占位移除,避免孤立 typing-bubble。
+        if (aiMsg && aiMsg.status === 'loading') {
+          const idx = messages.value.indexOf(aiMsg)
+          if (idx !== -1) messages.value.splice(idx, 1)
+        }
+      } else if (aiMsg && aiMsg.status === 'loading') {
+        aiMsg.status = 'error'
+        aiMsg.error = text
+        aiMsg.recovery = classifyError(raw, undefined, 'ai')
+      }
+    }
+
+    ws.onopen = () => {
+      if (aborted) {
+        try { ws.close() } catch { /* ignore */ }
+        return
+      }
+      open = true
+      clearOpenWaitTimer()
+      ws.send(JSON.stringify({
+        type: 'start',
+        sessionId: sessionId.value,
+        turnId,
+        sampleRate: opts.sampleRate,
+        bits: opts.bits ?? 16,
+        channels: opts.channels ?? 1,
+      }))
+      for (const c of chunkQueue) ws.send(c)
+      chunkQueue = []
+      if (pendingCommitBlob) {
+        const blob = pendingCommitBlob
+        pendingCommitBlob = null
+        pushPlaceholders(blob)
+        ws.send(JSON.stringify({ type: 'stop' }))
+      }
+    }
+
+    ws.onmessage = (e: MessageEvent) => {
+      if (!committed) return  // tokens shouldn't arrive before commit; defensive
+      try {
+        const data = JSON.parse(e.data)
+        if (data.type === 'transcript' && studentMsg) {
+          studentMsg.content = data.text
+          studentMsg.status = 'done'
+          if (data.audioDuration != null) {
+            studentMsg.audioDuration = data.audioDuration
+          }
+          if (aiMsg && !messages.value.includes(aiMsg)) {
+            messages.value.push(aiMsg)
+          }
+        }
+        else if (data.type === 'token' && aiMsg) {
+          aiMsg.content += data.content
+        }
+        else if (data.type === 'done') {
+          if (aiMsg) aiMsg.status = 'done'
+          else if (studentMsg && studentMsg.status === 'loading') studentMsg.status = 'done'
+          isComplete.value = !!data.isComplete
+          if (!data.isComplete) currentRound.value++
+          ws.close()
+        }
+        else if (data.type === 'error') {
+          finalizeError(data.message)
+          ws.close()
+        }
+      } catch { /* ignore */ }
+    }
+
+    ws.onerror = () => {
+      if (aborted) return
+      if (pendingCommitBlob) {
+        // commit 已发但 ws 仍 CONNECTING → 直接出错给用户,不再自动降级 HTTP。
+        const blob = pendingCommitBlob
+        pendingCommitBlob = null
+        clearOpenWaitTimer()
+        pushPlaceholders(blob)
+        finalizeError('WebSocket error')
+        try { ws.close() } catch { /* ignore */ }
+        return
+      }
+      if (committed) finalizeError('WebSocket error')
+      // commit 之前断开:什么都不做,等用户按完成时由 commit 处理 readyState。
+    }
+    ws.onclose = () => {
+      if (aborted) return
+      if (pendingCommitBlob) {
+        const blob = pendingCommitBlob
+        pendingCommitBlob = null
+        clearOpenWaitTimer()
+        pushPlaceholders(blob)
+        finalizeError('Connection closed')
+        return
+      }
+      if (!committed) return
+      if (studentMsg?.status === 'loading') finalizeError('Connection closed')
+      else if (aiMsg?.status === 'loading') finalizeError('Connection closed')
+    }
+
+    const pushChunk = (chunk: ArrayBuffer) => {
+      if (aborted) return
+      if (open && ws.readyState === WebSocket.OPEN) ws.send(chunk)
+      else chunkQueue.push(chunk)
+    }
+
+    const commit = (blob: Blob) => {
+      if (committed) return
+      committed = true
+
+      if (open && ws.readyState === WebSocket.OPEN) {
+        pushPlaceholders(blob)
+        ws.send(JSON.stringify({ type: 'stop' }))
+        return
+      }
+
+      if (ws.readyState === WebSocket.CONNECTING) {
+        // 连接中按完成:等 onopen 至多 2.5s。超时还没来 = 直接报错给用户重试。
+        pendingCommitBlob = blob
+        openWaitTimer = setTimeout(() => {
+          if (!pendingCommitBlob) return
+          const b = pendingCommitBlob
+          pendingCommitBlob = null
+          try { ws.close() } catch { /* ignore */ }
+          pushPlaceholders(b)
+          finalizeError('Connection closed')
+        }, 2500)
+        return
+      }
+
+      // CLOSING / CLOSED:commit 时 ws 已断开 → 直接报错。
+      pushPlaceholders(blob)
+      finalizeError('Connection closed')
+    }
+
+    const abort = () => {
+      aborted = true
+      pendingCommitBlob = null
+      clearOpenWaitTimer()
+      try { ws.close() } catch { /* ignore */ }
+      // No messages were pushed (commit not called) → nothing to clean up.
+    }
+
+    return { turnId, pushChunk, commit, abort }
+  }
+
+  /**
+   * 丢弃当前轮次的所有消息(student + ai),用于"重录"按钮。
+   * 通过 turnId 精确定位同一轮的两条消息并一并移除。
+   */
+  function discardCurrentTurn(messageId: string) {
+    const msg = messages.value.find(m => m.id === messageId)
+    if (!msg) return
+    const turnId = msg.turnId
+    if (!turnId) {
+      // Defensive: messages without turnId (legacy / greeting) can't be turn-grouped.
+      messages.value = messages.value.filter(m => m.id !== messageId)
+      return
+    }
+    // Splice out both rows of the turn (student + ai) sharing this turnId.
+    messages.value = messages.value.filter(m => m.turnId !== turnId)
+  }
+
+  // ==================== Cleanup ====================
+
+  onUnmounted(() => {
+    abort()
+    greetingAbortController?.abort()
+    stopCountdown()
+  })
+
+  return {
+    messages,
+    sessionId,
+    currentRound,
+    totalRounds,
+    isComplete,
+    isProcessing,
+    canRecord,
+    countdownSeconds,
+    greetingInflight,
+    isFinalRound,
+
+    attachSession,
+    generateGreeting,
+    retryGreeting,
+    sendStudentMessage,
+    beginStudentStream,
+    retryMessage,
+    regenerateAiMessage,
+    discardCurrentTurn,
+    getReport,
+    completeSession,
+    abort,
+  }
+}
+
+// ==================== Helpers ====================
+
+/** 把后端英文错误转成面向用户的中文友好文案 */
+function friendlyErrorMessage(raw: string | undefined): string {
+  const map: Record<string, string> = {
+    'No speech detected': '没听清,请再说一次',
+    'Session not found': '会话已失效,请刷新页面重新开始',
+    'Session is not active': '会话已结束',
+    'sessionId required': '会话参数缺失',
+    'First message must be start': '协议异常,请刷新重试',
+    'Invalid start payload': '协议异常,请刷新重试',
+    'Internal error': '服务暂时不可用,请稍后重试',
+    'WebSocket error': '网络连接异常',
+    'Connection closed': '连接中断,请重试',
+    'Aborted': '已取消',
+  }
+  if (!raw) return '请求失败,请重试'
+  return map[raw] || raw
+}
+
+export type Recovery = 'retry' | 'rerecord' | 'restart'
+
+export function classifyError(
+  raw: string | undefined,
+  status: number | undefined,
+  role: 'student' | 'ai',
+): Recovery {
+  if (status === 404 || status === 409) return 'restart'
+  if (raw === 'Session not found' || raw === 'Session is not active') return 'restart'
+  if (raw === 'No speech detected' && role === 'student') return 'rerecord'
+  return 'retry'
+}

+ 505 - 0
src/views/Editor/EnglishSpeaking/configs/ArticlePracticeConfig.vue

@@ -0,0 +1,505 @@
+<template>
+  <div class="article-practice-config">
+    <!-- 滚动内容区域 -->
+    <div class="config-scroll">
+
+      <div class="config-card">
+
+        <!-- 1. 文章标题 -->
+        <div class="form-group">
+          <label class="form-label">{{ lang.ssArticleTitle }}</label>
+          <input
+            type="text"
+            :value="store.config.title"
+            @input="store.config.title = ($event.target as HTMLInputElement).value"
+            class="form-input"
+            :placeholder="lang.ssArticleTitlePlaceholder as string"
+          />
+        </div>
+
+        <!-- 2. 文章正文 -->
+        <div class="form-group">
+          <div class="label-row">
+            <label class="form-label">{{ lang.ssArticleContent }}</label>
+            <button
+              class="btn-auto-format"
+              :disabled="!store.config.content.trim() || isFormatting"
+              @click="openOverwrite"
+            >
+              {{ lang.ssArticleAutoFormat }}
+            </button>
+          </div>
+          <textarea
+            ref="contentRef"
+            :value="store.config.content"
+            :disabled="isFormatting"
+            @input="onContentInput(($event.target as HTMLTextAreaElement).value)"
+            class="form-textarea"
+            :placeholder="lang.ssArticleContentPlaceholder as string"
+          ></textarea>
+        </div>
+
+        <!-- 3. 练习时长 -->
+        <div class="form-group slider-group">
+          <label class="sub-label">{{ lang.ssArticleTotalDuration }}</label>
+          <input
+            type="range"
+            :value="store.config.practice.duration"
+            min="1" max="60" step="1"
+            class="config-slider"
+            @input="store.config.practice.duration = Number(($event.target as HTMLInputElement).value)"
+          />
+          <div class="slider-labels">
+            <span class="slider-min">1 {{ lang.ssMinute }}</span>
+            <span class="slider-value">{{ store.config.practice.duration }} {{ lang.ssMinute }}</span>
+            <span class="slider-max">60 {{ lang.ssMinute }}</span>
+          </div>
+        </div>
+
+        <!-- 4. 展示学习报告 -->
+        <div class="toggle-row">
+          <span class="toggle-label">{{ lang.ssShowReport }}</span>
+          <button
+            class="toggle-switch"
+            :class="{ on: store.config.evaluation.showReport }"
+            @click="store.config.evaluation.showReport = !store.config.evaluation.showReport"
+          >
+            <span class="toggle-knob"></span>
+          </button>
+        </div>
+
+      </div>
+
+      <div class="bottom-spacer"></div>
+    </div>
+
+    <!-- 底部操作栏 —— 不置灰:有人在练也照常可按,后端 409 时弹确认框(spec §4.3 B) -->
+    <div class="config-footer">
+      <button
+        class="btn-apply"
+        :disabled="applying || isFormatting"
+        @click="handleApply"
+      >
+        <!-- 保存会同步把整篇示范音频合成好,正文改过的那次要几秒。按钮必须说出
+             它在等什么 —— 只转圈或只置灰,老师看到的是「应用配置卡住了」。 -->
+        <span v-if="applying" class="btn-apply__spinner"></span>
+        {{ applying ? lang.ssArticleGeneratingDemo : lang.ssApplyConfig }}
+      </button>
+    </div>
+
+    <ArticleFormatDialog
+      :visible="dialogVisible"
+      :mode="dialogMode"
+      :loading="isFormatting"
+      @confirm="confirmDialog"
+      @cancel="handleDialogCancel"
+    />
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { computed, nextTick, ref, watch } from 'vue'
+import { lang } from '@/main'
+import { useArticleReadingStore } from '@/store/articleReading'
+import useCreateElement from '@/hooks/useCreateElement'
+import { getSpeakingConfig } from '@/services/speaking'
+import { createArticleConfig, updateArticleConfig } from '../services/articleReading'
+import { articleActiveCountFromError, friendlyArticleError } from '../services/articleErrors'
+import { ARTICLE_READING_TOOL_TYPE } from '@/configs/englishSpeakingTools'
+import type { ArticleReadingConfig } from '@/types/articleReading'
+import message from '@/utils/message'
+import { showConfirmDialog } from '@/utils/confirmDialog'
+import ArticleFormatDialog from '../components/ArticleFormatDialog.vue'
+import { useArticleAutoFormat } from '../composables/useArticleAutoFormat'
+
+defineEmits<{
+  (e: 'back'): void
+}>()
+
+const store = useArticleReadingStore()
+const { createFrameElement } = useCreateElement()
+const applying = ref(false)
+
+// 自动分段(spec §3.2):三种触发共用一个弹框状态机
+const contentRef = ref<HTMLTextAreaElement | null>(null)
+const content = computed({
+  get: () => store.config.content,
+  set: (value: string) => {
+    store.config.content = value
+  },
+})
+const {
+  dialogVisible,
+  dialogMode,
+  isFormatting,
+  onContentInput,
+  openOverwrite,
+  guardBeforeApply,
+  confirmDialog,
+  cancelDialog,
+} = useArticleAutoFormat(content)
+
+// 守卫框的「手动修改」= 关框 + 焦点回文本框;其余 mode 只关框
+const handleDialogCancel = () => {
+  if (isFormatting.value) return
+  const wasGuard = dialogMode.value === 'guard'
+  cancelDialog()
+  if (wasGuard) nextTick(() => contentRef.value?.focus())
+}
+
+// 载入已有配置:竞态用 token 作废旧响应,防慢请求覆盖新选中的元素
+let loadToken = 0
+watch(() => store.activeConfigId, async (configId) => {
+  if (!configId) return
+  const token = ++loadToken
+  try {
+    const { config } = await getSpeakingConfig<ArticleReadingConfig>(configId)
+    if (token !== loadToken) return
+    if (config?.type !== 'article') {
+      console.warn('[article-reading] ignored non-article config:', configId)
+      return
+    }
+    store.replaceConfig(config)
+  } catch (err) {
+    if (token !== loadToken) return
+    console.error('[article-reading] load config failed:', err)
+  }
+}, { immediate: true })
+
+/**
+ * 保存到已有 config。返回 **false = 老师在确认框里选了取消**,一个字都没写。
+ *
+ * 409 是问句不是墙(spec §4.3 B,2026-07-24 定):detail 里带着按下那一刻的在练人数,
+ * 用它填确认框把后果说清楚 —— 那 N 个学生刷新会回到开始页、这一轮成绩不进班级名单 ——
+ * 老师选「仍要保存」就带 force 原样重发一次。绝不把 409 的原始 JSON 甩给老师
+ * (承 §9.2.5 那次教训:`request()` 抛的是 `response.text()`)。
+ */
+async function saveExistingConfig(configId: string): Promise<boolean> {
+  try {
+    await updateArticleConfig(configId, store.config)
+    return true
+  }
+  catch (err: unknown) {
+    const practising = articleActiveCountFromError(err)
+    if (practising === null) throw err // 别的错误照旧交给 handleApply 的 catch
+
+    const confirmed = await showConfirmDialog({
+      title: lang.ssArticlePractisingOverwriteTitle as string,
+      content: String(lang.ssArticlePractisingOverwriteMessage).replace('{n}', String(practising)),
+      confirmText: lang.ssArticlePractisingOverwriteConfirm as string,
+      cancelText: lang.ssArticlePractisingOverwriteCancel as string,
+    })
+    if (!confirmed) return false
+
+    await updateArticleConfig(configId, store.config, true)
+    return true
+  }
+}
+
+const handleApply = async () => {
+  if (applying.value) return
+  if (!store.config.title.trim() || !store.config.content.trim()) {
+    message.error(lang.ssArticleRequired as string)
+    return
+  }
+  // 换行归一化回写 + 单段 300 词硬守卫(spec §3.2 step 4);被拦下时弹框已开
+  if (!guardBeforeApply()) return
+
+  applying.value = true
+  try {
+    // 写入目标 = **面板正在编辑的那个 config**。`activeConfigId` 由两个入口置上:双击画布上的
+    // 文章朗读元素(`FrameElement`)、Layer2 插入新工具。面板的表单内容也是照它加载的
+    // (见下方 watch),所以它才是唯一正确的写入目标。
+    //
+    // 曾经的写法是「在当前幻灯片上 find 第一个 article frame」,那是错的(2026-07-27 实测撞到):
+    // 面板开着切页后,find 命中的是**另一个**元素 —— 把 A 的正文写进 B;一个都找不到时更糟,
+    // 掉进下面的新建分支凭空多一个 config,老师看到「保存成功」,而学生手上那个 config 一个字
+    // 都没变,服务端的在练检查也就只数了那个新 config 名下的 0 个人,怎么看都「拦不住」。
+    if (store.activeConfigId) {
+      // 文章专属的保存接口(spec §4.3 B):服务端在真正写入的那一刻再数一次在练人数 ——
+      // 面板不预先问、也不置灰,挡不住的正是「老师想了两分钟、期间有学生按了开始」这种时间差。
+      // 取消 = 什么都没发生:不重置预览、不弹「已保存」。
+      if (!await saveExistingConfig(store.activeConfigId)) return
+    } else {
+      // 面板没有可编辑的 config(两个入口都会带 id,正常进不到这里)。当作全新建。
+      // 走文章自己的 POST 而不是共用的那个:新建同样要把整篇示范音频合成好才写入,
+      // 否则这条兜底路生出来的 config 是唯一一份「学生点示范要等」的配置。
+      const { id } = await createArticleConfig(store.config)
+      createFrameElement(id, ARTICLE_READING_TOOL_TYPE)
+      store.setActiveConfigId(id)
+    }
+    store.requestResetPreview()
+    message.success(lang.ssArticleSaved as string)
+  } catch (err: unknown) {
+    console.error('[article-reading] apply failed:', err)
+    // 「有人在练」那一条已经在 saveExistingConfig 里变成确认框了,走到这里的都是真错误
+    message.error(friendlyArticleError(err))
+  } finally {
+    applying.value = false
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.article-practice-config {
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+}
+
+.config-scroll {
+  flex: 1;
+  overflow-y: auto;
+  padding: 16px;
+}
+
+.config-card {
+  background: #fff;
+  border: 1px solid #e5e7eb;
+  border-radius: 12px;
+  padding: 16px;
+}
+
+.form-group {
+  margin-bottom: 12px;
+
+  &:last-child {
+    margin-bottom: 0;
+  }
+}
+
+.form-label {
+  display: block;
+  font-size: 12px;
+  font-weight: 600;
+  color: #374151;
+  margin-bottom: 6px;
+}
+
+.label-row {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 6px;
+
+  .form-label {
+    margin-bottom: 0;
+  }
+}
+
+/* enspeak: px-2.5 py-1 text-[11px] text-orange-600 bg-orange-50 hover:bg-orange-100
+   disabled:bg-gray-100 disabled:text-gray-400 rounded-md font-medium */
+.btn-auto-format {
+  padding: 4px 10px;
+  border: none;
+  border-radius: 6px;
+  font-size: 11px;
+  font-weight: 500;
+  color: #ea580c;
+  background: #fff7ed;
+  cursor: pointer;
+  transition: background 0.2s;
+
+  &:hover:not(:disabled) {
+    background: #ffedd5;
+  }
+
+  &:disabled {
+    background: #f3f4f6;
+    color: #9ca3af;
+    cursor: not-allowed;
+  }
+}
+
+.sub-label {
+  display: block;
+  font-size: 12px;
+  color: #6b7280;
+  margin-bottom: 6px;
+}
+
+.form-input {
+  width: 100%;
+  height: 36px;
+  padding: 0 12px;
+  border: 1px solid #e5e7eb;
+  border-radius: 8px;
+  font-size: 13px;
+  color: #374151;
+  background: #fff;
+  transition: all 0.2s;
+  box-sizing: border-box;
+
+  &:focus {
+    outline: none;
+    border-color: #f97316;
+  }
+
+  &::placeholder {
+    color: #9ca3af;
+  }
+}
+
+.form-textarea {
+  width: 100%;
+  height: 128px;
+  padding: 8px 12px;
+  border: 1px solid #e5e7eb;
+  border-radius: 8px;
+  font-size: 13px;
+  line-height: 1.6;
+  color: #374151;
+  background: #fff;
+  font-family: inherit;
+  resize: none;
+  transition: all 0.2s;
+  box-sizing: border-box;
+
+  &:focus {
+    outline: none;
+    border-color: #f97316;
+  }
+
+  &::placeholder {
+    color: #9ca3af;
+  }
+
+  &:disabled {
+    background: #f3f4f6;
+    color: #9ca3af;
+    cursor: not-allowed;
+  }
+}
+
+.config-slider {
+  width: 100%;
+  height: 6px;
+  border-radius: 3px;
+  appearance: none;
+  background: #e5e7eb;
+  cursor: pointer;
+  accent-color: #f97316;
+
+  &::-webkit-slider-thumb {
+    appearance: none;
+    width: 16px;
+    height: 16px;
+    border-radius: 50%;
+    background: #f97316;
+    cursor: pointer;
+    border: 2px solid #fff;
+    box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
+  }
+}
+
+.slider-labels {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-top: 6px;
+  font-size: 12px;
+}
+
+.slider-min, .slider-max {
+  color: #9ca3af;
+}
+
+.slider-value {
+  font-weight: 500;
+  color: #374151;
+}
+
+.toggle-row {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-top: 12px;
+}
+
+.toggle-label {
+  font-size: 12px;
+  color: #4b5563;
+}
+
+.toggle-switch {
+  position: relative;
+  width: 44px;
+  height: 24px;
+  border-radius: 12px;
+  border: none;
+  cursor: pointer;
+  transition: background 0.2s;
+  background: #d1d5db;
+
+  &.on {
+    background: #f97316;
+
+    .toggle-knob {
+      left: 22px;
+    }
+  }
+}
+
+.toggle-knob {
+  position: absolute;
+  top: 4px;
+  left: 4px;
+  width: 16px;
+  height: 16px;
+  border-radius: 50%;
+  background: #fff;
+  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
+  transition: left 0.2s;
+}
+
+.bottom-spacer {
+  height: 24px;
+}
+
+.config-footer {
+  border-top: 1px solid #f3f4f6;
+  padding: 12px 16px;
+  background: #fff;
+  flex-shrink: 0;
+}
+
+.btn-apply {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  gap: 8px;
+  width: 100%;
+  height: 36px;
+  border: none;
+  border-radius: 8px;
+  background: #f97316;
+  color: #fff;
+  font-size: 13px;
+  font-weight: 500;
+  cursor: pointer;
+  transition: background 0.2s;
+
+  &:hover:not(:disabled) {
+    background: #ea580c;
+  }
+
+  &:disabled {
+    opacity: 0.6;
+    cursor: not-allowed;
+  }
+}
+
+.btn-apply__spinner {
+  width: 12px;
+  height: 12px;
+  flex-shrink: 0;
+  border-radius: 50%;
+  border: 1.5px solid currentColor;
+  border-top-color: transparent;
+  animation: article-apply-spin 0.7s linear infinite;
+}
+
+@keyframes article-apply-spin {
+  to { transform: rotate(360deg); }
+}
+</style>

+ 1082 - 0
src/views/Editor/EnglishSpeaking/configs/TopicDiscussionConfig.vue

@@ -0,0 +1,1082 @@
+<template>
+  <div class="topic-discussion-config">
+    <!-- 滚动内容区域 -->
+    <div class="config-scroll">
+
+      <!-- 主配置卡片 -->
+      <div class="config-card">
+
+        <!-- 1. 讨论话题 -->
+        <div class="form-group">
+          <label class="form-label">{{ lang.ssDiscussionTopic }}</label>
+          <input
+            type="text"
+            :value="store.config.topic"
+            @input="store.updateTopic(($event.target as HTMLInputElement).value)"
+            class="form-input"
+            :placeholder="lang.ssEnterTopic as string"
+          />
+        </div>
+
+        <!-- 2. 学习目标 -->
+        <div class="form-group">
+          <button class="section-toggle" @click="learningGoalsExpanded = !learningGoalsExpanded">
+            <span class="section-toggle-label">{{ lang.ssLearningGoals }}</span>
+            <svg
+              class="section-toggle-arrow"
+              :class="{ collapsed: !learningGoalsExpanded }"
+              width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
+            >
+              <path d="M6 9l6 6 6-6" />
+            </svg>
+          </button>
+
+          <div v-if="learningGoalsExpanded" class="learning-goals-body">
+            <!-- 练习词汇 -->
+            <div class="sub-section">
+              <div class="sub-section-header">
+                <span class="sub-label">{{ lang.ssPracticeVocabulary }}</span>
+                <div v-if="!isAddingVocab" class="sub-actions">
+                  <button class="icon-btn" @click="isAddingVocab = true" title="添加词汇">
+                    <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+                      <line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
+                    </svg>
+                  </button>
+                  <button class="icon-btn" @click="showBatchPaste = true" :title="lang.ssBatchPaste as string">
+                    <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+                      <path d="M16 4h2a2 2 0 012 2v14a2 2 0 01-2 2H6a2 2 0 01-2-2V6a2 2 0 012-2h2" />
+                      <rect x="8" y="2" width="8" height="4" rx="1" ry="1" />
+                    </svg>
+                  </button>
+                </div>
+              </div>
+              <div class="vocab-tags">
+                <span
+                  v-for="(word, index) in store.config.learningGoals.vocabulary"
+                  :key="index"
+                  class="vocab-tag"
+                >
+                  {{ word }}
+                  <button class="tag-remove" @click="store.removeVocabulary(index)">
+                    <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+                      <line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
+                    </svg>
+                  </button>
+                </span>
+                <input
+                  v-if="isAddingVocab"
+                  ref="vocabInputRef"
+                  v-model="newVocab"
+                  class="vocab-inline-input"
+                  :placeholder="lang.ssAddWord as string"
+                  @keydown.enter="handleAddVocab"
+                  @keydown.escape="cancelAddVocab"
+                  @blur="handleAddVocabBlur"
+                />
+              </div>
+            </div>
+
+            <!-- 练习句型 -->
+            <div class="sub-section">
+              <div class="sub-section-header">
+                <span class="sub-label">{{ lang.ssPracticeSentences }}</span>
+                <div v-if="!isAddingSentence" class="sub-actions">
+                  <button class="icon-btn" @click="isAddingSentence = true" title="添加句型">
+                    <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+                      <line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
+                    </svg>
+                  </button>
+                  <button class="icon-btn" @click="showBatchPaste = true" :title="lang.ssBatchPaste as string">
+                    <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+                      <path d="M16 4h2a2 2 0 012 2v14a2 2 0 01-2 2H6a2 2 0 01-2-2V6a2 2 0 012-2h2" />
+                      <rect x="8" y="2" width="8" height="4" rx="1" ry="1" />
+                    </svg>
+                  </button>
+                </div>
+              </div>
+              <div class="sentence-list">
+                <div
+                  v-for="(sentence, index) in store.config.learningGoals.sentences"
+                  :key="index"
+                >
+                  <!-- 编辑模式 -->
+                  <input
+                    v-if="editingSentenceIndex === index"
+                    v-model="editingSentenceValue"
+                    class="sentence-edit-input"
+                    @keydown.enter="handleSaveEditSentence"
+                    @keydown.escape="cancelEditSentence"
+                    @blur="handleSaveEditSentenceBlur"
+                  />
+                  <!-- 展示模式 -->
+                  <div v-else class="sentence-item" @dblclick="handleEditSentence(index)">
+                    <span class="sentence-text">{{ sentence }}</span>
+                    <button class="sentence-remove" @click="store.removeSentence(index)">
+                      <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+                        <line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
+                      </svg>
+                    </button>
+                  </div>
+                </div>
+                <input
+                  v-if="isAddingSentence"
+                  ref="sentenceInputRef"
+                  v-model="newSentence"
+                  class="sentence-edit-input"
+                  :placeholder="lang.ssAddSentence as string"
+                  @keydown.enter="handleAddSentence"
+                  @keydown.escape="cancelAddSentence"
+                  @blur="handleAddSentenceBlur"
+                />
+              </div>
+            </div>
+          </div>
+        </div>
+
+        <!-- 分割线 -->
+        <div class="config-divider"></div>
+
+        <!-- 3. 练习方式 -->
+        <div class="form-group">
+          <label class="sub-label">{{ lang.ssPracticeMode }}</label>
+          <div class="mode-tabs">
+            <button
+              class="mode-tab"
+              :class="{ active: store.config.practice.mode === 'time' }"
+              @click="store.updatePracticeMode('time')"
+            >
+              {{ lang.ssTimeLimit }}
+            </button>
+            <button
+              class="mode-tab"
+              :class="{ active: store.config.practice.mode === 'rounds' }"
+              @click="store.updatePracticeMode('rounds')"
+            >
+              {{ lang.ssRoundLimit }}
+            </button>
+          </div>
+        </div>
+
+        <!-- 滑块 -->
+        <div class="form-group slider-group">
+          <template v-if="store.config.practice.mode === 'time'">
+            <input
+              type="range"
+              :value="store.config.practice.duration"
+              min="1" max="60" step="1"
+              class="config-slider"
+              @input="store.updateDuration(Number(($event.target as HTMLInputElement).value))"
+            />
+            <div class="slider-labels">
+              <span class="slider-min">1 {{ lang.ssMinute }}</span>
+              <span class="slider-value">{{ store.config.practice.duration }} {{ lang.ssMinute }}</span>
+              <span class="slider-max">60 {{ lang.ssMinute }}</span>
+            </div>
+          </template>
+          <template v-else>
+            <input
+              type="range"
+              :value="store.config.practice.rounds"
+              min="1" max="60" step="1"
+              class="config-slider"
+              @input="store.updateRounds(Number(($event.target as HTMLInputElement).value))"
+            />
+            <div class="slider-labels">
+              <span class="slider-min">1 {{ lang.ssRound }}</span>
+              <span class="slider-value">{{ store.config.practice.rounds }} {{ lang.ssRound }}</span>
+              <span class="slider-max">60 {{ lang.ssRound }}</span>
+            </div>
+          </template>
+        </div>
+
+        <!-- 展示学习报告 -->
+        <div class="toggle-row">
+          <span class="toggle-label">{{ lang.ssShowReport }}</span>
+          <button
+            class="toggle-switch"
+            :class="{ on: store.config.evaluation.showReport }"
+            @click="store.toggleShowReport()"
+          >
+            <span class="toggle-knob"></span>
+          </button>
+        </div>
+      </div>
+
+      <!-- 分割线 -->
+      <div class="section-spacer"></div>
+
+      <!-- 4. 高级配置 -->
+      <div class="advanced-section">
+        <button class="section-toggle" @click="advancedExpanded = !advancedExpanded">
+          <span class="section-toggle-label">{{ lang.ssAdvancedConfig }}</span>
+          <svg
+            class="section-toggle-arrow"
+            :class="{ collapsed: !advancedExpanded }"
+            width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
+          >
+            <path d="M6 9l6 6 6-6" />
+          </svg>
+        </button>
+
+        <div v-if="advancedExpanded" class="advanced-body">
+          <!-- 辅助功能 -->
+          <div class="config-card sub-card">
+            <div class="sub-card-title">{{ lang.ssAssistance }}</div>
+            <div class="checkbox-group">
+              <label class="checkbox-item">
+                <input type="checkbox" :checked="store.config.practice.showEnglish" @change="store.togglePracticeSetting('showEnglish')" />
+                <span>{{ lang.ssShowEnglish }}</span>
+              </label>
+              <label class="checkbox-item">
+                <input type="checkbox" :checked="store.config.practice.taskHint" @change="store.togglePracticeSetting('taskHint')" />
+                <span>{{ lang.ssTaskHint }}</span>
+              </label>
+              <label class="checkbox-item">
+                <input type="checkbox" :checked="store.config.practice.stutterHint" @change="store.togglePracticeSetting('stutterHint')" />
+                <span>{{ lang.ssStutterHint }}</span>
+              </label>
+            </div>
+          </div>
+
+          <!-- 评估详情 -->
+          <div class="config-card sub-card">
+            <div class="sub-card-title">{{ lang.ssEvalDetails }}</div>
+
+            <!-- 评估维度 -->
+            <div class="eval-group">
+              <div class="sub-label">{{ lang.ssEvalDimensions }}</div>
+              <div class="checkbox-group">
+                <label class="checkbox-item">
+                  <input type="checkbox" :checked="store.config.evaluation.dimensions.accuracy" @change="store.toggleEvalDimension('accuracy')" />
+                  <span>{{ lang.ssAccuracy }}</span>
+                </label>
+                <label class="checkbox-item">
+                  <input type="checkbox" :checked="store.config.evaluation.dimensions.fluency" @change="store.toggleEvalDimension('fluency')" />
+                  <span>{{ lang.ssFluency }}</span>
+                </label>
+                <label class="checkbox-item">
+                  <input type="checkbox" :checked="store.config.evaluation.dimensions.completeness" @change="store.toggleEvalDimension('completeness')" />
+                  <span>{{ lang.ssCompleteness }}</span>
+                </label>
+                <label class="checkbox-item">
+                  <input type="checkbox" :checked="store.config.evaluation.dimensions.rhythm" @change="store.toggleEvalDimension('rhythm')" />
+                  <span>{{ lang.ssRhythm }}</span>
+                </label>
+              </div>
+            </div>
+
+            <!-- 评分方式 -->
+            <div class="eval-group">
+              <div class="sub-label">{{ lang.ssScoreMode }}</div>
+              <div class="radio-group">
+                <label class="radio-item" @click="store.updateScoreMode('letter')">
+                  <span class="radio-circle" :class="{ active: store.config.evaluation.scoreMode === 'letter' }">
+                    <span v-if="store.config.evaluation.scoreMode === 'letter'" class="radio-dot"></span>
+                  </span>
+                  <span>{{ lang.ssLetterScore }}</span>
+                </label>
+                <label class="radio-item" @click="store.updateScoreMode('numeric')">
+                  <span class="radio-circle" :class="{ active: store.config.evaluation.scoreMode === 'numeric' }">
+                    <span v-if="store.config.evaluation.scoreMode === 'numeric'" class="radio-dot"></span>
+                  </span>
+                  <span>{{ lang.ssNumericScore }}</span>
+                </label>
+                <label class="radio-item" @click="store.updateScoreMode('comment_only')">
+                  <span class="radio-circle" :class="{ active: store.config.evaluation.scoreMode === 'comment_only' }">
+                    <span v-if="store.config.evaluation.scoreMode === 'comment_only'" class="radio-dot"></span>
+                  </span>
+                  <span>{{ lang.ssCommentOnly }}</span>
+                </label>
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+
+      <!-- 底部留白 -->
+      <div class="bottom-spacer"></div>
+    </div>
+
+    <!-- 底部操作栏 -->
+    <div class="config-footer">
+      <button class="btn-apply" @click="handleApply">
+        {{ lang.ssApplyConfig }}
+      </button>
+    </div>
+
+    <!-- 批量粘贴弹窗 -->
+    <div v-if="showBatchPaste" class="batch-paste-overlay" @click.self="showBatchPaste = false">
+      <div class="batch-paste-dialog">
+        <h3 class="dialog-title">{{ lang.ssBatchPasteTitle }}</h3>
+        <div class="dialog-body">
+          <div class="dialog-field">
+            <label class="dialog-label">{{ lang.ssBatchPasteVocab }}</label>
+            <textarea v-model="batchVocab" class="dialog-textarea" rows="4"></textarea>
+          </div>
+          <div class="dialog-field">
+            <label class="dialog-label">{{ lang.ssBatchPasteSentences }}</label>
+            <textarea v-model="batchSentences" class="dialog-textarea" rows="4"></textarea>
+          </div>
+        </div>
+        <div class="dialog-footer">
+          <button class="btn-cancel" @click="showBatchPaste = false">{{ lang.ssCancel }}</button>
+          <button class="btn-confirm" @click="handleBatchPasteConfirm">{{ lang.ssConfirm }}</button>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { ref, nextTick } from 'vue'
+import { storeToRefs } from 'pinia'
+import { lang } from '@/main'
+import { useSpeakingStore } from '@/store/speaking'
+import { useSlidesStore } from '@/store'
+import useCreateElement from '@/hooks/useCreateElement'
+import { createSpeakingConfig, updateSpeakingConfig } from '@/services/speaking'
+import message from '@/utils/message'
+
+const emit = defineEmits<{
+  (e: 'back'): void
+}>()
+
+const store = useSpeakingStore()
+
+// UI 状态
+const learningGoalsExpanded = ref(true)
+const advancedExpanded = ref(false)
+
+// 词汇新增
+const isAddingVocab = ref(false)
+const newVocab = ref('')
+const vocabInputRef = ref<HTMLInputElement | null>(null)
+
+// 句型新增 / 编辑
+const isAddingSentence = ref(false)
+const newSentence = ref('')
+const sentenceInputRef = ref<HTMLInputElement | null>(null)
+const editingSentenceIndex = ref<number | null>(null)
+const editingSentenceValue = ref('')
+
+// 批量粘贴
+const showBatchPaste = ref(false)
+const batchVocab = ref('')
+const batchSentences = ref('')
+
+// 词汇操作
+const handleAddVocab = () => {
+  if (newVocab.value.trim()) {
+    store.addVocabulary(newVocab.value.trim())
+    newVocab.value = ''
+  }
+}
+
+const handleAddVocabBlur = () => {
+  if (newVocab.value.trim()) {
+    handleAddVocab()
+  }
+  isAddingVocab.value = false
+}
+
+const cancelAddVocab = () => {
+  isAddingVocab.value = false
+  newVocab.value = ''
+}
+
+// 句型操作
+const handleAddSentence = () => {
+  if (newSentence.value.trim()) {
+    store.addSentence(newSentence.value.trim())
+    newSentence.value = ''
+  }
+}
+
+const handleAddSentenceBlur = () => {
+  if (newSentence.value.trim()) {
+    handleAddSentence()
+  }
+  isAddingSentence.value = false
+}
+
+const cancelAddSentence = () => {
+  isAddingSentence.value = false
+  newSentence.value = ''
+}
+
+const handleEditSentence = (index: number) => {
+  editingSentenceIndex.value = index
+  editingSentenceValue.value = store.config.learningGoals.sentences[index]
+}
+
+const handleSaveEditSentence = () => {
+  if (editingSentenceIndex.value !== null && editingSentenceValue.value.trim()) {
+    store.updateSentence(editingSentenceIndex.value, editingSentenceValue.value.trim())
+    editingSentenceIndex.value = null
+    editingSentenceValue.value = ''
+  }
+}
+
+const handleSaveEditSentenceBlur = () => {
+  if (editingSentenceValue.value.trim()) {
+    handleSaveEditSentence()
+  } else {
+    cancelEditSentence()
+  }
+}
+
+const cancelEditSentence = () => {
+  editingSentenceIndex.value = null
+  editingSentenceValue.value = ''
+}
+
+// 批量粘贴
+const handleBatchPasteConfirm = () => {
+  const vocabItems = batchVocab.value.split('\n').map(s => s.trim()).filter(Boolean)
+  const sentenceItems = batchSentences.value.split('\n').map(s => s.trim()).filter(Boolean)
+  vocabItems.forEach(item => store.addVocabulary(item))
+  sentenceItems.forEach(item => store.addSentence(item))
+  batchVocab.value = ''
+  batchSentences.value = ''
+  showBatchPaste.value = false
+}
+
+// 应用配置 → 更新画布上已有 77 型 frame 关联的配置
+// 新交互流程下,元素由 Layer2 点卡片即创建;此处"应用"只负责 PUT 更新到后端
+const { createFrameElement } = useCreateElement()
+const { currentSlide } = storeToRefs(useSlidesStore())
+const applying = ref(false)
+
+const handleApply = async () => {
+  if (applying.value) return
+  applying.value = true
+  try {
+    const existing = currentSlide.value?.elements?.find(
+      (el: any) => el.type === 'frame' && Number(el.toolType) === 77
+    ) as { url?: string } | undefined
+
+    if (existing?.url) {
+      await updateSpeakingConfig(existing.url, store.config)
+      message.success('配置已更新')
+    } else {
+      // 兜底:画布上没有 77 型 frame(用户删了或从未创建),此时先 POST + 插入
+      const { id } = await createSpeakingConfig(store.config)
+      createFrameElement(id, 77)
+      message.success('已创建口语工具')
+    }
+  } catch (err: any) {
+    console.error('[speaking] apply failed:', err)
+    message.error(err?.message || '保存配置失败')
+  } finally {
+    applying.value = false
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.topic-discussion-config {
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+}
+
+.config-scroll {
+  flex: 1;
+  overflow-y: auto;
+  padding: 16px;
+}
+
+// 配置卡片
+.config-card {
+  background: #fff;
+  border: 1px solid #e5e7eb;
+  border-radius: 12px;
+  padding: 16px;
+}
+
+.config-divider {
+  height: 1px;
+  background: #f3f4f6;
+  margin: 16px 0;
+}
+
+.section-spacer {
+  height: 1px;
+  background: #f3f4f6;
+  margin: 16px 0;
+}
+
+.bottom-spacer {
+  height: 24px;
+}
+
+// 表单元素
+.form-group {
+  margin-bottom: 12px;
+
+  &:last-child {
+    margin-bottom: 0;
+  }
+}
+
+.form-label {
+  display: block;
+  font-size: 12px;
+  font-weight: 600;
+  color: #374151;
+  margin-bottom: 6px;
+}
+
+.form-input {
+  width: 100%;
+  height: 36px;
+  padding: 0 12px;
+  border: 1px solid #e5e7eb;
+  border-radius: 8px;
+  font-size: 13px;
+  color: #374151;
+  background: #fff;
+  transition: all 0.2s;
+  box-sizing: border-box;
+
+  &:focus {
+    outline: none;
+    border-color: #f97316;
+  }
+
+  &::placeholder {
+    color: #9ca3af;
+  }
+}
+
+// 折叠切换
+.section-toggle {
+  width: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 4px 0;
+  background: none;
+  border: none;
+  cursor: pointer;
+  transition: color 0.2s;
+
+  &:hover {
+    .section-toggle-label {
+      color: #111827;
+    }
+  }
+}
+
+.section-toggle-label {
+  font-size: 12px;
+  font-weight: 600;
+  color: #374151;
+}
+
+.section-toggle-arrow {
+  color: #9ca3af;
+  transition: transform 0.2s;
+
+  &.collapsed {
+    transform: rotate(-90deg);
+  }
+}
+
+// 学习目标
+.learning-goals-body {
+  margin-top: 12px;
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+}
+
+.sub-section {
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+}
+
+.sub-section-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+}
+
+.sub-label {
+  font-size: 12px;
+  color: #6b7280;
+}
+
+.sub-actions {
+  display: flex;
+  gap: 6px;
+}
+
+.icon-btn {
+  width: 24px;
+  height: 24px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  border: 1px dashed #d1d5db;
+  border-radius: 4px;
+  background: none;
+  color: #9ca3af;
+  cursor: pointer;
+  transition: all 0.2s;
+
+  &:hover {
+    border-color: #f97316;
+    color: #f97316;
+    background: #fff7ed;
+  }
+}
+
+// 词汇标签
+.vocab-tags {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 6px;
+}
+
+.vocab-tag {
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
+  padding: 4px 10px;
+  background: #f9fafb;
+  border-radius: 6px;
+  font-size: 12px;
+  color: #374151;
+  transition: background 0.2s;
+
+  &:hover {
+    background: #f3f4f6;
+
+    .tag-remove {
+      opacity: 1;
+    }
+  }
+}
+
+.tag-remove {
+  display: flex;
+  background: none;
+  border: none;
+  padding: 0;
+  color: #9ca3af;
+  cursor: pointer;
+  opacity: 0;
+  transition: all 0.2s;
+
+  &:hover {
+    color: #6b7280;
+  }
+}
+
+.vocab-inline-input {
+  padding: 4px 10px;
+  border: 1px solid #d1d5db;
+  border-radius: 6px;
+  font-size: 12px;
+  color: #374151;
+  width: 96px;
+
+  &:focus {
+    outline: none;
+    border-color: #9ca3af;
+  }
+}
+
+// 句型列表
+.sentence-list {
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+}
+
+.sentence-item {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 6px 10px;
+  background: #f9fafb;
+  border-radius: 8px;
+  cursor: pointer;
+  transition: background 0.2s;
+
+  &:hover {
+    background: #f3f4f6;
+
+    .sentence-remove {
+      opacity: 1;
+    }
+  }
+}
+
+.sentence-text {
+  font-size: 12px;
+  color: #374151;
+}
+
+.sentence-remove {
+  display: flex;
+  background: none;
+  border: none;
+  padding: 0;
+  color: #9ca3af;
+  cursor: pointer;
+  opacity: 0;
+  transition: all 0.2s;
+
+  &:hover {
+    color: #6b7280;
+  }
+}
+
+.sentence-edit-input {
+  width: 100%;
+  padding: 6px 10px;
+  border: 1px solid #d1d5db;
+  border-radius: 8px;
+  font-size: 12px;
+  color: #374151;
+  box-sizing: border-box;
+
+  &:focus {
+    outline: none;
+    border-color: #9ca3af;
+  }
+}
+
+// 练习方式 Tab
+.mode-tabs {
+  display: flex;
+  gap: 8px;
+}
+
+.mode-tab {
+  flex: 1;
+  height: 32px;
+  border-radius: 8px;
+  border: none;
+  font-size: 13px;
+  font-weight: 500;
+  cursor: pointer;
+  transition: all 0.2s;
+  background: #f9fafb;
+  color: #4b5563;
+
+  &:hover:not(.active) {
+    background: #f3f4f6;
+  }
+
+  &.active {
+    background: #f97316;
+    color: #fff;
+  }
+}
+
+// 滑块
+.slider-group {
+  margin-top: 4px;
+}
+
+.config-slider {
+  width: 100%;
+  height: 6px;
+  border-radius: 3px;
+  appearance: none;
+  background: #e5e7eb;
+  cursor: pointer;
+  accent-color: #f97316;
+
+  &::-webkit-slider-thumb {
+    appearance: none;
+    width: 16px;
+    height: 16px;
+    border-radius: 50%;
+    background: #f97316;
+    cursor: pointer;
+    border: 2px solid #fff;
+    box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
+  }
+}
+
+.slider-labels {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-top: 6px;
+  font-size: 12px;
+}
+
+.slider-min, .slider-max {
+  color: #9ca3af;
+}
+
+.slider-value {
+  font-weight: 500;
+  color: #374151;
+}
+
+// Toggle 开关
+.toggle-row {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-top: 12px;
+}
+
+.toggle-label {
+  font-size: 12px;
+  color: #4b5563;
+}
+
+.toggle-switch {
+  position: relative;
+  width: 44px;
+  height: 24px;
+  border-radius: 12px;
+  border: none;
+  cursor: pointer;
+  transition: background 0.2s;
+  background: #d1d5db;
+
+  &.on {
+    background: #f97316;
+
+    .toggle-knob {
+      left: 22px;
+    }
+  }
+}
+
+.toggle-knob {
+  position: absolute;
+  top: 4px;
+  left: 4px;
+  width: 16px;
+  height: 16px;
+  border-radius: 50%;
+  background: #fff;
+  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
+  transition: left 0.2s;
+}
+
+// 高级配置
+.advanced-section {
+  // 外层无需额外样式
+}
+
+.advanced-body {
+  margin-top: 12px;
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+}
+
+.sub-card {
+  padding: 12px;
+}
+
+.sub-card-title {
+  font-size: 12px;
+  font-weight: 600;
+  color: #374151;
+  margin-bottom: 10px;
+}
+
+.checkbox-group {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 12px 16px;
+}
+
+.checkbox-item {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  cursor: pointer;
+  font-size: 12px;
+  color: #4b5563;
+
+  input[type="checkbox"] {
+    width: 14px;
+    height: 14px;
+    accent-color: #f97316;
+  }
+}
+
+.eval-group {
+  margin-top: 10px;
+}
+
+.radio-group {
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+  margin-top: 4px;
+}
+
+.radio-item {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  cursor: pointer;
+  font-size: 12px;
+  color: #374151;
+  padding: 2px 0;
+  user-select: none;
+}
+
+.radio-circle {
+  width: 16px;
+  height: 16px;
+  border-radius: 50%;
+  border: 2px solid #d1d5db;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  transition: border-color 0.2s;
+
+  &.active {
+    border-color: #f97316;
+  }
+}
+
+.radio-dot {
+  width: 8px;
+  height: 8px;
+  border-radius: 50%;
+  background: #f97316;
+}
+
+// 底部操作栏
+.config-footer {
+  border-top: 1px solid #f3f4f6;
+  padding: 12px 16px;
+  background: #fff;
+  flex-shrink: 0;
+}
+
+.btn-apply {
+  width: 100%;
+  height: 36px;
+  border: none;
+  border-radius: 8px;
+  background: #f97316;
+  color: #fff;
+  font-size: 13px;
+  font-weight: 500;
+  cursor: pointer;
+  transition: background 0.2s;
+
+  &:hover {
+    background: #ea580c;
+  }
+}
+
+// 批量粘贴弹窗
+.batch-paste-overlay {
+  position: fixed;
+  inset: 0;
+  background: rgba(0, 0, 0, 0.3);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  z-index: 1000;
+}
+
+.batch-paste-dialog {
+  width: 360px;
+  background: #fff;
+  border-radius: 16px;
+  box-shadow: 0 20px 40px rgba(0, 0, 0, 0.15);
+  overflow: hidden;
+}
+
+.dialog-title {
+  font-size: 15px;
+  font-weight: 600;
+  color: #111827;
+  padding: 16px 20px;
+  border-bottom: 1px solid #f3f4f6;
+  margin: 0;
+}
+
+.dialog-body {
+  padding: 16px 20px;
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+}
+
+.dialog-field {
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+}
+
+.dialog-label {
+  font-size: 12px;
+  font-weight: 500;
+  color: #374151;
+}
+
+.dialog-textarea {
+  width: 100%;
+  padding: 8px 12px;
+  border: 1px solid #e5e7eb;
+  border-radius: 8px;
+  font-size: 13px;
+  color: #374151;
+  resize: vertical;
+  font-family: inherit;
+  box-sizing: border-box;
+
+  &:focus {
+    outline: none;
+    border-color: #f97316;
+  }
+}
+
+.dialog-footer {
+  display: flex;
+  justify-content: flex-end;
+  gap: 8px;
+  padding: 12px 20px;
+  border-top: 1px solid #f3f4f6;
+}
+
+.btn-cancel {
+  padding: 6px 16px;
+  border: 1px solid #e5e7eb;
+  border-radius: 8px;
+  background: #fff;
+  font-size: 13px;
+  color: #4b5563;
+  cursor: pointer;
+  transition: all 0.2s;
+
+  &:hover {
+    background: #f9fafb;
+  }
+}
+
+.btn-confirm {
+  padding: 6px 16px;
+  border: none;
+  border-radius: 8px;
+  background: #f97316;
+  font-size: 13px;
+  color: #fff;
+  cursor: pointer;
+  transition: all 0.2s;
+
+  &:hover {
+    background: #ea580c;
+  }
+}
+</style>

+ 16 - 0
src/views/Editor/EnglishSpeaking/data/articleReadingTasks.json

@@ -0,0 +1,16 @@
+{
+  "SHEP-5-上-Unit2": [
+    {
+      "id": "ar-unit2-zoo",
+      "taskType": "article-reading",
+      "titleZh": "动物园的一天",
+      "titleEn": "A Day at the Zoo",
+      "subtitle": "朗读一篇关于动物园游览经历的短文",
+      "difficulty": 2,
+      "count": 1,
+      "unit": "篇",
+      "durationMinutes": 10,
+      "content": "Last Sunday, I went to the zoo with my family. It was a sunny day and we were very excited.\n\nFirst, we saw the pandas. They were eating bamboo. The pandas were black and white, and they looked so cute. \"Look at the baby panda!\" I said. \"It is so small and round.\"\n\nThen, we went to see the monkeys. The monkeys were very funny. They jumped from tree to tree and made loud sounds. One little monkey was eating a banana.\n\nAfter that, we visited the bird house. There were many colorful birds. Some birds could talk! A parrot said \"Hello!\" to me. I was so surprised.\n\nAt the end of the day, we saw the elephants. They were the biggest animals in the zoo. The elephants had long noses and big ears. They used their noses to drink water. It was amazing!\n\nI love going to the zoo. I learned many things about animals."
+    }
+  ]
+}

+ 113 - 0
src/views/Editor/EnglishSpeaking/data/curriculum.json

@@ -0,0 +1,113 @@
+{
+  "textbooks": [
+    {
+      "id": "shep",
+      "name": "沪教版(上海教育出版社)",
+      "grades": [
+        {
+          "id": "grade5-1",
+          "name": "五年级",
+          "semester": "上",
+          "units": [
+            {
+              "id": "SHEP-5-上-Unit1",
+              "number": "Unit 1",
+              "title": "My future",
+              "description": "学习描述职业和未来理想。掌握词汇 want, pilot, teach, cook, taxi driver, job, become 等;能用 \"What do you want to be?\" 询问,用 \"I want to be a pilot.\" 回答",
+              "vocabulary": ["want", "pilot", "teach", "cook", "taxi driver", "job", "become", "singer", "fall", "lifeguard", "save", "(be) good at"],
+              "sentences": ["What do you want to be?", "I want to be a pilot."]
+            },
+            {
+              "id": "SHEP-5-上-Unit2",
+              "number": "Unit 2",
+              "title": "Going to school",
+              "description": "学习描述上学方式和交通工具。掌握核心词汇 by, walk, Ms, underground, take, after, hour(目录 Key words);认读词汇包括 journey, primary school, station, bus stop, by bus, on foot, by bike, by car, get off 等;语音重点:辅音组合 sp(wasp, crisp)",
+              "vocabulary": ["by", "walk", "Ms", "underground", "take", "after", "hour", "journey", "primary school", "station", "bus stop", "by bus", "far from", "on foot", "by bike", "by car", "get off"],
+              "sentences": ["How do you come to school?", "I come to school by bus.", "I come to school on foot.", "Do you live near school?", "I live far from school.", "How long does it take?", "My father goes to work by...", "In my class, ... students come to school on foot."]
+            },
+            {
+              "id": "SHEP-5-上-Unit3",
+              "number": "Unit 3",
+              "title": "My birthday",
+              "description": "学习描述生日和日期。掌握词汇 party, when, begin, bring, thing, favourite, interesting, hat 等;能用 \"When's your birthday?\" 询问,用 \"It's on 26th September.\" 回答",
+              "vocabulary": ["party", "when", "begin", "bring", "thing", "favourite", "interesting", "hat", "have fun"],
+              "sentences": ["When's your birthday?", "It's on 26th September."]
+            },
+            {
+              "id": "SHEP-5-上-Unit4",
+              "number": "Unit 4",
+              "title": "Grandparents",
+              "description": "学习描述与祖父母的日常活动及频率。掌握词汇 usually, often, visit, sometimes, always, never 等;能用 \"What do you usually do with your grandparents?\" 询问,用 \"I usually play chess with my grandpa.\" 回答",
+              "vocabulary": ["usually", "often", "visit", "sometimes", "always", "never", "play sport", "go shopping"],
+              "sentences": ["What do you usually do with your grandparents?", "I usually play chess with my grandpa."]
+            },
+            {
+              "id": "SHEP-5-上-Unit5",
+              "number": "Unit 5",
+              "title": "Friends",
+              "description": "学习描述朋友特点和共同爱好。掌握词汇 clever, same, class, both, heavy, different, word, easy, say, ask, answer 等;能用 \"We both like sport.\" 描述共同爱好",
+              "vocabulary": ["clever", "same", "class", "both", "heavy", "different", "word", "easy", "say", "ask", "answer", "cross", "carry", "bored", "soon", "each other", "make phone calls", "tell a story"],
+              "sentences": ["We both like sport."]
+            },
+            {
+              "id": "SHEP-5-上-Unit6",
+              "number": "Unit 6",
+              "title": "Family life",
+              "description": "学习描述家庭日常生活和家居场所。掌握词汇 living room, bedroom, kitchen, bathroom, their, light, watch, TV, before 等;能用 \"I'm doing my homework.\" 描述正在进行的动作",
+              "vocabulary": ["living room", "bedroom", "kitchen", "bathroom", "their", "light", "watch", "TV", "before", "bedtime", "do homework", "turn off", "watch TV", "model plane", "life"],
+              "sentences": ["I'm doing my homework."]
+            },
+            {
+              "id": "SHEP-5-上-Unit7",
+              "number": "Unit 7",
+              "title": "At the beach",
+              "description": "学习描述海滩活动和现在进行时。掌握词汇 beach, enjoy, collect, sea, letter, put, know, year 等;能用 \"Is Paul collecting shells? Yes, he is.\" 描述他人正在进行的活动",
+              "vocabulary": ["beach", "enjoy", "collect", "sea", "letter", "put", "know", "year", "shell", "sunshine", "on holiday", "have a good time"],
+              "sentences": ["Is Paul collecting shells?", "Yes, he is."]
+            },
+            {
+              "id": "SHEP-5-上-Unit8",
+              "number": "Unit 8",
+              "title": "An outing",
+              "description": "学习描述外出活动和询问他人活动。掌握词汇 map, hill, find, another, lake, key, think 等;能用 \"What are you doing? I'm reading the map.\" 进行问答",
+              "vocabulary": ["map", "hill", "find", "another", "lake", "key", "think", "outing", "diamond", "funny", "hole", "at the top of", "get through"],
+              "sentences": ["What are you doing?", "I'm reading the map."]
+            },
+            {
+              "id": "SHEP-5-上-Unit9",
+              "number": "Unit 9",
+              "title": "Around the city",
+              "description": "学习描述城市方位和问路指路。掌握词汇 post office, quite, along, turn, left, right, between, flower shop, hospital, toy shop 等;能用 \"Walk along Winter Street. Turn left at Spring Street.\" 描述路线",
+              "vocabulary": ["post office", "quite", "along", "turn", "left", "right", "between", "flower shop", "hospital", "toy shop", "road", "get to", "straight"],
+              "sentences": ["Walk along Winter Street.", "Turn left at Spring Street."]
+            },
+            {
+              "id": "SHEP-5-上-Unit10",
+              "number": "Unit 10",
+              "title": "Wind",
+              "description": "学习描述风的特征和副词用法。掌握词汇 blow, gently, softly, strongly, happily, move, slowly, quickly, sound, paper, quiet 等;能用 \"The children are flying their kites happily.\" 描述动作方式",
+              "vocabulary": ["blow", "gently", "softly", "strongly", "happily", "move", "slowly", "quickly", "sound", "paper", "quiet", "windmill", "wind-bell", "cut"],
+              "sentences": ["The children are flying their kites happily."]
+            },
+            {
+              "id": "SHEP-5-上-Unit11",
+              "number": "Unit 11",
+              "title": "Water",
+              "description": "学习描述水的用途和自然现象。掌握词汇 use, clothes, farmer, useful, up, shine, over, tree, ground 等;能用 \"We use water to wash our hands.\" 表达用途",
+              "vocabulary": ["use", "clothes", "farmer", "useful", "up", "shine", "over", "tree", "ground", "tap", "vegetable", "drop", "mountain", "inside", "grow crops", "put out fires"],
+              "sentences": ["We use water to wash our hands."]
+            },
+            {
+              "id": "SHEP-5-上-Unit12",
+              "number": "Unit 12",
+              "title": "Fire",
+              "description": "学习描述火灾安全和情态动词 must/mustn't。掌握词汇 fire, hurt, must, careful, smoke, hate 等;能用 \"We must be careful with fire. We mustn't play with matches.\" 表达规则",
+              "vocabulary": ["fire", "hurt", "must", "careful", "smoke", "hate", "burn", "safety", "match", "heat", "burn down", "(be) careful with", "not at all"],
+              "sentences": ["We must be careful with fire.", "We mustn't play with matches."]
+            }
+          ]
+        }
+      ]
+    }
+  ]
+}

+ 56 - 0
src/views/Editor/EnglishSpeaking/data/topicDiscussionTasks.json

@@ -0,0 +1,56 @@
+{
+  "SHEP-5-上-Unit2": [
+    {
+      "id": "td-unit2-1",
+      "taskType": "topic-discussion",
+      "titleZh": "我的上学方式",
+      "titleEn": "How I get to school",
+      "subtitle": "和 AI 聊聊你每天怎么上学",
+      "difficulty": 1,
+      "rounds": 6,
+      "durationMinutes": 4,
+      "vocabulary": ["on foot", "by bus", "by bike", "by car", "by underground", "bus stop", "near", "far from"],
+      "sentences": ["How do you come to school?", "I come to school by bus.", "Do you live near school?", "I live far from school.", "How long does it take?"],
+      "note": "核心句型:How do you come to school? I come to school by/on..."
+    },
+    {
+      "id": "td-unit2-2",
+      "taskType": "topic-discussion",
+      "titleZh": "比较交通方式",
+      "titleEn": "Comparing ways to travel",
+      "subtitle": "讨论不同交通方式的优缺点",
+      "difficulty": 2,
+      "rounds": 8,
+      "durationMinutes": 5,
+      "vocabulary": ["fast", "slow", "cheap", "expensive", "safe", "convenient", "crowded", "on foot", "by bus", "by underground"],
+      "sentences": ["Which is faster, bus or underground?", "I think... is more convenient.", "Going by bike is good for health."],
+      "note": "对比 on foot, by bus, by bike, by underground 等方式"
+    },
+    {
+      "id": "td-unit2-3",
+      "taskType": "topic-discussion",
+      "titleZh": "家人的出行方式",
+      "titleEn": "How my family gets around",
+      "subtitle": "介绍家人上班上学的交通方式",
+      "difficulty": 2,
+      "rounds": 7,
+      "durationMinutes": 5,
+      "vocabulary": ["goes to work", "takes the bus", "drives", "walk to", "by underground", "far from", "near", "every day"],
+      "sentences": ["My father/mother goes to work by...", "How does your father come to work?", "I go to school on foot.", "We live near the school."],
+      "note": "句型:My father/mother goes to work by... I go to school..."
+    },
+    {
+      "id": "td-unit2-4",
+      "taskType": "topic-discussion",
+      "titleZh": "上学路上的见闻",
+      "titleEn": "Things I see on my way to school",
+      "subtitle": "分享上学路上看到的有趣事物",
+      "difficulty": 3,
+      "rounds": 8,
+      "durationMinutes": 6,
+      "vocabulary": ["bus stop", "traffic lights", "road", "cross", "walk", "along", "turn", "corner", "shop", "people"],
+      "sentences": ["On my way to school, I can see...", "I walk along...", "I turn left/right at...", "It takes me ... minutes to get to school."],
+      "note": "综合运用本单元词汇和句型,培养叙事能力"
+    }
+  ]
+}

+ 63 - 0
src/views/Editor/EnglishSpeaking/layers/Layer1Home.vue

@@ -0,0 +1,63 @@
+<template>
+  <div class="layer1-home">
+    <div class="layer1-content">
+      <!-- 教材/年级/单元联动选择 -->
+      <CurriculumSelector
+        :selectedTextbook="selectedTextbook"
+        :selectedGrade="selectedGrade"
+        :selectedUnit="selectedUnit"
+        @update:selectedTextbook="$emit('update:selectedTextbook', $event)"
+        @update:selectedGrade="$emit('update:selectedGrade', $event)"
+        @update:selectedUnit="$emit('update:selectedUnit', $event)"
+      />
+
+      <!-- 单元信息卡片 -->
+      <UnitInfoCard :unit="currentUnit" />
+
+      <!-- 练习类型网格 -->
+      <ExerciseTypeGrid @select="$emit('selectExerciseType', $event)" />
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { computed } from 'vue'
+import curriculumData from '../data/curriculum.json'
+import type { CurriculumData, ExerciseType } from '@/types/englishSpeaking'
+import CurriculumSelector from '../components/CurriculumSelector.vue'
+import UnitInfoCard from '../components/UnitInfoCard.vue'
+import ExerciseTypeGrid from '../components/ExerciseTypeGrid.vue'
+
+const props = defineProps<{
+  selectedTextbook: string
+  selectedGrade: string
+  selectedUnit: string
+}>()
+
+defineEmits<{
+  (e: 'update:selectedTextbook', value: string): void
+  (e: 'update:selectedGrade', value: string): void
+  (e: 'update:selectedUnit', value: string): void
+  (e: 'selectExerciseType', type: ExerciseType): void
+}>()
+
+const data = curriculumData as CurriculumData
+
+const currentUnit = computed(() => {
+  const tb = data.textbooks.find(t => t.id === props.selectedTextbook)
+  const grade = tb?.grades.find(g => g.id === props.selectedGrade)
+  return grade?.units.find(u => u.id === props.selectedUnit)
+})
+</script>
+
+<style lang="scss" scoped>
+.layer1-home {
+  padding: 16px 20px;
+}
+
+.layer1-content {
+  display: flex;
+  flex-direction: column;
+  gap: 20px;
+}
+</style>

+ 388 - 0
src/views/Editor/EnglishSpeaking/layers/Layer2Speaking.vue

@@ -0,0 +1,388 @@
+<template>
+  <div class="layer2-speaking">
+    <!-- 创建方式切换 -->
+    <CreationModeSwitch v-model="creationMode" />
+
+    <!-- AI 生成模式 -->
+    <AIGenerationForm v-if="creationMode === 'ai'" />
+
+    <!-- 手动创建模式 —— 直接进入配置页 -->
+    <div v-else-if="creationMode === 'manual'" class="manual-create">
+      <div class="manual-hint">
+        <p class="manual-text">选择一种口语任务类型开始手动创建</p>
+      </div>
+      <div class="manual-task-list">
+        <button
+          v-for="taskType in manualTaskTypes"
+          :key="taskType.id"
+          class="manual-task-item"
+          @click="handleManualCreate(taskType.id)"
+        >
+          <div class="task-item-icon" v-html="taskType.icon"></div>
+          <div class="task-item-info">
+            <span class="task-item-name">{{ taskType.name }}</span>
+            <span class="task-item-category">{{ taskType.category }}</span>
+          </div>
+          <svg class="task-item-arrow" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+            <path d="M9 18l6-6-6-6" />
+          </svg>
+        </button>
+      </div>
+    </div>
+
+    <!-- 智能推荐模式 -->
+    <template v-else>
+      <!-- 任务类型筛选 -->
+      <div class="filter-bar">
+        <div class="filter-left">
+          <span class="filter-label">任务类型</span>
+          <div class="filter-select-wrap">
+            <select v-model="selectedTaskType" class="filter-select">
+              <option v-for="opt in taskTypeOptions" :key="opt.id" :value="opt.id">
+                {{ opt.name }}
+              </option>
+            </select>
+            <svg class="select-arrow" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+              <path d="M6 9l6 6 6-6" />
+            </svg>
+          </div>
+        </div>
+      </div>
+
+      <!-- 推荐卡片列表 -->
+      <div v-if="filteredTasks.length > 0" class="card-grid">
+        <RecommendCard
+          v-for="task in filteredTasks"
+          :key="task.id"
+          :task="task"
+          @select="handleSelectTask(task)"
+        />
+      </div>
+
+      <!-- 空状态 -->
+      <div v-else class="empty-state">
+        <svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
+          <path d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2" />
+          <rect x="9" y="3" width="6" height="4" rx="1" />
+          <line x1="9" y1="12" x2="15" y2="12" />
+          <line x1="9" y1="16" x2="13" y2="16" />
+        </svg>
+        <p class="empty-text">{{ lang.ssNoRecommendations }}</p>
+      </div>
+    </template>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { ref, computed } from 'vue'
+import { lang } from '@/main'
+import type { CreationMode, SpeakingRecommendationTask, TopicDiscussionTask } from '@/types/englishSpeaking'
+import type { ArticleReadingTask } from '@/types/articleReading'
+import { ARTICLE_READING_TOOL_TYPE, TOPIC_DISCUSSION_TOOL_TYPE } from '@/configs/englishSpeakingTools'
+import { filterSpeakingTasks, type SpeakingTaskFilter } from '../preview/articleReadingModel'
+import { useSpeakingStore } from '@/store/speaking'
+import { useArticleReadingStore } from '@/store/articleReading'
+import useCreateElement from '@/hooks/useCreateElement'
+import useSlideHandler from '@/hooks/useSlideHandler'
+import { createSpeakingConfig } from '@/services/speaking'
+import { createArticleConfig } from '../services/articleReading'
+import { friendlyArticleError } from '../services/articleErrors'
+import message from '@/utils/message'
+import topicTasksData from '../data/topicDiscussionTasks.json'
+import articleTasksData from '../data/articleReadingTasks.json'
+import CreationModeSwitch from '../components/CreationModeSwitch.vue'
+import RecommendCard from '../components/RecommendCard.vue'
+import AIGenerationForm from '../components/AIGenerationForm.vue'
+
+const props = defineProps<{
+  unitId: string
+}>()
+
+const emit = defineEmits<{
+  (e: 'openConfig'): void
+}>()
+
+const speakingStore = useSpeakingStore()
+const articleStore = useArticleReadingStore()
+
+const creationMode = ref<CreationMode>('smart')
+const selectedTaskType = ref<SpeakingTaskFilter>('all')
+
+// 任务类型筛选选项
+const taskTypeOptions = [
+  { id: 'all', name: lang.ssAll as string },
+  { id: 'topic-discussion', name: lang.ssTopicDiscussion as string },
+  { id: 'article-reading', name: lang.ssArticleReading as string },
+]
+
+// 手动创建的任务类型列表
+const manualTaskTypes = [
+  {
+    id: 'topic-discussion',
+    name: lang.ssTopicDiscussion as string,
+    category: lang.ssFreeDialogue as string,
+    icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg>',
+  },
+  {
+    id: 'article-reading',
+    name: lang.ssArticleReading as string,
+    category: lang.ssArticleReadAloud as string,
+    icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 19.5A2.5 2.5 0 016.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 014 19.5v-15A2.5 2.5 0 016.5 2z"/></svg>',
+  },
+]
+
+// 根据 unitId 获取推荐任务(话题讨论 + 文章朗读,靠 taskType 判别,不互相伪装)
+const unitTasks = computed<SpeakingRecommendationTask[]>(() => {
+  const topics = topicTasksData as Record<string, TopicDiscussionTask[]>
+  const articles = articleTasksData as Record<string, ArticleReadingTask[]>
+  return [...(topics[props.unitId] || []), ...(articles[props.unitId] || [])]
+})
+
+// 按任务类型筛选
+const filteredTasks = computed(() => filterSpeakingTasks(unitTasks.value, selectedTaskType.value))
+
+// 新交互:点卡片即新建一页 + 创建配置 + 插入画布元素;配置页改由"点击画布里的 77 型元素"唤起
+const { createFrameElement } = useCreateElement()
+const { createSlide } = useSlideHandler()
+const inserting = ref(false)
+
+async function insertSpeakingToolToCanvas(source: 'select' | 'manual') {
+  if (inserting.value) return
+  inserting.value = true
+  try {
+    const { id } = await createSpeakingConfig(speakingStore.config)
+    createSlide()
+    createFrameElement(id, TOPIC_DISCUSSION_TOOL_TYPE)
+    // 创建后立即唤起左侧配置面板(与点击画布 77 型 frame 同款信号),使用户可直接编辑
+    speakingStore.openConfigPanel()
+    message.success(source === 'select' ? '已添加口语练习到幻灯片' : '已添加空白口语工具,点击画布上的元素进行配置')
+  } catch (err: any) {
+    console.error('[speaking] create failed:', err)
+    message.error(err?.message || '创建口语工具失败')
+  } finally {
+    inserting.value = false
+  }
+}
+
+// 文章朗读走同一把 inserting 门闩:POST 成功后才建页与元素,失败不留孤立页面。
+// 用文章自己的建立接口而不是共用的 createSpeakingConfig:后端在那里同步把整篇示范
+// 音频合成好,全部成功才写入。所以「选推荐卡片」这条路(正文一出生就是满的)建完
+// 就已经烤热,学生点示范时是 S3 直接命中。这一步因此可能要几秒 —— inserting 门闩
+// 本来就在挡重复点击,按钮的 loading 态也照旧生效。
+async function insertArticleToolToCanvas(source: 'select' | 'manual') {
+  if (inserting.value) return
+  inserting.value = true
+  // 选推荐卡片这条路正文一出生就是满的,后端会当场把整篇示范音频合成好 —— 几秒。
+  // 这里的卡片点下去没有任何自身的 loading 态,不挂一条常驻提示,老师看到的就是
+  // 「点了没反应」,然后再点一次(inserting 门闩会吃掉,但他不知道)。
+  // 手动创建那条路正文是空的,后端立刻返回,提示一闪而过,无妨。
+  const pending = message.info(lang.ssArticleGeneratingDemo as string, { duration: 0 })
+  try {
+    const { id } = await createArticleConfig(articleStore.config)
+    createSlide()
+    createFrameElement(id, ARTICLE_READING_TOOL_TYPE)
+    articleStore.openConfigPanel(id)
+    message.success(source === 'select' ? lang.ssArticleTemplateCreated : lang.ssArticleManualCreated)
+  } catch (err: unknown) {
+    console.error('[article-reading] create failed:', err)
+    // 经 friendlyArticleError:合成失败时后端给的是 `Demo audio synthesis failed: [2]`,
+    // 原样甩出去老师看不懂,也不会知道配置根本没保存
+    message.error(friendlyArticleError(err))
+  } finally {
+    pending.close()
+    inserting.value = false
+  }
+}
+
+// 选择推荐卡片 → 预填充 store → 立即创建配置 + 插入元素
+const handleSelectTask = (task: SpeakingRecommendationTask) => {
+  if (task.taskType === 'article-reading') {
+    articleStore.prefillFromTask(task, speakingStore.config.grade)
+    void insertArticleToolToCanvas('select')
+    return
+  }
+  speakingStore.prefillFromTask(task.titleEn, task.vocabulary, task.sentences)
+  void insertSpeakingToolToCanvas('select')
+}
+
+// 手动创建 → 清空 store → 立即创建配置 + 插入元素
+const handleManualCreate = (taskTypeId: string) => {
+  if (taskTypeId === 'article-reading') {
+    articleStore.resetConfigEmpty(speakingStore.config.grade)
+    void insertArticleToolToCanvas('manual')
+    return
+  }
+  speakingStore.resetConfigEmpty()
+  void insertSpeakingToolToCanvas('manual')
+}
+</script>
+
+<style lang="scss" scoped>
+.layer2-speaking {
+  padding: 16px 20px;
+  display: flex;
+  flex-direction: column;
+  gap: 16px;
+}
+
+// 筛选栏
+.filter-bar {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 12px;
+}
+
+.filter-left {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  flex: 1;
+}
+
+.filter-label {
+  font-size: 12px;
+  font-weight: 500;
+  color: #6b7280;
+  white-space: nowrap;
+}
+
+.filter-select-wrap {
+  position: relative;
+  flex: 1;
+}
+
+.filter-select {
+  width: 100%;
+  appearance: none;
+  padding: 6px 28px 6px 12px;
+  border: 1px solid rgba(0, 0, 0, 0.08);
+  border-radius: 12px;
+  background: #f9fafb;
+  font-size: 13px;
+  color: #374151;
+  cursor: pointer;
+  transition: all 0.2s;
+
+  &:focus {
+    outline: none;
+    border-color: #f97316;
+    background: #fff;
+    box-shadow: 0 0 0 2px rgba(249, 115, 22, 0.1);
+  }
+}
+
+.select-arrow {
+  position: absolute;
+  right: 10px;
+  top: 50%;
+  transform: translateY(-50%);
+  color: #9ca3af;
+  pointer-events: none;
+}
+
+// 推荐卡片网格
+.card-grid {
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  gap: 10px;
+}
+
+// 空状态
+.empty-state {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 8px;
+  padding: 32px 16px;
+  color: #d1d5db;
+}
+
+.empty-text {
+  font-size: 13px;
+  color: #9ca3af;
+  margin: 0;
+}
+
+// 手动创建
+.manual-create {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+}
+
+.manual-hint {
+  padding: 0;
+}
+
+.manual-text {
+  font-size: 13px;
+  color: #6b7280;
+  margin: 0;
+}
+
+.manual-task-list {
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+}
+
+.manual-task-item {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  padding: 12px 14px;
+  background: #fff;
+  border: 1px solid rgba(0, 0, 0, 0.06);
+  border-radius: 14px;
+  cursor: pointer;
+  transition: all 0.2s;
+  text-align: left;
+
+  &:hover {
+    border-color: rgba(0, 0, 0, 0.12);
+    box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
+
+    .task-item-arrow {
+      color: #f97316;
+    }
+  }
+}
+
+.task-item-icon {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 32px;
+  height: 32px;
+  border-radius: 10px;
+  background: linear-gradient(135deg, #fff7ed, #fff);
+  border: 1px solid rgba(249, 115, 22, 0.15);
+  color: #f97316;
+  flex-shrink: 0;
+}
+
+.task-item-info {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  gap: 2px;
+}
+
+.task-item-name {
+  font-size: 13px;
+  font-weight: 500;
+  color: #111827;
+}
+
+.task-item-category {
+  font-size: 11px;
+  color: #9ca3af;
+}
+
+.task-item-arrow {
+  color: #d1d5db;
+  flex-shrink: 0;
+  transition: color 0.2s;
+}
+</style>

+ 186 - 0
src/views/Editor/EnglishSpeaking/preview/ArticleDetailedReport.vue

@@ -0,0 +1,186 @@
+<template>
+  <div class="article-report">
+    <div class="article-report__scroll">
+      <!-- 1. 整篇总评(还原 enspeak OverallReport scoreLayout="compactStars") -->
+      <ArticleOverallReport
+        :report="report"
+        :show-restart="showRestart"
+        @restart="emit('restart')"
+      />
+
+      <!-- 2. 逐段详情 -->
+      <div class="report-paragraphs">
+        <div class="report-paragraphs__head">
+          <span class="report-paragraphs__title">{{ lang.ssArticleParagraphsTitle }}</span>
+          <button
+            v-if="recordedIdxs.length"
+            class="report-paragraphs__toggle"
+            @click="toggleAll"
+          >
+            {{ allExpanded ? lang.ssArticleCollapseAll : lang.ssArticleExpandAll }}
+          </button>
+        </div>
+
+        <div
+          v-for="(paragraph, index) in sortedParagraphs"
+          :key="paragraph.idx"
+          class="paragraph-block"
+        >
+          <!-- 段落分隔线 -->
+          <div v-if="index > 0" class="paragraph-divider">
+            <span class="paragraph-divider__line"></span>
+            <span class="paragraph-divider__label">{{ paragraphLabel(index) }}</span>
+            <span class="paragraph-divider__line"></span>
+          </div>
+
+          <div class="paragraph-shell">
+            <ArticleParagraphCard
+              :paragraph="paragraph"
+              :expanded="expandedIdxs.has(paragraph.idx)"
+              :demo-audio-loading="loadingId === `demo-${paragraph.idx}`"
+              :demo-audio-playing="playingId === `demo-${paragraph.idx}`"
+              :own-audio-loading="loadingId === `own-${paragraph.idx}`"
+              :own-audio-playing="playingId === `own-${paragraph.idx}`"
+              :own-audio-available="Boolean(paragraph.audioUrl)"
+              @toggle="toggleOne(paragraph.idx)"
+              @play-demo="emit('play-demo', paragraph.idx)"
+              @play-own="emit('play-own', paragraph.idx)"
+            />
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { computed, ref, watch } from 'vue'
+import { lang } from '@/main'
+import ArticleOverallReport from './ArticleOverallReport.vue'
+import ArticleParagraphCard from './ArticleParagraphCard.vue'
+import type { ArticleReadingReport } from '@/types/articleReading'
+
+const props = withDefaults(defineProps<{
+  report: ArticleReadingReport
+  playingId: string | null
+  loadingId: string | null
+  /** 学生自己的报告页给「再来一次」;老师端 StudentReportModal 不给。 */
+  showRestart?: boolean
+}>(), { showRestart: false })
+
+const emit = defineEmits<{
+  (event: 'play-demo', paragraphIdx: number): void
+  (event: 'play-own', paragraphIdx: number): void
+  (event: 'restart'): void
+}>()
+
+const sortedParagraphs = computed(() => [...props.report.paragraphs].sort((a, b) => a.idx - b.idx))
+
+// 只有已出分的段落有评价区可展开(原型同款)
+const recordedIdxs = computed(() =>
+  sortedParagraphs.value.filter(paragraph => paragraph.status === 'completed').map(p => p.idx),
+)
+
+const expandedIdxs = ref<Set<number>>(new Set())
+
+// 默认展开全部已录段落;换一份报告(老师逐个学生看)时重新初始化
+watch(
+  () => `${props.report.sessionId}|${recordedIdxs.value.join(',')}`,
+  () => {
+    expandedIdxs.value = new Set(recordedIdxs.value)
+  },
+  { immediate: true },
+)
+
+const allExpanded = computed(() =>
+  recordedIdxs.value.length > 0 && recordedIdxs.value.every(idx => expandedIdxs.value.has(idx)),
+)
+
+function toggleAll() {
+  expandedIdxs.value = allExpanded.value ? new Set() : new Set(recordedIdxs.value)
+}
+
+function toggleOne(idx: number) {
+  const next = new Set(expandedIdxs.value)
+  if (next.has(idx)) next.delete(idx)
+  else next.add(idx)
+  expandedIdxs.value = next
+}
+
+function paragraphLabel(index: number): string {
+  return String(lang.ssArticleParagraphIndex).replace('{n}', String(index + 1))
+}
+</script>
+
+<style lang="scss" scoped>
+.article-report {
+  width: 100%;
+  height: 100%;
+  background: #fff;
+  overflow: hidden;
+}
+
+.article-report__scroll {
+  height: 100%;
+  overflow-y: auto;
+  padding: 24px;
+  max-width: 720px;
+  margin: 0 auto;
+  display: flex;
+  flex-direction: column;
+  gap: 16px;
+}
+
+.report-paragraphs__head {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 8px;
+  margin-bottom: 8px;
+}
+
+.report-paragraphs__title {
+  font-size: 12px;
+  font-weight: 500;
+  color: #6b7280;
+}
+
+.report-paragraphs__toggle {
+  border: none;
+  background: transparent;
+  padding: 0;
+  font-size: 10px;
+  color: #f97316;
+  cursor: pointer;
+  transition: color 0.2s;
+
+  &:hover { color: #ea580c; }
+}
+
+// 段落之间的「第 N 段」分隔线
+.paragraph-divider {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  margin: 12px 0;
+}
+
+.paragraph-divider__line {
+  flex: 1;
+  height: 1px;
+  background: #e5e7eb;
+}
+
+.paragraph-divider__label {
+  font-size: 9px;
+  color: #9ca3af;
+  white-space: nowrap;
+}
+
+.paragraph-shell {
+  padding: 16px;
+  border: 1px solid #e5e7eb;
+  border-radius: 12px;
+  background: #fff;
+}
+</style>

+ 357 - 0
src/views/Editor/EnglishSpeaking/preview/ArticleOverallReport.vue

@@ -0,0 +1,357 @@
+<template>
+  <div class="overall-report">
+    <!-- 完成提示(enspeak OverallReport.tsx:178-184) -->
+    <div class="completion-header">
+      <div class="completion-emoji">🎉</div>
+      <h2 class="completion-title">{{ lang.ssArticleReportDone }}</h2>
+      <p class="completion-subtitle">{{ subtitle }}</p>
+    </div>
+
+    <template v-if="overall">
+      <!-- 综合评分:五星紧凑横排 + 两列维度星级(scoreLayout="compactStars") -->
+      <div class="score-card">
+        <div class="score-card__head">
+          <div class="score-card__summary">
+            <span class="score-card__label">{{ lang.ssArticleOverallScore }}</span>
+            <ArticleStarRating :score="overall.overallScore" size="lg" step="half" />
+            <span class="score-card__value">{{ overallStars }} {{ lang.ssArticlePointsUnit }}</span>
+          </div>
+          <span class="score-card__praise">{{ praise }}</span>
+        </div>
+
+        <div class="score-card__dims">
+          <div v-for="dim in dimRows" :key="dim.key" class="score-card__dim">
+            <span class="score-card__dim-label">{{ dim.label }}</span>
+            <ArticleStarRating :score="dim.score" size="sm" step="half" />
+          </div>
+        </div>
+      </div>
+
+      <!-- AI 点评 -->
+      <div class="comment-card">
+        <div class="comment-card__inner">
+          <div class="comment-card__avatar">👨‍🏫</div>
+          <div class="comment-card__body">
+            <div class="comment-card__name">{{ lang.ssArticleTeacherSays }}</div>
+            <p class="comment-card__text">{{ overall.aiComment }}</p>
+          </div>
+        </div>
+      </div>
+
+      <!-- 亮点与建议 -->
+      <div class="notes-grid">
+        <div class="note-card">
+          <div class="note-card__head">
+            <span class="note-card__icon note-card__icon--good">✓</span>
+            {{ lang.ssArticleHighlights }}
+          </div>
+          <div class="note-card__list">
+            <div v-for="(item, index) in overall.highlights.slice(0, 3)" :key="`h-${index}`" class="note-card__item">
+              • {{ item }}
+            </div>
+          </div>
+        </div>
+
+        <div class="note-card">
+          <div class="note-card__head">
+            <span class="note-card__icon note-card__icon--warn">→</span>
+            {{ lang.ssArticleSuggestions }}
+          </div>
+          <div class="note-card__list">
+            <div v-for="(item, index) in overall.suggestions.slice(0, 3)" :key="`s-${index}`" class="note-card__item">
+              • {{ item }}
+            </div>
+          </div>
+        </div>
+      </div>
+    </template>
+
+    <!-- 软失败 / 无总评:不挡住逐段结果(spec §4.1 边界 5) -->
+    <div v-else class="report-notice">
+      {{ report.overallStatus === 'failed' ? lang.ssArticleOverallFailed : lang.ssArticleOverallMissing }}
+    </div>
+
+    <!-- 再来一次(enspeak OverallReport.tsx:301-309;老师端看学生报告时不给这个出口) -->
+    <div v-if="showRestart" class="report-actions">
+      <button class="restart-btn" @click="emit('restart')">
+        <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+          <polyline points="23 4 23 10 17 10" />
+          <path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
+        </svg>
+        {{ lang.ssArticleTryAgain }}
+      </button>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { computed } from 'vue'
+import { lang } from '@/main'
+import ArticleStarRating from './ArticleStarRating.vue'
+import { scoreToStars } from './articleReadingModel'
+import type { ArticleReadingReport } from '@/types/articleReading'
+
+const props = withDefaults(defineProps<{
+  report: ArticleReadingReport
+  /** 学生自己的报告页给「再来一次」;老师端 StudentReportModal 不给。 */
+  showRestart?: boolean
+}>(), { showRestart: false })
+
+const emit = defineEmits<{ restart: [] }>()
+
+const overall = computed(() => props.report.overall)
+
+const overallStars = computed(() => Math.round(scoreToStars(overall.value?.overallScore ?? null) * 2) / 2)
+
+// 星级 → 鼓励文案(enspeak scoreToPraise)
+const praise = computed(() => {
+  const stars = overallStars.value
+  if (stars >= 5) return lang.ssArticlePraiseExcellent as string
+  if (stars >= 4) return lang.ssArticlePraiseGreat as string
+  if (stars >= 3) return lang.ssArticlePraiseGood as string
+  return lang.ssArticlePraiseKeepGoing as string
+})
+
+const dimRows = computed(() => {
+  const dims = overall.value?.dims
+  return [
+    { key: 'accuracy', label: lang.ssAccuracy as string, score: dims?.accuracy ?? null },
+    { key: 'fluency', label: lang.ssFluency as string, score: dims?.fluency ?? null },
+    { key: 'completeness', label: lang.ssCompleteness as string, score: dims?.completeness ?? null },
+    { key: 'prosody', label: lang.ssRhythm as string, score: dims?.prosody ?? null },
+  ]
+})
+
+const readCount = computed(() =>
+  props.report.paragraphs.filter(paragraph => paragraph.status === 'completed').length,
+)
+
+// 原型副标题是「与 X 对话 N 轮 · 时长」,朗读没有轮次,换成同位置的篇名 + 已读段数 + 时长
+const subtitle = computed(() => {
+  const parts: string[] = []
+  if (props.report.title) parts.push(props.report.title)
+  parts.push(`${lang.ssArticleReadCount}:${readCount.value} / ${props.report.paragraphs.length} ${lang.ssArticleParagraphsUnit}`)
+  if (props.report.totalDurationSeconds !== null) {
+    parts.push(`${lang.ssArticleTotalDuration}:${formatDuration(props.report.totalDurationSeconds)}`)
+  }
+  return parts.join(' · ')
+})
+
+function formatDuration(seconds: number): string {
+  const mm = Math.floor(seconds / 60)
+  const ss = seconds % 60
+  return mm > 0 ? `${mm} ${lang.ssMinute} ${ss} ${lang.ssArticleSecondsUnit}` : `${ss} ${lang.ssArticleSecondsUnit}`
+}
+</script>
+
+<style lang="scss" scoped>
+// 原型是 max-w-md mx-auto space-y-3 的窄栏,报告页外层已限宽,这里只保留纵向节奏
+.overall-report {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+}
+
+// ---- 完成提示 ----
+.completion-header {
+  text-align: center;
+}
+
+.completion-emoji {
+  font-size: 30px;
+  line-height: 1.2;
+  margin-bottom: 4px;
+}
+
+.completion-title {
+  margin: 0;
+  font-size: 16px;
+  font-weight: 600;
+  color: #111827;
+}
+
+.completion-subtitle {
+  margin: 2px 0 0;
+  font-size: 12px;
+  line-height: 1.5;
+  color: #6b7280;
+}
+
+// ---- 综合评分卡 ----
+.score-card {
+  border: 1px solid #f3f4f6;
+  border-radius: 12px;
+  background: #fff;
+  overflow: hidden;
+}
+
+.score-card__head {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 12px;
+  padding: 14px 16px;
+  border-bottom: 1px solid #f3f4f6;
+  background: linear-gradient(to right, #fff7ed, #fffdf9);
+}
+
+.score-card__summary {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+}
+
+.score-card__label {
+  font-size: 12px;
+  color: #6b7280;
+}
+
+.score-card__value {
+  font-size: 12px;
+  font-weight: 600;
+  color: #f59e0b;
+  font-variant-numeric: tabular-nums;
+}
+
+.score-card__praise {
+  font-size: 12px;
+  font-weight: 600;
+  color: #f59e0b;
+}
+
+.score-card__dims {
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  column-gap: 16px;
+  row-gap: 10px;
+  padding: 12px 16px;
+}
+
+.score-card__dim {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 8px;
+}
+
+.score-card__dim-label {
+  font-size: 12px;
+  color: #4b5563;
+}
+
+// ---- AI 点评 ----
+.comment-card {
+  padding: 12px;
+  border: 1px solid #f3f4f6;
+  border-radius: 12px;
+  background: #fff;
+}
+
+.comment-card__inner {
+  display: flex;
+  gap: 10px;
+}
+
+.comment-card__avatar {
+  flex-shrink: 0;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 32px;
+  height: 32px;
+  border-radius: 50%;
+  background: #fff7ed;
+  font-size: 16px;
+}
+
+.comment-card__body {
+  flex: 1;
+  min-width: 0;
+}
+
+.comment-card__name {
+  margin-bottom: 4px;
+  font-size: 12px;
+  color: #9ca3af;
+}
+
+.comment-card__text {
+  margin: 0;
+  font-size: 12px;
+  line-height: 1.65;
+  color: #374151;
+}
+
+// ---- 亮点与建议 ----
+.notes-grid {
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  gap: 8px;
+}
+
+.note-card {
+  padding: 10px;
+  border: 1px solid #f3f4f6;
+  border-radius: 12px;
+  background: #fff;
+}
+
+.note-card__head {
+  display: flex;
+  align-items: center;
+  gap: 4px;
+  margin-bottom: 6px;
+  font-size: 12px;
+  color: #6b7280;
+}
+
+.note-card__icon--good { color: #22c55e; }
+.note-card__icon--warn { color: #f59e0b; }
+
+.note-card__list {
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+}
+
+.note-card__item {
+  font-size: 10px;
+  line-height: 1.5;
+  color: #4b5563;
+}
+
+// ---- 软失败提示 ----
+.report-notice {
+  padding: 12px 16px;
+  border: 1px solid #fed7aa;
+  border-radius: 12px;
+  background: #fff7ed;
+  font-size: 12px;
+  color: #c2410c;
+}
+
+// ---- 再来一次 ----
+.report-actions {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding-top: 4px;
+}
+
+.restart-btn {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+  padding: 10px 24px;
+  border: none;
+  border-radius: 8px;
+  background: #f97316;
+  color: #fff;
+  font-size: 12px;
+  font-weight: 500;
+  cursor: pointer;
+  transition: background 0.2s;
+
+  &:hover { background: #ea580c; }
+}
+</style>

+ 409 - 0
src/views/Editor/EnglishSpeaking/preview/ArticleParagraphCard.vue

@@ -0,0 +1,409 @@
+<template>
+  <div class="paragraph-card">
+    <!-- 段落原文 -->
+    <p class="paragraph-card__text" :class="{ 'paragraph-card__text--muted': !isCompleted }">
+      {{ paragraph.text }}
+    </p>
+
+    <!-- 星级 / 状态 + 操作按钮(enspeak ArticleDetailedReport.tsx:108-169) -->
+    <div class="paragraph-card__meta">
+      <div class="paragraph-card__status">
+        <template v-if="isCompleted">
+          <ArticleStarRating :score="paragraph.overallScore" size="md" step="full" />
+          <span class="status-tag status-tag--done">
+            <svg width="12" height="12" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
+              <path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
+            </svg>
+            {{ lang.ssArticleRecordedTag }}
+          </span>
+        </template>
+
+        <span v-else-if="paragraph.status === 'generating'" class="status-tag status-tag--generating">
+          <span class="status-spinner"></span>
+          {{ lang.ssArticleProcessing }}
+        </span>
+
+        <span v-else-if="paragraph.status === 'failed'" class="status-tag status-tag--failed">
+          {{ lang.ssArticleEvalFailed }}
+        </span>
+      </div>
+
+      <div class="paragraph-card__actions" :class="{ 'paragraph-card__actions--locked': !interactive }">
+        <!-- 系统朗读示范:任何状态都可听(后端按 reference_text 合成) -->
+        <button
+          class="act-btn act-btn--demo"
+          :class="{ playing: demoAudioPlaying }"
+          :disabled="demoAudioLoading"
+          :tabindex="interactive ? undefined : -1"
+          :aria-pressed="demoAudioPlaying"
+          :title="lang.ssArticleDemoAudio as string"
+          @click.stop="emit('play-demo')"
+        >
+          <span v-if="demoAudioLoading" class="act-btn__spinner"></span>
+          <svg v-else width="14" height="14" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
+            <path d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zM7 8a1 1 0 012 0v4a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v4a1 1 0 102 0V8a1 1 0 00-1-1z" />
+          </svg>
+          {{ lang.ssArticleDemoAudio }}
+        </button>
+
+        <!-- 我的录音回放:仅已录 -->
+        <button
+          v-if="ownAudioAvailable"
+          class="act-btn act-btn--own"
+          :class="{ playing: ownAudioPlaying }"
+          :disabled="ownAudioLoading"
+          :tabindex="interactive ? undefined : -1"
+          :aria-pressed="ownAudioPlaying"
+          :title="lang.ssArticleMyAudio as string"
+          @click.stop="emit('play-own')"
+        >
+          <span v-if="ownAudioLoading" class="act-btn__spinner"></span>
+          <svg v-else width="14" height="14" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
+            <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clip-rule="evenodd" />
+          </svg>
+          {{ lang.ssArticleMyAudio }}
+        </button>
+
+        <!-- 展开评价:容器负责状态,这里只是开关 -->
+        <button
+          v-if="isCompleted"
+          class="expand-btn"
+          :class="{ open: expanded }"
+          :tabindex="interactive ? undefined : -1"
+          :title="lang.ssArticleViewEval as string"
+          @click.stop="emit('toggle')"
+        >
+          <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+            <path stroke-linecap="round" stroke-linejoin="round" d="M6 9l6 6 6-6" />
+          </svg>
+        </button>
+      </div>
+    </div>
+
+    <!-- 展开的评价区 -->
+    <div v-if="expanded && isCompleted" class="paragraph-card__evaluation">
+      <!-- 段落总评:四维横排五星 -->
+      <div class="evaluation-block">
+        <p class="evaluation-block__title">
+          <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
+            <path stroke-linecap="round" stroke-linejoin="round" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
+          </svg>
+          {{ lang.ssArticleParagraphSummary }}
+        </p>
+        <div class="dims-row">
+          <template v-for="(dim, index) in dimRows" :key="dim.key">
+            <span v-if="index > 0" class="dims-row__sep">|</span>
+            <span class="dims-row__item">
+              <span class="dims-row__label">{{ dim.label }}</span>
+              <ArticleStarRating :score="dim.score" size="sm" step="full" />
+            </span>
+          </template>
+        </div>
+      </div>
+
+      <!-- 整段反馈:一条连续词流,不分句 -->
+      <div class="evaluation-block">
+        <p class="evaluation-block__title">
+          <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
+            <path stroke-linecap="round" stroke-linejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
+          </svg>
+          {{ lang.ssArticleWordFeedback }}
+        </p>
+        <p class="word-stream"><span
+          v-for="(token, index) in words"
+          :key="`${index}-${token.word}`"
+          class="article-word"
+          :class="`article-word--${token.status}`"
+        ><template v-if="token.status === 'omission'">[{{ token.word }}]</template><template v-else>{{ token.word }}</template>{{ ' ' }}</span></p>
+
+        <!-- 词级统计栏:每一项自带该类词的视觉标记 -->
+        <div class="word-legend">
+          <span class="article-word article-word--good">word {{ lang.ssArticleWordCorrect }} {{ counts.good }} {{ lang.ssArticleWordsUnit }}</span>
+          <span class="word-legend__sep">|</span>
+          <span class="article-word article-word--misread">word {{ lang.ssArticleWordMisread }} {{ counts.misread }} {{ lang.ssArticleWordsUnit }}</span>
+          <span class="word-legend__sep">|</span>
+          <span class="article-word article-word--omission">[word] {{ lang.ssArticleWordOmission }} {{ counts.omission }} {{ lang.ssArticleWordsUnit }}</span>
+          <span class="word-legend__sep">|</span>
+          <span class="article-word article-word--insertion">word {{ lang.ssArticleWordInsertion }} {{ counts.insertion }} {{ lang.ssArticleWordsUnit }}</span>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { computed } from 'vue'
+import { lang } from '@/main'
+import ArticleStarRating from './ArticleStarRating.vue'
+import type { ArticleParagraphResult } from '@/types/articleReading'
+
+const props = withDefaults(defineProps<{
+  paragraph: ArticleParagraphResult
+  expanded: boolean
+  demoAudioLoading: boolean
+  demoAudioPlaying: boolean
+  ownAudioLoading: boolean
+  ownAudioPlaying: boolean
+  ownAudioAvailable: boolean
+  /**
+   * 卡内按钮是否接管点击。练习页只有**选中的那一段**是 true —— 其余段落整张卡
+   * 只做一件事:把自己选中。报告页没有「当前段」的概念,全部可点。
+   */
+  interactive?: boolean
+}>(), { interactive: true })
+
+const emit = defineEmits<{
+  (event: 'toggle'): void
+  (event: 'play-demo'): void
+  (event: 'play-own'): void
+}>()
+
+const isCompleted = computed(() => props.paragraph.status === 'completed')
+
+// 后端给的就是段内扁平词级(spec §6),不再有句层可展平。
+const words = computed(() => props.paragraph.words)
+
+/**
+ * 统计栏只数「自带标记」的四类:正确(绿)/ 错读(波浪线)/ 漏读([])/ 多读(删除线)。
+ * warn / bad 在词流里照常着色,但它们没有独立标记,原型的统计栏也不含这两档。
+ */
+const counts = computed(() => {
+  const tally = { good: 0, misread: 0, omission: 0, insertion: 0 }
+  for (const token of words.value) {
+    if (token.status in tally) tally[token.status as keyof typeof tally] += 1
+  }
+  return tally
+})
+
+const dimRows = computed(() => {
+  const dims = props.paragraph.dims
+  return [
+    { key: 'accuracy', label: lang.ssAccuracy as string, score: dims?.accuracy ?? null },
+    { key: 'fluency', label: lang.ssFluency as string, score: dims?.fluency ?? null },
+    { key: 'completeness', label: lang.ssCompleteness as string, score: dims?.completeness ?? null },
+    { key: 'prosody', label: lang.ssRhythm as string, score: dims?.prosody ?? null },
+  ]
+})
+</script>
+
+<style lang="scss" scoped>
+.paragraph-card__text {
+  margin: 0;
+  font-size: 14px;
+  line-height: 1.7;
+  color: #1f2937;
+
+  &--muted { color: #9ca3af; }
+}
+
+.paragraph-card__meta {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 8px;
+  margin-top: 12px;
+  flex-wrap: wrap;
+}
+
+.paragraph-card__status {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
+.paragraph-card__actions {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+
+  // 未选中的段落:这一下点击是「我要练这一段」,别让按钮把它吃掉。
+  // pointer-events 而不是 disabled —— 事件要能冒泡到外层的段落壳去选中,
+  // 而且 disabled 的半透明叠在整卡已有的 opacity 上会糊成一团。
+  &--locked {
+    pointer-events: none;
+  }
+}
+
+// ---- 状态药丸 ----
+.status-tag {
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
+  padding: 3px 8px;
+  border-radius: 999px;
+  font-size: 12px;
+  font-weight: 500;
+  white-space: nowrap;
+
+  &--done {
+    background: #ecfdf5;
+    color: #059669;
+  }
+
+  &--generating {
+    background: #fff7ed;
+    color: #ea580c;
+  }
+
+  &--failed {
+    background: #fef2f2;
+    color: #dc2626;
+  }
+}
+
+// ---- 操作按钮 ----
+.act-btn {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+  padding: 5px 10px;
+  border-radius: 8px;
+  font-size: 10px;
+  font-weight: 500;
+  cursor: pointer;
+  transition: background 0.2s, border-color 0.2s;
+
+  &:disabled {
+    opacity: 0.45;
+    cursor: not-allowed;
+  }
+
+  &--demo {
+    border: 1px solid #fed7aa;
+    background: #fff7ed;
+    color: #ea580c;
+
+    &:hover:not(:disabled) { background: #ffedd5; }
+    &.playing { background: #ffedd5; border-color: #fdba74; }
+  }
+
+  &--own {
+    border: 1px solid #bfdbfe;
+    background: #eff6ff;
+    color: #2563eb;
+
+    &:hover:not(:disabled) { background: #dbeafe; }
+    &.playing { background: #dbeafe; border-color: #93c5fd; }
+  }
+}
+
+.act-btn__spinner,
+.status-spinner {
+  width: 10px;
+  height: 10px;
+  border-radius: 50%;
+  border: 1.5px solid currentColor;
+  border-top-color: transparent;
+  animation: article-card-spin 0.7s linear infinite;
+}
+
+@keyframes article-card-spin {
+  to { transform: rotate(360deg); }
+}
+
+.expand-btn {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  width: 24px;
+  height: 24px;
+  border: none;
+  border-radius: 6px;
+  background: transparent;
+  color: #f97316;
+  cursor: pointer;
+  transition: background 0.2s, transform 0.2s;
+
+  &:hover { background: #ffedd5; }
+  &.open { transform: rotate(180deg); }
+}
+
+// ---- 展开的评价区 ----
+.paragraph-card__evaluation {
+  margin-top: 12px;
+  padding-top: 12px;
+  border-top: 1px solid #e5e7eb;
+  display: flex;
+  flex-direction: column;
+  gap: 16px;
+}
+
+.evaluation-block__title {
+  display: flex;
+  align-items: center;
+  gap: 4px;
+  margin: 0 0 8px;
+  font-size: 12px;
+  font-weight: 500;
+  color: #9ca3af;
+
+  svg {
+    color: #fb923c;
+    opacity: 0.6;
+    flex-shrink: 0;
+  }
+}
+
+// 四维横排,维度之间用浅灰竖线分隔
+.dims-row {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  flex-wrap: wrap;
+  font-size: 11px;
+  color: #6b7280;
+}
+
+.dims-row__item {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+}
+
+.dims-row__sep {
+  color: #e5e7eb;
+}
+
+.word-stream {
+  margin: 0;
+  font-size: 14px;
+  line-height: 1.75;
+  color: #374151;
+}
+
+.article-word {
+  font-weight: 500;
+}
+
+.article-word--good { color: #047857; }
+.article-word--warn { color: #d97706; }
+.article-word--bad { color: #dc2626; }
+
+.article-word--misread {
+  color: #dc2626;
+  text-decoration-line: underline;
+  text-decoration-style: wavy;
+  text-decoration-color: #f87171;
+}
+
+.article-word--omission { color: #dc2626; }
+
+.article-word--insertion {
+  color: #dc2626;
+  text-decoration: line-through;
+  text-decoration-color: #f87171;
+}
+
+.word-legend {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  flex-wrap: wrap;
+  margin-top: 8px;
+  font-size: 11px;
+}
+
+.word-legend__sep {
+  color: #d1d5db;
+}
+</style>

+ 408 - 0
src/views/Editor/EnglishSpeaking/preview/ArticleReadingPreview.vue

@@ -0,0 +1,408 @@
+<template>
+  <div class="article-reading-preview">
+    <!-- resume 探针在途:隐藏起始页,避免刷新闪开始页 -->
+    <div v-if="resuming" class="article-report-loading">
+      <span class="spinner"></span>
+    </div>
+
+    <!-- ready -->
+    <div v-else-if="engine.stage.value === 'ready'" class="article-ready-stage">
+      <h1 class="ready-title">{{ articleStore.config.title || lang.ssArticleReading }}</h1>
+      <p v-if="hasArticle" class="ready-summary">{{ articleSummary }}</p>
+      <p v-else class="ready-placeholder">{{ lang.ssArticlePleaseConfigure }}</p>
+
+      <p v-if="hasArticle" class="ready-meta">
+        {{ articleStore.config.practice.mode === 'time'
+          ? `${articleStore.config.practice.duration} ${lang.ssMinute}`
+          : lang.ssArticleUnlimitedLabel }}
+      </p>
+
+      <p v-if="readyError" class="article-error">{{ readyError }}</p>
+
+      <button class="start-btn" :disabled="startDisabled" @click="engine.startSession">
+        {{ lang.ssArticleStart }}
+      </button>
+    </div>
+
+    <!-- reading -->
+    <ArticleReadingView
+      v-else-if="engine.stage.value === 'reading' && engine.report.value"
+      :paragraphs="engine.report.value.paragraphs"
+      :current-paragraph-idx="engine.currentParagraphIdx.value"
+      :recording-state="engine.recordingState.value"
+      :recording-duration="engine.recordingDuration.value"
+      :elapsed-practice-seconds="engine.elapsedPracticeSeconds.value"
+      :show-report="showReport"
+      :session-ended="engine.sessionEnded.value"
+      :overall-started="engine.overallStarted.value"
+      :can-view-report="engine.canViewReport.value"
+      :local-audio-idxs="localAudioIdxs"
+      :playing-id="engine.playingId.value"
+      :loading-id="engine.loadingId.value"
+      :error="readingError"
+      @select-paragraph="engine.selectParagraph"
+      @start-recording="engine.startRecording"
+      @finish-recording="engine.finishRecording"
+      @cancel-recording="engine.cancelRecording"
+      @play-demo="engine.playDemoAudio"
+      @play-own="engine.playOwnAudio"
+      @view-report="engine.completeAndShowReport"
+      @restart="handleRestart"
+      @dismiss-error="engine.dismissError"
+    />
+
+    <!-- report-loading:一次性显示门,不暴露半成品报告 -->
+    <div v-else-if="engine.stage.value === 'report-loading'" class="article-report-loading">
+      <span class="spinner"></span>
+      <p class="loading-text">{{ lang.ssArticleGeneratingReport }}</p>
+      <p v-if="engine.error.value" class="article-error">{{ engine.error.value }}</p>
+      <button v-if="engine.error.value" class="retry-btn" @click="engine.completeAndShowReport">
+        {{ lang.ssArticleRetry }}
+      </button>
+    </div>
+
+    <!-- report -->
+    <ArticleDetailedReport
+      v-else-if="engine.report.value"
+      :report="engine.report.value"
+      :playing-id="engine.playingId.value"
+      :loading-id="engine.loadingId.value"
+      show-restart
+      @play-demo="engine.playDemoAudio"
+      @play-own="engine.playOwnAudio"
+      @restart="handleRestart"
+    />
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { ref, computed, watch, toRef, inject, onUnmounted } from 'vue'
+import { lang } from '@/main'
+import ArticleReadingView from './ArticleReadingView.vue'
+import ArticleDetailedReport from './ArticleDetailedReport.vue'
+import { useArticleReadingEngine } from '../composables/useArticleReadingEngine'
+import { useArticleReadingStore } from '@/store/articleReading'
+import { getSpeakingConfig } from '@/services/speaking'
+import { getLatestArticleSession } from '../services/articleReading'
+import { friendlyArticleError } from '../services/articleErrors'
+import { ARTICLE_READING_TOOL_TYPE } from '@/configs/englishSpeakingTools'
+import message from '@/utils/message'
+import type { ArticleReadingConfig } from '@/types/articleReading'
+
+const props = defineProps<{
+  configId: string
+}>()
+
+const articleStore = useArticleReadingStore()
+const loadError = ref<string | null>(null)
+
+// 'active' 覆盖两种「这一轮尚未完成」:真正开始,以及重新开始把旧轮作废。
+// 老师端忽略这个值、只是据此重取,所以关键在于广播有没有发出去。
+type SpeakingNotify = (
+  status: 'active' | 'completed',
+  payload: { configId: string; sessionId: string },
+) => void
+const notifySpeakingProgress = inject<SpeakingNotify>('notifySpeakingProgress', () => {})
+const recordSpeakingStart = inject<(sessionId: string, toolType?: number) => void>(
+  'recordSpeakingStart',
+  () => {},
+)
+
+// **两道门缺一不可**:`type` 说这个人是什么身份,`userid` 才说他是谁。
+// 只看 userid 会把老师也算进去 —— 编辑器 URL(App.vue:103)自己就带着 userid。
+//
+// 身份用项目既有的 `type`(`Student/index.vue:625`:老师端 type=1、学生端 type=2),
+// 不用 `mode`:`mode=student` 是**视图**不是身份,老师上课时走的也是这个视图
+// (班级面板就渲染在里面)。拿视图当身份,老师在上课页试录就会被算成学生 —— 正是
+// 本次要修的那个 bug 换个地方复发。`type` 是身份,与哪个视图被渲染出来无关。
+const runtimeParams = computed(() => {
+  const params = new URLSearchParams(window.location.search)
+  return { role: params.get('type'), userId: params.get('userid') || '' }
+})
+const isStudentRuntime = computed(() => runtimeParams.value.role === '2')
+const runtimeUserId = computed(() => runtimeParams.value.userId)
+/** 传给后端的身份:编辑态是 null = 老师预览(spec §4.5),不进任何统计。 */
+const sessionUserId = computed(() => (isStudentRuntime.value ? runtimeUserId.value : null))
+
+/**
+ * 缺省为 true。store 用 Object.assign 合并服务端配置,所以一行 `evaluation: {}` 会把
+ * 默认值冲掉、留下 undefined —— 而 undefined 落到的是最严的那一档(一次机会、永久只读、
+ * 无报告、老师端也没有重置入口)。现有写入方都带这个键,但失败方向不对,值一行钱。
+ */
+const showReport = computed(() => articleStore.config.evaluation.showReport !== false)
+
+const engine = useArticleReadingEngine({
+  configId: toRef(props, 'configId'),
+  userId: sessionUserId,
+  durationMinutes: computed(() => articleStore.config.practice.duration),
+  showReport,
+  onSessionStarted(sessionId) {
+    if (!isStudentRuntime.value) return
+    notifySpeakingProgress('active', { configId: props.configId, sessionId })
+    recordSpeakingStart(sessionId, ARTICLE_READING_TOOL_TYPE)
+  },
+  onSessionCompleted(sessionId) {
+    if (!isStudentRuntime.value) return
+    notifySpeakingProgress('completed', { configId: props.configId, sessionId })
+  },
+  onSessionAbandoned(sessionId) {
+    if (!isStudentRuntime.value) return
+    // 库里这一轮已经不作数了,但老师端只在收到学生广播时重取。不发这一条,他会一直
+    // 看着一个「已完成 + 分数」,点进去还是作废那轮的报告(spec §4.2)。
+    notifySpeakingProgress('active', { configId: props.configId, sessionId })
+  },
+})
+
+const localAudioIdxs = computed(() => [...engine.localAudioByParagraph.value.keys()])
+
+/**
+ * 练习页要显示的错误文案。后端 409 = 这一轮已被作废,重试没有意义,
+ * 直接换成「请重新开始」,而不是把 `{"detail":"..."}` 原样甩给学生。
+ */
+const readingError = computed(() => {
+  if (engine.sessionInvalid.value) return lang.ssArticleSessionExpired as string
+  return engine.error.value
+})
+
+async function handleRestart() {
+  const ok = await engine.restartSession()
+  if (!ok) {
+    message.error(lang.ssArticleRestartFailed as string)
+    return
+  }
+  // 与另外两处 reset() 调用点一致:作废在途的 resume 探针,否则它落地时会把刚清掉的
+  // 那一轮又水合回来。今天走不到(探针只在 resuming 遮住 UI 时跑),但这是唯一一条
+  // 不作废探针的 reset 路径,留着就是下一个人的坑。
+  resumeToken += 1
+}
+
+const hasArticle = computed(() =>
+  Boolean(articleStore.config.title.trim() && articleStore.config.content.trim()),
+)
+
+const articleSummary = computed(() => {
+  const content = articleStore.config.content.trim()
+  return content.length > 120 ? `${content.slice(0, 120)}...` : content
+})
+
+/** 还没开始就已知的阻塞原因:配置没加载出来、或学生态缺 userid。这两条也决定按钮是否可点。 */
+const startBlockedReason = computed(() => {
+  if (loadError.value) return loadError.value
+  // 编辑态没有身份是正常的(那就是老师预览);只有学生态缺 userid 才是真的进不去。
+  if (hasArticle.value && isStudentRuntime.value && !runtimeUserId.value) {
+    return lang.ssArticleMissingUser as string
+  }
+  return null
+})
+
+/**
+ * ready 页面真正渲染的那句话(spec §9.2.5 A2)。
+ * 除了「按不下去的原因」,还必须包含「按下去了但失败了」—— 此前 engine.error 在这个 stage
+ * 根本没有出口,`POST /session` 一失败学生看到的就是一片空白:按钮弹回、什么都没说。
+ */
+const readyError = computed(() => startBlockedReason.value || engine.error.value)
+
+// 只看阻塞原因,不看 engine.error:开始失败之后学生应该还能再点一次。
+// `engine.starting` 是例外 —— POST 在途时必须置灰,否则双击会建出两条 session
+// (后端 get-or-create 无锁,见 useArticleReadingEngine.startSession 的注释)。
+const startDisabled = computed(
+  () => !hasArticle.value || engine.starting.value || Boolean(startBlockedReason.value),
+)
+
+async function loadConfig() {
+  loadError.value = null
+  if (!props.configId) return
+  try {
+    const record = await getSpeakingConfig<ArticleReadingConfig>(props.configId)
+    if (record.config.type !== 'article') {
+      throw new Error('Speaking config is not an article')
+    }
+    articleStore.replaceConfig(record.config)
+  } catch (cause) {
+    console.error('[article-reading] load config failed:', cause)
+    // 共用端点的 parse() 抛的是 `[404] {"detail":"..."}`,直接渲染就是把原始 JSON
+    // 甩给用户。走同一层:命中就是中文,命不中也是兜底文案,不会是响应体。
+    loadError.value = friendlyArticleError(cause)
+  }
+}
+
+// resume 探针在途标记:为 true 时隐藏 ready 起始页,避免刷新瞬间闪一下开始页
+// (对齐话题讨论的 checking-history)。仅学生端(有 userid)才探测。
+const resuming = ref(false)
+// 作废在途 resume:configId 变化 / 老师重置时递增,丢弃过期探针结果
+let resumeToken = 0
+
+async function maybeResumeSession(token: number) {
+  // 编辑态不探测:预览 session 的 user_id 是 NULL,后端按定义查不到(spec §4.5),
+  // 而 GET /sessions/latest 对空 userId 仍然回 400 —— 发出去只会在控制台留一条噪音。
+  if (!isStudentRuntime.value) return
+  if (!props.configId || !runtimeUserId.value) return
+  try {
+    const { session } = await getLatestArticleSession(props.configId, runtimeUserId.value)
+    if (token !== resumeToken || !session) return
+    // 有历史 session → 跳过起始页,直接回到朗读 / 结果页
+    engine.resumeFromReport(session)
+    notifySpeakingProgress(
+      session.status === 'completed' ? 'completed' : 'active',
+      { configId: props.configId, sessionId: session.sessionId },
+    )
+  }
+  catch (cause) {
+    console.error('[article-reading] resume probe failed:', cause)
+    // 探针失败 → 保持 ready 起始页,学生可手动开始
+  }
+  finally {
+    if (token === resumeToken) resuming.value = false
+  }
+}
+
+watch(() => props.configId, async () => {
+  const token = ++resumeToken
+  engine.reset()
+  resuming.value = Boolean(props.configId && isStudentRuntime.value && runtimeUserId.value)
+  await loadConfig()
+  if (token !== resumeToken) return
+  await maybeResumeSession(token)
+}, { immediate: true })
+
+// 把阶段同步进 store,供 CanvasTool 决定「重置预览」显不显示(镜像话题讨论
+// TopicDiscussionPreview 的 setPreviewState)。`immediate` 不能省:切页换实例时新预览
+// 一挂就得把 store 拨回 ready,否则按钮沿用上一个实例的阶段。
+watch(() => engine.stage.value, (stage) => {
+  articleStore.setPreviewStage(stage)
+}, { immediate: true })
+
+// 老师在配置面板点应用 → 预览回到 ready 并重载(编辑态不 resume,见 maybeResumeSession)
+watch(() => articleStore.resetSignal, () => {
+  resumeToken += 1
+  resuming.value = false
+  engine.reset()
+  void loadConfig()
+})
+
+onUnmounted(() => engine.dispose())
+</script>
+
+<style lang="scss" scoped>
+.article-reading-preview {
+  position: relative;
+  width: 100%;
+  height: 100%;
+  background: #fff;
+  overflow: hidden;
+}
+
+// ---- ready ----
+.article-ready-stage {
+  width: 100%;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  gap: 12px;
+  padding: 32px;
+  text-align: center;
+}
+
+.ready-title {
+  margin: 0;
+  font-size: 24px;
+  font-weight: 600;
+  color: #111827;
+}
+
+.ready-summary {
+  margin: 0;
+  max-width: 560px;
+  font-size: 14px;
+  line-height: 1.7;
+  color: #6b7280;
+}
+
+.ready-placeholder {
+  margin: 0;
+  font-size: 14px;
+  color: #9ca3af;
+}
+
+.ready-meta {
+  margin: 0;
+  font-size: 12px;
+  color: #9ca3af;
+}
+
+.start-btn {
+  margin-top: 8px;
+  padding: 10px 32px;
+  border: none;
+  border-radius: 999px;
+  background: #f97316;
+  color: #fff;
+  font-size: 14px;
+  font-weight: 500;
+  cursor: pointer;
+  box-shadow: 0 4px 6px -1px rgba(249, 115, 22, 0.25);
+  transition: background 0.2s;
+
+  &:hover:not(:disabled) { background: #ea580c; }
+
+  &:disabled {
+    background: #d1d5db;
+    box-shadow: none;
+    cursor: not-allowed;
+  }
+}
+
+// ---- report loading ----
+.article-report-loading {
+  width: 100%;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  gap: 12px;
+  padding: 32px;
+  text-align: center;
+}
+
+.spinner {
+  width: 28px;
+  height: 28px;
+  border-radius: 50%;
+  border: 3px solid #fb923c;
+  border-top-color: transparent;
+  animation: article-preview-spin 0.8s linear infinite;
+}
+
+@keyframes article-preview-spin {
+  to { transform: rotate(360deg); }
+}
+
+.loading-text {
+  margin: 0;
+  font-size: 14px;
+  color: #6b7280;
+}
+
+.article-error {
+  margin: 0;
+  font-size: 12px;
+  color: #dc2626;
+}
+
+.retry-btn {
+  padding: 6px 20px;
+  border: 1px solid #fdba74;
+  border-radius: 999px;
+  background: #fff;
+  color: #ea580c;
+  font-size: 12px;
+  font-weight: 500;
+  cursor: pointer;
+  transition: background 0.2s;
+
+  &:hover { background: #fff7ed; }
+}
+</style>

+ 829 - 0
src/views/Editor/EnglishSpeaking/preview/ArticleReadingView.vue

@@ -0,0 +1,829 @@
+<template>
+  <div class="article-reading-view">
+    <!-- 顶部操作栏 -->
+    <div class="reading-topbar">
+      <div class="reading-topbar__progress">
+        {{ progressLabel }}
+      </div>
+      <div class="reading-topbar__right">
+        <span class="timer-pill">
+          <svg width="15" height="15" viewBox="0 0 24 24" fill="none" aria-hidden="true">
+            <rect x="9.5" y="1.5" width="5" height="2.2" rx="1" fill="#6B7280" />
+            <circle cx="12" cy="14" r="8.2" fill="#F3F4F6" stroke="#9CA3AF" stroke-width="1.8" />
+            <path d="M12 14V9.4" stroke="#6B7280" stroke-width="1.8" stroke-linecap="round" />
+            <path d="M12 14l3 1.8" stroke="#6B7280" stroke-width="1.8" stroke-linecap="round" />
+          </svg>
+          {{ timerLabel }}
+        </span>
+        <button
+          class="icon-btn"
+          :title="lang.ssArticleMore as string"
+          :disabled="readOnly"
+          @click="showExitConfirm = true"
+        >
+          <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+            <circle cx="5" cy="12" r="1" /><circle cx="12" cy="12" r="1" /><circle cx="19" cy="12" r="1" />
+          </svg>
+        </button>
+      </div>
+    </div>
+
+    <!-- 段落列表:当前段全不透明,其余淡出 -->
+    <div ref="listRef" class="reading-list">
+      <div class="reading-list__inner">
+        <div
+          v-for="paragraph in paragraphs"
+          :key="paragraph.idx"
+          :ref="el => setCardRef(paragraph.idx, el)"
+          class="paragraph-shell"
+          :class="{ 'paragraph-shell--current': paragraph.idx === currentParagraphIdx }"
+          @click="selectParagraph(paragraph.idx)"
+        >
+          <!-- 「已读」只由卡内那颗状态药丸说(已录 / 处理中 / 评估失败)。
+               这里曾经还有一个绝对定位的打勾徽章,和绿色药丸说的是同一件事。 -->
+          <ArticleParagraphCard
+            :paragraph="paragraph"
+            :expanded="isExpanded(paragraph)"
+            :interactive="paragraph.idx === currentParagraphIdx"
+            :demo-audio-loading="loadingId === `demo-${paragraph.idx}`"
+            :demo-audio-playing="playingId === `demo-${paragraph.idx}`"
+            :own-audio-loading="loadingId === `own-${paragraph.idx}`"
+            :own-audio-playing="playingId === `own-${paragraph.idx}`"
+            :own-audio-available="hasOwnAudio(paragraph)"
+            @toggle="toggleExpanded"
+            @play-demo="emit('play-demo', paragraph.idx)"
+            @play-own="emit('play-own', paragraph.idx)"
+          />
+        </div>
+      </div>
+    </div>
+
+    <!-- 错误横幅:麦克风 / 示范音频 / 轮询失败都靠它露出。
+         到点 complete 那条重试动作已随 §4.4 移除 —— 判定权在后端,前端没有会失败的
+         complete 需要重试;学生只需要看见发生了什么,然后再点一次原来那个按钮。 -->
+    <div v-if="error" class="reading-error" role="alert">
+      <span class="reading-error__text">{{ error }}</span>
+      <button class="reading-error__dismiss" @click="emit('dismiss-error')">
+        {{ lang.ssArticleDismiss }}
+      </button>
+    </div>
+
+    <!-- 底部录音栏:idle / recording / processing / done 四态 -->
+    <div class="reading-bottombar">
+      <div class="bottombar-progress">
+        <div class="bottombar-progress__track" :class="{ active: recordingState === 'recording' }">
+          <div class="bottombar-progress__fill" :class="{ active: recordingState === 'recording' }"></div>
+        </div>
+      </div>
+
+      <div class="bottombar-states">
+        <!-- idle -->
+        <div class="bottombar-state bottombar-state--idle" :class="{ visible: recordingState === 'idle' }">
+          <button v-if="!isFirstParagraph" class="nav-btn" @click="goPrevious">
+            <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+              <path d="M15 18l-6-6 6-6" />
+            </svg>
+            {{ lang.ssArticlePrevious }}
+          </button>
+          <div v-else class="nav-spacer"></div>
+
+          <div class="bottombar-center">
+            <button class="record-btn" :disabled="busy || readOnly" @click="emit('start-recording')">
+              <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
+                <path d="M12 14a3 3 0 003-3V5a3 3 0 00-6 0v6a3 3 0 003 3z" />
+                <path d="M19 11a7 7 0 01-14 0H3a9 9 0 008 8.94V23h2v-3.06A9 9 0 0021 11h-2z" />
+              </svg>
+              {{ currentRecorded ? lang.ssArticleRerecordBtn : lang.ssArticleStartRecord }}
+            </button>
+            <button v-if="currentRecorded" class="replay-btn" :title="lang.ssArticleReplay as string" @click="emit('play-own', currentParagraphIdx)">
+              <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
+                <path d="M8 5v14l11-7z" />
+              </svg>
+              {{ lang.ssArticleReplay }}
+            </button>
+          </div>
+
+          <button v-if="canViewReport && showReport" class="report-btn" @click="emit('view-report')">
+            {{ lang.ssArticleViewReport }}
+            <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+              <path d="M5 12h14M13 6l6 6-6 6" />
+            </svg>
+          </button>
+          <button v-else-if="!isLastParagraph" class="nav-btn" @click="goNext">
+            {{ lang.ssArticleNext }}
+            <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+              <path d="M5 12h14M13 6l6 6-6 6" />
+            </svg>
+          </button>
+          <div v-else class="nav-spacer"></div>
+        </div>
+
+        <!-- recording -->
+        <div class="bottombar-state bottombar-state--recording" :class="{ visible: recordingState === 'recording' }">
+          <div class="recording-pill">
+            <button class="cancel-btn" :disabled="busy" @click="emit('cancel-recording')">
+              <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+                <line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
+              </svg>
+              {{ lang.ssArticleCancelRecord }}
+            </button>
+            <div class="recording-indicator">
+              <span class="recording-dot"></span>
+              <span class="recording-time">{{ formatTime(recordingDuration) }}</span>
+            </div>
+            <button class="finish-btn" :disabled="busy" @click="emit('finish-recording')">
+              <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
+                <path d="M20 6L9 17l-5-5" />
+              </svg>
+              {{ lang.ssArticleFinishRecord }}
+            </button>
+          </div>
+        </div>
+
+        <!-- processing。原型第四态「完成 ✓」不存在于此:ack 一到就打回 idle 并跳下一段
+             (决策 A),没有可以停留的时刻,详见 ArticleRecordingState 的注释 -->
+        <div class="bottombar-state bottombar-state--centered" :class="{ visible: recordingState === 'processing' }">
+          <span class="state-spinner"></span>
+          <span class="state-text">{{ lang.ssArticleProcessing }}</span>
+        </div>
+      </div>
+    </div>
+
+    <!-- 退出确认 -->
+    <div v-if="showExitConfirm" class="exit-overlay" @click.self="showExitConfirm = false">
+      <div class="exit-dialog">
+        <div class="exit-dialog__head">
+          <h3 class="exit-dialog__title">{{ lang.ssArticleExitTitle }}</h3>
+          <button class="exit-dialog__close" @click="showExitConfirm = false">
+            <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+              <line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
+            </svg>
+          </button>
+        </div>
+        <p class="exit-dialog__hint">{{ lang.ssArticleExitHint }}</p>
+        <div class="exit-dialog__actions">
+          <button class="exit-dialog__secondary" @click="showExitConfirm = false">
+            {{ lang.ssArticleContinuePractice }}
+          </button>
+          <button class="exit-dialog__secondary" @click="handleRestart">
+            {{ lang.ssArticleRestart }}
+          </button>
+          <button v-if="showReport" class="exit-dialog__primary" @click="confirmExit">
+            {{ lang.ssArticleEndAndReport }}
+          </button>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { ref, computed, watch, nextTick } from 'vue'
+import { lang } from '@/main'
+import ArticleParagraphCard from './ArticleParagraphCard.vue'
+import type { ArticleParagraphResult } from '@/types/articleReading'
+import type { ArticleRecordingState } from '../composables/useArticleReadingEngine'
+
+const props = defineProps<{
+  paragraphs: ArticleParagraphResult[]
+  currentParagraphIdx: number
+  recordingState: ArticleRecordingState
+  recordingDuration: number
+  elapsedPracticeSeconds: number | null
+  canViewReport: boolean
+  localAudioIdxs: number[]
+  playingId: string | null
+  loadingId: string | null
+  showReport: boolean
+  sessionEnded: boolean
+  /** 总评已开始生成 → 后端已不接受重录,练习页转只读。 */
+  overallStarted: boolean
+  /** 已本地化的错误文案;null = 无错。 */
+  error: string | null
+}>()
+
+// 具名元组式,不是调用签名式:事件多到这个数量后,调用签名的重载联合会把参数类型
+// 推断成 `any[] | unknown[]`,父组件那边每个带参数的处理器都会报不可赋值。
+const emit = defineEmits<{
+  'select-paragraph': [idx: number]
+  'start-recording': []
+  'finish-recording': []
+  'cancel-recording': []
+  'play-demo': [idx: number]
+  'play-own': [idx: number]
+  'view-report': []
+  'restart': []
+  'dismiss-error': []
+}>()
+
+const showExitConfirm = ref(false)
+/**
+ * 展开态只跟着「当前段」走:选中的那一段只要评完了就默认摊开,学生不用再点一次箭头。
+ * 这个 ref 是学生手动收起当前段的一次性覆盖,换段即失效 —— 非当前段的箭头本来就点不到
+ * (interactive=false),所以不需要按 idx 记一整张表。
+ */
+const currentCollapsed = ref(false)
+const listRef = ref<HTMLElement | null>(null)
+const cardRefs = new Map<number, HTMLElement>()
+
+function setCardRef(idx: number, el: unknown) {
+  if (el instanceof HTMLElement) cardRefs.set(idx, el)
+  else cardRefs.delete(idx)
+}
+
+// 录音/处理中禁止重复触发
+const busy = computed(() => props.recordingState === 'processing')
+
+// 两条路都把练习页转为只读:
+//   ① 总评已经开始生成 —— 后端在这之后拒绝重录(409 `Report already generated`,2026-07-27)。
+//      **两种模式都成立**:不锁的话那颗录音按钮按下去必然报错。
+//   ② 关闭「展示学习报告」且这一轮已结束(spec §9.2.1)。只禁 ⋯ 而留录音会让学生
+//      「能改成绩却不能重来」,老师端状态也在完成/进行中之间反复跳,所以一并禁用。
+const readOnly = computed(() =>
+  props.overallStarted || (!props.showReport && props.sessionEnded)
+)
+
+const isFirstParagraph = computed(() => props.currentParagraphIdx <= 0)
+const isLastParagraph = computed(() => props.currentParagraphIdx >= props.paragraphs.length - 1)
+
+const progressLabel = computed(() =>
+  String(lang.ssArticleParagraphProgress)
+    .replace('{cur}', String(props.currentParagraphIdx + 1))
+    .replace('{total}', String(props.paragraphs.length)),
+)
+
+const timerLabel = computed(() =>
+  props.elapsedPracticeSeconds === null
+    ? (lang.ssArticleUnlimitedLabel as string)
+    : formatTime(props.elapsedPracticeSeconds),
+)
+
+// 分钟不补零,还原 enspeak ArticleReadingView.tsx:276-280 的 `${mins}:${secs.padStart(2,'0')}`。
+// 录音药丸共用本函数,原型同样共用,所以录音计时一并变成 0:07 —— 是还原,不是副作用。
+function formatTime(seconds: number): string {
+  const safe = Math.max(0, Math.floor(seconds))
+  const mm = String(Math.floor(safe / 60))
+  const ss = String(safe % 60).padStart(2, '0')
+  return `${mm}:${ss}`
+}
+
+function isRecorded(paragraph: ArticleParagraphResult): boolean {
+  return paragraph.status !== 'pending' || props.localAudioIdxs.includes(paragraph.idx)
+}
+
+function hasOwnAudio(paragraph: ArticleParagraphResult): boolean {
+  return props.localAudioIdxs.includes(paragraph.idx) || Boolean(paragraph.audioUrl)
+}
+
+const currentRecorded = computed(() => {
+  const paragraph = props.paragraphs.find(p => p.idx === props.currentParagraphIdx)
+  return paragraph ? isRecorded(paragraph) : false
+})
+
+function selectParagraph(idx: number) {
+  if (idx === props.currentParagraphIdx) return
+  emit('select-paragraph', idx)
+}
+
+function isExpanded(paragraph: ArticleParagraphResult): boolean {
+  return paragraph.idx === props.currentParagraphIdx && !currentCollapsed.value
+}
+
+function toggleExpanded() {
+  currentCollapsed.value = !currentCollapsed.value
+}
+
+function goPrevious() {
+  if (!isFirstParagraph.value) selectParagraph(props.currentParagraphIdx - 1)
+}
+
+function goNext() {
+  if (!isLastParagraph.value) selectParagraph(props.currentParagraphIdx + 1)
+}
+
+function confirmExit() {
+  showExitConfirm.value = false
+  emit('view-report')
+}
+
+function handleRestart() {
+  showExitConfirm.value = false
+  emit('restart')
+}
+
+// 选中段自动滚到视野中央。收起状态一并归零 —— 换段不只有点击一条路,
+// 录完一段后引擎也会自己往下推(advanceAfterAck),那条路不经过 selectParagraph。
+watch(() => props.currentParagraphIdx, async idx => {
+  currentCollapsed.value = false
+  await nextTick()
+  cardRefs.get(idx)?.scrollIntoView({ behavior: 'smooth', block: 'center' })
+})
+</script>
+
+<style lang="scss" scoped>
+.article-reading-view {
+  width: 100%;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  background: #fff;
+}
+
+// ---- 顶部栏 ----
+.reading-topbar {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 8px 12px;
+  border-bottom: 1px solid #f3f4f6;
+  flex-shrink: 0;
+}
+
+.reading-topbar__progress {
+  font-size: 11px;
+  font-weight: 500;
+  color: #6b7280;
+}
+
+.reading-topbar__right {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+}
+
+.timer-pill {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+  padding: 4px 10px;
+  border-radius: 999px;
+  border: 1px solid #e5e7eb;
+  background: #f9fafb;
+  color: #4b5563;
+  font-size: 13px;
+  font-weight: 600;
+  font-variant-numeric: tabular-nums;
+}
+
+.icon-btn {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  padding: 6px;
+  border: none;
+  border-radius: 6px;
+  background: transparent;
+  color: #9ca3af;
+  cursor: pointer;
+  transition: background 0.2s, color 0.2s;
+
+  &:hover {
+    background: #f3f4f6;
+    color: #4b5563;
+  }
+
+  &:disabled {
+    opacity: 0.4;
+    cursor: not-allowed;
+  }
+}
+
+// ---- 段落列表 ----
+.reading-list {
+  flex: 1;
+  overflow-y: auto;
+  padding: 24px;
+}
+
+.reading-list__inner {
+  max-width: 672px;
+  margin: 0 auto;
+  display: flex;
+  flex-direction: column;
+  gap: 16px;
+}
+
+.paragraph-shell {
+  position: relative;
+  padding: 16px;
+  border: 2px solid #e5e7eb;
+  border-radius: 8px;
+  background: #fff;
+  cursor: pointer;
+  opacity: 0.4;
+  transition: all 0.3s;
+
+  &:hover { opacity: 0.6; border-color: #d1d5db; }
+
+  &--current {
+    border-color: #f97316;
+    background: #fff7ed;
+    box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
+    transform: scale(1.02);
+    opacity: 1;
+
+    &:hover { opacity: 1; border-color: #f97316; }
+  }
+}
+
+// ---- 错误横幅(spec §9.2.3)----
+.reading-error {
+  flex-shrink: 0;
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  margin: 0 16px 8px;
+  padding: 8px 12px;
+  border: 1px solid #fecaca;
+  border-radius: 8px;
+  background: #fef2f2;
+}
+
+.reading-error__text {
+  flex: 1;
+  font-size: 12px;
+  line-height: 1.5;
+  color: #b91c1c;
+}
+
+.reading-error__action,
+.reading-error__dismiss {
+  flex-shrink: 0;
+  padding: 4px 10px;
+  border-radius: 6px;
+  font-size: 12px;
+  font-weight: 500;
+  cursor: pointer;
+  transition: background 0.2s;
+}
+
+.reading-error__action {
+  border: 1px solid #fdba74;
+  background: #fff;
+  color: #ea580c;
+
+  &:hover {
+    background: #fff7ed;
+  }
+}
+
+.reading-error__dismiss {
+  border: none;
+  background: transparent;
+  color: #9ca3af;
+
+  &:hover {
+    background: #fee2e2;
+  }
+}
+
+// ---- 底部录音栏 ----
+.reading-bottombar {
+  flex-shrink: 0;
+  border-top: 1px solid #f3f4f6;
+  background: #fff;
+  padding: 10px 16px 12px;
+}
+
+.bottombar-progress {
+  max-width: 384px;
+  margin: 0 auto 8px;
+}
+
+.bottombar-progress__track {
+  height: 1px;
+  border-radius: 999px;
+  overflow: hidden;
+  background: transparent;
+
+  &.active { background: #f3f4f6; }
+}
+
+.bottombar-progress__fill {
+  height: 100%;
+  width: 0;
+  opacity: 0;
+  border-radius: 999px;
+  background: #fb923c;
+  transition: width 1s linear, opacity 0.2s ease;
+
+  &.active { width: 100%; opacity: 1; }
+}
+
+.bottombar-states {
+  position: relative;
+  max-width: 384px;
+  height: 36px;
+  margin: 0 auto;
+}
+
+.bottombar-state {
+  position: absolute;
+  inset: 0;
+  display: flex;
+  align-items: center;
+  opacity: 0;
+  pointer-events: none;
+  transition: opacity 0.18s ease-out;
+
+  &.visible {
+    opacity: 1;
+    pointer-events: auto;
+  }
+
+  &--idle {
+    justify-content: space-between;
+    gap: 4px;
+  }
+
+  &--centered {
+    justify-content: center;
+    gap: 8px;
+    pointer-events: none;
+  }
+}
+
+.bottombar-center {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+}
+
+.nav-spacer {
+  width: 60px;
+  flex-shrink: 0;
+}
+
+.nav-btn {
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
+  padding: 4px 10px;
+  border-radius: 999px;
+  border: 1px solid #e5e7eb;
+  background: #fff;
+  color: #6b7280;
+  font-size: 11px;
+  font-weight: 500;
+  cursor: pointer;
+  flex-shrink: 0;
+  transition: background 0.2s, border-color 0.2s, color 0.2s;
+
+  &:hover {
+    background: #f9fafb;
+    border-color: #d1d5db;
+    color: #4b5563;
+  }
+}
+
+.report-btn {
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
+  padding: 4px 10px;
+  border-radius: 999px;
+  border: none;
+  background: #f97316;
+  color: #fff;
+  font-size: 11px;
+  font-weight: 500;
+  cursor: pointer;
+  flex-shrink: 0;
+  transition: background 0.2s;
+
+  &:hover { background: #ea580c; }
+}
+
+.record-btn {
+  display: inline-flex;
+  align-items: center;
+  gap: 8px;
+  padding: 8px 24px;
+  border-radius: 999px;
+  border: none;
+  background: #f97316;
+  color: #fff;
+  font-size: 14px;
+  font-weight: 500;
+  cursor: pointer;
+  box-shadow: 0 4px 6px -1px rgba(249, 115, 22, 0.25);
+  transition: background 0.2s, transform 0.15s;
+
+  &:hover:not(:disabled) { background: #ea580c; }
+  &:active:not(:disabled) { transform: scale(0.95); }
+  &:disabled { opacity: 0.6; cursor: not-allowed; }
+}
+
+.replay-btn {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+  padding: 6px 14px;
+  border-radius: 999px;
+  border: 1px solid #fdba74;
+  background: #fff;
+  color: #ea580c;
+  font-size: 12px;
+  font-weight: 500;
+  cursor: pointer;
+  flex-shrink: 0;
+  transition: background 0.2s, transform 0.15s;
+
+  &:hover { background: #fff7ed; }
+  &:active { transform: scale(0.95); }
+}
+
+.recording-pill {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  width: 100%;
+  padding: 6px 10px;
+  border-radius: 999px;
+  border: 1px solid #f3f4f6;
+  background: #f9fafb;
+}
+
+.recording-indicator {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: 6px;
+  flex: 1;
+}
+
+.recording-dot {
+  width: 6px;
+  height: 6px;
+  border-radius: 50%;
+  background: #ef4444;
+  animation: article-pulse 1.4s ease-in-out infinite;
+}
+
+@keyframes article-pulse {
+  0%, 100% { opacity: 1; }
+  50% { opacity: 0.35; }
+}
+
+.recording-time {
+  font-size: 12px;
+  font-weight: 600;
+  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+  color: #374151;
+}
+
+.cancel-btn {
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
+  padding: 4px 10px;
+  border-radius: 999px;
+  border: 1px solid #e5e7eb;
+  background: #fff;
+  color: #6b7280;
+  font-size: 11px;
+  font-weight: 500;
+  cursor: pointer;
+  flex-shrink: 0;
+  transition: background 0.2s, border-color 0.2s, color 0.2s;
+
+  &:hover:not(:disabled) {
+    background: #fef2f2;
+    border-color: #fecaca;
+    color: #ef4444;
+  }
+
+  &:disabled { opacity: 0.6; cursor: not-allowed; }
+}
+
+.finish-btn {
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
+  padding: 4px 10px;
+  border-radius: 999px;
+  border: none;
+  background: #f97316;
+  color: #fff;
+  font-size: 11px;
+  font-weight: 500;
+  cursor: pointer;
+  flex-shrink: 0;
+  transition: background 0.2s;
+
+  &:hover:not(:disabled) { background: #ea580c; }
+  &:disabled { opacity: 0.6; cursor: not-allowed; }
+}
+
+.state-spinner {
+  width: 16px;
+  height: 16px;
+  border-radius: 50%;
+  border: 2px solid #fb923c;
+  border-top-color: transparent;
+  animation: article-spin 0.7s linear infinite;
+}
+
+@keyframes article-spin {
+  to { transform: rotate(360deg); }
+}
+
+.state-text {
+  font-size: 12px;
+  color: #9ca3af;
+}
+
+// ---- 退出确认 ----
+.exit-overlay {
+  position: absolute;
+  inset: 0;
+  z-index: 50;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 24px;
+  background: rgba(0, 0, 0, 0.3);
+  backdrop-filter: blur(2px);
+}
+
+.exit-dialog {
+  width: 100%;
+  max-width: 320px;
+  padding: 24px;
+  border-radius: 16px;
+  background: #fff;
+  box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.2);
+}
+
+.exit-dialog__head {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 4px;
+}
+
+.exit-dialog__title {
+  margin: 0;
+  font-size: 14px;
+  font-weight: 600;
+  color: #111827;
+}
+
+.exit-dialog__close {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  width: 28px;
+  height: 28px;
+  border: none;
+  border-radius: 8px;
+  background: #f3f4f6;
+  color: #6b7280;
+  cursor: pointer;
+  transition: background 0.2s;
+
+  &:hover { background: #e5e7eb; }
+}
+
+.exit-dialog__hint {
+  margin: 0 0 16px;
+  font-size: 12px;
+  color: #9ca3af;
+}
+
+.exit-dialog__actions {
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+}
+
+.exit-dialog__secondary {
+  width: 100%;
+  padding: 10px 0;
+  border: 1px solid #e5e7eb;
+  border-radius: 12px;
+  background: transparent;
+  color: #4b5563;
+  font-size: 12px;
+  font-weight: 500;
+  cursor: pointer;
+  transition: background 0.2s;
+
+  &:hover { background: #f9fafb; }
+}
+
+.exit-dialog__primary {
+  width: 100%;
+  padding: 10px 0;
+  border: none;
+  border-radius: 12px;
+  background: #f97316;
+  color: #fff;
+  font-size: 12px;
+  font-weight: 500;
+  cursor: pointer;
+  transition: background 0.2s;
+
+  &:hover { background: #ea580c; }
+}
+</style>

+ 60 - 0
src/views/Editor/EnglishSpeaking/preview/ArticleStarRating.vue

@@ -0,0 +1,60 @@
+<template>
+  <div class="star-rating" :class="`star-rating--${size}`" role="img" :aria-label="ariaLabel">
+    <div class="star-rating__track" aria-hidden="true">★★★★★</div>
+    <div class="star-rating__fill" :style="{ width: `${fillPercent}%` }" aria-hidden="true">★★★★★</div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { computed } from 'vue'
+import { scoreToStars } from './articleReadingModel'
+
+/**
+ * 还原 enspeak 的两种星级口径(`OverallReport.tsx:19` / `ArticleDetailedReport.tsx:12`):
+ * 总评用 0.5 步进(避免 88 和 96 都落到 5 星),段落卡用整星。
+ * `exact` 是本组件原有的连续填充,班级面板继续用它。
+ */
+const props = withDefaults(defineProps<{
+  score: number | null
+  size?: 'xs' | 'sm' | 'md' | 'lg'
+  step?: 'exact' | 'half' | 'full'
+}>(), { size: 'md', step: 'exact' })
+
+const stars = computed(() => {
+  const raw = scoreToStars(props.score)
+  if (props.step === 'half') return Math.round(raw * 2) / 2
+  if (props.step === 'full') return Math.round(raw)
+  return raw
+})
+
+const fillPercent = computed(() => (stars.value / 5) * 100)
+const ariaLabel = computed(() => `${stars.value} out of 5 stars`)
+</script>
+
+<style lang="scss" scoped>
+.star-rating {
+  position: relative;
+  display: inline-block;
+  line-height: 1;
+  white-space: nowrap;
+  letter-spacing: 1px;
+
+  &--xs { font-size: 11px; }
+  &--sm { font-size: 14px; }
+  &--md { font-size: 16px; }
+  &--lg { font-size: 18px; }
+}
+
+// 空星 gray-200 / 实星 amber-400,取自 enspeak StarRating(#E5E7EB / #FBBF24)
+.star-rating__track {
+  color: #e5e7eb;
+}
+
+// 覆盖层按比例截断,得到小数星
+.star-rating__fill {
+  position: absolute;
+  inset: 0;
+  overflow: hidden;
+  color: #fbbf24;
+}
+</style>

+ 374 - 0
src/views/Editor/EnglishSpeaking/preview/DetailedReport.vue

@@ -0,0 +1,374 @@
+<template>
+  <div class="detailed-report">
+    <div class="report-header">
+      <h3 class="report-title">详细报告</h3>
+      <button class="toggle-all-btn" @click="toggleAll">
+        {{ allExpanded ? '收起全部' : '展开全部' }}
+        <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" :class="{ 'chevron-up': allExpanded }">
+          <polyline points="6 9 12 15 18 9" />
+        </svg>
+      </button>
+    </div>
+
+    <!-- 按轮次分组 -->
+    <div
+      v-for="(roundData, roundIndex) in groupedByRound"
+      :key="roundIndex"
+      class="round-group"
+    >
+      <div class="round-label">Round {{ roundIndex + 1 }}</div>
+      <div class="round-cards">
+        <div
+          v-for="sentence in roundData"
+          :key="sentence.id"
+          class="sentence-card"
+          :class="{ 'card-student': sentence.role === 'student', 'card-expanded': expandedIds.has(sentence.id) }"
+          @click="sentence.role === 'student' && hasDetails(sentence) && toggleExpand(sentence.id)"
+        >
+          <!-- 卡片主体 -->
+          <div class="card-body" :class="{ 'body-student': sentence.role === 'student' }">
+            <div class="card-left">
+              <!-- 语音条(仅学生卡片,AI 无 audio_url 故纯文字呈现) -->
+              <div v-if="sentence.role === 'student'" class="card-voice-bar bar-student">
+                <button
+                  class="card-play-btn play-student"
+                  :disabled="!sentence.audioUrl"
+                  @click.stop="handleSentencePlay(sentence)"
+                >
+                  <!-- loading -->
+                  <svg
+                    v-if="player.loadingId.value === sentence.id"
+                    class="play-spinner"
+                    width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor"
+                    stroke-width="2" stroke-linecap="round"
+                  >
+                    <path d="M21 12a9 9 0 1 1-6.219-8.56" />
+                  </svg>
+                  <!-- playing -->
+                  <svg
+                    v-else-if="player.playingId.value === sentence.id"
+                    width="10" height="10" viewBox="0 0 24 24" fill="currentColor"
+                  >
+                    <rect x="6" y="4" width="4" height="16" /><rect x="14" y="4" width="4" height="16" />
+                  </svg>
+                  <!-- error -->
+                  <svg
+                    v-else-if="player.errorId.value === sentence.id"
+                    width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor"
+                    stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
+                  >
+                    <path d="M12 9v4" /><path d="M12 17h.01" />
+                    <path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
+                  </svg>
+                  <!-- idle -->
+                  <svg
+                    v-else
+                    width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor"
+                    stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
+                  >
+                    <polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
+                    <path d="M15.54 8.46a5 5 0 0 1 0 7.07" />
+                  </svg>
+                </button>
+                <div class="card-waveform">
+                  <div
+                    v-for="i in 8"
+                    :key="i"
+                    class="card-wave-bar cw-student"
+                    :style="{ height: `${Math.sin(i * 0.6) * 4 + 3}px` }"
+                  />
+                </div>
+                <span class="card-duration cd-student">
+                  {{ formatDuration(sentence.audioDuration) }}
+                </span>
+              </div>
+
+              <!-- 文本 -->
+              <p class="card-text">{{ sentence.content }}</p>
+              <p v-if="sentence.contentZh" class="card-text-zh">{{ sentence.contentZh }}</p>
+            </div>
+
+            <!-- 评分标签 -->
+            <div v-if="sentence.role === 'student' && hasDetails(sentence)" class="card-right">
+              <span v-if="sentence.score !== undefined" class="card-score" :class="getScoreClass(sentence.score)">{{ sentence.score }}</span>
+              <svg class="expand-icon" :class="{ 'expanded': expandedIds.has(sentence.id) }" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#9ca3af" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+                <polyline points="6 9 12 15 18 9" />
+              </svg>
+            </div>
+          </div>
+
+          <!-- 展开详情 -->
+          <div v-if="sentence.role === 'student' && expandedIds.has(sentence.id)" class="card-detail">
+            <!-- 发音评分 -->
+            <div v-if="sentence.pronunciation" class="pron-scores">
+              <div class="pron-row">
+                <span class="pron-label">准确度</span>
+                <div class="pron-track"><div class="pron-fill" :style="{ width: `${sentence.pronunciation.accuracy}%` }" /></div>
+                <span class="pron-value">{{ sentence.pronunciation.accuracy }}</span>
+              </div>
+              <div class="pron-row">
+                <span class="pron-label">语调</span>
+                <div class="pron-track"><div class="pron-fill" :style="{ width: `${sentence.pronunciation.intonation}%` }" /></div>
+                <span class="pron-value">{{ sentence.pronunciation.intonation }}</span>
+              </div>
+              <div class="pron-row">
+                <span class="pron-label">重音</span>
+                <div class="pron-track"><div class="pron-fill" :style="{ width: `${sentence.pronunciation.stress}%` }" /></div>
+                <span class="pron-value">{{ sentence.pronunciation.stress }}</span>
+              </div>
+              <div class="pron-row">
+                <span class="pron-label">流畅度</span>
+                <div class="pron-track"><div class="pron-fill" :style="{ width: `${sentence.pronunciation.fluency}%` }" /></div>
+                <span class="pron-value">{{ sentence.pronunciation.fluency }}</span>
+              </div>
+            </div>
+
+            <!-- 反馈信息 -->
+            <div v-if="sentence.feedback" class="feedback-section">
+              <div v-if="sentence.feedback.comment" class="feedback-block">
+                <div class="feedback-block-label"><span class="fb-good">✓</span> 一句话点评</div>
+                <p class="feedback-text">{{ sentence.feedback.comment }}</p>
+              </div>
+              <div v-if="sentence.feedback.betterExpression" class="feedback-block">
+                <div class="feedback-block-label"><span class="fb-suggest">→</span> 进阶表达</div>
+                <p class="better-expression">{{ sentence.feedback.betterExpression }}</p>
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { ref, computed } from 'vue'
+import type { SentenceEvaluation } from '@/types/englishSpeaking'
+import { useAudioPlayer } from '../composables/useAudioPlayer'
+
+interface Props {
+  sentenceEvaluations: SentenceEvaluation[]
+}
+
+const props = defineProps<Props>()
+
+const player = useAudioPlayer()
+
+const expandedIds = ref(new Set<string>())
+const allExpanded = ref(false)
+
+const groupedByRound = computed(() => {
+  const groups: SentenceEvaluation[][] = []
+  for (const s of props.sentenceEvaluations) {
+    const idx = s.round - 1
+    if (!groups[idx]) groups[idx] = []
+    groups[idx].push(s)
+  }
+  return groups
+})
+
+function toggleExpand(id: string) {
+  if (expandedIds.value.has(id)) {
+    expandedIds.value.delete(id)
+  } else {
+    expandedIds.value.add(id)
+  }
+}
+
+function toggleAll() {
+  if (allExpanded.value) {
+    expandedIds.value.clear()
+  } else {
+    props.sentenceEvaluations
+      .filter(s => s.role === 'student' && hasDetails(s))
+      .forEach(s => expandedIds.value.add(s.id))
+  }
+  allExpanded.value = !allExpanded.value
+}
+
+function hasDetails(sentence: SentenceEvaluation) {
+  return Boolean(sentence.pronunciation || sentence.feedback)
+}
+
+function formatDuration(seconds: number | null | undefined): string {
+  if (seconds == null || !Number.isFinite(seconds)) return '--:--'
+  const total = Math.round(seconds)
+  const mins = Math.floor(total / 60)
+  const secs = total % 60
+  return `${mins}:${String(secs).padStart(2, '0')}`
+}
+
+function handleSentencePlay(s: SentenceEvaluation) {
+  if (!s.audioUrl) return
+  // 同 id 二次点击:toggle 停止;否则播放新源
+  if (player.playingId.value === s.id || player.loadingId.value === s.id) {
+    player.stop()
+    return
+  }
+  player.play(s.id, { kind: 'url', url: s.audioUrl })
+}
+
+function getScoreClass(score: number) {
+  if (score >= 90) return 'score-excellent'
+  if (score >= 80) return 'score-good'
+  if (score >= 70) return 'score-fair'
+  return 'score-low'
+}
+</script>
+
+<style lang="scss" scoped>
+.detailed-report {
+  width: 100%;
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+}
+
+.report-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+}
+.report-title { font-size: 14px; font-weight: 600; color: #111827; margin: 0; }
+.toggle-all-btn {
+  font-size: 11px;
+  color: #f97316;
+  background: none;
+  border: none;
+  cursor: pointer;
+  display: flex;
+  align-items: center;
+  gap: 2px;
+  &:hover { color: #ea580c; }
+  svg { transition: transform 0.2s; }
+  .chevron-up { transform: rotate(180deg); }
+}
+
+.round-group { display: flex; flex-direction: column; gap: 6px; }
+.round-label { font-size: 10px; font-weight: 500; color: #9ca3af; text-transform: uppercase; letter-spacing: 0.5px; }
+
+.round-cards { display: flex; flex-direction: column; gap: 6px; }
+
+.sentence-card {
+  background: #fff;
+  border-radius: 10px;
+  border: 1px solid #f3f4f6;
+  overflow: hidden;
+  transition: border-color 0.2s;
+}
+.card-student { cursor: pointer; &:hover { border-color: #fed7aa; } }
+.card-expanded { border-color: rgba(249,115,22,0.3); }
+
+.card-body {
+  padding: 10px 12px;
+  display: flex;
+  align-items: flex-start;
+  justify-content: space-between;
+  gap: 8px;
+}
+.card-left { flex: 1; min-width: 0; }
+
+// 卡片语音条
+.card-voice-bar {
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
+  padding: 4px 8px;
+  border-radius: 8px;
+  margin-bottom: 4px;
+}
+.bar-ai { background: #fff; border: 1px solid #f3f4f6; }
+.bar-student { background: #f97316; }
+
+.card-play-btn {
+  padding: 2px;
+  border-radius: 50%;
+  border: none;
+  cursor: pointer;
+}
+.play-ai { background: rgba(249,115,22,0.1); color: #f97316; }
+.play-student { background: rgba(255,255,255,0.2); color: #fff; }
+
+.card-waveform { display: flex; align-items: center; gap: 1px; }
+.card-wave-bar { width: 1.5px; border-radius: 999px; }
+.cw-ai { background: rgba(249,115,22,0.4); }
+.cw-student { background: rgba(255,255,255,0.5); }
+
+.card-duration { font-size: 9px; }
+.cd-ai { color: #9ca3af; }
+.cd-student { color: rgba(255,255,255,0.8); }
+
+.card-text { font-size: 12px; color: #1f2937; margin: 0; line-height: 1.5; }
+.card-text-zh { font-size: 10px; color: #9ca3af; margin: 2px 0 0; }
+
+.card-right {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 4px;
+}
+.card-score {
+  font-size: 12px;
+  font-weight: 600;
+  padding: 2px 8px;
+  border-radius: 999px;
+}
+.score-excellent { background: #dcfce7; color: #16a34a; }
+.score-good { background: #dbeafe; color: #2563eb; }
+.score-fair { background: #fef3c7; color: #d97706; }
+.score-low { background: #fee2e2; color: #ef4444; }
+
+.expand-icon {
+  transition: transform 0.2s;
+  &.expanded { transform: rotate(180deg); }
+}
+
+// 展开详情
+.card-detail {
+  padding: 10px 12px;
+  border-top: 1px solid #f9fafb;
+  background: #fafbfc;
+}
+
+.pron-scores { display: flex; flex-direction: column; gap: 8px; margin-bottom: 10px; }
+.pron-row { display: flex; align-items: center; gap: 8px; }
+.pron-label { font-size: 10px; color: #6b7280; width: 36px; flex-shrink: 0; }
+.pron-track { flex: 1; height: 4px; background: #e5e7eb; border-radius: 999px; overflow: hidden; }
+.pron-fill { height: 100%; background: #f97316; border-radius: 999px; transition: width 0.5s; }
+.pron-value { font-size: 10px; font-weight: 500; color: #f97316; width: 20px; text-align: right; }
+
+.feedback-section { display: flex; flex-direction: column; gap: 10px; }
+.feedback-block { margin-bottom: 2px; }
+.feedback-block-label { font-size: 10px; color: #6b7280; display: flex; align-items: center; gap: 4px; margin-bottom: 4px; }
+.fb-good { color: #22c55e; }
+.fb-fix { color: #3b82f6; }
+.fb-suggest { color: #f59e0b; }
+.feedback-text {
+  margin: 0;
+  font-size: 11px;
+  color: #4b5563;
+  line-height: 1.5;
+}
+.better-expression {
+  margin: 0;
+  padding: 8px 10px;
+  background: #fff;
+  border: 1px solid #f3f4f6;
+  border-radius: 8px;
+  font-size: 12px;
+  color: #111827;
+  line-height: 1.5;
+}
+.feedback-list {
+  margin: 0;
+  padding-left: 16px;
+  li { font-size: 10px; color: #4b5563; line-height: 1.6; }
+}
+
+.correction-item { margin-bottom: 6px; }
+.correction-row { display: flex; align-items: center; gap: 4px; font-size: 11px; }
+.correction-original { color: #ef4444; text-decoration: line-through; }
+.correction-arrow { color: #9ca3af; }
+.correction-corrected { color: #22c55e; font-weight: 500; }
+.correction-explanation { font-size: 10px; color: #6b7280; margin: 2px 0 0; }
+</style>

+ 1936 - 0
src/views/Editor/EnglishSpeaking/preview/DialogueChatView.vue

@@ -0,0 +1,1936 @@
+<template>
+  <div class="dialogue-chat-view">
+    <!-- ── 结束态全屏 loading ── -->
+    <div v-if="reportFetchInflight" class="finalizing-screen">
+      <div class="finalizing-spinner">
+        <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor"
+          stroke-width="2" stroke-linecap="round">
+          <path d="M21 12a9 9 0 1 1-6.219-8.56" />
+        </svg>
+      </div>
+      <p class="finalizing-title">AI 正在为你整理报告...</p>
+      <div class="finalizing-stats-card">
+        <span class="finalizing-stat">{{ finalizingStats.rounds }} 轮</span>
+        <span class="finalizing-stats-sep">·</span>
+        <span class="finalizing-stat">{{ finalizingStats.sentences }} 句</span>
+        <span class="finalizing-stats-sep">·</span>
+        <span class="finalizing-stat">累计 {{ finalizingStats.durationText }}</span>
+      </div>
+    </div>
+
+    <template v-else>
+      <!-- ── HEADER ── -->
+      <div class="chat-header">
+        <div class="header-left">
+          <div
+            class="ai-avatar"
+            :class="{ breathing: state === 'idle' || state === 'ai_thinking' }"
+          >{{ aiAvatar }}</div>
+          <span class="ai-name">{{ aiName }}</span>
+
+          <span v-if="state === 'idle' && showIdleHint" class="idle-hint fade-in">
+            在等你的回答...
+          </span>
+          <span
+            v-else-if="state !== 'idle' && state !== 'ai_thinking'"
+            class="online-dot"
+            title="在线"
+          />
+        </div>
+
+        <div class="header-right">
+          <span class="round-indicator">{{ currentRound }} / {{ totalRounds }} 轮</span>
+          <span v-if="engine.countdownSeconds.value != null" class="total-time">
+            {{ formatSeconds(engine.countdownSeconds.value) }}
+          </span>
+          <button class="icon-btn" title="更多操作" @click="showExitConfirm = true">
+            <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
+              stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+              <circle cx="12" cy="12" r="1" /><circle cx="19" cy="12" r="1" /><circle cx="5" cy="12" r="1" />
+            </svg>
+          </button>
+        </div>
+      </div>
+
+      <!-- 麦克风权限引导 -->
+      <div v-if="recorder.permissionState.value === 'denied'" class="permission-banner">
+        <span class="permission-icon">🎤</span>
+        <span>麦克风权限已被拒绝,请在浏览器设置中开启后刷新页面</span>
+      </div>
+
+      <!-- ── CHAT AREA ── -->
+      <div ref="chatContainerRef" class="chat-area">
+        <template v-for="message in engine.messages.value" :key="message.id">
+          <!-- AI 消息 -->
+          <div v-if="message.role === 'ai'" class="msg-row msg-ai fade-in">
+            <div class="avatar-sm">{{ aiAvatar }}</div>
+            <div class="msg-col">
+              <!-- 音频条 -->
+              <div v-if="message.content || message.status === 'done'" class="voice-bar voice-ai">
+                <button
+                  class="play-btn play-ai"
+                  :class="{ 'play-btn-error': player.errorId.value === message.id }"
+                  @click="togglePlay(message.id)"
+                >
+                  <svg
+                    v-if="player.loadingId.value === message.id"
+                    class="play-spinner"
+                    width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
+                    stroke-width="2" stroke-linecap="round"
+                  >
+                    <path d="M21 12a9 9 0 1 1-6.219-8.56" />
+                  </svg>
+                  <svg
+                    v-else-if="player.playingId.value === message.id"
+                    width="12" height="12" viewBox="0 0 24 24" fill="currentColor"
+                  >
+                    <rect x="6" y="4" width="4" height="16" /><rect x="14" y="4" width="4" height="16" />
+                  </svg>
+                  <svg
+                    v-else-if="player.errorId.value === message.id"
+                    width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
+                    stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
+                  >
+                    <path d="M12 9v4" />
+                    <path d="M12 17h.01" />
+                    <path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
+                  </svg>
+                  <svg
+                    v-else
+                    width="12" height="12" viewBox="0 0 24 24" fill="currentColor"
+                  >
+                    <polygon points="5 3 19 12 5 21 5 3" />
+                  </svg>
+                </button>
+                <div class="wave-bar-group">
+                  <div
+                    v-for="i in 14"
+                    :key="i"
+                    class="wave-bar wave-ai"
+                    :style="{ height: `${Math.abs(Math.sin(i * 0.7)) * 8 + 3}px` }"
+                  />
+                </div>
+                <span
+                  v-if="player.errorId.value === message.id"
+                  class="play-error-hint"
+                >点击重试</span>
+              </div>
+
+              <!-- 英文文本 -->
+              <div v-if="showEnglishText && message.content" class="bubble bubble-ai">
+                {{ message.content }}
+              </div>
+
+              <!-- 流式加载中 -->
+              <div v-if="message.status === 'loading' && !message.content" class="typing-bubble">
+                <span class="typing-dot" style="animation-delay: 0ms" />
+                <span class="typing-dot" style="animation-delay: 150ms" />
+                <span class="typing-dot" style="animation-delay: 300ms" />
+              </div>
+
+              <!-- AI 错误 -->
+              <div v-if="message.status === 'error'" class="error-card">
+                <span class="error-text">{{ message.error || '生成失败' }}</span>
+                <button
+                  v-if="hasRetryButton(message)"
+                  class="retry-btn"
+                  :disabled="engine.greetingInflight.value"
+                  @click="handleRetry(message)"
+                >{{ retryButtonLabel(message) }}</button>
+              </div>
+            </div>
+          </div>
+
+          <!-- 学生消息 -->
+          <div v-else class="msg-row msg-student fade-in">
+            <!-- 音频条(橙色) -->
+            <div v-if="message.content || message.status !== 'loading'" class="voice-bar voice-student">
+              <span
+                v-if="player.errorId.value === message.id"
+                class="play-error-hint play-error-hint-student"
+              >点击重试</span>
+              <span
+                v-else
+                class="voice-duration voice-duration-student"
+              >{{ formatDuration(message.audioDuration) }}</span>
+              <div class="wave-bar-group">
+                <div
+                  v-for="i in 14"
+                  :key="i"
+                  class="wave-bar wave-student"
+                  :style="{ height: `${Math.abs(Math.sin(i * 0.7)) * 8 + 3}px` }"
+                />
+              </div>
+              <button
+                class="play-btn play-student"
+                :class="{ 'play-btn-error': player.errorId.value === message.id }"
+                @click="togglePlay(message.id)"
+              >
+                <svg
+                  v-if="player.loadingId.value === message.id"
+                  class="play-spinner"
+                  width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
+                  stroke-width="2" stroke-linecap="round"
+                >
+                  <path d="M21 12a9 9 0 1 1-6.219-8.56" />
+                </svg>
+                <svg
+                  v-else-if="player.playingId.value === message.id"
+                  width="12" height="12" viewBox="0 0 24 24" fill="currentColor"
+                >
+                  <rect x="6" y="4" width="4" height="16" /><rect x="14" y="4" width="4" height="16" />
+                </svg>
+                <svg
+                  v-else-if="player.errorId.value === message.id"
+                  width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
+                  stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
+                >
+                  <path d="M12 9v4" />
+                  <path d="M12 17h.01" />
+                  <path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
+                </svg>
+                <svg
+                  v-else
+                  width="12" height="12" viewBox="0 0 24 24" fill="currentColor"
+                >
+                  <polygon points="5 3 19 12 5 21 5 3" />
+                </svg>
+              </button>
+            </div>
+
+            <!-- 学生 STT 加载中(与 AI typing-bubble 对称,避免按完成后 student 侧空白) -->
+            <div v-if="message.status === 'loading' && !message.content" class="typing-bubble typing-bubble-student">
+              <span class="typing-dot" style="animation-delay: 0ms" />
+              <span class="typing-dot" style="animation-delay: 150ms" />
+              <span class="typing-dot" style="animation-delay: 300ms" />
+            </div>
+
+            <!-- 英文识别文本(带高亮) -->
+            <div v-if="showEnglishText && message.content" class="bubble bubble-student">
+              <template v-if="message.evaluation?.wordAnalysis">
+                <template v-for="(word, idx) in message.content.split(' ')" :key="idx">
+                  <span
+                    v-if="getWordAnalysis(message, word)?.status === 'improvable'"
+                    class="improvable-word"
+                    @click="openPhonemeDetail(getWordAnalysis(message, word)!)"
+                  >{{ word }}</span>
+                  <span v-else>{{ word }}</span>
+                  {{ ' ' }}
+                </template>
+              </template>
+              <template v-else>{{ message.content }}</template>
+            </div>
+
+            <!-- 学生错误 -->
+            <div v-if="message.status === 'error'" class="error-card">
+              <span class="error-text">{{ message.error || '发送失败' }}</span>
+              <button
+                v-if="hasRetryButton(message)"
+                class="retry-btn"
+                @click="handleRetry(message)"
+              >{{ retryButtonLabel(message) }}</button>
+              <button
+                v-if="hasRerecordButton(message)"
+                class="rerecord-btn"
+                @click="handleRerecord(message)"
+              >重录</button>
+            </div>
+
+            <!-- L1 评分卡 -->
+            <div v-if="message.evaluation" class="eval-card">
+              <div class="eval-l1">
+                <div class="dim-row">
+                  <span class="dim-label">准确</span>
+                  <DimBadge :level="message.evaluation.dimensions.accuracy" />
+                  <span class="dim-sep">|</span>
+                  <span class="dim-label">流畅</span>
+                  <DimBadge :level="message.evaluation.dimensions.fluency" />
+                  <span class="dim-sep">|</span>
+                  <span class="dim-label">完整</span>
+                  <DimBadge :level="message.evaluation.dimensions.completeness" />
+                  <span class="dim-sep">|</span>
+                  <span class="dim-label">节奏</span>
+                  <DimBadge :level="message.evaluation.dimensions.rhythm" />
+                </div>
+                <div class="sugg-row">
+                  <p class="sugg-text">
+                    <span class="sugg-icon">💡</span>
+                    <span class="truncate">{{ message.evaluation.suggestion }}</span>
+                  </p>
+                  <button class="detail-toggle" @click="toggleExpand(message.id)">
+                    {{ expandedMessageId === message.id ? '收起' : '详情' }}
+                    <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor"
+                      stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
+                      :class="{ 'chev-up': expandedMessageId === message.id }">
+                      <polyline points="6 9 12 15 18 9" />
+                    </svg>
+                  </button>
+                </div>
+              </div>
+
+              <div v-if="expandedMessageId === message.id" class="eval-l2 fade-in">
+                <div v-if="message.evaluation.betterExpression" class="better-exp">
+                  <p class="detail-label"><span>✨</span> Better expression:</p>
+                  <p class="detail-content">{{ message.evaluation.betterExpression }}</p>
+                </div>
+                <div v-if="message.evaluation.suggestedWords?.length" class="suggested-words">
+                  <p class="detail-label"><span>🎯</span> Try these words:</p>
+                  <div class="word-tags">
+                    <span v-for="(w, i) in message.evaluation.suggestedWords" :key="i" class="word-tag">{{ w }}</span>
+                  </div>
+                </div>
+              </div>
+            </div>
+          </div>
+        </template>
+
+      </div>
+
+      <!-- 沉默提示浮层 -->
+      <div v-if="state === 'recording' && silenceHintText" class="silence-hint-wrap">
+        <div class="silence-hint fade-in">
+          <span class="silence-icon">💡</span>
+          <p class="silence-text">{{ silenceHintText }}</p>
+          <button class="silence-close" @click="silenceHintText = ''">
+            <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
+              stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+              <line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
+            </svg>
+          </button>
+        </div>
+      </div>
+
+      <!-- ── CONTROL ZONE ── -->
+      <div class="control-zone">
+        <!-- 进度条(仅录音时可见) -->
+        <div class="progress-wrap">
+          <div
+            class="progress-track"
+            :style="{ background: state === 'recording' ? '#f3f4f6' : 'transparent' }"
+          >
+            <div
+              class="progress-fill"
+              :class="{ 'near-limit': isNearLimit }"
+              :style="{
+                width: state === 'recording' ? `${progressPct}%` : '0%',
+                opacity: state === 'recording' ? 1 : 0,
+              }"
+            />
+          </div>
+        </div>
+
+        <!-- 状态叠放区 -->
+        <div class="state-stack">
+          <!-- idle -->
+          <div
+            class="state-layer state-idle"
+            :style="stateStyle('idle')"
+          >
+            <button class="hint-btn" @click="openTaskHint">
+              <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
+                stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+                <path d="M9 18h6" /><path d="M10 22h4" />
+                <path d="M15.09 14c.18-.98.65-1.74 1.41-2.5A4.65 4.65 0 0 0 18 8 6 6 0 0 0 6 8c0 1 .23 2.23 1.5 3.5A4.61 4.61 0 0 1 8.91 14" />
+              </svg>
+              提示
+            </button>
+            <button
+              class="mic-btn"
+              :disabled="!engine.canRecord.value"
+              @click="handleStartRecording"
+            >
+              <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
+                stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+                <path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z" />
+                <path d="M19 10v2a7 7 0 0 1-14 0v-2" />
+                <line x1="12" y1="19" x2="12" y2="23" />
+                <line x1="8" y1="23" x2="16" y2="23" />
+              </svg>
+              开始录音
+            </button>
+          </div>
+
+          <!-- starting -->
+          <div class="state-layer state-starting" :style="stateStyle('starting')">
+            <div class="record-capsule">
+              <button class="cancel-btn" @click="handleCancelStarting">
+                <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
+                  stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+                  <line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
+                </svg>
+                取消
+              </button>
+              <div class="record-meter">
+                <svg class="spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
+                  stroke-width="2" stroke-linecap="round">
+                  <path d="M21 12a9 9 0 1 1-6.219-8.56" />
+                </svg>
+                <span class="record-time">准备录音中...</span>
+              </div>
+              <button class="finish-btn" disabled>
+                <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
+                  stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+                  <path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
+                  <polyline points="22 4 12 14.01 9 11.01" />
+                </svg>
+                完成
+              </button>
+            </div>
+          </div>
+
+          <!-- recording -->
+          <div class="state-layer state-recording" :style="stateStyle('recording')">
+            <div class="record-capsule">
+              <button class="cancel-btn" @click="handleCancelRecording">
+                <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
+                  stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+                  <line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
+                </svg>
+                取消
+              </button>
+              <div class="record-meter">
+                <div class="animated-wave">
+                  <div
+                    v-for="i in 7"
+                    :key="i"
+                    class="aw-bar"
+                    :class="{ 'near-limit': isNearLimit }"
+                    :style="{
+                      height: `${Math.abs(Math.sin(i * 0.9)) * 9 + 3}px`,
+                      animationDelay: `${(i - 1) * 0.1}s`,
+                    }"
+                  />
+                </div>
+                <span class="record-time" :class="{ 'near-limit': isNearLimit }">
+                  {{ formatSeconds(recorder.recordingDuration.value) }}
+                </span>
+                <span class="record-time-max">/ {{ formatSeconds(MAX_RECORDING_SECONDS) }}</span>
+              </div>
+              <button class="finish-btn" @click="handleFinishRecording">
+                <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
+                  stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+                  <path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
+                  <polyline points="22 4 12 14.01 9 11.01" />
+                </svg>
+                完成
+              </button>
+            </div>
+          </div>
+
+          <!-- stt -->
+          <div class="state-layer state-center" :style="stateStyle('stt')">
+            <svg class="spinner" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
+              stroke-width="2" stroke-linecap="round">
+              <path d="M21 12a9 9 0 1 1-6.219-8.56" />
+            </svg>
+            <span class="center-text">正在识别语音...</span>
+          </div>
+
+          <!-- ai_thinking -->
+          <div class="state-layer state-center" :style="stateStyle('ai_thinking')">
+            <div class="mini-avatar">{{ aiAvatar }}</div>
+            <span class="center-text">{{ aiName }} 正在回复...</span>
+          </div>
+
+          <!-- error -->
+          <div class="state-layer state-error" :style="stateStyle('error')">
+            <div class="error-info">
+              <span class="warn-icon">⚠️</span>
+              <span class="warn-text">{{ lastErrorText }}</span>
+            </div>
+            <button
+              v-if="lastErroredMessage && hasRetryButton(lastErroredMessage)"
+              class="retry-pill"
+              @click="handleRetry(lastErroredMessage)"
+            >{{ retryButtonLabel(lastErroredMessage) }}</button>
+          </div>
+
+        </div>
+      </div>
+
+      <!-- ─────── OVERLAYS ─────── -->
+
+      <!-- 任务提示弹窗 -->
+      <TaskHintModal
+        :visible="showHintModal"
+        :loading="taskHintLoading"
+        :error="taskHintError"
+        :hint="taskHint"
+        :ai-name="aiName"
+        @close="showHintModal = false"
+        @retry="loadTaskHint"
+      />
+
+      <!-- 音素详情弹窗 -->
+      <div v-if="phonemeDetail" class="modal-mask" @click.self="phonemeDetail = null">
+        <div class="modal phoneme-modal scale-in">
+          <div class="modal-head">
+            <div>
+              <h3 class="phoneme-word">{{ phonemeDetail.word }}</h3>
+              <p class="phoneme-sub">发音详情</p>
+            </div>
+            <button class="close-btn" @click="phonemeDetail = null">
+              <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
+                stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+                <line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
+              </svg>
+            </button>
+          </div>
+
+          <div class="phoneme-body">
+            <div class="pho-card pho-user">
+              <div>
+                <p class="pho-label">你的发音</p>
+                <p class="pho-value">{{ phonemeDetail.userPronunciation }}</p>
+              </div>
+              <button class="pho-play pho-play-user">
+                <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3" /></svg>
+              </button>
+            </div>
+            <div class="pho-card pho-standard">
+              <div>
+                <p class="pho-label">标准发音</p>
+                <p class="pho-value pho-value-green">{{ phonemeDetail.standardPronunciation }}</p>
+              </div>
+              <button class="pho-play pho-play-standard">
+                <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3" /></svg>
+              </button>
+            </div>
+            <div v-if="phonemeDetail.tip" class="pho-card pho-tip">
+              <p class="pho-label">小提示</p>
+              <p class="pho-tip-text">{{ phonemeDetail.tip }}</p>
+            </div>
+
+            <button class="pho-practice-btn" @click="practiceThisWord">
+              针对这个词重练一次
+            </button>
+          </div>
+        </div>
+      </div>
+
+      <!-- 退出/重开确认弹窗 -->
+      <div v-if="showExitConfirm" class="modal-mask" @click.self="showExitConfirm = false">
+        <div class="modal exit-modal scale-in">
+          <div class="modal-head">
+            <h3 class="modal-title">选择操作</h3>
+            <button class="close-btn" @click="showExitConfirm = false">
+              <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
+                stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+                <line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
+              </svg>
+            </button>
+          </div>
+          <p class="exit-hint">请选择你的操作:</p>
+          <div class="exit-actions">
+            <button class="exit-secondary" @click="showExitConfirm = false">继续练习</button>
+            <button class="exit-secondary" @click="handleRestart">重新开始</button>
+            <button class="exit-primary" @click="handleExitConfirm">结束并查看报告</button>
+          </div>
+        </div>
+      </div>
+
+      <!-- 徽章弹窗 -->
+      <div v-if="showBadge" class="badge-popup scale-in">
+        <div class="badge-card">
+          <span class="badge-icon">{{ showBadge.icon }}</span>
+          <div>
+            <p class="badge-name">{{ showBadge.name }}</p>
+            <p class="badge-desc">{{ showBadge.description }}</p>
+          </div>
+        </div>
+      </div>
+    </template>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { ref, computed, watch, onMounted, onUnmounted, nextTick, h, defineComponent } from 'vue'
+import type { PropType } from 'vue'
+import type { PreviewChatMessage, BadgeAchievement, DialogueReport, SessionStartInfo, TaskHint } from '@/types/englishSpeaking'
+import { useDialogueEngine } from '../composables/useDialogueEngine'
+import { useAudioRecorder } from '../composables/useAudioRecorder'
+import { useAudioPlayer } from '../composables/useAudioPlayer'
+import TaskHintModal from './TaskHintModal.vue'
+import { createDialogueApi } from '../services/llmService'
+import { SPEAKING_TRANSPORT } from '../services/speakingApiConfig'
+
+// ─────────────────────────────────────────────
+// Props / Emits
+// ─────────────────────────────────────────────
+
+interface Props {
+  topic?: string
+  keywords?: string[]
+  aiName?: string
+  aiAvatar?: string
+  totalRounds?: number
+  showEnglishText?: boolean
+  showChineseText?: boolean
+  sessionInfo?: SessionStartInfo | null
+}
+
+const props = withDefaults(defineProps<Props>(), {
+  topic: '',
+  keywords: () => ['animal', 'zoo', 'cute', 'favorite'],
+  aiName: 'Amy',
+  aiAvatar: '😊',
+  totalRounds: 3,
+  showEnglishText: true,
+  showChineseText: false,
+  sessionInfo: null,
+})
+
+const emit = defineEmits<{
+  complete: [report: DialogueReport | null]
+  restart: []
+}>()
+
+// ─────────────────────────────────────────────
+// Config
+// ─────────────────────────────────────────────
+
+const MAX_RECORDING_SECONDS = 60
+
+// 学生录音上送方式:'websocket'(默认,流式)或 'http'(单次 POST /speak)。
+// 由环境变量 VITE_SPEAKING_TRANSPORT 控制;未配置时默认 websocket。
+const speakTransport = SPEAKING_TRANSPORT
+
+const SILENCE_HINTS = [
+  'You could say: "I really like pandas because they are so cute!"',
+  'Try: "My favorite animal is the elephant. It\'s very smart."',
+  'You could say: "I went to the zoo last weekend with my family."',
+]
+
+const BADGE_CONFIG: Record<string, BadgeAchievement> = {
+  smooth_talker: { id: 'smooth_talker', name: '流畅达人', nameEn: 'Smooth Talker', icon: '💬', description: '连续3句流畅度优秀' },
+  pronunciation_pro: { id: 'pronunciation_pro', name: '发音专家', nameEn: 'Pronunciation Pro', icon: '🎯', description: '连续5句发音准确' },
+  perfect_round: { id: 'perfect_round', name: '完美一轮', nameEn: 'Perfect Round', icon: '⭐', description: '单轮四维度全优' },
+}
+
+// ─────────────────────────────────────────────
+// Composables
+// ─────────────────────────────────────────────
+
+const engine = useDialogueEngine()
+const recorder = useAudioRecorder()
+const player = useAudioPlayer()
+
+// ─────────────────────────────────────────────
+// Local UI State
+// ─────────────────────────────────────────────
+
+const chatContainerRef = ref<HTMLDivElement>()
+const expandedMessageId = ref<string | null>(null)
+const showHintModal = ref(false)
+const taskHint = ref<TaskHint | null>(null)
+const taskHintLoading = ref(false)
+const taskHintError = ref<string | null>(null)
+const taskHintApi = createDialogueApi()
+const showExitConfirm = ref(false)
+const phonemeDetail = ref<{
+  word: string
+  userPronunciation?: string
+  standardPronunciation?: string
+  tip?: string
+} | null>(null)
+
+const silenceHintText = ref('')
+const showIdleHint = ref(false)
+let idleHintTimer: ReturnType<typeof setTimeout> | null = null
+
+const isStarting = ref(false)
+const reportFetchInflight = ref(false)
+let startAbortController: AbortController | null = null
+
+// 徽章
+const showBadge = ref<BadgeAchievement | null>(null)
+const consecutiveFluent = ref(0)
+const consecutiveAccurate = ref(0)
+let badgeTimer: ReturnType<typeof setTimeout> | null = null
+
+// ─────────────────────────────────────────────
+// Derived State
+// ─────────────────────────────────────────────
+
+const currentRound = computed(() => engine.currentRound.value)
+
+// 状态机:starting → recording → stt → ai_thinking → idle / error / done
+const state = computed<
+  'idle' | 'starting' | 'recording' | 'stt' | 'ai_thinking' | 'error' | 'done'
+>(() => {
+  if (isStarting.value) return 'starting'
+  if (recorder.isRecording.value) return 'recording'
+  if (engine.isComplete.value) return 'done'
+
+  const msgs = engine.messages.value
+  const last = msgs[msgs.length - 1]
+
+  if (last?.status === 'error') return 'error'
+
+  // 学生消息 loading 且无 content → 正在 STT
+  if (last?.role === 'student' && last.status === 'loading' && !last.content) return 'stt'
+
+  if (engine.isProcessing.value) return 'ai_thinking'
+
+  return 'idle'
+})
+
+const progressPct = computed(() => Math.min((recorder.recordingDuration.value / MAX_RECORDING_SECONDS) * 100, 100))
+const isNearLimit = computed(() => recorder.recordingDuration.value >= MAX_RECORDING_SECONDS * 0.8)
+
+const lastErrorText = computed(() => {
+  const msgs = engine.messages.value
+  const last = msgs[msgs.length - 1]
+  return last?.error || '请求异常,请稍后再试'
+})
+
+const finalizingStats = computed(() => {
+  const studentMsgs = engine.messages.value.filter(m => m.role === 'student')
+  const sentenceCount = studentMsgs.filter(m => m.status !== 'error').length
+  const totalDurationSec = studentMsgs.reduce(
+    (sum, m) => sum + (m.audioDuration ?? 0),
+    0,
+  )
+  return {
+    rounds: engine.currentRound.value,
+    sentences: sentenceCount,
+    durationText: formatSeconds(Math.round(totalDurationSec)),
+  }
+})
+
+// ─────────────────────────────────────────────
+// Sub-Component: DimBadge
+// ─────────────────────────────────────────────
+
+const DimBadge = defineComponent({
+  props: { level: { type: String as PropType<'excellent' | 'good' | 'improve'>, required: true } },
+  setup(p) {
+    return () => {
+      if (p.level === 'excellent') return h('span', { class: 'dim-badge dim-excellent' }, '✓✓')
+      if (p.level === 'good') return h('span', { class: 'dim-badge dim-good' }, '✓')
+      return h('span', { class: 'dim-badge dim-improve' }, '△')
+    }
+  },
+})
+
+// ─────────────────────────────────────────────
+// Helpers
+// ─────────────────────────────────────────────
+
+function formatSeconds(s: number): string {
+  const m = Math.floor(s / 60)
+  const sec = s % 60
+  return `${m}:${sec.toString().padStart(2, '0')}`
+}
+
+function formatDuration(seconds: number | null | undefined): string {
+  if (seconds == null || !Number.isFinite(seconds)) return '--:--'
+  const total = Math.round(seconds)
+  const mins = Math.floor(total / 60)
+  const secs = total % 60
+  return `${mins}:${String(secs).padStart(2, '0')}`
+}
+
+function getWordAnalysis(message: PreviewChatMessage, word: string) {
+  const clean = word.replace(/[.,!?]/g, '')
+  return message.evaluation?.wordAnalysis?.find(w => w.word === clean)
+}
+
+function stateStyle(target: string) {
+  const active = state.value === target
+  return {
+    opacity: active ? 1 : 0,
+    pointerEvents: active ? 'auto' : 'none',
+    transition: 'opacity 0.18s ease-out',
+  } as const
+}
+
+// ─────────────────────────────────────────────
+// Actions
+// ─────────────────────────────────────────────
+
+// 当前流式会话控制器(开始录音时打开 WebSocket,结束时收尾)
+let streamCtl: ReturnType<typeof engine.beginStudentStream> = null
+
+async function handleStartRecording() {
+  if (!engine.canRecord.value || recorder.isRecording.value || isStarting.value) return
+  player.stop()
+
+  isStarting.value = true
+  startAbortController = new AbortController()
+
+  try {
+    await recorder.startRecording(startAbortController.signal)
+    // 学生录音上送方式由 speakingApiConfig.SPEAKING_TRANSPORT 决定:
+    // - websocket(默认):开 WS 流式推 PCM;失败由 useDialogueEngine 暴露 error,用户点"重试"才走 HTTP。
+    // - http:跳过 WS,handleFinishRecording 直接走 sendStudentMessage → /speak。
+    if (speakTransport === 'websocket') {
+      streamCtl = engine.beginStudentStream({
+        sampleRate: recorder.sampleRate.value,
+        bits: 16,
+        channels: 1,
+      })
+      if (streamCtl) {
+        recorder.onChunk.value = streamCtl.pushChunk
+      }
+    }
+  } catch (err: any) {
+    if (err.name === 'AbortError') {
+      // User cancelled during getUserMedia. State naturally returns to idle.
+    } else {
+      console.error('Failed to start recording:', err)
+    }
+  } finally {
+    isStarting.value = false
+    startAbortController = null
+  }
+}
+
+function handleCancelStarting() {
+  startAbortController?.abort()
+  // recorder.startRecording will reject with AbortError; no further cleanup needed.
+}
+
+function handleCancelRecording() {
+  // 停止录音 + 中止流式会话(丢弃本次录音)
+  recorder.onChunk.value = null
+  if (streamCtl) {
+    streamCtl.abort()  // close WS; nothing on the message list to clean
+    streamCtl = null
+  }
+  if (recorder.isRecording.value) {
+    recorder.stopRecording().catch(() => {})
+  }
+  recorder.cleanup()
+}
+
+async function handleFinishRecording() {
+  if (!recorder.isRecording.value) return
+  const ctl = streamCtl
+  streamCtl = null
+  try {
+    const blob = await recorder.stopRecording()
+    recorder.onChunk.value = null
+    if (ctl) {
+      ctl.commit(blob)  // pushes placeholders, attaches blob, sends 'stop'
+    } else {
+      // Fallback: direct HTTP path
+      await engine.sendStudentMessage(blob, crypto.randomUUID())
+    }
+  } catch (err) {
+    console.error('Recording/send failed:', err)
+  }
+}
+
+function hasRetryButton(m: PreviewChatMessage): boolean {
+  return m.recovery === 'retry' || m.recovery === 'restart'
+}
+function hasRerecordButton(m: PreviewChatMessage): boolean {
+  return m.role === 'student' && (m.recovery === 'retry' || m.recovery === 'rerecord')
+}
+function retryButtonLabel(m: PreviewChatMessage): string {
+  if (m.recovery === 'restart') return '返回重开'
+  return '重试'
+}
+async function handleRetry(m: PreviewChatMessage) {
+  if (m.recovery === 'restart') {
+    emit('restart')
+    return
+  }
+  if (m.role === 'student') {
+    await engine.retryMessage(m.id)
+  } else {
+    // greeting case: first AI message with no prior student
+    const idx = engine.messages.value.indexOf(m)
+    const isGreeting = !engine.messages.value
+      .slice(0, idx)
+      .some(x => x.role === 'student')
+    if (isGreeting) {
+      await engine.retryGreeting()
+    } else {
+      await engine.regenerateAiMessage(m.id)
+    }
+  }
+}
+function handleRerecord(m: PreviewChatMessage) {
+  engine.discardCurrentTurn(m.id)
+}
+
+const lastErroredMessage = computed<PreviewChatMessage | null>(() => {
+  const msgs = engine.messages.value
+  for (let i = msgs.length - 1; i >= 0; i--) {
+    if (msgs[i].status === 'error') return msgs[i]
+  }
+  return null
+})
+
+function toggleExpand(id: string) {
+  expandedMessageId.value = expandedMessageId.value === id ? null : id
+}
+
+function openTaskHint() {
+  showHintModal.value = true
+  if (!taskHint.value && !taskHintLoading.value) {
+    loadTaskHint()
+  }
+}
+
+async function loadTaskHint() {
+  if (!props.sessionInfo?.sessionId) {
+    taskHintError.value = '当前会话未准备好,请稍后重试'
+    return
+  }
+
+  taskHintLoading.value = true
+  taskHintError.value = null
+
+  try {
+    taskHint.value = await taskHintApi.generateTaskHint(props.sessionInfo.sessionId)
+  } catch {
+    taskHintError.value = '生成任务提示失败,请重试'
+  } finally {
+    taskHintLoading.value = false
+  }
+}
+
+function togglePlay(id: string) {
+  // Same id is currently playing or loading → stop.
+  if (player.playingId.value === id || player.loadingId.value === id) {
+    player.stop()
+    return
+  }
+
+  const msg = engine.messages.value.find(m => m.id === id)
+  if (!msg) return
+
+  if (msg.role === 'student' && msg.audioBlob) {
+    player.play(id, { kind: 'blob', blob: msg.audioBlob })
+  }
+  else if (msg.role === 'ai' && msg.content) {
+    player.play(id, { kind: 'tts', text: msg.content })
+  }
+}
+
+function openPhonemeDetail(wa: NonNullable<NonNullable<PreviewChatMessage['evaluation']>['wordAnalysis']>[0]) {
+  phonemeDetail.value = {
+    word: wa.word,
+    userPronunciation: wa.userPronunciation,
+    standardPronunciation: wa.standardPronunciation,
+    tip: wa.tip,
+  }
+}
+
+function practiceThisWord() {
+  phonemeDetail.value = null
+  handleStartRecording()
+}
+
+async function handleExitConfirm() {
+  showExitConfirm.value = false
+  emit('complete', await fetchReportSafe())
+}
+
+async function fetchReportSafe(): Promise<DialogueReport | null> {
+  reportFetchInflight.value = true
+  try {
+    await engine.completeSession()
+    return await engine.getReport()
+  } catch (err) {
+    console.warn('[speaking] getReport failed:', err)
+    return null
+  } finally {
+    reportFetchInflight.value = false
+  }
+}
+
+function handleRestart() {
+  showExitConfirm.value = false
+  engine.abort()
+  player.stop()
+  emit('restart')
+}
+
+// ─────────────────────────────────────────────
+// Badge detection
+// ─────────────────────────────────────────────
+
+function triggerBadge(id: string) {
+  const badge = BADGE_CONFIG[id]
+  if (!badge) return
+  showBadge.value = badge
+  if (badgeTimer) clearTimeout(badgeTimer)
+  badgeTimer = setTimeout(() => { showBadge.value = null }, 2800)
+}
+
+function checkBadges(ev: NonNullable<PreviewChatMessage['evaluation']>) {
+  const d = ev.dimensions
+  if (d.fluency === 'excellent') {
+    consecutiveFluent.value += 1
+    if (consecutiveFluent.value === 3) triggerBadge('smooth_talker')
+  } else {
+    consecutiveFluent.value = 0
+  }
+  if (d.accuracy === 'excellent') {
+    consecutiveAccurate.value += 1
+    if (consecutiveAccurate.value === 5) triggerBadge('pronunciation_pro')
+  } else {
+    consecutiveAccurate.value = 0
+  }
+  if (d.accuracy === 'excellent' && d.fluency === 'excellent' && d.completeness === 'excellent' && d.rhythm === 'excellent') {
+    setTimeout(() => triggerBadge('perfect_round'), 400)
+  }
+}
+
+// ─────────────────────────────────────────────
+// Watchers
+// ─────────────────────────────────────────────
+
+// 自动滚动到底部
+watch(
+  () => engine.messages.value.map(m => m.content + m.status).join('|'),
+  () => {
+    nextTick(() => {
+      if (chatContainerRef.value) {
+        chatContainerRef.value.scrollTop = chatContainerRef.value.scrollHeight
+      }
+    })
+  },
+)
+
+// 自动播放:AI 消息流式 done 后,合成并播一次。
+// 用 Set 去重防止 watcher 因为不相关重渲染重复触发。
+const autoPlayedIds = new Set<string>()
+const seededHistoricalMessageIds = new Set<string>()
+watch(
+  () => engine.messages.value.map(m => `${m.id}:${m.status}`).join('|'),
+  () => {
+    for (const m of engine.messages.value) {
+      if (
+        m.role === 'ai' &&
+        m.status === 'done' &&
+        m.content &&
+        !seededHistoricalMessageIds.has(m.id) &&
+        !autoPlayedIds.has(m.id)
+      ) {
+        autoPlayedIds.add(m.id)
+        player.play(m.id, { kind: 'tts', text: m.content })
+      }
+    }
+  },
+)
+
+// AI 消息 done 后,延迟 600ms 显示 "在等你的回答..."
+watch(
+  () => {
+    const msgs = engine.messages.value
+    const last = msgs[msgs.length - 1]
+    return last?.role === 'ai' && last.status === 'done' ? last.id : null
+  },
+  (val) => {
+    if (idleHintTimer) clearTimeout(idleHintTimer)
+    if (val) {
+      showIdleHint.value = false
+      idleHintTimer = setTimeout(() => { showIdleHint.value = true }, 600)
+    } else {
+      showIdleHint.value = false
+    }
+  },
+)
+
+// 学生消息产生 evaluation 时,检查徽章 + 清理沉默提示
+watch(
+  () => engine.messages.value.filter(m => m.role === 'student' && m.evaluation).length,
+  () => {
+    const done = engine.messages.value.filter(m => m.role === 'student' && m.evaluation)
+    const last = done[done.length - 1]
+    if (last?.evaluation) checkBadges(last.evaluation)
+  },
+)
+
+// 对话完成 → 通知父组件
+watch(
+  () => engine.isComplete.value,
+  async (complete) => {
+    if (complete) emit('complete', await fetchReportSafe())
+  },
+)
+
+// 沉默检测 → 随机提示
+watch(
+  () => recorder.silenceDetected.value,
+  (silent) => {
+    if (silent && recorder.isRecording.value) {
+      silenceHintText.value = SILENCE_HINTS[Math.floor(Math.random() * SILENCE_HINTS.length)]
+    } else {
+      silenceHintText.value = ''
+    }
+  },
+)
+
+// 录音时长达到上限,自动完成
+watch(
+  () => recorder.recordingDuration.value,
+  (v) => {
+    if (v >= MAX_RECORDING_SECONDS && recorder.isRecording.value) {
+      handleFinishRecording()
+    }
+  },
+)
+
+// ─────────────────────────────────────────────
+// Lifecycle
+// ─────────────────────────────────────────────
+
+onMounted(() => {
+  if (props.sessionInfo) {
+    const hasHistory = !!props.sessionInfo.messages?.length
+    seededHistoricalMessageIds.clear()
+    for (const message of props.sessionInfo.messages ?? []) {
+      seededHistoricalMessageIds.add(message.id)
+    }
+    engine.attachSession({
+      sessionId: props.sessionInfo.sessionId,
+      expiresAt: props.sessionInfo.expiresAt,
+      totalRounds: props.totalRounds,
+      currentRound: props.sessionInfo.currentRound,
+      messages: props.sessionInfo.messages,
+    })
+    if (!hasHistory) engine.generateGreeting()
+  } else {
+    console.warn('[DialogueChatView] mounted without sessionInfo; chat is inert. Parent must createSession before mounting.')
+  }
+  // 无 sessionInfo 时聊天区保持空(父组件应当先创建 session 再挂载本组件)
+})
+
+onUnmounted(() => {
+  if (idleHintTimer) clearTimeout(idleHintTimer)
+  if (badgeTimer) clearTimeout(badgeTimer)
+})
+</script>
+
+<style lang="scss" scoped>
+// ─────────────────────────────────────────────
+// Root
+// ─────────────────────────────────────────────
+.dialogue-chat-view {
+  width: 100%;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  position: relative;
+  user-select: none;
+  background: #fff;
+  font-size: 12px;
+}
+
+// ─────────────────────────────────────────────
+// Header
+// ─────────────────────────────────────────────
+.chat-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 8px 12px;
+  border-bottom: 1px solid #f3f4f6;
+  background: #fff;
+  flex-shrink: 0;
+}
+.header-left {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  min-width: 0;
+}
+.ai-avatar {
+  width: 24px; height: 24px;
+  border-radius: 50%;
+  background: #fff7ed;
+  border: 1px solid #fed7aa;
+  display: flex; align-items: center; justify-content: center;
+  font-size: 12px;
+  flex-shrink: 0;
+  &.breathing { animation: breathing 2.4s ease-in-out infinite; }
+}
+.ai-name { font-size: 12px; font-weight: 600; color: #1f2937; }
+.idle-hint { font-size: 11px; color: #9ca3af; }
+.online-dot {
+  width: 6px; height: 6px;
+  background: #10b981;
+  border-radius: 50%;
+}
+.header-right {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+}
+.round-indicator { font-size: 11px; font-weight: 500; color: #f97316; }
+.total-time { font-size: 11px; font-weight: 500; color: #6b7280; font-variant-numeric: tabular-nums; }
+.icon-btn {
+  padding: 6px;
+  border: none; background: transparent;
+  color: #9ca3af;
+  border-radius: 6px;
+  display: flex; align-items: center; justify-content: center;
+  cursor: pointer;
+  transition: background 0.15s, color 0.15s;
+  &:hover { background: #f3f4f6; color: #4b5563; }
+}
+
+// ─────────────────────────────────────────────
+// Permission banner
+// ─────────────────────────────────────────────
+.permission-banner {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  padding: 8px 12px;
+  background: #fef3c7;
+  border-bottom: 1px solid #fde68a;
+  font-size: 11px;
+  color: #92400e;
+  flex-shrink: 0;
+}
+.permission-icon { font-size: 14px; }
+
+// ─────────────────────────────────────────────
+// Chat area
+// ─────────────────────────────────────────────
+.chat-area {
+  flex: 1;
+  overflow-y: auto;
+  padding: 12px;
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+  background: #f7f8fa;
+  min-height: 0;
+}
+.msg-row {
+  display: flex;
+  align-items: flex-end;
+  gap: 8px;
+}
+.msg-ai { justify-content: flex-start; }
+.msg-student {
+  flex-direction: column;
+  align-items: flex-end;
+  gap: 4px;
+}
+.msg-col {
+  max-width: 78%;
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+}
+.avatar-sm {
+  width: 28px; height: 28px;
+  border-radius: 50%;
+  background: #fff7ed;
+  border: 1px solid #fed7aa;
+  display: flex; align-items: center; justify-content: center;
+  font-size: 14px;
+  flex-shrink: 0;
+  margin-bottom: 2px;
+}
+
+// 语音条
+.voice-bar {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  padding: 8px 12px;
+  width: clamp(140px, 44%, 210px);
+}
+.voice-ai {
+  background: #fff;
+  border: 1px solid #f3f4f6;
+  border-radius: 16px;
+  border-top-left-radius: 4px;
+  box-shadow: 0 1px 2px rgba(0,0,0,0.05);
+}
+.voice-student {
+  background: #f97316;
+  border-radius: 16px;
+  border-top-right-radius: 4px;
+}
+.play-btn {
+  width: 24px; height: 24px;
+  border-radius: 50%;
+  border: none;
+  display: flex; align-items: center; justify-content: center;
+  flex-shrink: 0;
+  cursor: pointer;
+  transition: background 0.2s;
+}
+.play-ai {
+  background: rgba(249,115,22,0.1);
+  color: #f97316;
+  &:hover { background: rgba(249,115,22,0.2); }
+}
+.play-student {
+  background: rgba(255,255,255,0.2);
+  color: #fff;
+  &:hover { background: rgba(255,255,255,0.3); }
+}
+.play-btn-error {
+  background: #fef2f2 !important;
+  color: #dc2626 !important;
+  border: 1px solid #fecaca;
+  &:hover { background: #fee2e2 !important; }
+}
+.play-spinner {
+  animation: spin 1s linear infinite;
+}
+.play-error-hint {
+  font-size: 10px;
+  color: #dc2626;
+  font-weight: 500;
+  flex-shrink: 0;
+  white-space: nowrap;
+}
+.play-error-hint-student {
+  color: #fff;
+}
+.wave-bar-group {
+  flex: 1;
+  display: flex;
+  align-items: center;
+  gap: 1px;
+}
+.wave-bar {
+  width: 2px;
+  border-radius: 999px;
+  flex-shrink: 0;
+}
+.wave-ai { background: rgba(249,115,22,0.4); }
+.wave-student { background: rgba(255,255,255,0.5); }
+.voice-duration { font-size: 10px; flex-shrink: 0; }
+.voice-duration-ai { color: #9ca3af; }
+.voice-duration-student { color: rgba(255,255,255,0.7); }
+
+// 气泡
+.bubble {
+  padding: 8px 12px;
+  border-radius: 12px;
+  font-size: 12px;
+  line-height: 1.55;
+  max-width: 100%;
+}
+.bubble-ai {
+  background: #fff;
+  border: 1px solid #f3f4f6;
+  border-top-left-radius: 4px;
+  color: #374151;
+  box-shadow: 0 1px 2px rgba(0,0,0,0.05);
+}
+.bubble-student {
+  background: #fff7ed;
+  border: 1px solid #fed7aa;
+  border-top-right-radius: 4px;
+  color: #374151;
+  max-width: 78%;
+  padding: 6px 12px;
+}
+.improvable-word {
+  color: #d97706;
+  text-decoration: underline wavy #fbbf24;
+  cursor: pointer;
+  padding: 0 2px;
+  border-radius: 2px;
+  transition: background 0.2s;
+  &:hover { background: #fef3c7; }
+}
+
+// typing 指示器
+.typing-bubble {
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
+  padding: 10px 14px;
+  background: #fff;
+  border: 1px solid #f3f4f6;
+  border-radius: 16px;
+  border-top-left-radius: 4px;
+  box-shadow: 0 1px 2px rgba(0,0,0,0.05);
+}
+.typing-bubble-student {
+  border-top-left-radius: 16px;
+  border-top-right-radius: 4px;
+}
+.typing-dot {
+  width: 6px; height: 6px;
+  background: rgba(249,115,22,0.7);
+  border-radius: 50%;
+  animation: typing-bounce 1s ease-in-out infinite;
+}
+@keyframes typing-bounce {
+  0%, 100% { transform: translateY(0); opacity: 0.5; }
+  50%      { transform: translateY(-3px); opacity: 1; }
+}
+
+// 错误卡
+.error-card {
+  margin-top: 4px;
+  padding: 6px 10px;
+  background: #fef2f2;
+  border: 1px solid #fecaca;
+  border-radius: 10px;
+  display: inline-flex;
+  align-items: center;
+  gap: 8px;
+}
+.error-text { font-size: 11px; color: #dc2626; }
+.retry-btn {
+  padding: 3px 10px;
+  background: #fff;
+  border: 1px solid #fecaca;
+  border-radius: 999px;
+  font-size: 11px;
+  color: #dc2626;
+  cursor: pointer;
+  &:hover { background: #fef2f2; border-color: #f87171; }
+}
+.rerecord-btn {
+  padding: 3px 10px;
+  background: transparent;
+  border: 1px solid #d1d5db;
+  border-radius: 999px;
+  font-size: 11px;
+  color: #6b7280;
+  cursor: pointer;
+  margin-left: 4px;
+  &:hover { background: #f9fafb; border-color: #9ca3af; color: #374151; }
+}
+
+// 评分卡
+.eval-card {
+  background: #fff;
+  border: 1px solid #f3f4f6;
+  border-radius: 12px;
+  box-shadow: 0 1px 2px rgba(0,0,0,0.05);
+  overflow: hidden;
+  width: clamp(260px, 88%, 420px);
+  text-align: left;
+  margin-top: 2px;
+}
+.eval-l1 { padding: 8px 12px; }
+.dim-row {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  font-size: 10px;
+  color: #6b7280;
+  flex-wrap: wrap;
+}
+.dim-label { color: #6b7280; }
+.dim-sep { color: #e5e7eb; margin: 0 2px; }
+.dim-badge {
+  font-size: 10px;
+  font-weight: 600;
+  padding: 1px 4px;
+  border-radius: 3px;
+}
+.dim-excellent { color: #059669; background: #ecfdf5; }
+.dim-good      { color: #10b981; background: #ecfdf5; }
+.dim-improve   { color: #f59e0b; background: #fffbeb; }
+
+.sugg-row {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 8px;
+  margin-top: 6px;
+}
+.sugg-text {
+  flex: 1;
+  min-width: 0;
+  font-size: 10px;
+  color: #6b7280;
+  display: flex;
+  align-items: center;
+  gap: 4px;
+  margin: 0;
+}
+.sugg-icon { color: #f59e0b; flex-shrink: 0; }
+.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.detail-toggle {
+  font-size: 10px;
+  color: #f97316;
+  background: transparent;
+  border: none;
+  cursor: pointer;
+  display: flex;
+  align-items: center;
+  gap: 2px;
+  flex-shrink: 0;
+  &:hover { color: #ea580c; }
+  svg { transition: transform 0.2s; }
+  .chev-up { transform: rotate(180deg); }
+}
+.eval-l2 {
+  padding: 10px 12px;
+  border-top: 1px solid #f9fafb;
+  background: #fafbfc;
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+}
+.detail-label {
+  font-size: 10px;
+  color: #9ca3af;
+  margin: 0 0 4px;
+  display: flex;
+  align-items: center;
+  gap: 4px;
+}
+.detail-content {
+  font-size: 12px;
+  color: #374151;
+  background: #fff;
+  padding: 6px 10px;
+  border: 1px solid #f3f4f6;
+  border-radius: 8px;
+  margin: 0;
+  line-height: 1.5;
+}
+.word-tags { display: flex; flex-wrap: wrap; gap: 4px; }
+.word-tag {
+  padding: 2px 8px;
+  background: #fff7ed;
+  color: #ea580c;
+  font-size: 10px;
+  border-radius: 999px;
+  border: 1px solid #fed7aa;
+}
+
+// ─────────────────────────────────────────────
+// Silence hint
+// ─────────────────────────────────────────────
+.silence-hint-wrap {
+  position: absolute;
+  bottom: 76px;
+  left: 0; right: 0;
+  display: flex;
+  justify-content: center;
+  z-index: 10;
+  padding: 0 16px;
+  pointer-events: none;
+}
+.silence-hint {
+  background: rgba(255,255,255,0.95);
+  backdrop-filter: blur(6px);
+  border: 1px solid #fed7aa;
+  border-radius: 14px;
+  box-shadow: 0 4px 12px rgba(0,0,0,0.08);
+  padding: 10px 14px;
+  display: flex;
+  align-items: flex-start;
+  gap: 10px;
+  max-width: 320px;
+  width: 100%;
+  pointer-events: auto;
+}
+.silence-icon { color: #fb923c; font-size: 14px; flex-shrink: 0; margin-top: 2px; }
+.silence-text { font-size: 12px; color: #4b5563; margin: 0; line-height: 1.5; flex: 1; }
+.silence-close {
+  background: transparent; border: none;
+  color: #d1d5db;
+  cursor: pointer;
+  padding: 2px;
+  flex-shrink: 0;
+  &:hover { color: #6b7280; }
+}
+
+// ─────────────────────────────────────────────
+// Control zone
+// ─────────────────────────────────────────────
+.control-zone {
+  flex-shrink: 0;
+  border-top: 1px solid #f3f4f6;
+  background: #fff;
+  padding: 10px 16px 12px;
+}
+.progress-wrap {
+  max-width: 384px;
+  margin: 0 auto 8px;
+}
+.progress-track {
+  height: 1px;
+  border-radius: 999px;
+  overflow: hidden;
+}
+.progress-fill {
+  height: 100%;
+  background: #fb923c;
+  transition: width 1s linear, background-color 0.4s ease, opacity 0.2s ease;
+  &.near-limit { background: #f87171; }
+}
+.state-stack {
+  position: relative;
+  height: 36px;
+  max-width: 384px;
+  margin: 0 auto;
+}
+.state-layer {
+  position: absolute;
+  inset: 0;
+  display: flex;
+  align-items: center;
+}
+.state-idle {
+  justify-content: center;
+  gap: 12px;
+}
+.state-center { justify-content: center; gap: 8px; }
+.state-error {
+  justify-content: space-between;
+  gap: 12px;
+}
+// idle buttons
+.hint-btn {
+  display: inline-flex;
+  align-items: center;
+  gap: 5px;
+  padding: 5px 14px;
+  border-radius: 999px;
+  background: #f9fafb;
+  border: 1px solid #e5e7eb;
+  color: #6b7280;
+  font-size: 11px;
+  font-weight: 500;
+  cursor: pointer;
+  transition: background 0.2s, border-color 0.2s, color 0.2s;
+  &:hover {
+    background: #fff7ed;
+    border-color: #fed7aa;
+    color: #f97316;
+  }
+}
+.mic-btn {
+  display: inline-flex;
+  align-items: center;
+  gap: 8px;
+  padding: 7px 24px;
+  border-radius: 999px;
+  background: #f97316;
+  border: none;
+  color: #fff;
+  font-size: 13px;
+  font-weight: 500;
+  cursor: pointer;
+  box-shadow: 0 4px 10px rgba(249,115,22,0.25);
+  transition: background 0.2s, transform 0.15s;
+  &:hover { background: #ea580c; }
+  &:active { transform: scale(0.96); }
+  &:disabled { opacity: 0.5; cursor: not-allowed; background: #d1d5db; box-shadow: none; }
+}
+
+// recording capsule
+.record-capsule {
+  width: 100%;
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  padding: 5px 10px;
+  border-radius: 999px;
+  background: #f9fafb;
+  border: 1px solid #f3f4f6;
+}
+.cancel-btn, .finish-btn {
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
+  padding: 4px 10px;
+  border-radius: 999px;
+  font-size: 11px;
+  font-weight: 500;
+  cursor: pointer;
+  flex-shrink: 0;
+  transition: background 0.2s, border-color 0.2s, color 0.2s;
+}
+.cancel-btn {
+  background: #fff;
+  border: 1px solid #e5e7eb;
+  color: #6b7280;
+  &:hover { background: #fef2f2; border-color: #fecaca; color: #ef4444; }
+}
+.finish-btn {
+  background: #f97316;
+  border: none;
+  color: #fff;
+  &:hover:not(:disabled) { background: #ea580c; }
+  &:disabled {
+    background: #d1d5db;
+    color: #9ca3af;
+    cursor: not-allowed;
+  }
+}
+.record-meter {
+  flex: 1;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: 6px;
+}
+.animated-wave { display: flex; align-items: center; gap: 1px; }
+.aw-bar {
+  width: 2px;
+  border-radius: 999px;
+  background: #f97316;
+  animation: aw-pulse 1.2s ease-in-out infinite;
+  &.near-limit { background: #ef4444; }
+}
+@keyframes aw-pulse {
+  0%, 100% { opacity: 0.55; }
+  50%      { opacity: 1; }
+}
+.record-time {
+  font-size: 12px;
+  font-family: monospace;
+  font-weight: 600;
+  color: #1f2937;
+  font-variant-numeric: tabular-nums;
+  &.near-limit { color: #ef4444; }
+}
+.record-time-max { font-size: 10px; color: #d1d5db; }
+
+// stt / thinking
+.spinner {
+  color: #fb923c;
+  animation: spin 1s linear infinite;
+}
+@keyframes spin {
+  to { transform: rotate(360deg); }
+}
+.mini-avatar {
+  width: 16px; height: 16px;
+  border-radius: 50%;
+  background: #fff7ed;
+  display: flex; align-items: center; justify-content: center;
+  font-size: 10px;
+}
+.center-text { font-size: 12px; color: #9ca3af; }
+
+// error
+.error-info { display: flex; align-items: center; gap: 6px; }
+.warn-icon { color: #f59e0b; font-size: 12px; }
+.warn-text { font-size: 12px; color: #4b5563; }
+.retry-pill {
+  padding: 5px 14px;
+  border-radius: 999px;
+  background: #f97316;
+  color: #fff;
+  border: none;
+  font-size: 12px;
+  font-weight: 500;
+  cursor: pointer;
+  &:hover { background: #ea580c; }
+}
+
+// ─────────────────────────────────────────────
+// Modals
+// ─────────────────────────────────────────────
+.modal-mask {
+  position: fixed;
+  inset: 0;
+  background: rgba(0,0,0,0.3);
+  backdrop-filter: blur(2px);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  z-index: 50;
+  padding: 16px;
+}
+.modal {
+  background: #fff;
+  border-radius: 16px;
+  width: 100%;
+  max-height: 80vh;
+  overflow-y: auto;
+  box-shadow: 0 20px 60px rgba(0,0,0,0.15);
+}
+.phoneme-modal { max-width: 320px; }
+.exit-modal { max-width: 320px; padding: 20px; }
+
+.modal-head {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 12px;
+}
+.modal-title {
+  font-size: 14px;
+  font-weight: 600;
+  color: #111827;
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  margin: 0;
+}
+.close-btn {
+  width: 26px; height: 26px;
+  background: #f3f4f6;
+  border: none;
+  border-radius: 8px;
+  color: #6b7280;
+  cursor: pointer;
+  display: flex; align-items: center; justify-content: center;
+  &:hover { background: #e5e7eb; }
+}
+
+// 音素
+.phoneme-word { text-align: left; font-size: 16px; font-weight: 700; color: #111827; margin: 0; }
+.phoneme-sub { font-size: 11px; color: #9ca3af; margin: 2px 0 0; }
+.phoneme-body {
+  padding: 0 20px 20px;
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+}
+.modal-head + .phoneme-body { padding-top: 0; }
+.phoneme-modal .modal-head { padding: 20px 20px 12px; margin-bottom: 0; }
+.pho-card {
+  border-radius: 12px;
+  padding: 12px;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 12px;
+}
+.pho-user { background: #fffbeb; border: 1px solid #fde68a; }
+.pho-standard { background: #ecfdf5; border: 1px solid #bbf7d0; }
+.pho-tip { background: #eff6ff; border: 1px solid #bfdbfe; display: block; }
+.pho-label { font-size: 10px; font-weight: 500; margin: 0 0 2px; }
+.pho-user .pho-label { color: #d97706; }
+.pho-standard .pho-label { color: #059669; }
+.pho-tip .pho-label { color: #2563eb; }
+.pho-value { font-size: 14px; font-family: monospace; color: #b45309; margin: 0; }
+.pho-value-green { color: #15803d; }
+.pho-tip-text { font-size: 12px; color: #1d4ed8; margin: 0; line-height: 1.5; }
+.pho-play {
+  width: 30px; height: 30px;
+  border-radius: 8px;
+  border: 1px solid;
+  display: flex; align-items: center; justify-content: center;
+  cursor: pointer;
+}
+.pho-play-user { background: #fef3c7; border-color: #fde68a; color: #d97706; &:hover { background: #fde68a; } }
+.pho-play-standard { background: #d1fae5; border-color: #bbf7d0; color: #059669; &:hover { background: #bbf7d0; } }
+.pho-practice-btn {
+  width: 100%;
+  padding: 9px;
+  border-radius: 12px;
+  background: #f97316;
+  color: #fff;
+  border: none;
+  font-size: 12px;
+  font-weight: 500;
+  cursor: pointer;
+  &:hover { background: #ea580c; }
+}
+
+// 退出弹窗
+.exit-hint { font-size: 11px; color: #9ca3af; margin: 0 0 14px; }
+.exit-actions { display: flex; flex-direction: column; gap: 10px; }
+.exit-secondary {
+  padding: 9px 0;
+  border-radius: 12px;
+  background: transparent;
+  border: 1px solid #e5e7eb;
+  color: #4b5563;
+  font-size: 12px;
+  font-weight: 500;
+  cursor: pointer;
+  &:hover { background: #f9fafb; }
+}
+.exit-primary {
+  padding: 9px 0;
+  border-radius: 12px;
+  background: #f97316;
+  border: none;
+  color: #fff;
+  font-size: 12px;
+  font-weight: 500;
+  cursor: pointer;
+  &:hover { background: #ea580c; }
+}
+
+// 徽章
+.badge-popup {
+  position: fixed;
+  top: 64px;
+  right: 16px;
+  z-index: 60;
+}
+.badge-card {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  padding: 10px 16px;
+  background: linear-gradient(to right, #f97316, #f59e0b);
+  border-radius: 16px;
+  box-shadow: 0 8px 24px rgba(249,115,22,0.3);
+}
+.badge-icon { font-size: 24px; }
+.badge-name { font-size: 12px; font-weight: 600; color: #fff; margin: 0; }
+.badge-desc { font-size: 10px; color: rgba(255,255,255,0.8); margin: 2px 0 0; }
+
+// ─────────────────────────────────────────────
+// Animations
+// ─────────────────────────────────────────────
+@keyframes breathing {
+  0%, 100% { transform: scale(1); }
+  50%      { transform: scale(1.06); box-shadow: 0 0 0 3px rgba(249,115,22,0.12); }
+}
+@keyframes fade-in-frames {
+  from { opacity: 0; transform: translateY(4px); }
+  to   { opacity: 1; transform: translateY(0); }
+}
+.fade-in { animation: fade-in-frames 0.22s ease-out; }
+@keyframes scale-in-frames {
+  from { opacity: 0; transform: scale(0.96); }
+  to   { opacity: 1; transform: scale(1); }
+}
+.scale-in { animation: scale-in-frames 0.22s ease-out; }
+
+// ─────────────────────────────────────────────
+// Finalizing screen (full-view loading while fetching report)
+// ─────────────────────────────────────────────
+.finalizing-screen {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  gap: 16px;
+  padding: 24px;
+  background: #fff;
+}
+.finalizing-spinner {
+  color: #fb923c;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  svg { animation: spin 1s linear infinite; }
+}
+.finalizing-title {
+  font-size: 14px;
+  color: #374151;
+  margin: 0;
+  font-weight: 500;
+}
+.finalizing-stats-card {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  padding: 12px 18px;
+  background: #f9fafb;
+  border: 1px solid #f3f4f6;
+  border-radius: 12px;
+  font-size: 12px;
+  color: #6b7280;
+  font-variant-numeric: tabular-nums;
+}
+.finalizing-stat { white-space: nowrap; }
+.finalizing-stats-sep { color: #d1d5db; }
+</style>

Some files were not shown because too many files changed in this diff