jimmylee пре 3 месеци
родитељ
комит
6bc1242b89

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

@@ -1,16 +1,16 @@
 ## 1. 基础设施
 
-- [ ] 1.1 创建 `src/types/englishSpeaking.ts`,从 enspeak `useTopicDiscussionConfig.ts` 移植话题讨论相关类型(TopicDiscussionConfig, PracticeSettings, EvaluationSettings, LearningGoals, Role, PracticeMode, ScoreMode 等)
-- [ ] 1.2 创建 `src/store/speaking.ts` Pinia store,管理话题讨论配置状态(对应 demo 的 useTopicDiscussionConfig hook)
-- [ ] 1.3 在 i18n 文件(cn.json/en.json/hk.json)中新增英语口语相关翻译 key
+- [x] 1.1 创建 `src/types/englishSpeaking.ts`,从 enspeak `useTopicDiscussionConfig.ts` 移植话题讨论相关类型(TopicDiscussionConfig, PracticeSettings, EvaluationSettings, LearningGoals, Role, PracticeMode, ScoreMode 等)
+- [x] 1.2 创建 `src/store/speaking.ts` Pinia store,管理话题讨论配置状态(对应 demo 的 useTopicDiscussionConfig hook)
+- [x] 1.3 在 i18n 文件(cn.json/en.json/hk.json)中新增英语口语相关翻译 key
 
 ## 2. Layer 1 — 英语学科主页
 
-- [ ] 2.1 创建 `SpeakingPanel.vue` 三层页面容器,管理 pageMode(layer1/layer2/config)切换和面包屑导航
-- [ ] 2.2 创建 `CurriculumSelector.vue` 教材/年级/单元三级联动下拉框,数据从 `curriculum.json` 读取
-- [ ] 2.3 创建 `UnitInfoCard.vue` 单元信息展示卡片(标题 + 描述)
-- [ ] 2.4 创建 `ExerciseTypeGrid.vue` 练习类型网格(口语/听力/阅读/写作四个入口,本次只有口语可点击进入 Layer 2)
-- [ ] 2.5 创建 `Layer1Home.vue` 组合以上组件
+- [x] 2.1 创建 `SpeakingPanel.vue` 三层页面容器,管理 pageMode(layer1/layer2/config)切换和面包屑导航
+- [x] 2.2 创建 `CurriculumSelector.vue` 教材/年级/单元三级联动下拉框,数据从 `curriculum.json` 读取
+- [x] 2.3 创建 `UnitInfoCard.vue` 单元信息展示卡片(标题 + 描述)
+- [x] 2.4 创建 `ExerciseTypeGrid.vue` 练习类型网格(口语/听力/阅读/写作四个入口,本次只有口语可点击进入 Layer 2)
+- [x] 2.5 创建 `Layer1Home.vue` 组合以上组件
 
 ## 3. Layer 2 — 口语子页面
 
@@ -29,7 +29,7 @@
 
 ## 5. 编辑器集成
 
-- [ ] 5.1 将 `CollapsibleToolbar/index2.vue` 英语 tab submenu 内容替换为 `SpeakingPanel` 组件
+- [x] 5.1 将 `CollapsibleToolbar/index2.vue` 英语 tab submenu 内容替换为 `SpeakingPanel` 组件
 - [ ] 5.2 在 `BaseFrameElement.vue` 的 `getTypeLabel` 中注册 toolType 77
 
 ## 6. 样式与验证

+ 8 - 28
src/components/CollapsibleToolbar/index2.vue

@@ -505,34 +505,8 @@
         </div> -->
       </div>
     </div>
-    <div class="submenu" :class="{ visible: activeSubmenu === 'english' }">
-      <div class="submenu-title">
-        <div class="title">{{ lang.ssAddEnglish }}</div>
-        <div class="close-icon" @click="toggleSubmenu('english')">
-          <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>
+    <div class="submenu submenu-english" :class="{ visible: activeSubmenu === 'english' }">
+      <SpeakingPanel />
     </div>
 
     <div v-if="exporting" class="parsing-modal">
@@ -566,6 +540,7 @@ import useSlideHandler from '@/hooks/useSlideHandler'
 import { useSlidesStore } from '@/store'
 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 toolChoice from '@/assets/img/tool_choice.jpeg'
 import toolAnswer from '@/assets/img/tool_answer.png'
@@ -1136,6 +1111,11 @@ const handleParsingClose = () => {
       width: 320px;
     }
   }
