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

feat(speaking): expandable streamed-report AISummary panel

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
jimmylee 1 месяц назад
Родитель
Сommit
a237f8b323
1 измененных файлов с 108 добавлено и 16 удалено
  1. 108 16
      src/views/Student/components/SpeakingClassPanel/AISummary.vue

+ 108 - 16
src/views/Student/components/SpeakingClassPanel/AISummary.vue

@@ -2,23 +2,36 @@
   <div class="ai-summary">
     <div class="ai-header">
       <h3 class="ai-title">{{ lang.ssSpkAISummary }}</h3>
-      <button class="ai-refresh-btn" :disabled="loading" @click="$emit('refresh')">
+      <button class="ai-refresh-btn" :disabled="streaming" @click="$emit('refresh')">
         <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
              stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
-             :class="{ spinning: loading }">
+             :class="{ spinning: streaming }">
           <path d="M1 4v6h6M23 20v-6h-6" />
           <path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15" />
         </svg>
-        {{ lang.ssSpkRefresh }}
+        {{ streaming ? lang.ssSpkReportStreaming : lang.ssSpkRegenerate }}
       </button>
     </div>
-    <ul class="ai-bullets">
-      <li>• {{ bullets[0] }}</li>
-      <li v-if="loading && !bullets[1]" class="ai-skeleton"><span /></li>
-      <li v-else>• {{ bullets[1] }}</li>
-      <li v-if="loading && !bullets[2]" class="ai-skeleton"><span /></li>
-      <li v-else>• {{ bullets[2] }}</li>
-    </ul>
+
+    <!-- 折叠态常驻行 -->
+    <div class="ai-completion">{{ completionLine }}</div>
+
+    <!-- 展开/收起 -->
+    <button class="ai-toggle" @click="expanded = !expanded">
+      {{ expanded ? lang.ssSpkCollapseReport : lang.ssSpkViewFullReport }}
+      <span class="ai-caret" :class="{ open: expanded }">▾</span>
+    </button>
+
+    <div v-if="expanded" class="ai-report-region">
+      <div v-if="error" class="ai-report-error">
+        <span>{{ lang.ssSpkReportFailed }}</span>
+        <button @click="$emit('refresh')">{{ lang.ssSpkRetry }}</button>
+      </div>
+      <div v-else-if="streaming && !markdown" class="ai-skeleton"><span /></div>
+      <!-- 渲染结果已由 renderReportMarkdown 经 DOMPurify 消毒 -->
+      <div v-else class="ai-report-body" v-html="renderedHtml"></div>
+    </div>
+
     <div v-if="generatedAtLabel" class="ai-meta">{{ generatedAtLabel }}</div>
   </div>
 </template>
@@ -26,14 +39,21 @@
 <script lang="ts" setup>
 import { computed, ref, onMounted, onUnmounted } from 'vue'
 import { lang } from '@/main'
+import { renderReportMarkdown } from './renderReport'
 
 const props = defineProps<{
-  bullets: [string, string, string]
-  loading: boolean
+  completionLine: string
+  markdown: string
+  streaming: boolean
   generatedAt: string | null
+  error: string | null
 }>()
 defineEmits<{ refresh: [] }>()
 
+const expanded = ref(false)
+
+const renderedHtml = computed(() => renderReportMarkdown(props.markdown))
+
 // 「刚刚生成」/「N 秒前生成」/「N 分钟前生成」 — 每秒重算
 const now = ref(Date.now())
 let timer: number | null = null
@@ -90,10 +110,82 @@ const generatedAtLabel = computed(() => {
 }
 @keyframes spin { from { transform: rotate(0); } to { transform: rotate(360deg); } }
 
-.ai-bullets {
-  list-style: none; padding: 0; margin: 0;
-  display: flex; flex-direction: column; gap: 4px;
-  font-size: 12px; color: #374151;
+.ai-completion {
+  font-size: 12px;
+  color: #374151;
+  margin-bottom: 8px;
+}
+
+.ai-toggle {
+  display: flex;
+  align-items: center;
+  gap: 4px;
+  font-size: 12px;
+  font-weight: 500;
+  color: #2563eb;
+  background: transparent;
+  border: none;
+  cursor: pointer;
+  padding: 0;
+}
+.ai-caret {
+  transition: transform .15s;
+  &.open { transform: rotate(180deg); }
+}
+
+.ai-report-region {
+  margin-top: 12px;
+  max-height: 360px;
+  overflow-y: auto;
+  border-top: 1px solid #e5e7eb;
+  padding-top: 12px;
+}
+
+.ai-report-error {
+  background: #fef2f2;
+  color: #b91c1c;
+  padding: 8px 12px;
+  border-radius: 8px;
+  font-size: 12px;
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+
+  button {
+    padding: 2px 8px;
+    background: #fff;
+    border: 1px solid #fecaca;
+    border-radius: 4px;
+    cursor: pointer;
+    color: #b91c1c;
+    font-size: 12px;
+  }
+}
+
+.ai-report-body {
+  font-size: 12px;
+  color: #374151;
+  line-height: 1.6;
+
+  :deep(table) {
+    border-collapse: collapse;
+    width: 100%;
+    margin: 8px 0;
+  }
+  :deep(th), :deep(td) {
+    border: 1px solid #e5e7eb;
+    padding: 4px 8px;
+    text-align: left;
+  }
+  :deep(th) { background: #f3f4f6; font-weight: 600; }
+  :deep(pre) {
+    background: #f3f4f6;
+    padding: 8px;
+    border-radius: 6px;
+    overflow-x: auto;
+    font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+  }
+  :deep(h3) { font-size: 13px; margin: 12px 0 6px; }
 }
 
 .ai-skeleton span {