Просмотр исходного кода

重画对话ui, 重置按钮移动位置

jimmylee 2 недель назад
Родитель
Сommit
22ed347cee

+ 12 - 0
src/store/speaking.ts

@@ -4,6 +4,7 @@ import type {
   PracticeMode,
   PracticeMode,
   ScoreMode,
   ScoreMode,
   EvalDimensions,
   EvalDimensions,
+  PreviewDialogueState,
 } from '@/types/englishSpeaking'
 } from '@/types/englishSpeaking'
 
 
 const DEFAULT_CONFIG: TopicDiscussionConfig = {
 const DEFAULT_CONFIG: TopicDiscussionConfig = {
@@ -43,6 +44,10 @@ const DEFAULT_CONFIG: TopicDiscussionConfig = {
 export const useSpeakingStore = defineStore('speaking', {
 export const useSpeakingStore = defineStore('speaking', {
   state: () => ({
   state: () => ({
     config: { ...DEFAULT_CONFIG } as TopicDiscussionConfig,
     config: { ...DEFAULT_CONFIG } as TopicDiscussionConfig,
+    // 话题讨论预览当前阶段(由 TopicDiscussionPreview 同步)
+    previewState: 'ready' as PreviewDialogueState,
+    // 请求重置预览的递增信号(由 CanvasTool 触发 → TopicDiscussionPreview 监听)
+    resetSignal: 0,
   }),
   }),
 
 
   actions: {
   actions: {
@@ -51,6 +56,13 @@ export const useSpeakingStore = defineStore('speaking', {
       this.config = JSON.parse(JSON.stringify(DEFAULT_CONFIG))
       this.config = JSON.parse(JSON.stringify(DEFAULT_CONFIG))
     },
     },
 
 
+    setPreviewState(state: PreviewDialogueState) {
+      this.previewState = state
+    },
+    requestResetPreview() {
+      this.resetSignal += 1
+    },
+
     // 用推荐卡片数据预填充
     // 用推荐卡片数据预填充
     prefillFromTask(topic: string, vocabulary: string[], sentences: string[]) {
     prefillFromTask(topic: string, vocabulary: string[], sentences: string[]) {
       this.resetConfig()
       this.resetConfig()

+ 46 - 0
src/views/Editor/CanvasTool/index2.vue

@@ -104,6 +104,20 @@
           <span>{{ lang.ssShape }}</span>
           <span>{{ lang.ssShape }}</span>
         </div>
         </div>
       </Popover>
       </Popover>
+
+      <!-- 英语口语工具:重置预览 -->
+      <div
+        v-if="canResetSpeakingPreview"
+        class="handler-item reset-preview-btn"
+        @click="handleResetSpeakingPreview"
+      >
+        <svg width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor"
+          stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+          <polyline points="23 4 23 10 17 10" />
+          <path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
+        </svg>
+        <span>重置预览</span>
+      </div>
     </div>
     </div>
 
 
 
 
@@ -133,6 +147,7 @@
 import { ref, computed } from 'vue'
 import { ref, computed } from 'vue'
 import { storeToRefs } from 'pinia'
 import { storeToRefs } from 'pinia'
 import { useMainStore, useSnapshotStore, useSlidesStore } from '@/store'
 import { useMainStore, useSnapshotStore, useSlidesStore } from '@/store'
+import { useSpeakingStore } from '@/store/speaking'
 import { getImageDataURL } from '@/utils/image'
 import { getImageDataURL } from '@/utils/image'
 import useImport from '@/hooks/useImport'
 import useImport from '@/hooks/useImport'
 import type { ShapePoolItem } from '@/configs/shapes'
 import type { ShapePoolItem } from '@/configs/shapes'
@@ -158,9 +173,26 @@ import Button from '@/components/Button.vue'
 
 
 const mainStore = useMainStore()
 const mainStore = useMainStore()
 const slidesStore = useSlidesStore()
 const slidesStore = useSlidesStore()
+const speakingStore = useSpeakingStore()
 const { creatingElement, creatingCustomShape, showSelectPanel, showSearchPanel, showNotesPanel } = storeToRefs(mainStore)
 const { creatingElement, creatingCustomShape, showSelectPanel, showSearchPanel, showNotesPanel } = storeToRefs(mainStore)
 const { canUndo, canRedo } = storeToRefs(useSnapshotStore())
 const { canUndo, canRedo } = storeToRefs(useSnapshotStore())
 const { currentSlide } = storeToRefs(slidesStore)
 const { currentSlide } = storeToRefs(slidesStore)
+const { previewState } = storeToRefs(speakingStore)
+
+// 当前 slide 是否包含英语口语工具(toolType=77)
+const hasEnglishSpeakingTool = computed(() => {
+  const elements = currentSlide.value?.elements || []
+  return elements.some((el: any) => el.type === 'frame' && Number(el.toolType) === 77)
+})
+
+// 仅当存在 toolType=77 且预览不在 ready 阶段时,显示"重置预览"按钮
+const canResetSpeakingPreview = computed(
+  () => hasEnglishSpeakingTool.value && previewState.value !== 'ready'
+)
+
+const handleResetSpeakingPreview = () => {
+  speakingStore.requestResetPreview()
+}
 
 
 const getInitialViewMode = () => {
 const getInitialViewMode = () => {
   const urlParams = new URLSearchParams(window.location.search)
   const urlParams = new URLSearchParams(window.location.search)
@@ -481,6 +513,20 @@ const editContent = (toolType: number) => {
   }
   }
 }
 }
 
 
+.reset-preview-btn {
+  gap: 5px;
+  color: #6b7280;
+
+  svg {
+    width: 14px;
+    height: 14px;
+  }
+
+  &:hover {
+    color: #f97316;
+  }
+}
+
 .left-handler {
 .left-handler {
   display: flex;
   display: flex;
   align-items: center;
   align-items: center;

Разница между файлами не показана из-за своего большого размера
+ 610 - 216
src/views/Editor/EnglishSpeaking/preview/DialogueChatView.vue


+ 123 - 126
src/views/Editor/EnglishSpeaking/preview/TopicDiscussionPreview.vue

@@ -1,62 +1,49 @@
 <template>
 <template>
   <div class="topic-discussion-preview">
   <div class="topic-discussion-preview">
-    <!-- Ready 阶段 -->
-    <StudentPreview
-      v-if="dialogueState === 'ready'"
-      title="Talk About Animals"
-      subtitle="Topic Discussion"
-      titleIcon="🗣️"
-    >
-      <div class="ready-content">
-        <div class="topic-icon-large">🐼</div>
-        <h2 class="topic-name">我最喜欢的动物</h2>
-        <p class="topic-desc">和 AI 伙伴 {{ mockRole.name }} 一起练习关于动物的英语对话</p>
-        <div class="topic-meta">
-          <span class="meta-item">🔄 {{ totalRounds }} 轮对话</span>
-          <span class="meta-item">⏱️ 约 {{ totalRounds * 2 }} 分钟</span>
-        </div>
-        <div class="vocab-preview">
-          <span v-for="word in previewVocab" :key="word" class="vocab-chip">{{ word }}</span>
-        </div>
+    <!-- Ready 阶段:极简首页(参照 enspeak 布局) -->
+    <div v-if="dialogueState === 'ready'" class="ready-stage">
+      <div class="ready-header">
+        <h1 class="ready-title">
+          <span class="ready-title-icon">💬</span>
+          Topic Discussion
+        </h1>
+        <p class="ready-subtitle">话题讨论 · 与 AI 伙伴练习英语对话</p>
       </div>
       </div>
-      <template #action>
-        <div class="action-center">
-          <button class="start-btn" @click="startDialogue">
-            <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
-              <polygon points="5 3 19 12 5 21 5 3" />
-            </svg>
-            开始对话
-          </button>
-        </div>
-      </template>
-    </StudentPreview>
 
 
-    <!-- Chatting 阶段 -->
-    <StudentPreview
+      <div class="ready-body">
+        <span class="topic-emoji">🐼</span>
+        <h3 class="topic-name">{{ topic }}</h3>
+      </div>
+
+      <div class="ready-footer">
+        <button class="start-btn" @click="startDialogue">
+          <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
+            stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+            <path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z" />
+            <path d="M19 10v2a7 7 0 0 1-14 0v-2" />
+            <line x1="12" y1="19" x2="12" y2="23" />
+            <line x1="8" y1="23" x2="16" y2="23" />
+          </svg>
+          开始对话
+        </button>
+      </div>
+    </div>
+
+    <!-- Chatting 阶段:直接渲染 DialogueChatView -->
+    <DialogueChatView
       v-else-if="dialogueState === 'chatting'"
       v-else-if="dialogueState === 'chatting'"
-      :title="`与 ${mockRole.name} 对话中`"
-      titleIcon="💬"
-      :progress="`Round ${currentRound}/${totalRounds}`"
-      :fullscreen="true"
-    >
-      <DialogueChatView
-        :topic="'我最喜欢的动物'"
-        :ai-name="mockRole.name"
-        :ai-avatar="mockRole.avatar"
-        :total-rounds="totalRounds"
-        :mode="mode"
-        @complete="handleDialogueComplete"
-      />
-    </StudentPreview>
+      :topic="topic"
+      :keywords="keywords"
+      :ai-name="mockRole.name"
+      :ai-avatar="mockRole.avatar"
+      :total-rounds="totalRounds"
+      :mode="mode"
+      @complete="handleDialogueComplete"
+    />
 
 
-    <!-- Completed 阶段 -->
-    <StudentPreview
-      v-else
-      title="学习报告"
-      titleIcon="📊"
-      :fullscreen="true"
-    >
-      <div class="report-container">
+    <!-- Completed 阶段:直接渲染报告 -->
+    <div v-else class="report-stage">
+      <div class="report-scroll">
         <OverallReport
         <OverallReport
           :evaluation="mockEvaluation"
           :evaluation="mockEvaluation"
           :role="mockRole"
           :role="mockRole"
@@ -65,44 +52,37 @@
           @complete="resetPreview"
           @complete="resetPreview"
         />
         />
         <div class="report-divider" />
         <div class="report-divider" />
-        <DetailedReport
-          :sentenceEvaluations="mockEvaluation.sentenceEvaluations"
-        />
+        <DetailedReport :sentenceEvaluations="mockEvaluation.sentenceEvaluations" />
       </div>
       </div>
-    </StudentPreview>
+    </div>
 
 
-    <!-- 重置按钮 -->
-    <button v-if="dialogueState !== 'ready'" class="reset-btn" @click="resetPreview">
-      <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
-        <polyline points="23 4 23 10 17 10" /><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
-      </svg>
-      重置预览
-    </button>
   </div>
   </div>
 </template>
 </template>
 
 
 <script lang="ts" setup>
 <script lang="ts" setup>
-import { ref } from 'vue'
+import { ref, watch, onMounted, onUnmounted } from 'vue'
 import type { OverallEvaluation, PreviewAIRole, PreviewDialogueState } from '@/types/englishSpeaking'
 import type { OverallEvaluation, PreviewAIRole, PreviewDialogueState } from '@/types/englishSpeaking'
+import { useSpeakingStore } from '@/store/speaking'
 
 
-import StudentPreview from './StudentPreview.vue'
 import DialogueChatView from './DialogueChatView.vue'
 import DialogueChatView from './DialogueChatView.vue'
 import OverallReport from './OverallReport.vue'
 import OverallReport from './OverallReport.vue'
 import DetailedReport from './DetailedReport.vue'
 import DetailedReport from './DetailedReport.vue'
 
 
 interface Props {
 interface Props {
   mode?: 'preview' | 'real'
   mode?: 'preview' | 'real'
+  topic?: string
+  keywords?: string[]
+  totalRounds?: number
 }
 }
 
 
 const props = withDefaults(defineProps<Props>(), {
 const props = withDefaults(defineProps<Props>(), {
   mode: 'real',
   mode: 'real',
+  topic: '我最喜欢的动物',
+  keywords: () => ['favorite', 'adorable', 'bamboo', 'habitat', 'wildlife', 'endangered'],
+  totalRounds: 3,
 })
 })
 
 
 const dialogueState = ref<PreviewDialogueState>('ready')
 const dialogueState = ref<PreviewDialogueState>('ready')
-const currentRound = ref(1)
-const totalRounds = 3
-
-const previewVocab = ['favorite', 'adorable', 'bamboo', 'habitat', 'wildlife', 'endangered']
 
 
 const mockRole: PreviewAIRole = {
 const mockRole: PreviewAIRole = {
   id: 'tom',
   id: 'tom',
@@ -213,8 +193,22 @@ function handleDialogueComplete() {
 
 
 function resetPreview() {
 function resetPreview() {
   dialogueState.value = 'ready'
   dialogueState.value = 'ready'
-  currentRound.value = 1
 }
 }
+
+// ── Sync with speakingStore (让 CanvasTool 可以驱动"重置预览") ──
+const speakingStore = useSpeakingStore()
+
+watch(dialogueState, (s) => { speakingStore.setPreviewState(s) }, { immediate: true })
+
+watch(
+  () => speakingStore.resetSignal,
+  (val, old) => {
+    if (val !== old) resetPreview()
+  },
+)
+
+onMounted(() => { speakingStore.setPreviewState(dialogueState.value) })
+onUnmounted(() => { speakingStore.setPreviewState('ready') })
 </script>
 </script>
 
 
 <style lang="scss" scoped>
 <style lang="scss" scoped>
@@ -223,70 +217,92 @@ function resetPreview() {
   height: 100%;
   height: 100%;
   display: flex;
   display: flex;
   flex-direction: column;
   flex-direction: column;
-  background: #f9fafb;
+  background: #fff;
   position: relative;
   position: relative;
   overflow: hidden;
   overflow: hidden;
 }
 }
 
 
-// Ready 阶段
-.ready-content {
+// ─── Ready 阶段 ───
+.ready-stage {
+  flex: 1;
   display: flex;
   display: flex;
   flex-direction: column;
   flex-direction: column;
-  align-items: center;
+  background: #fff;
+}
+.ready-header {
+  padding: 20px 24px 12px;
   text-align: center;
   text-align: center;
+  border-bottom: 1px solid #f9fafb;
+}
+.ready-title {
+  font-size: 20px;
+  font-weight: 600;
+  color: #111827;
+  margin: 0;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
   gap: 8px;
   gap: 8px;
 }
 }
-.topic-icon-large { font-size: 48px; }
-.topic-name { font-size: 18px; font-weight: 600; color: #111827; margin: 0; }
-.topic-desc { font-size: 13px; color: #6b7280; margin: 0; }
-.topic-meta {
+.ready-title-icon { font-size: 22px; }
+.ready-subtitle {
+  font-size: 13px;
+  color: #9ca3af;
+  margin: 4px 0 0;
+}
+.ready-body {
+  flex: 1;
   display: flex;
   display: flex;
+  flex-direction: column;
   align-items: center;
   align-items: center;
+  justify-content: center;
   gap: 16px;
   gap: 16px;
-  margin-top: 4px;
+  padding: 24px;
 }
 }
-.meta-item { font-size: 12px; color: #9ca3af; }
-
-.vocab-preview {
+.topic-emoji { font-size: 56px; line-height: 1; }
+.topic-name {
+  font-size: 18px;
+  font-weight: 600;
+  color: #1f2937;
+  margin: 0;
+}
+.ready-footer {
+  padding: 16px 24px 20px;
+  border-top: 1px solid #f9fafb;
+  background: #fafbfc;
   display: flex;
   display: flex;
-  flex-wrap: wrap;
   justify-content: center;
   justify-content: center;
-  gap: 6px;
-  margin-top: 8px;
-}
-.vocab-chip {
-  padding: 4px 10px;
-  background: #fff7ed;
-  color: #ea580c;
-  font-size: 11px;
-  border-radius: 999px;
-  border: 1px solid #fed7aa;
 }
 }
-
-// 开始按钮
-.action-center { display: flex; justify-content: center; }
 .start-btn {
 .start-btn {
-  display: flex;
+  display: inline-flex;
   align-items: center;
   align-items: center;
   gap: 8px;
   gap: 8px;
-  padding: 10px 28px;
+  padding: 9px 28px;
+  border-radius: 999px;
   background: #f97316;
   background: #f97316;
   color: #fff;
   color: #fff;
   border: none;
   border: none;
-  border-radius: 999px;
   font-size: 14px;
   font-size: 14px;
   font-weight: 500;
   font-weight: 500;
   cursor: pointer;
   cursor: pointer;
-  transition: background 0.2s;
-  box-shadow: 0 4px 12px rgba(249, 115, 22, 0.3);
+  box-shadow: 0 4px 12px rgba(249, 115, 22, 0.25);
+  transition: background 0.2s, transform 0.15s;
   &:hover { background: #ea580c; }
   &:hover { background: #ea580c; }
+  &:active { transform: scale(0.97); }
 }
 }
 
 
-// 报告容器
-.report-container {
-  width: 100%;
-  padding: 16px;
+// ─── Completed 阶段 ───
+.report-stage {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+  background: #fff;
+}
+.report-scroll {
+  flex: 1;
   overflow-y: auto;
   overflow-y: auto;
+  padding: 16px;
   display: flex;
   display: flex;
   flex-direction: column;
   flex-direction: column;
   gap: 24px;
   gap: 24px;
@@ -294,26 +310,7 @@ function resetPreview() {
 .report-divider {
 .report-divider {
   height: 1px;
   height: 1px;
   background: #e5e7eb;
   background: #e5e7eb;
-  margin: 0 16px;
+  margin: 0;
 }
 }
 
 
-// 重置按钮
-.reset-btn {
-  position: absolute;
-  top: 8px;
-  right: 8px;
-  display: flex;
-  align-items: center;
-  gap: 4px;
-  padding: 4px 10px;
-  border-radius: 999px;
-  background: rgba(255,255,255,0.9);
-  backdrop-filter: blur(4px);
-  border: 1px solid #e5e7eb;
-  color: #6b7280;
-  font-size: 10px;
-  cursor: pointer;
-  z-index: 10;
-  &:hover { background: #f3f4f6; color: #374151; }
-}
 </style>
 </style>

Некоторые файлы не были показаны из-за большого количества измененных файлов