+
+  &.submenu-english {
+    display: flex;
+    flex-direction: column;
+  }
 }
 
 .submenu-title {

+ 111 - 0
src/store/speaking.ts

@@ -0,0 +1,111 @@
+import { defineStore } from 'pinia'
+import type {
+  TopicDiscussionConfig,
+  PracticeMode,
+  ScoreMode,
+  EvalDimensions,
+} from '@/types/englishSpeaking'
+
+const DEFAULT_CONFIG: TopicDiscussionConfig = {
+  topic: 'How I get to school',
+  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: 20,
+    duration: 5,
+    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,
+  }),
+
+  actions: {
+    // 重置为默认配置
+    resetConfig() {
+      this.config = JSON.parse(JSON.stringify(DEFAULT_CONFIG))
+    },
+
+    // 用推荐卡片数据预填充
+    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
+    },
+
+    // 学习目标 - 词汇
+    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
+    },
+  },
+})

+ 110 - 0
src/types/englishSpeaking.ts

@@ -0,0 +1,110 @@
+// 练习方式
+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
+  selectedRole: string
+  learningGoals: LearningGoals
+  practice: PracticeSettings
+  evaluation: EvaluationSettings
+}
+
+// 推荐任务卡片
+export interface TopicDiscussionTask {
+  id: string
+  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'
+
+// 页面层级
+export type PageMode = 'layer1' | 'layer2' | 'config'
+
+// 练习类型
+export type ExerciseType = 'speaking' | 'listening' | 'reading' | 'writing'

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

@@ -0,0 +1,140 @@
+<template>
+  <div class="speaking-panel">
+    <!-- 头部面包屑 -->
+    <div class="panel-header">
+      <div class="breadcrumb">
+        <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>
+          <template v-if="pageMode === 'config'">
+            <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">{{ lang.ssTopicDiscussion }}</span>
+          </template>
+          <template v-else>
+            <span class="breadcrumb-current">{{ lang.ssSpeaking }}</span>
+          </template>
+        </template>
+      </div>
+    </div>
+
+    <!-- 内容区域 -->
+    <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"
+      />
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { ref } from 'vue'
+import { lang } from '@/main'
+import type { PageMode, ExerciseType } from '@/types/englishSpeaking'
+import Layer1Home from './layers/Layer1Home.vue'
+import Layer2Speaking from './layers/Layer2Speaking.vue'
+import TopicDiscussionConfig from './configs/TopicDiscussionConfig.vue'
+
+const pageMode = ref<PageMode>('layer1')
+const selectedTextbook = ref('shep')
+const selectedGrade = ref('grade5-1')
+const selectedUnit = ref('SHEP-5-上-Unit2')
+
+const handleSelectExerciseType = (type: ExerciseType) => {
+  if (type === 'speaking') {
+    pageMode.value = 'layer2'
+  }
+}
+
+const handleOpenConfig = () => {
+  pageMode.value = 'config'
+}
+
+const goHome = () => {
+  pageMode.value = 'layer1'
+}
+
+const goLayer2 = () => {
+  pageMode.value = 'layer2'
+}
+</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>

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

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

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

@@ -0,0 +1,11 @@
+<template>
+  <div class="topic-discussion-config">
+    <p>TopicDiscussionConfig — TODO</p>
+  </div>
+</template>
+
+<script lang="ts" setup>
+const emit = defineEmits<{
+  (e: 'back'): void
+}>()
+</script>

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

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

@@ -0,0 +1,15 @@
+<template>
+  <div class="layer2-speaking">
+    <p>Layer 2 — TODO</p>
+  </div>
+</template>
+
+<script lang="ts" setup>
+defineProps<{
+  unitId: string
+}>()
+
+const emit = defineEmits<{
+  (e: 'openConfig'): void
+}>()
+</script>

+ 62 - 1
src/views/lang/cn.json

@@ -759,5 +759,66 @@
   "ssSubStu2":"已提交学生",
   "ssUnsubStu2":"未提交学生",
   "ssViewSubmitStatus2":"点击查看提交情况",
-  "ssEnterQuestion":"请输入你的问题..."
+  "ssEnterQuestion":"请输入你的问题...",
+  "ssEnglishSubject": "英语学科",
+  "ssSpeaking": "口语",
+  "ssListening": "听力",
+  "ssReading": "阅读",
+  "ssWriting": "写作",
+  "ssAddExercise": "添加练习",
+  "ssSmartRecommend": "智能推荐",
+  "ssManualCreate": "手动创建",
+  "ssBackToRecommend": "返回推荐",
+  "ssTopicDiscussion": "话题讨论",
+  "ssDiscussionTopic": "讨论话题",
+  "ssEnterTopic": "输入讨论话题...",
+  "ssLearningGoals": "学习目标",
+  "ssPracticeVocabulary": "练习词汇",
+  "ssPracticeSentences": "练习句型",
+  "ssAddWord": "输入单词...",
+  "ssAddSentence": "输入句型...",
+  "ssBatchPaste": "批量粘贴",
+  "ssPracticeMode": "练习方式",
+  "ssTimeLimit": "限制时长",
+  "ssRoundLimit": "限制轮次",
+  "ssMinute": "分钟",
+  "ssRound": "轮",
+  "ssShowReport": "展示学习报告",
+  "ssAdvancedConfig": "高级配置",
+  "ssAssistance": "辅助功能",
+  "ssShowEnglish": "显示英文",
+  "ssTaskHint": "任务提示",
+  "ssStutterHint": "卡顿提示",
+  "ssEvalDetails": "评估详情",
+  "ssEvalDimensions": "评估维度",
+  "ssAccuracy": "准确度",
+  "ssFluency": "流畅度",
+  "ssCompleteness": "完整度",
+  "ssRhythm": "节奏韵律",
+  "ssScoreMode": "评分方式",
+  "ssLetterScore": "字母评分 (A-D)",
+  "ssNumericScore": "量化评分 (0-100)",
+  "ssCommentOnly": "仅展示评语",
+  "ssApplyConfig": "应用配置",
+  "ssAll": "所有",
+  "ssBasicPractice": "基础跟练",
+  "ssWordReading": "单词跟读",
+  "ssSentenceReading": "句子跟读",
+  "ssArticleReading": "文章朗读",
+  "ssSpeechPractice": "演讲练习",
+  "ssFreeDialogue": "自由对话",
+  "ssCasualChat": "轻松闲聊",
+  "ssMockInterview": "模拟访谈",
+  "ssScenarioTask": "情境任务",
+  "ssDifficultyBeginner": "初级",
+  "ssDifficultyIntermediate": "中级",
+  "ssDifficultyAdvanced": "中高级",
+  "ssRoundsUnit": "轮",
+  "ssMinutesUnit": "分钟",
+  "ssEnglishSpeakingTool": "英语口语",
+  "ssNoRecommendations": "暂无推荐数据",
+  "ssBatchPasteTitle": "批量粘贴",
+  "ssBatchPasteVocab": "词汇(每行一个)",
+  "ssBatchPasteSentences": "句型(每行一个)",
+  "ssFunctionDeveloping": "功能开发中"
 }

