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

feat(speaking): add AISummary.vue with skeleton + timestamp

jimmylee 2 месяцев назад
Родитель
Сommit
02c4c8fb34
1 измененных файлов с 117 добавлено и 0 удалено
  1. 117 0
      src/views/Student/components/SpeakingClassPanel/AISummary.vue

+ 117 - 0
src/views/Student/components/SpeakingClassPanel/AISummary.vue

@@ -0,0 +1,117 @@
+<template>
+  <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')">
+        <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 }">
+          <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 }}
+      </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 v-if="generatedAtLabel" class="ai-meta">{{ generatedAtLabel }}</div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { computed, ref, onMounted, onUnmounted } from 'vue'
+import { lang } from '@/main'
+
+const props = defineProps<{
+  bullets: [string, string, string]
+  loading: boolean
+  generatedAt: string | null
+}>()
+defineEmits<{ refresh: [] }>()
+
+// 「刚刚生成」/「N 秒前生成」/「N 分钟前生成」 — 每秒重算
+const now = ref(Date.now())
+let timer: number | null = null
+onMounted(() => { timer = window.setInterval(() => { now.value = Date.now() }, 1000) })
+onUnmounted(() => { if (timer) clearInterval(timer) })
+
+function formatTpl(tpl: string, vars: Record<string, string | number>): string {
+  return tpl.replace(/\{(\w+)\}/g, (_, k) => String(vars[k] ?? ''))
+}
+
+const generatedAtLabel = computed(() => {
+  if (!props.generatedAt) return ''
+  const ts = new Date(props.generatedAt).getTime()
+  if (isNaN(ts)) return ''
+  const elapsed = Math.max(0, Math.floor((now.value - ts) / 1000))
+  if (elapsed < 5)  return (lang as any).ssSpkJustNow
+  if (elapsed < 60) return formatTpl((lang as any).ssSpkSecondsAgoTpl, { n: elapsed })
+  return formatTpl((lang as any).ssSpkMinutesAgoTpl, { n: Math.floor(elapsed / 60) })
+})
+</script>
+
+<style lang="scss" scoped>
+.ai-summary {
+  background: #f9fafb;
+  border: 1px solid #e5e7eb;
+  border-radius: 12px;
+  padding: 20px;
+}
+
+.ai-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 12px;
+}
+.ai-title {
+  font-size: 14px;
+  font-weight: 600;
+  color: #111827;
+  margin: 0;
+}
+.ai-refresh-btn {
+  display: flex;
+  align-items: center;
+  gap: 4px;
+  font-size: 12px;
+  color: #6b7280;
+  background: transparent;
+  border: none;
+  cursor: pointer;
+  &:hover:not(:disabled) { color: #374151; }
+  &:disabled { opacity: .6; cursor: default; }
+  svg.spinning { animation: spin 1s linear infinite; }
+}
+@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-skeleton span {
+  display: inline-block;
+  width: 60%; height: 12px;
+  background: linear-gradient(90deg, #e5e7eb 0%, #f3f4f6 50%, #e5e7eb 100%);
+  background-size: 200% 100%;
+  border-radius: 4px;
+  animation: shimmer 1.4s ease-in-out infinite;
+}
+@keyframes shimmer {
+  0% { background-position: 200% 0; }
+  100% { background-position: -200% 0; }
+}
+
+.ai-meta {
+  margin-top: 8px;
+  font-size: 11px;
+  color: #9ca3af;
+}
+</style>