jimmylee před 3 měsíci
rodič
revize
ae575d7087

+ 1 - 0
.gitignore

@@ -32,4 +32,5 @@ coverage
 
 
 .claude
+.agent
 

+ 11 - 11
openspec/changes/english-speaking/tasks.md

@@ -14,25 +14,25 @@
 
 ## 3. Layer 2 — 口语子页面
 
-- [ ] 3.1 创建 `CreationModeSwitch.vue` 创建方式切换组件(智能推荐/AI生成/手动创建),参照 demo 实现
-- [ ] 3.2 创建 `RecommendCard.vue` 推荐任务卡片组件(标题、副标题、难度、轮次、时长)
-- [ ] 3.3 创建 `AIGenerationForm.vue` AI 生成表单(仅 UI 壳子,不实现后端功能)
-- [ ] 3.4 创建 `Layer2Speaking.vue` 组合以上组件,实现:任务类型筛选 tabs、推荐卡片列表(数据从 `topicDiscussionTasks.json` 读取)、点击卡片进入 Layer 3 配置页
+- [x] 3.1 创建 `CreationModeSwitch.vue` 创建方式切换组件(智能推荐/AI生成/手动创建),参照 demo 实现
+- [x] 3.2 创建 `RecommendCard.vue` 推荐任务卡片组件(标题、副标题、难度、轮次、时长)
+- [x] 3.3 创建 `AIGenerationForm.vue` AI 生成表单(仅 UI 壳子,不实现后端功能)
+- [x] 3.4 创建 `Layer2Speaking.vue` 组合以上组件,实现:任务类型筛选 tabs、推荐卡片列表(数据从 `topicDiscussionTasks.json` 读取)、点击卡片进入 Layer 3 配置页
 
 ## 4. Layer 3 — 话题讨论配置
 
-- [ ] 4.1 创建 `TopicDiscussionConfig.vue` 主框架,参照 demo `TopicDiscussionConfig.tsx`
-- [ ] 4.2 实现讨论话题输入 + 学习目标(练习词汇标签增删 + 练习句型列表增删编辑 + 批量粘贴)
-- [ ] 4.3 实现练习方式配置(限制时长/限制轮次切换 + 滑块)+ 展示学习报告开关
-- [ ] 4.4 实现高级配置(辅助功能 checkbox + 评估维度 checkbox + 评分方式 radio)
-- [ ] 4.5 实现底部"应用配置"按钮,调用 `parentWindow.addTool(77)`
+- [x] 4.1 创建 `TopicDiscussionConfig.vue` 主框架,参照 demo `TopicDiscussionConfig.tsx`
+- [x] 4.2 实现讨论话题输入 + 学习目标(练习词汇标签增删 + 练习句型列表增删编辑 + 批量粘贴)
+- [x] 4.3 实现练习方式配置(限制时长/限制轮次切换 + 滑块)+ 展示学习报告开关
+- [x] 4.4 实现高级配置(辅助功能 checkbox + 评估维度 checkbox + 评分方式 radio)
+- [x] 4.5 实现底部"应用配置"按钮,调用 `parentWindow.addTool(77)`
 
 ## 5. 编辑器集成
 
 - [x] 5.1 将 `CollapsibleToolbar/index2.vue` 英语 tab submenu 内容替换为 `SpeakingPanel` 组件
-- [ ] 5.2 在 `BaseFrameElement.vue` 的 `getTypeLabel` 中注册 toolType 77
+- [x] 5.2 在 `BaseFrameElement.vue` 的 `getTypeLabel` 中注册 toolType 77
 
 ## 6. 样式与验证
 
-- [ ] 6.1 将 demo Tailwind 样式转换为 SCSS,适配 PPT 项目样式体系
+- [x] 6.1 将 demo Tailwind 样式转换为 SCSS,适配 PPT 项目样式体系
 - [ ] 6.2 端到端验证:英语 tab → Layer 1 选单元 → 点口语 → Layer 2 选推荐卡片 → Layer 3 配置 → 应用配置

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

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