+ 62 - 2
src/views/lang/en.json

@@ -754,10 +754,70 @@
   "ssViewKeywordCloud": "Click to view keyword cloud",
   "ssKeyword": "Keyword",
   "ssKeywordCloud": "Word Cloud",
-  "ssGeneratingContent": "Generating Content",
   "ssSubmitStatus":"Submission Status",
   "ssSubStu2":"Submitted Students",
   "ssUnsubStu2":"Unsubmitted Students",
   "ssViewSubmitStatus2":"Click to view submission status",
-  "ssEnterQuestion":"Please enter your question..."
+  "ssEnterQuestion":"Please enter your question...",
+  "ssGeneratingContent": "Generating Content",
+  "ssEnglishSubject": "English",
+  "ssSpeaking": "Speaking",
+  "ssListening": "Listening",
+  "ssReading": "Reading",
+  "ssWriting": "Writing",
+  "ssAddExercise": "Add Exercise",
+  "ssSmartRecommend": "Smart Recommend",
+  "ssManualCreate": "Manual Create",
+  "ssBackToRecommend": "Back to Recommend",
+  "ssTopicDiscussion": "Topic Discussion",
+  "ssDiscussionTopic": "Discussion Topic",
+  "ssEnterTopic": "Enter discussion topic...",
+  "ssLearningGoals": "Learning Goals",
+  "ssPracticeVocabulary": "Vocabulary",
+  "ssPracticeSentences": "Sentences",
+  "ssAddWord": "Enter word...",
+  "ssAddSentence": "Enter sentence...",
+  "ssBatchPaste": "Batch Paste",
+  "ssPracticeMode": "Practice Mode",
+  "ssTimeLimit": "Time Limit",
+  "ssRoundLimit": "Round Limit",
+  "ssMinute": "min",
+  "ssRound": "rounds",
+  "ssShowReport": "Show Learning Report",
+  "ssAdvancedConfig": "Advanced Settings",
+  "ssAssistance": "Assistance",
+  "ssShowEnglish": "Show English",
+  "ssTaskHint": "Task Hint",
+  "ssStutterHint": "Stutter Hint",
+  "ssEvalDetails": "Evaluation Details",
+  "ssEvalDimensions": "Evaluation Dimensions",
+  "ssFluency": "Fluency",
+  "ssCompleteness": "Completeness",
+  "ssRhythm": "Rhythm",
+  "ssScoreMode": "Score Mode",
+  "ssLetterScore": "Letter Score (A-D)",
+  "ssNumericScore": "Numeric Score (0-100)",
+  "ssCommentOnly": "Comment Only",
+  "ssApplyConfig": "Apply Config",
+  "ssAll": "All",
+  "ssBasicPractice": "Basic Practice",
+  "ssWordReading": "Word Reading",
+  "ssSentenceReading": "Sentence Reading",
+  "ssArticleReading": "Article Reading",
+  "ssSpeechPractice": "Speech Practice",
+  "ssFreeDialogue": "Free Dialogue",
+  "ssCasualChat": "Casual Chat",
+  "ssMockInterview": "Mock Interview",
+  "ssScenarioTask": "Scenario Task",
+  "ssDifficultyBeginner": "Beginner",
+  "ssDifficultyIntermediate": "Intermediate",
+  "ssDifficultyAdvanced": "Upper-Intermediate",
+  "ssRoundsUnit": "rounds",
+  "ssMinutesUnit": "min",
+  "ssEnglishSpeakingTool": "English Speaking",
+  "ssNoRecommendations": "No recommendations available",
+  "ssBatchPasteTitle": "Batch Paste",
+  "ssBatchPasteVocab": "Vocabulary (one per line)",
+  "ssBatchPasteSentences": "Sentences (one per line)",
+  "ssFunctionDeveloping": "Under Development"
 }

