Layer2Speaking.vue 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406
  1. <template>
  2. <div class="layer2-speaking">
  3. <!-- 创建方式切换 -->
  4. <CreationModeSwitch v-model="creationMode" />
  5. <!-- AI 生成模式 -->
  6. <AIGenerationForm v-if="creationMode === 'ai'" />
  7. <!-- 手动创建模式 —— 直接进入配置页 -->
  8. <div v-else-if="creationMode === 'manual'" class="manual-create">
  9. <div class="manual-hint">
  10. <p class="manual-text">选择一种口语任务类型开始手动创建</p>
  11. </div>
  12. <div class="manual-task-list">
  13. <button
  14. v-for="taskType in manualTaskTypes"
  15. :key="taskType.id"
  16. class="manual-task-item"
  17. @click="handleManualCreate(taskType.id)"
  18. >
  19. <div class="task-item-icon" v-html="taskType.icon"></div>
  20. <div class="task-item-info">
  21. <span class="task-item-name">{{ taskType.name }}</span>
  22. <span class="task-item-category">{{ taskType.category }}</span>
  23. </div>
  24. <svg class="task-item-arrow" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
  25. <path d="M9 18l6-6-6-6" />
  26. </svg>
  27. </button>
  28. </div>
  29. </div>
  30. <!-- 智能推荐模式 -->
  31. <template v-else>
  32. <!-- 任务类型筛选 -->
  33. <div class="filter-bar">
  34. <div class="filter-left">
  35. <span class="filter-label">任务类型</span>
  36. <div class="filter-select-wrap">
  37. <select v-model="selectedTaskType" class="filter-select">
  38. <option v-for="opt in taskTypeOptions" :key="opt.id" :value="opt.id">
  39. {{ opt.name }}
  40. </option>
  41. </select>
  42. <svg class="select-arrow" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
  43. <path d="M6 9l6 6 6-6" />
  44. </svg>
  45. </div>
  46. </div>
  47. </div>
  48. <!-- 推荐卡片列表 -->
  49. <div v-if="filteredTasks.length > 0" class="card-grid">
  50. <RecommendCard
  51. v-for="task in filteredTasks"
  52. :key="task.id"
  53. :task="task"
  54. @select="handleSelectTask(task)"
  55. />
  56. </div>
  57. <!-- 空状态 -->
  58. <div v-else class="empty-state">
  59. <svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
  60. <path d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2" />
  61. <rect x="9" y="3" width="6" height="4" rx="1" />
  62. <line x1="9" y1="12" x2="15" y2="12" />
  63. <line x1="9" y1="16" x2="13" y2="16" />
  64. </svg>
  65. <p class="empty-text">{{ lang.ssNoRecommendations }}</p>
  66. </div>
  67. </template>
  68. </div>
  69. </template>
  70. <script lang="ts" setup>
  71. import { ref, computed, inject } from 'vue'
  72. import { lang } from '@/main'
  73. import type { CreationMode, SpeakingRecommendationTask, TopicDiscussionTask } from '@/types/englishSpeaking'
  74. import type { ArticleReadingTask } from '@/types/articleReading'
  75. import { ARTICLE_READING_TOOL_TYPE, TOPIC_DISCUSSION_TOOL_TYPE } from '@/configs/englishSpeakingTools'
  76. import { injectKeySingleSlideMode } from '@/types/injectKey'
  77. import { filterSpeakingTasks, type SpeakingTaskFilter } from '../preview/articleReadingModel'
  78. import { useSpeakingStore } from '@/store/speaking'
  79. import { useArticleReadingStore } from '@/store/articleReading'
  80. import useCreateElement from '@/hooks/useCreateElement'
  81. import useSlideHandler from '@/hooks/useSlideHandler'
  82. import { createSpeakingConfig } from '@/services/speaking'
  83. import { createArticleConfig } from '../services/articleReading'
  84. import { friendlyArticleError } from '../services/articleErrors'
  85. import message from '@/utils/message'
  86. import topicTasksData from '../data/topicDiscussionTasks.json'
  87. import articleTasksData from '../data/articleReadingTasks.json'
  88. import CreationModeSwitch from '../components/CreationModeSwitch.vue'
  89. import RecommendCard from '../components/RecommendCard.vue'
  90. import AIGenerationForm from '../components/AIGenerationForm.vue'
  91. const props = defineProps<{
  92. unitId: string
  93. }>()
  94. const emit = defineEmits<{
  95. (e: 'openConfig'): void
  96. }>()
  97. const speakingStore = useSpeakingStore()
  98. const articleStore = useArticleReadingStore()
  99. const creationMode = ref<CreationMode>('smart')
  100. const selectedTaskType = ref<SpeakingTaskFilter>('all')
  101. // 任务类型筛选选项
  102. const taskTypeOptions = [
  103. { id: 'all', name: lang.ssAll as string },
  104. { id: 'topic-discussion', name: lang.ssTopicDiscussion as string },
  105. { id: 'article-reading', name: lang.ssArticleReading as string },
  106. ]
  107. // 手动创建的任务类型列表
  108. const manualTaskTypes = [
  109. {
  110. id: 'topic-discussion',
  111. name: lang.ssTopicDiscussion as string,
  112. category: lang.ssFreeDialogue as string,
  113. 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>',
  114. },
  115. {
  116. id: 'article-reading',
  117. name: lang.ssArticleReading as string,
  118. category: lang.ssArticleReadAloud as string,
  119. 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>',
  120. },
  121. ]
  122. // 根据 unitId 获取推荐任务(话题讨论 + 文章朗读,靠 taskType 判别,不互相伪装)
  123. const unitTasks = computed<SpeakingRecommendationTask[]>(() => {
  124. const topics = topicTasksData as Record<string, TopicDiscussionTask[]>
  125. const articles = articleTasksData as Record<string, ArticleReadingTask[]>
  126. return [...(topics[props.unitId] || []), ...(articles[props.unitId] || [])]
  127. })
  128. // 按任务类型筛选
  129. const filteredTasks = computed(() => filterSpeakingTasks(unitTasks.value, selectedTaskType.value))
  130. // 新交互:点卡片即新建一页 + 创建配置 + 插入画布元素;配置页改由"点击画布里的 77 型元素"唤起
  131. const { createFrameElement } = useCreateElement()
  132. const { createSlide, replaceAllSlidesWithNew } = useSlideHandler()
  133. const inserting = ref(false)
  134. // 由 indexEn.vue provide;其余编辑器没有 provide,这里拿到 default false
  135. const singleSlideMode = inject(injectKeySingleSlideMode, false)
  136. // 腾出一页空白页来放新工具。
  137. //
  138. // 单页模式(英语展示页)下换成「替换掉全部页面」:那个编辑器藏了缩略图、只允许一页,
  139. // 再往后加页的话老师看不见也切不过去。两条路都保证新页是空的,因为 createFrameElement
  140. // 会拒绝插进已经有 frame 的页面。
  141. //
  142. // 已知问题(两种模式都有,单页模式下更频繁):被替换掉的那个工具,它的 config 已经
  143. // POST 到后端了,这里只是丢掉画布上引用它的 frame 元素,后端那条记录会变成孤儿。
  144. // 服务端目前没有删除 config 的接口,前端无法回收。
  145. const openSlideForNewTool = () => {
  146. if (singleSlideMode) replaceAllSlidesWithNew()
  147. else createSlide()
  148. }
  149. async function insertSpeakingToolToCanvas(source: 'select' | 'manual') {
  150. if (inserting.value) return
  151. inserting.value = true
  152. try {
  153. const { id } = await createSpeakingConfig(speakingStore.config)
  154. openSlideForNewTool()
  155. createFrameElement(id, TOPIC_DISCUSSION_TOOL_TYPE)
  156. // 创建后立即唤起左侧配置面板(与点击画布 77 型 frame 同款信号),使用户可直接编辑
  157. speakingStore.openConfigPanel()
  158. message.success(source === 'select' ? '已添加口语练习到幻灯片' : '已添加空白口语工具,点击画布上的元素进行配置')
  159. } catch (err: any) {
  160. console.error('[speaking] create failed:', err)
  161. message.error(err?.message || '创建口语工具失败')
  162. } finally {
  163. inserting.value = false
  164. }
  165. }
  166. // 文章朗读走同一把 inserting 门闩:POST 成功后才建页与元素,失败不留孤立页面。
  167. // 用文章自己的建立接口而不是共用的 createSpeakingConfig:后端在那里同步把整篇示范
  168. // 音频合成好,全部成功才写入。所以「选推荐卡片」这条路(正文一出生就是满的)建完
  169. // 就已经烤热,学生点示范时是 S3 直接命中。这一步因此可能要几秒 —— inserting 门闩
  170. // 本来就在挡重复点击,按钮的 loading 态也照旧生效。
  171. async function insertArticleToolToCanvas(source: 'select' | 'manual') {
  172. if (inserting.value) return
  173. inserting.value = true
  174. // 选推荐卡片这条路正文一出生就是满的,后端会当场把整篇示范音频合成好 —— 几秒。
  175. // 这里的卡片点下去没有任何自身的 loading 态,不挂一条常驻提示,老师看到的就是
  176. // 「点了没反应」,然后再点一次(inserting 门闩会吃掉,但他不知道)。
  177. // 手动创建那条路正文是空的,后端立刻返回,提示一闪而过,无妨。
  178. const pending = message.info(lang.ssArticleGeneratingDemo as string, { duration: 0 })
  179. try {
  180. const { id } = await createArticleConfig(articleStore.config)
  181. openSlideForNewTool()
  182. createFrameElement(id, ARTICLE_READING_TOOL_TYPE)
  183. articleStore.openConfigPanel(id)
  184. message.success(source === 'select' ? lang.ssArticleTemplateCreated : lang.ssArticleManualCreated)
  185. } catch (err: unknown) {
  186. console.error('[article-reading] create failed:', err)
  187. // 经 friendlyArticleError:合成失败时后端给的是 `Demo audio synthesis failed: [2]`,
  188. // 原样甩出去老师看不懂,也不会知道配置根本没保存
  189. message.error(friendlyArticleError(err))
  190. } finally {
  191. pending.close()
  192. inserting.value = false
  193. }
  194. }
  195. // 选择推荐卡片 → 预填充 store → 立即创建配置 + 插入元素
  196. const handleSelectTask = (task: SpeakingRecommendationTask) => {
  197. if (task.taskType === 'article-reading') {
  198. articleStore.prefillFromTask(task, speakingStore.config.grade)
  199. void insertArticleToolToCanvas('select')
  200. return
  201. }
  202. speakingStore.prefillFromTask(task.titleEn, task.vocabulary, task.sentences)
  203. void insertSpeakingToolToCanvas('select')
  204. }
  205. // 手动创建 → 清空 store → 立即创建配置 + 插入元素
  206. const handleManualCreate = (taskTypeId: string) => {
  207. if (taskTypeId === 'article-reading') {
  208. articleStore.resetConfigEmpty(speakingStore.config.grade)
  209. void insertArticleToolToCanvas('manual')
  210. return
  211. }
  212. speakingStore.resetConfigEmpty()
  213. void insertSpeakingToolToCanvas('manual')
  214. }
  215. </script>
  216. <style lang="scss" scoped>
  217. .layer2-speaking {
  218. padding: 16px 20px;
  219. display: flex;
  220. flex-direction: column;
  221. gap: 16px;
  222. }
  223. // 筛选栏
  224. .filter-bar {
  225. display: flex;
  226. align-items: center;
  227. justify-content: space-between;
  228. gap: 12px;
  229. }
  230. .filter-left {
  231. display: flex;
  232. align-items: center;
  233. gap: 8px;
  234. flex: 1;
  235. }
  236. .filter-label {
  237. font-size: 12px;
  238. font-weight: 500;
  239. color: #6b7280;
  240. white-space: nowrap;
  241. }
  242. .filter-select-wrap {
  243. position: relative;
  244. flex: 1;
  245. }
  246. .filter-select {
  247. width: 100%;
  248. appearance: none;
  249. padding: 6px 28px 6px 12px;
  250. border: 1px solid rgba(0, 0, 0, 0.08);
  251. border-radius: 12px;
  252. background: #f9fafb;
  253. font-size: 13px;
  254. color: #374151;
  255. cursor: pointer;
  256. transition: all 0.2s;
  257. &:focus {
  258. outline: none;
  259. border-color: #f97316;
  260. background: #fff;
  261. box-shadow: 0 0 0 2px rgba(249, 115, 22, 0.1);
  262. }
  263. }
  264. .select-arrow {
  265. position: absolute;
  266. right: 10px;
  267. top: 50%;
  268. transform: translateY(-50%);
  269. color: #9ca3af;
  270. pointer-events: none;
  271. }
  272. // 推荐卡片网格
  273. .card-grid {
  274. display: grid;
  275. grid-template-columns: 1fr 1fr;
  276. gap: 10px;
  277. }
  278. // 空状态
  279. .empty-state {
  280. display: flex;
  281. flex-direction: column;
  282. align-items: center;
  283. gap: 8px;
  284. padding: 32px 16px;
  285. color: #d1d5db;
  286. }
  287. .empty-text {
  288. font-size: 13px;
  289. color: #9ca3af;
  290. margin: 0;
  291. }
  292. // 手动创建
  293. .manual-create {
  294. display: flex;
  295. flex-direction: column;
  296. gap: 12px;
  297. }
  298. .manual-hint {
  299. padding: 0;
  300. }
  301. .manual-text {
  302. font-size: 13px;
  303. color: #6b7280;
  304. margin: 0;
  305. }
  306. .manual-task-list {
  307. display: flex;
  308. flex-direction: column;
  309. gap: 8px;
  310. }
  311. .manual-task-item {
  312. display: flex;
  313. align-items: center;
  314. gap: 10px;
  315. padding: 12px 14px;
  316. background: #fff;
  317. border: 1px solid rgba(0, 0, 0, 0.06);
  318. border-radius: 14px;
  319. cursor: pointer;
  320. transition: all 0.2s;
  321. text-align: left;
  322. &:hover {
  323. border-color: rgba(0, 0, 0, 0.12);
  324. box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
  325. .task-item-arrow {
  326. color: #f97316;
  327. }
  328. }
  329. }
  330. .task-item-icon {
  331. display: flex;
  332. align-items: center;
  333. justify-content: center;
  334. width: 32px;
  335. height: 32px;
  336. border-radius: 10px;
  337. background: linear-gradient(135deg, #fff7ed, #fff);
  338. border: 1px solid rgba(249, 115, 22, 0.15);
  339. color: #f97316;
  340. flex-shrink: 0;
  341. }
  342. .task-item-info {
  343. flex: 1;
  344. display: flex;
  345. flex-direction: column;
  346. gap: 2px;
  347. }
  348. .task-item-name {
  349. font-size: 13px;
  350. font-weight: 500;
  351. color: #111827;
  352. }
  353. .task-item-category {
  354. font-size: 11px;
  355. color: #9ca3af;
  356. }
  357. .task-item-arrow {
  358. color: #d1d5db;
  359. flex-shrink: 0;
  360. transition: color 0.2s;
  361. }
  362. </style>