@@ -0,0 +1,121 @@
+<template>
+  <div class="creation-mode-switch">
+    <span class="mode-title">
+      {{ currentModeLabel }}
+    </span>
+    <div class="mode-actions">
+      <!-- 当前是 smart 模式时,显示 AI生成 和 手动创建 按钮 -->
+      <template v-if="modelValue === 'smart'">
+        <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>

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

@@ -0,0 +1,123 @@
+<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>{{ lang.ssTopicDiscussion }}</span>
+        <span class="meta-dot">·</span>
+        <span>{{ task.rounds }}{{ lang.ssRoundsUnit }}</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 { TopicDiscussionTask } from '@/types/englishSpeaking'
+
+const props = defineProps<{
+  task: TopicDiscussionTask
+}>()
+
+defineEmits<{
+  (e: 'select'): void
+}>()
+
+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>

+ 1044 - 1
src/views/Editor/EnglishSpeaking/configs/TopicDiscussionConfig.vue

@@ -1,11 +1,1054 @@
 <template>
   <div class="topic-discussion-config">
-    <p>TopicDiscussionConfig — TODO</p>
+    <!-- 滚动内容区域 -->
+    <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 { lang } from '@/main'
+import { useSpeakingStore } from '@/store/speaking'
+
 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
+}
+
+// 应用配置 → 通知父应用
+const handleApply = () => {
+  const parentWindow = (window as any).parent
+  if (parentWindow && parentWindow.addTool) {
+    parentWindow.addTool(77)
+  }
+}
 </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>

+ 296 - 2
src/views/Editor/EnglishSpeaking/layers/Layer2Speaking.vue

@@ -1,15 +1,309 @@
 <template>
   <div class="layer2-speaking">
-    <p>Layer 2 — TODO</p>
+    <!-- 创建方式切换 -->
+    <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>
-defineProps<{
+import { ref, computed } from 'vue'
+import { lang } from '@/main'
+import type { CreationMode, TopicDiscussionTask } from '@/types/englishSpeaking'
+import { useSpeakingStore } from '@/store/speaking'
+import tasksData from '../data/topicDiscussionTasks.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 creationMode = ref<CreationMode>('smart')
+const selectedTaskType = ref('all')
+
+// 任务类型筛选选项(本次仅口语 - 话题讨论)
+const taskTypeOptions = [
+  { id: 'all', name: lang.ssAll as string },
+  { id: 'topic-discussion', name: lang.ssTopicDiscussion 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>',
+  },
+]
+
+// 根据 unitId 获取推荐任务
+const unitTasks = computed<TopicDiscussionTask[]>(() => {
+  const data = tasksData as Record<string, TopicDiscussionTask[]>
+  return data[props.unitId] || []
+})
+
+// 按任务类型筛选
+const filteredTasks = computed(() => {
+  if (selectedTaskType.value === 'all') return unitTasks.value
+  // 目前所有数据都是话题讨论类型,所以 topic-discussion 返回全部
+  return unitTasks.value
+})
+
+// 选择推荐卡片 → 预填充 store → 进入配置页
+const handleSelectTask = (task: TopicDiscussionTask) => {
+  speakingStore.prefillFromTask(task.titleEn, task.vocabulary, task.sentences)
+  emit('openConfig')
+}
+
+// 手动创建 → 重置 store → 进入配置页
+const handleManualCreate = (taskTypeId: string) => {
+  speakingStore.resetConfig()
+  emit('openConfig')
+}
 </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>

+ 1 - 0
src/views/components/element/FrameElement/BaseFrameElement.vue

@@ -165,6 +165,7 @@ const getTypeLabel = (type: number): string => {
     74: 'ssVideo',
     75: lang.lang == 'cn' ? 'ssBiliVideo' : 'ssYouTube',
     76: 'ssCreateSpace',
+    77: "ssEnglishSpeakingTool",
   }
   const key = typeMap[type]
   return (key ? lang[key] : lang.ssUnknown) as string