+ 61 - 1
src/views/lang/hk.json

@@ -759,5 +759,65 @@
   "ssSubStu2":"已提交學生",
   "ssUnsubStu2":"未提交學生",
   "ssViewSubmitStatus2":"點擊查看提交狀態",
-  "ssEnterQuestion":"請輸入你的問題..."
+  "ssEnterQuestion":"請輸入你的問題...",
+  "ssEnglishSubject": "英語學科",
+  "ssSpeaking": "口語",
+  "ssListening": "聽力",
+  "ssReading": "閱讀",
+  "ssWriting": "寫作",
+  "ssAddExercise": "添加練習",
+  "ssSmartRecommend": "智能推薦",
+  "ssManualCreate": "手動創建",
+  "ssBackToRecommend": "返回推薦",
+  "ssTopicDiscussion": "話題討論",
+  "ssDiscussionTopic": "討論話題",
+  "ssEnterTopic": "輸入討論話題...",
+  "ssLearningGoals": "學習目標",
+  "ssPracticeVocabulary": "練習詞彙",
+  "ssPracticeSentences": "練習句型",
+  "ssAddWord": "輸入單詞...",
+  "ssAddSentence": "輸入句型...",
+  "ssBatchPaste": "批量粘貼",
+  "ssPracticeMode": "練習方式",
+  "ssTimeLimit": "限制時長",
+  "ssRoundLimit": "限制輪次",
+  "ssMinute": "分鐘",
+  "ssRound": "輪",
+  "ssShowReport": "展示學習報告",
+  "ssAdvancedConfig": "高級配置",
+  "ssAssistance": "輔助功能",
+  "ssShowEnglish": "顯示英文",
+  "ssTaskHint": "任務提示",
+  "ssStutterHint": "卡頓提示",
+  "ssEvalDetails": "評估詳情",
+  "ssEvalDimensions": "評估維度",
+  "ssFluency": "流暢度",
+  "ssCompleteness": "完整度",
+  "ssRhythm": "節奏韻律",
+  "ssScoreMode": "評分方式",
+  "ssLetterScore": "字母評分 (A-D)",
+  "ssNumericScore": "量化評分 (0-100)",
+  "ssCommentOnly": "僅展示評語",
+  "ssApplyConfig": "應用配置",
+  "ssAll": "所有",
+  "ssBasicPractice": "基礎跟練",
+  "ssWordReading": "單詞跟讀",
+  "ssSentenceReading": "句子跟讀",
+  "ssArticleReading": "文章朗讀",
+  "ssSpeechPractice": "演講練習",
+  "ssFreeDialogue": "自由對話",
+  "ssCasualChat": "輕鬆閒聊",
+  "ssMockInterview": "模擬訪談",
+  "ssScenarioTask": "情境任務",
+  "ssDifficultyBeginner": "初級",
+  "ssDifficultyIntermediate": "中級",
+  "ssDifficultyAdvanced": "中高級",
+  "ssRoundsUnit": "輪",
+  "ssMinutesUnit": "分鐘",
+  "ssEnglishSpeakingTool": "英語口語",
+  "ssNoRecommendations": "暫無推薦數據",
+  "ssBatchPasteTitle": "批量粘貼",
+  "ssBatchPasteVocab": "詞彙(每行一個)",
+  "ssBatchPasteSentences": "句型(每行一個)",
+  "ssFunctionDeveloping": "功能開發中"
 }