Layer2Speaking.vue 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336
  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 } from 'vue'
  72. import { lang } from '@/main'
  73. import type { CreationMode, TopicDiscussionTask } from '@/types/englishSpeaking'
  74. import { useSpeakingStore } from '@/store/speaking'
  75. import useCreateElement from '@/hooks/useCreateElement'
  76. import useSlideHandler from '@/hooks/useSlideHandler'
  77. import { createSpeakingConfig } from '@/services/speaking'
  78. import message from '@/utils/message'
  79. import tasksData from '../data/topicDiscussionTasks.json'
  80. import CreationModeSwitch from '../components/CreationModeSwitch.vue'
  81. import RecommendCard from '../components/RecommendCard.vue'
  82. import AIGenerationForm from '../components/AIGenerationForm.vue'
  83. const props = defineProps<{
  84. unitId: string
  85. }>()
  86. const emit = defineEmits<{
  87. (e: 'openConfig'): void
  88. }>()
  89. const speakingStore = useSpeakingStore()
  90. const creationMode = ref<CreationMode>('smart')
  91. const selectedTaskType = ref('all')
  92. // 任务类型筛选选项(本次仅口语 - 话题讨论)
  93. const taskTypeOptions = [
  94. { id: 'all', name: lang.ssAll as string },
  95. { id: 'topic-discussion', name: lang.ssTopicDiscussion as string },
  96. ]
  97. // 手动创建的任务类型列表
  98. const manualTaskTypes = [
  99. {
  100. id: 'topic-discussion',
  101. name: lang.ssTopicDiscussion as string,
  102. category: lang.ssFreeDialogue as string,
  103. 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>',
  104. },
  105. ]
  106. // 根据 unitId 获取推荐任务
  107. const unitTasks = computed<TopicDiscussionTask[]>(() => {
  108. const data = tasksData as Record<string, TopicDiscussionTask[]>
  109. return data[props.unitId] || []
  110. })
  111. // 按任务类型筛选
  112. const filteredTasks = computed(() => {
  113. if (selectedTaskType.value === 'all') return unitTasks.value
  114. // 目前所有数据都是话题讨论类型,所以 topic-discussion 返回全部
  115. return unitTasks.value
  116. })
  117. // 新交互:点卡片即新建一页 + 创建配置 + 插入画布元素;配置页改由"点击画布里的 77 型元素"唤起
  118. const { createFrameElement } = useCreateElement()
  119. const { createSlide } = useSlideHandler()
  120. const inserting = ref(false)
  121. async function insertSpeakingToolToCanvas(source: 'select' | 'manual') {
  122. if (inserting.value) return
  123. inserting.value = true
  124. try {
  125. const { id } = await createSpeakingConfig(speakingStore.config)
  126. createSlide()
  127. createFrameElement(id, 77)
  128. // 创建后立即唤起左侧配置面板(与点击画布 77 型 frame 同款信号),使用户可直接编辑
  129. speakingStore.openConfigPanel()
  130. message.success(source === 'select' ? '已添加口语练习到幻灯片' : '已添加空白口语工具,点击画布上的元素进行配置')
  131. } catch (err: any) {
  132. console.error('[speaking] create failed:', err)
  133. message.error(err?.message || '创建口语工具失败')
  134. } finally {
  135. inserting.value = false
  136. }
  137. }
  138. // 选择推荐卡片 → 预填充 store → 立即创建配置 + 插入元素
  139. const handleSelectTask = (task: TopicDiscussionTask) => {
  140. speakingStore.prefillFromTask(task.titleEn, task.vocabulary, task.sentences)
  141. insertSpeakingToolToCanvas('select')
  142. }
  143. // 手动创建 → 清空 store → 立即创建配置 + 插入元素
  144. const handleManualCreate = (_taskTypeId: string) => {
  145. speakingStore.resetConfigEmpty()
  146. insertSpeakingToolToCanvas('manual')
  147. }
  148. </script>
  149. <style lang="scss" scoped>
  150. .layer2-speaking {
  151. padding: 16px 20px;
  152. display: flex;
  153. flex-direction: column;
  154. gap: 16px;
  155. }
  156. // 筛选栏
  157. .filter-bar {
  158. display: flex;
  159. align-items: center;
  160. justify-content: space-between;
  161. gap: 12px;
  162. }
  163. .filter-left {
  164. display: flex;
  165. align-items: center;
  166. gap: 8px;
  167. flex: 1;
  168. }
  169. .filter-label {
  170. font-size: 12px;
  171. font-weight: 500;
  172. color: #6b7280;
  173. white-space: nowrap;
  174. }
  175. .filter-select-wrap {
  176. position: relative;
  177. flex: 1;
  178. }
  179. .filter-select {
  180. width: 100%;
  181. appearance: none;
  182. padding: 6px 28px 6px 12px;
  183. border: 1px solid rgba(0, 0, 0, 0.08);
  184. border-radius: 12px;
  185. background: #f9fafb;
  186. font-size: 13px;
  187. color: #374151;
  188. cursor: pointer;
  189. transition: all 0.2s;
  190. &:focus {
  191. outline: none;
  192. border-color: #f97316;
  193. background: #fff;
  194. box-shadow: 0 0 0 2px rgba(249, 115, 22, 0.1);
  195. }
  196. }
  197. .select-arrow {
  198. position: absolute;
  199. right: 10px;
  200. top: 50%;
  201. transform: translateY(-50%);
  202. color: #9ca3af;
  203. pointer-events: none;
  204. }
  205. // 推荐卡片网格
  206. .card-grid {
  207. display: grid;
  208. grid-template-columns: 1fr 1fr;
  209. gap: 10px;
  210. }
  211. // 空状态
  212. .empty-state {
  213. display: flex;
  214. flex-direction: column;
  215. align-items: center;
  216. gap: 8px;
  217. padding: 32px 16px;
  218. color: #d1d5db;
  219. }
  220. .empty-text {
  221. font-size: 13px;
  222. color: #9ca3af;
  223. margin: 0;
  224. }
  225. // 手动创建
  226. .manual-create {
  227. display: flex;
  228. flex-direction: column;
  229. gap: 12px;
  230. }
  231. .manual-hint {
  232. padding: 0;
  233. }
  234. .manual-text {
  235. font-size: 13px;
  236. color: #6b7280;
  237. margin: 0;
  238. }
  239. .manual-task-list {
  240. display: flex;
  241. flex-direction: column;
  242. gap: 8px;
  243. }
  244. .manual-task-item {
  245. display: flex;
  246. align-items: center;
  247. gap: 10px;
  248. padding: 12px 14px;
  249. background: #fff;
  250. border: 1px solid rgba(0, 0, 0, 0.06);
  251. border-radius: 14px;
  252. cursor: pointer;
  253. transition: all 0.2s;
  254. text-align: left;
  255. &:hover {
  256. border-color: rgba(0, 0, 0, 0.12);
  257. box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
  258. .task-item-arrow {
  259. color: #f97316;
  260. }
  261. }
  262. }
  263. .task-item-icon {
  264. display: flex;
  265. align-items: center;
  266. justify-content: center;
  267. width: 32px;
  268. height: 32px;
  269. border-radius: 10px;
  270. background: linear-gradient(135deg, #fff7ed, #fff);
  271. border: 1px solid rgba(249, 115, 22, 0.15);
  272. color: #f97316;
  273. flex-shrink: 0;
  274. }
  275. .task-item-info {
  276. flex: 1;
  277. display: flex;
  278. flex-direction: column;
  279. gap: 2px;
  280. }
  281. .task-item-name {
  282. font-size: 13px;
  283. font-weight: 500;
  284. color: #111827;
  285. }
  286. .task-item-category {
  287. font-size: 11px;
  288. color: #9ca3af;
  289. }
  290. .task-item-arrow {
  291. color: #d1d5db;
  292. flex-shrink: 0;
  293. transition: color 0.2s;
  294. }
  295. </style>