|
|
@@ -0,0 +1,1040 @@
|
|
|
+<template>
|
|
|
+ <div class="dialogue-chat-view">
|
|
|
+ <!-- 顶部状态栏 -->
|
|
|
+ <div class="status-bar">
|
|
|
+ <div class="status-left">
|
|
|
+ <div class="ai-avatar-small">{{ aiAvatar }}</div>
|
|
|
+ <span class="ai-name">{{ aiName }}</span>
|
|
|
+ <span class="online-dot"></span>
|
|
|
+ </div>
|
|
|
+ <div class="status-right">
|
|
|
+ <span v-if="isRecording" class="recording-duration">{{ recordingDuration }}s</span>
|
|
|
+ <span class="round-indicator">{{ currentRound }}/{{ totalRounds }}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 对话消息区 -->
|
|
|
+ <div ref="chatContainerRef" class="chat-messages">
|
|
|
+ <div
|
|
|
+ v-for="message in messages"
|
|
|
+ :key="message.id"
|
|
|
+ class="message-row"
|
|
|
+ :class="{ 'message-student': message.role === 'student' }"
|
|
|
+ >
|
|
|
+ <div class="message-content" :class="{ 'student-content': message.role === 'student' }">
|
|
|
+ <!-- 语音条 -->
|
|
|
+ <div class="voice-bar" :class="message.role === 'ai' ? 'voice-ai' : 'voice-student'">
|
|
|
+ <button class="play-btn" :class="message.role === 'ai' ? 'play-ai' : 'play-student'">
|
|
|
+ <!-- VolumeIcon SVG -->
|
|
|
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
|
+ <polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
|
|
|
+ <path d="M15.54 8.46a5 5 0 0 1 0 7.07" />
|
|
|
+ </svg>
|
|
|
+ </button>
|
|
|
+ <div class="waveform">
|
|
|
+ <div
|
|
|
+ v-for="i in 12"
|
|
|
+ :key="i"
|
|
|
+ class="wave-bar"
|
|
|
+ :class="message.role === 'ai' ? 'wave-ai' : 'wave-student'"
|
|
|
+ :style="{ height: `${Math.sin(i * 0.5) * 6 + 4}px` }"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ <span class="duration" :class="message.role === 'ai' ? 'duration-ai' : 'duration-student'">0:03</span>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 英文文本(带单词高亮) -->
|
|
|
+ <div class="text-bubble" :class="message.role === 'ai' ? 'text-ai' : 'text-student'">
|
|
|
+ <template v-if="message.role === 'student' && message.evaluation?.wordAnalysis">
|
|
|
+ <template v-for="(word, idx) in message.content.split(' ')" :key="idx">
|
|
|
+ <span
|
|
|
+ v-if="getWordAnalysis(message, word)?.status === 'improvable'"
|
|
|
+ class="improvable-word"
|
|
|
+ @click="showPhonemeDetail(getWordAnalysis(message, word)!)"
|
|
|
+ >{{ word }}</span>
|
|
|
+ <span v-else>{{ word }}</span>
|
|
|
+ {{ ' ' }}
|
|
|
+ </template>
|
|
|
+ </template>
|
|
|
+ <template v-else>{{ message.content }}</template>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- L1 即时反馈 -->
|
|
|
+ <div v-if="message.role === 'student' && message.evaluation" class="feedback-card">
|
|
|
+ <div class="feedback-l1">
|
|
|
+ <div class="dimensions-row">
|
|
|
+ <span class="dim-label">准确</span>
|
|
|
+ <span :class="getDimClass(message.evaluation.dimensions.accuracy)">{{ getDimIcon(message.evaluation.dimensions.accuracy) }}</span>
|
|
|
+ <span class="dim-sep">|</span>
|
|
|
+ <span class="dim-label">流畅</span>
|
|
|
+ <span :class="getDimClass(message.evaluation.dimensions.fluency)">{{ getDimIcon(message.evaluation.dimensions.fluency) }}</span>
|
|
|
+ <span class="dim-sep">|</span>
|
|
|
+ <span class="dim-label">完整</span>
|
|
|
+ <span :class="getDimClass(message.evaluation.dimensions.completeness)">{{ getDimIcon(message.evaluation.dimensions.completeness) }}</span>
|
|
|
+ <span class="dim-sep">|</span>
|
|
|
+ <span class="dim-label">节奏</span>
|
|
|
+ <span :class="getDimClass(message.evaluation.dimensions.rhythm)">{{ getDimIcon(message.evaluation.dimensions.rhythm) }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="suggestion-row">
|
|
|
+ <p class="suggestion-text">
|
|
|
+ <span class="suggestion-icon">💡</span>
|
|
|
+ {{ message.evaluation.suggestion }}
|
|
|
+ </p>
|
|
|
+ <button class="detail-toggle" @click="toggleExpand(message.id)">
|
|
|
+ {{ expandedMessageId === message.id ? '收起' : '详情' }}
|
|
|
+ <!-- ChevronDown SVG -->
|
|
|
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" :class="{ 'chevron-up': expandedMessageId === message.id }">
|
|
|
+ <polyline points="6 9 12 15 18 9" />
|
|
|
+ </svg>
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- L2 展开详情 -->
|
|
|
+ <div v-if="expandedMessageId === message.id" class="feedback-l2">
|
|
|
+ <div v-if="message.evaluation.betterExpression" class="better-expression">
|
|
|
+ <p class="detail-label"><span>💡</span> Better expression:</p>
|
|
|
+ <p class="detail-content">{{ message.evaluation.betterExpression }}</p>
|
|
|
+ </div>
|
|
|
+ <div v-if="message.evaluation.suggestedWords?.length" class="suggested-words">
|
|
|
+ <p class="detail-label"><span>🎯</span> Try these words:</p>
|
|
|
+ <div class="word-tags">
|
|
|
+ <span v-for="(word, i) in message.evaluation.suggestedWords" :key="i" class="word-tag">{{ word }}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- AI 正在输入 -->
|
|
|
+ <div v-if="isWaiting" class="message-row">
|
|
|
+ <div class="typing-indicator">
|
|
|
+ <span class="typing-dot" style="animation-delay: 0ms" />
|
|
|
+ <span class="typing-dot" style="animation-delay: 150ms" />
|
|
|
+ <span class="typing-dot" style="animation-delay: 300ms" />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 录音时的沉默提示 -->
|
|
|
+ <div v-if="isRecording && silenceHint" class="silence-hint">
|
|
|
+ <div class="silence-hint-card">
|
|
|
+ <p class="silence-hint-text">
|
|
|
+ <span class="hint-icon">💡</span>
|
|
|
+ {{ silenceHint }}
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 底部录音控制区 -->
|
|
|
+ <div class="recording-controls">
|
|
|
+ <div class="controls-inner">
|
|
|
+ <!-- 提示按钮 -->
|
|
|
+ <button
|
|
|
+ class="side-btn"
|
|
|
+ :disabled="isWaiting || isRecording"
|
|
|
+ @click="showSmartHint = true"
|
|
|
+ >
|
|
|
+ <!-- LightbulbIcon SVG -->
|
|
|
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
|
+ <path d="M9 18h6" /><path d="M10 22h4" />
|
|
|
+ <path d="M15.09 14c.18-.98.65-1.74 1.41-2.5A4.65 4.65 0 0 0 18 8 6 6 0 0 0 6 8c0 1 .23 2.23 1.5 3.5A4.61 4.61 0 0 1 8.91 14" />
|
|
|
+ </svg>
|
|
|
+ <span>提示</span>
|
|
|
+ </button>
|
|
|
+
|
|
|
+ <!-- 录音按钮 -->
|
|
|
+ <div class="record-group">
|
|
|
+ <button
|
|
|
+ class="record-btn"
|
|
|
+ :class="{ recording: isRecording, waiting: isWaiting }"
|
|
|
+ :disabled="isWaiting"
|
|
|
+ @click="handleToggleRecording"
|
|
|
+ >
|
|
|
+ <!-- MicIcon SVG -->
|
|
|
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="white" 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 class="record-status">
|
|
|
+ <div v-if="isRecording" class="pulse-bars">
|
|
|
+ <div v-for="i in 4" :key="i" class="pulse-bar" :style="{ height: `${Math.sin((i) * 0.8) * 6 + 6}px`, animationDelay: `${i * 0.15}s` }" />
|
|
|
+ </div>
|
|
|
+ <span class="record-label" :class="{ 'label-recording': isRecording, 'label-waiting': isWaiting }">
|
|
|
+ {{ isRecording ? '录音中' : isWaiting ? '等待中...' : '点击说话' }}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 重录按钮 -->
|
|
|
+ <button class="side-btn" :disabled="isWaiting || isRecording">
|
|
|
+ <!-- RefreshIcon SVG -->
|
|
|
+ <svg width="14" height="14" 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>
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 提示弹窗 -->
|
|
|
+ <div v-if="showSmartHint" class="modal-overlay" @click.self="showSmartHint = false">
|
|
|
+ <div class="hint-modal">
|
|
|
+ <div class="hint-header">
|
|
|
+ <h3 class="hint-title">
|
|
|
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#f97316" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
|
+ <path d="M9 18h6" /><path d="M10 22h4" />
|
|
|
+ <path d="M15.09 14c.18-.98.65-1.74 1.41-2.5A4.65 4.65 0 0 0 18 8 6 6 0 0 0 6 8c0 1 .23 2.23 1.5 3.5A4.61 4.61 0 0 1 8.91 14" />
|
|
|
+ </svg>
|
|
|
+ 提示
|
|
|
+ </h3>
|
|
|
+ <button class="close-btn" @click="showSmartHint = false">
|
|
|
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
|
+ <line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
|
|
|
+ </svg>
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 任务提示 -->
|
|
|
+ <div class="hint-section">
|
|
|
+ <div class="hint-section-label">任务提示</div>
|
|
|
+ <div class="hint-task-box">
|
|
|
+ <p>{{ aiName }} 刚刚问你最喜欢的动物是什么,你可以告诉他你喜欢的动物以及原因。</p>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 句子提示 -->
|
|
|
+ <div class="hint-section">
|
|
|
+ <div class="hint-section-label">句子提示</div>
|
|
|
+ <div class="hint-sentences">
|
|
|
+ <div v-for="(hint, index) in sentenceHints" :key="index" class="hint-sentence-card">
|
|
|
+ <div class="hint-sentence-body">
|
|
|
+ <div class="hint-sentence-main">
|
|
|
+ <p class="hint-en">{{ hint.en }}</p>
|
|
|
+ <p class="hint-zh">{{ hint.zh }}</p>
|
|
|
+ <div class="hint-keywords">
|
|
|
+ <span v-for="(part, i) in hint.keyParts" :key="i" class="hint-keyword">{{ part }}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <button class="hint-play-btn" title="朗读句子">
|
|
|
+ <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="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
|
|
|
+ <path d="M15.54 8.46a5 5 0 0 1 0 7.07" />
|
|
|
+ </svg>
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 词汇提示 -->
|
|
|
+ <div class="hint-section">
|
|
|
+ <div class="hint-section-label">词汇提示</div>
|
|
|
+ <div class="hint-vocab-list">
|
|
|
+ <div v-for="(vocab, index) in vocabHints" :key="index" class="hint-vocab-item">
|
|
|
+ <div class="vocab-info">
|
|
|
+ <span class="vocab-word">{{ vocab.word }}</span>
|
|
|
+ <span class="vocab-phonetic">{{ vocab.phonetic }}</span>
|
|
|
+ <span class="vocab-meaning">{{ vocab.meaning }}</span>
|
|
|
+ </div>
|
|
|
+ <button class="vocab-play-btn" title="朗读单词">
|
|
|
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
|
+ <polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
|
|
|
+ <path d="M15.54 8.46a5 5 0 0 1 0 7.07" />
|
|
|
+ </svg>
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <p class="hint-footer">试着用自己的话说更好哦!</p>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- L3 音素详情弹窗 -->
|
|
|
+ <div v-if="phonemeDetailData" class="modal-overlay" @click.self="phonemeDetailData = null">
|
|
|
+ <div class="phoneme-modal">
|
|
|
+ <div class="hint-header">
|
|
|
+ <h3 class="hint-title">发音详情</h3>
|
|
|
+ <button class="close-btn" @click="phonemeDetailData = null">
|
|
|
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
|
+ <line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
|
|
|
+ </svg>
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="phoneme-word">{{ phonemeDetailData.word }}</div>
|
|
|
+
|
|
|
+ <div class="phoneme-cards">
|
|
|
+ <div class="phoneme-card phoneme-user">
|
|
|
+ <div class="phoneme-card-inner">
|
|
|
+ <div>
|
|
|
+ <p class="phoneme-card-label">🎧 你的发音</p>
|
|
|
+ <p class="phoneme-card-value">{{ phonemeDetailData.userPronunciation }}</p>
|
|
|
+ </div>
|
|
|
+ <button class="phoneme-play-btn phoneme-play-user">
|
|
|
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3" /></svg>
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="phoneme-card phoneme-standard">
|
|
|
+ <div class="phoneme-card-inner">
|
|
|
+ <div>
|
|
|
+ <p class="phoneme-card-label">🎯 标准发音</p>
|
|
|
+ <p class="phoneme-card-value phoneme-value-green">{{ phonemeDetailData.standardPronunciation }}</p>
|
|
|
+ </div>
|
|
|
+ <button class="phoneme-play-btn phoneme-play-standard">
|
|
|
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3" /></svg>
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div v-if="phonemeDetailData.tip" class="phoneme-card phoneme-tip">
|
|
|
+ <p class="phoneme-card-label">💡 小提示</p>
|
|
|
+ <p class="phoneme-tip-text">{{ phonemeDetailData.tip }}</p>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 徽章动画 -->
|
|
|
+ <div v-if="showBadge" class="badge-popup">
|
|
|
+ <div class="badge-card">
|
|
|
+ <span class="badge-icon">{{ showBadge.icon }}</span>
|
|
|
+ <div>
|
|
|
+ <p class="badge-name">{{ showBadge.name }}</p>
|
|
|
+ <p class="badge-desc">{{ showBadge.description }}</p>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script lang="ts" setup>
|
|
|
+import { ref, watch, onMounted, onUnmounted, nextTick } from 'vue'
|
|
|
+import type { PreviewChatMessage, BadgeAchievement } from '@/types/englishSpeaking'
|
|
|
+
|
|
|
+interface Props {
|
|
|
+ topic?: string
|
|
|
+ keywords?: string[]
|
|
|
+ aiName?: string
|
|
|
+ aiAvatar?: string
|
|
|
+ totalRounds?: number
|
|
|
+}
|
|
|
+
|
|
|
+const props = withDefaults(defineProps<Props>(), {
|
|
|
+ topic: '我最喜欢的动物',
|
|
|
+ keywords: () => ['animal', 'zoo', 'cute', 'favorite'],
|
|
|
+ aiName: 'Tom',
|
|
|
+ aiAvatar: '😊',
|
|
|
+ totalRounds: 3,
|
|
|
+})
|
|
|
+
|
|
|
+const emit = defineEmits<{
|
|
|
+ complete: []
|
|
|
+}>()
|
|
|
+
|
|
|
+const messages = ref<PreviewChatMessage[]>([])
|
|
|
+const currentRound = ref(1)
|
|
|
+const isRecording = ref(false)
|
|
|
+const isWaiting = ref(false)
|
|
|
+const showSmartHint = ref(false)
|
|
|
+const chatContainerRef = ref<HTMLDivElement>()
|
|
|
+
|
|
|
+const silenceHint = ref<string | null>(null)
|
|
|
+const recordingDuration = ref(0)
|
|
|
+let silenceTimer: ReturnType<typeof setTimeout> | null = null
|
|
|
+let recordingTimer: ReturnType<typeof setInterval> | null = null
|
|
|
+
|
|
|
+const expandedMessageId = ref<string | null>(null)
|
|
|
+
|
|
|
+const phonemeDetailData = ref<{
|
|
|
+ word: string
|
|
|
+ userPronunciation: string
|
|
|
+ standardPronunciation: string
|
|
|
+ tip: string
|
|
|
+} | null>(null)
|
|
|
+
|
|
|
+const showBadge = ref<BadgeAchievement | null>(null)
|
|
|
+const consecutiveFluent = ref(0)
|
|
|
+const consecutiveAccurate = ref(0)
|
|
|
+
|
|
|
+// 徽章配置
|
|
|
+const BADGE_CONFIG: Record<string, BadgeAchievement> = {
|
|
|
+ smooth_talker: { id: 'smooth_talker', name: '流畅达人', nameEn: 'Smooth Talker', icon: '💬', description: '连续3句流畅度优秀' },
|
|
|
+ pronunciation_pro: { id: 'pronunciation_pro', name: '发音专家', nameEn: 'Pronunciation Pro', icon: '🎯', description: '连续5句发音准确' },
|
|
|
+ perfect_round: { id: 'perfect_round', name: '完美一轮', nameEn: 'Perfect Round', icon: '⭐', description: '单轮四维度全优' },
|
|
|
+}
|
|
|
+
|
|
|
+// 提示数据
|
|
|
+const TRAVEL_HINTS = [
|
|
|
+ "You could say: I like pandas because they are cute!",
|
|
|
+ "You could say: My favorite animal is the elephant.",
|
|
|
+ "You could say: I went to the zoo last weekend.",
|
|
|
+]
|
|
|
+
|
|
|
+const sentenceHints = [
|
|
|
+ { en: 'I like pandas best because they are very cute.', zh: '我最喜欢大熊猫,因为它们非常可爱。', keyParts: ['I like', 'best', 'because'] },
|
|
|
+ { en: "My favorite animal is the elephant. It's really smart!", zh: '我最喜欢的动物是大象。它真的很聪明!', keyParts: ['My favorite', 'is', 'really'] },
|
|
|
+ { en: 'I enjoy watching animals at the zoo with my family.', zh: '我喜欢和家人一起在动物园看动物。', keyParts: ['I enjoy', 'watching', 'with'] },
|
|
|
+]
|
|
|
+
|
|
|
+const vocabHints = [
|
|
|
+ { word: 'favorite', phonetic: '/ˈfeɪvərɪt/', meaning: '最喜欢的' },
|
|
|
+ { word: 'adorable', phonetic: '/əˈdɔːrəbl/', meaning: '可爱的' },
|
|
|
+ { word: 'bamboo', phonetic: '/bæmˈbuː/', meaning: '竹子' },
|
|
|
+ { word: 'habitat', phonetic: '/ˈhæbɪtæt/', meaning: '栖息地' },
|
|
|
+ { word: 'wildlife', phonetic: '/ˈwaɪldlaɪf/', meaning: '野生动物' },
|
|
|
+ { word: 'endangered', phonetic: '/ɪnˈdeɪndʒərd/', meaning: '濒危的' },
|
|
|
+]
|
|
|
+
|
|
|
+// 对话脚本
|
|
|
+const dialogueScript = [
|
|
|
+ { ai: "Hi! What's your favorite animal?", student: 'I like pandas. They are very cute!' },
|
|
|
+ { ai: 'Pandas are adorable! Have you seen them at the zoo?', student: 'Yes, I went to the zoo last month.' },
|
|
|
+ { ai: "That's great! What do pandas like to eat?", student: 'They like to eat bamboo.' },
|
|
|
+]
|
|
|
+
|
|
|
+// 生成模拟评估数据
|
|
|
+function generateMockEvaluation(content: string): PreviewChatMessage['evaluation'] {
|
|
|
+ const dimensions = ['excellent', 'good', 'improve'] as const
|
|
|
+ const randomDim = () => dimensions[Math.floor(Math.random() * 2)]
|
|
|
+
|
|
|
+ const words = content.split(' ').filter(w => w.length > 3)
|
|
|
+ const wordAnalysis = words.slice(0, 3).map((word, i) => ({
|
|
|
+ word: word.replace(/[.,!?]/g, ''),
|
|
|
+ status: (i === 1 ? 'improvable' : 'correct') as 'correct' | 'improvable',
|
|
|
+ userPronunciation: i === 1 ? '/traˈvel/' : undefined,
|
|
|
+ standardPronunciation: i === 1 ? '/ˈtrævəl/' : undefined,
|
|
|
+ tip: i === 1 ? '重音在第一音节' : undefined,
|
|
|
+ }))
|
|
|
+
|
|
|
+ return {
|
|
|
+ dimensions: { accuracy: randomDim(), fluency: randomDim(), completeness: randomDim(), rhythm: randomDim() },
|
|
|
+ suggestion: 'Try: "I\'d like to..." instead of "I want to..."',
|
|
|
+ betterExpression: "I'd like to visit the zoo. It's one of the most famous places!",
|
|
|
+ suggestedWords: ['adorable', 'bamboo', 'habitat'],
|
|
|
+ wordAnalysis,
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 检查并触发徽章
|
|
|
+function checkAndTriggerBadge(evaluation: PreviewChatMessage['evaluation']) {
|
|
|
+ if (!evaluation) return
|
|
|
+
|
|
|
+ if (evaluation.dimensions.fluency === 'excellent') {
|
|
|
+ consecutiveFluent.value++
|
|
|
+ if (consecutiveFluent.value === 3) {
|
|
|
+ showBadge.value = BADGE_CONFIG.smooth_talker
|
|
|
+ setTimeout(() => { showBadge.value = null }, 2500)
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ consecutiveFluent.value = 0
|
|
|
+ }
|
|
|
+
|
|
|
+ if (evaluation.dimensions.accuracy === 'excellent') {
|
|
|
+ consecutiveAccurate.value++
|
|
|
+ if (consecutiveAccurate.value === 5) {
|
|
|
+ showBadge.value = BADGE_CONFIG.pronunciation_pro
|
|
|
+ setTimeout(() => { showBadge.value = null }, 2500)
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ consecutiveAccurate.value = 0
|
|
|
+ }
|
|
|
+
|
|
|
+ const dims = evaluation.dimensions
|
|
|
+ if (dims.accuracy === 'excellent' && dims.fluency === 'excellent' && dims.completeness === 'excellent' && dims.rhythm === 'excellent') {
|
|
|
+ setTimeout(() => {
|
|
|
+ showBadge.value = BADGE_CONFIG.perfect_round
|
|
|
+ setTimeout(() => { showBadge.value = null }, 2500)
|
|
|
+ }, 500)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 录音计时与沉默检测
|
|
|
+watch(isRecording, (recording) => {
|
|
|
+ if (recording) {
|
|
|
+ recordingTimer = setInterval(() => { recordingDuration.value++ }, 1000)
|
|
|
+ silenceTimer = setTimeout(() => {
|
|
|
+ silenceHint.value = TRAVEL_HINTS[Math.floor(Math.random() * TRAVEL_HINTS.length)]
|
|
|
+ setTimeout(() => { silenceHint.value = null }, 3000)
|
|
|
+ }, 5000)
|
|
|
+ } else {
|
|
|
+ recordingDuration.value = 0
|
|
|
+ silenceHint.value = null
|
|
|
+ if (silenceTimer) { clearTimeout(silenceTimer); silenceTimer = null }
|
|
|
+ if (recordingTimer) { clearInterval(recordingTimer); recordingTimer = null }
|
|
|
+ }
|
|
|
+})
|
|
|
+
|
|
|
+// 初始化 AI 第一条消息
|
|
|
+onMounted(() => {
|
|
|
+ if (messages.value.length === 0) {
|
|
|
+ messages.value.push({
|
|
|
+ id: crypto.randomUUID(),
|
|
|
+ role: 'ai',
|
|
|
+ content: dialogueScript[0].ai,
|
|
|
+ timestamp: new Date(),
|
|
|
+ })
|
|
|
+ }
|
|
|
+})
|
|
|
+
|
|
|
+onUnmounted(() => {
|
|
|
+ if (silenceTimer) clearTimeout(silenceTimer)
|
|
|
+ if (recordingTimer) clearInterval(recordingTimer)
|
|
|
+})
|
|
|
+
|
|
|
+// 滚动到底部
|
|
|
+watch(() => messages.value.length, () => {
|
|
|
+ nextTick(() => {
|
|
|
+ if (chatContainerRef.value) {
|
|
|
+ chatContainerRef.value.scrollTop = chatContainerRef.value.scrollHeight
|
|
|
+ }
|
|
|
+ })
|
|
|
+})
|
|
|
+
|
|
|
+// 录音切换
|
|
|
+function handleToggleRecording() {
|
|
|
+ if (isWaiting.value) return
|
|
|
+
|
|
|
+ if (isRecording.value) {
|
|
|
+ isRecording.value = false
|
|
|
+ isWaiting.value = true
|
|
|
+
|
|
|
+ const scriptIndex = Math.min(currentRound.value - 1, dialogueScript.length - 1)
|
|
|
+ const studentResponse = dialogueScript[scriptIndex].student
|
|
|
+ const evaluation = generateMockEvaluation(studentResponse)
|
|
|
+
|
|
|
+ messages.value.push({
|
|
|
+ id: crypto.randomUUID(),
|
|
|
+ role: 'student',
|
|
|
+ content: studentResponse,
|
|
|
+ timestamp: new Date(),
|
|
|
+ evaluation,
|
|
|
+ })
|
|
|
+ checkAndTriggerBadge(evaluation)
|
|
|
+
|
|
|
+ setTimeout(() => {
|
|
|
+ isWaiting.value = false
|
|
|
+ if (currentRound.value < props.totalRounds && scriptIndex + 1 < dialogueScript.length) {
|
|
|
+ messages.value.push({
|
|
|
+ id: crypto.randomUUID(),
|
|
|
+ role: 'ai',
|
|
|
+ content: dialogueScript[scriptIndex + 1].ai,
|
|
|
+ timestamp: new Date(),
|
|
|
+ })
|
|
|
+ currentRound.value++
|
|
|
+ } else {
|
|
|
+ emit('complete')
|
|
|
+ }
|
|
|
+ }, 1200)
|
|
|
+ } else {
|
|
|
+ isRecording.value = true
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 工具函数
|
|
|
+function toggleExpand(id: string) {
|
|
|
+ expandedMessageId.value = expandedMessageId.value === id ? null : id
|
|
|
+}
|
|
|
+
|
|
|
+function getDimIcon(level: string) {
|
|
|
+ if (level === 'excellent') return '✓✓'
|
|
|
+ if (level === 'good') return '✓'
|
|
|
+ return '△'
|
|
|
+}
|
|
|
+
|
|
|
+function getDimClass(level: string) {
|
|
|
+ if (level === 'excellent') return 'dim-excellent'
|
|
|
+ if (level === 'good') return 'dim-good'
|
|
|
+ return 'dim-improve'
|
|
|
+}
|
|
|
+
|
|
|
+function getWordAnalysis(message: PreviewChatMessage, word: string) {
|
|
|
+ const cleanWord = word.replace(/[.,!?]/g, '')
|
|
|
+ return message.evaluation?.wordAnalysis?.find(w => w.word === cleanWord)
|
|
|
+}
|
|
|
+
|
|
|
+function showPhonemeDetail(analysis: NonNullable<NonNullable<PreviewChatMessage['evaluation']>['wordAnalysis']>[0]) {
|
|
|
+ phonemeDetailData.value = {
|
|
|
+ word: analysis.word,
|
|
|
+ userPronunciation: analysis.userPronunciation || '',
|
|
|
+ standardPronunciation: analysis.standardPronunciation || '',
|
|
|
+ tip: analysis.tip || '',
|
|
|
+ }
|
|
|
+}
|
|
|
+</script>
|
|
|
+
|
|
|
+<style lang="scss" scoped>
|
|
|
+.dialogue-chat-view {
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ position: relative;
|
|
|
+}
|
|
|
+
|
|
|
+// 顶部状态栏
|
|
|
+.status-bar {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: space-between;
|
|
|
+ padding: 4px 12px;
|
|
|
+ border-bottom: 1px solid #f3f4f6;
|
|
|
+}
|
|
|
+.status-left {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 6px;
|
|
|
+}
|
|
|
+.ai-avatar-small {
|
|
|
+ width: 20px; height: 20px;
|
|
|
+ border-radius: 50%;
|
|
|
+ background: #fff7ed;
|
|
|
+ border: 1px solid rgba(249,115,22,0.2);
|
|
|
+ display: flex; align-items: center; justify-content: center;
|
|
|
+ font-size: 10px;
|
|
|
+}
|
|
|
+.ai-name { font-size: 10px; font-weight: 500; color: #111827; }
|
|
|
+.online-dot { width: 4px; height: 4px; background: #22c55e; border-radius: 50%; }
|
|
|
+.status-right { display: flex; align-items: center; gap: 8px; font-size: 10px; color: #9ca3af; }
|
|
|
+.recording-duration { color: #ef4444; font-weight: 500; }
|
|
|
+.round-indicator { color: #f97316; font-weight: 500; }
|
|
|
+
|
|
|
+// 消息区域
|
|
|
+.chat-messages {
|
|
|
+ flex: 1;
|
|
|
+ overflow-y: auto;
|
|
|
+ padding: 12px 16px;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 12px;
|
|
|
+ background: #fafbfc;
|
|
|
+}
|
|
|
+.message-row { display: flex; justify-content: flex-start; }
|
|
|
+.message-student { justify-content: flex-end; }
|
|
|
+.message-content { max-width: 85%; }
|
|
|
+.student-content { text-align: right; }
|
|
|
+
|
|
|
+// 语音条
|
|
|
+.voice-bar {
|
|
|
+ display: inline-flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 8px;
|
|
|
+ padding: 6px 10px;
|
|
|
+ border-radius: 12px;
|
|
|
+ box-shadow: 0 1px 3px rgba(0,0,0,0.08);
|
|
|
+}
|
|
|
+.voice-ai { background: #fff; border: 1px solid #f3f4f6; border-top-left-radius: 4px; }
|
|
|
+.voice-student { background: #f97316; border-top-right-radius: 4px; }
|
|
|
+
|
|
|
+.play-btn {
|
|
|
+ padding: 4px; border-radius: 50%; border: none; cursor: pointer; transition: background 0.2s;
|
|
|
+}
|
|
|
+.play-ai { background: rgba(249,115,22,0.1); color: #f97316; }
|
|
|
+.play-ai:hover { background: rgba(249,115,22,0.2); }
|
|
|
+.play-student { background: rgba(255,255,255,0.2); color: #fff; }
|
|
|
+.play-student:hover { background: rgba(255,255,255,0.3); }
|
|
|
+
|
|
|
+.waveform { display: flex; align-items: center; gap: 1px; }
|
|
|
+.wave-bar { width: 2px; border-radius: 999px; }
|
|
|
+.wave-ai { background: rgba(249,115,22,0.4); }
|
|
|
+.wave-student { background: rgba(255,255,255,0.5); }
|
|
|
+
|
|
|
+.duration { font-size: 10px; }
|
|
|
+.duration-ai { color: #9ca3af; }
|
|
|
+.duration-student { color: rgba(255,255,255,0.8); }
|
|
|
+
|
|
|
+// 文本气泡
|
|
|
+.text-bubble {
|
|
|
+ margin-top: 6px;
|
|
|
+ padding: 6px 12px;
|
|
|
+ border-radius: 8px;
|
|
|
+ font-size: 12px;
|
|
|
+ line-height: 1.6;
|
|
|
+}
|
|
|
+.text-ai { background: #fff; color: #374151; border: 1px solid #f3f4f6; }
|
|
|
+.text-student { background: #fff8f0; color: #1f2937; border: 1px solid #fed7aa; }
|
|
|
+
|
|
|
+.improvable-word {
|
|
|
+ color: #d97706;
|
|
|
+ text-decoration: underline wavy #fbbf24;
|
|
|
+ cursor: pointer;
|
|
|
+ padding: 0 2px;
|
|
|
+ border-radius: 2px;
|
|
|
+ &:hover { background: #fef3c7; }
|
|
|
+}
|
|
|
+
|
|
|
+// 即时反馈
|
|
|
+.feedback-card {
|
|
|
+ margin-top: 8px;
|
|
|
+ background: #fff;
|
|
|
+ border-radius: 8px;
|
|
|
+ border: 1px solid #f3f4f6;
|
|
|
+ box-shadow: 0 1px 3px rgba(0,0,0,0.05);
|
|
|
+ overflow: hidden;
|
|
|
+ text-align: left;
|
|
|
+}
|
|
|
+.feedback-l1 { padding: 8px 12px; }
|
|
|
+.dimensions-row {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 8px;
|
|
|
+ font-size: 10px;
|
|
|
+}
|
|
|
+.dim-label { color: #6b7280; }
|
|
|
+.dim-sep { color: #d1d5db; }
|
|
|
+.dim-excellent { color: #16a34a; }
|
|
|
+.dim-good { color: #22c55e; }
|
|
|
+.dim-improve { color: #f59e0b; }
|
|
|
+
|
|
|
+.suggestion-row {
|
|
|
+ margin-top: 6px;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: space-between;
|
|
|
+}
|
|
|
+.suggestion-text {
|
|
|
+ font-size: 10px;
|
|
|
+ color: #4b5563;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 4px;
|
|
|
+ margin: 0;
|
|
|
+}
|
|
|
+.suggestion-icon { color: #f59e0b; }
|
|
|
+.detail-toggle {
|
|
|
+ font-size: 10px;
|
|
|
+ color: #f97316;
|
|
|
+ background: transparent;
|
|
|
+ border: none;
|
|
|
+ cursor: pointer;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 2px;
|
|
|
+ &:hover { color: #ea580c; }
|
|
|
+ svg { transition: transform 0.2s; }
|
|
|
+ .chevron-up { transform: rotate(180deg); }
|
|
|
+}
|
|
|
+
|
|
|
+// L2 展开
|
|
|
+.feedback-l2 {
|
|
|
+ padding: 8px 12px;
|
|
|
+ border-top: 1px solid #f9fafb;
|
|
|
+ background: #fafbfc;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 8px;
|
|
|
+}
|
|
|
+.detail-label {
|
|
|
+ font-size: 10px;
|
|
|
+ color: #9ca3af;
|
|
|
+ margin: 0 0 4px;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 4px;
|
|
|
+}
|
|
|
+.detail-content {
|
|
|
+ font-size: 12px;
|
|
|
+ color: #374151;
|
|
|
+ background: #fff;
|
|
|
+ padding: 6px 8px;
|
|
|
+ border-radius: 4px;
|
|
|
+ border: 1px solid #f3f4f6;
|
|
|
+ margin: 0;
|
|
|
+}
|
|
|
+.word-tags { display: flex; flex-wrap: wrap; gap: 4px; }
|
|
|
+.word-tag {
|
|
|
+ padding: 2px 8px;
|
|
|
+ background: #fff7ed;
|
|
|
+ color: #ea580c;
|
|
|
+ font-size: 10px;
|
|
|
+ border-radius: 999px;
|
|
|
+ border: 1px solid #fed7aa;
|
|
|
+}
|
|
|
+
|
|
|
+// 等待指示器
|
|
|
+.typing-indicator {
|
|
|
+ padding: 8px 12px;
|
|
|
+ background: #fff;
|
|
|
+ border: 1px solid #f3f4f6;
|
|
|
+ border-radius: 12px;
|
|
|
+ border-top-left-radius: 4px;
|
|
|
+ box-shadow: 0 1px 3px rgba(0,0,0,0.05);
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 4px;
|
|
|
+}
|
|
|
+.typing-dot {
|
|
|
+ width: 6px; height: 6px;
|
|
|
+ background: rgba(249,115,22,0.6);
|
|
|
+ border-radius: 50%;
|
|
|
+ animation: bounce 1s ease-in-out infinite;
|
|
|
+}
|
|
|
+@keyframes bounce {
|
|
|
+ 0%, 100% { transform: translateY(0); }
|
|
|
+ 50% { transform: translateY(-4px); }
|
|
|
+}
|
|
|
+
|
|
|
+// 沉默提示
|
|
|
+.silence-hint {
|
|
|
+ position: absolute;
|
|
|
+ bottom: 112px;
|
|
|
+ left: 16px; right: 16px;
|
|
|
+}
|
|
|
+.silence-hint-card {
|
|
|
+ background: rgba(255,255,255,0.95);
|
|
|
+ backdrop-filter: blur(8px);
|
|
|
+ border-radius: 8px;
|
|
|
+ border: 1px solid #fed7aa;
|
|
|
+ box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
|
|
+ padding: 8px 12px;
|
|
|
+}
|
|
|
+.silence-hint-text {
|
|
|
+ font-size: 12px;
|
|
|
+ color: #4b5563;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 6px;
|
|
|
+ margin: 0;
|
|
|
+}
|
|
|
+.hint-icon { color: #f97316; }
|
|
|
+
|
|
|
+// 底部录音控制
|
|
|
+.recording-controls {
|
|
|
+ padding: 10px 16px;
|
|
|
+ background: #fff;
|
|
|
+ border-top: 1px solid #f3f4f6;
|
|
|
+}
|
|
|
+.controls-inner {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ gap: 12px;
|
|
|
+}
|
|
|
+.side-btn {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 4px;
|
|
|
+ padding: 6px 10px;
|
|
|
+ border-radius: 999px;
|
|
|
+ background: #f9fafb;
|
|
|
+ border: 1px solid #e5e7eb;
|
|
|
+ color: #4b5563;
|
|
|
+ font-size: 11px;
|
|
|
+ font-weight: 500;
|
|
|
+ cursor: pointer;
|
|
|
+ transition: all 0.2s;
|
|
|
+ &:hover { background: #f3f4f6; border-color: #d1d5db; }
|
|
|
+ &:disabled { opacity: 0.4; cursor: not-allowed; }
|
|
|
+}
|
|
|
+
|
|
|
+.record-group { display: flex; align-items: center; gap: 10px; }
|
|
|
+.record-btn {
|
|
|
+ width: 40px; height: 40px;
|
|
|
+ border-radius: 50%;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ border: none;
|
|
|
+ cursor: pointer;
|
|
|
+ transition: background 0.2s;
|
|
|
+ background: #f97316;
|
|
|
+ &:hover { background: #ea580c; }
|
|
|
+ &.recording { background: #ef4444; }
|
|
|
+ &.waiting { background: #d1d5db; cursor: not-allowed; }
|
|
|
+}
|
|
|
+
|
|
|
+.record-status { display: flex; align-items: center; gap: 6px; }
|
|
|
+.pulse-bars { display: flex; align-items: center; gap: 2px; }
|
|
|
+.pulse-bar {
|
|
|
+ width: 2px;
|
|
|
+ background: #ef4444;
|
|
|
+ border-radius: 999px;
|
|
|
+ animation: pulse 1s ease-in-out infinite;
|
|
|
+}
|
|
|
+@keyframes pulse {
|
|
|
+ 0%, 100% { opacity: 0.6; }
|
|
|
+ 50% { opacity: 1; }
|
|
|
+}
|
|
|
+.record-label { font-size: 11px; font-weight: 500; color: #4b5563; }
|
|
|
+.label-recording { color: #ef4444; }
|
|
|
+.label-waiting { color: #9ca3af; }
|
|
|
+
|
|
|
+// 弹窗通用
|
|
|
+.modal-overlay {
|
|
|
+ position: fixed;
|
|
|
+ inset: 0;
|
|
|
+ background: rgba(0,0,0,0.4);
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ z-index: 50;
|
|
|
+}
|
|
|
+.hint-modal {
|
|
|
+ background: #fff;
|
|
|
+ border-radius: 16px;
|
|
|
+ width: 100%;
|
|
|
+ max-width: 448px;
|
|
|
+ margin: 0 16px;
|
|
|
+ padding: 20px;
|
|
|
+ max-height: 80vh;
|
|
|
+ overflow-y: auto;
|
|
|
+}
|
|
|
+.hint-header {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: space-between;
|
|
|
+ margin-bottom: 16px;
|
|
|
+}
|
|
|
+.hint-title {
|
|
|
+ font-size: 14px;
|
|
|
+ font-weight: 600;
|
|
|
+ color: #111827;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 8px;
|
|
|
+ margin: 0;
|
|
|
+}
|
|
|
+.close-btn {
|
|
|
+ width: 28px; height: 28px;
|
|
|
+ border-radius: 8px;
|
|
|
+ background: #f3f4f6;
|
|
|
+ border: none;
|
|
|
+ cursor: pointer;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ color: #6b7280;
|
|
|
+ &:hover { background: #e5e7eb; }
|
|
|
+}
|
|
|
+
|
|
|
+// 提示弹窗内容
|
|
|
+.hint-section { margin-bottom: 16px; }
|
|
|
+.hint-section-label { font-size: 12px; color: #9ca3af; margin-bottom: 8px; }
|
|
|
+.hint-task-box {
|
|
|
+ padding: 12px;
|
|
|
+ background: #fff7ed;
|
|
|
+ border-radius: 12px;
|
|
|
+ border: 1px solid rgba(249,115,22,0.2);
|
|
|
+ p { font-size: 14px; color: #374151; line-height: 1.6; margin: 0; }
|
|
|
+}
|
|
|
+
|
|
|
+.hint-sentences { display: flex; flex-direction: column; gap: 8px; }
|
|
|
+.hint-sentence-card {
|
|
|
+ padding: 12px;
|
|
|
+ background: #fafbfc;
|
|
|
+ border-radius: 12px;
|
|
|
+ border: 1px solid #f3f4f6;
|
|
|
+ transition: border-color 0.2s;
|
|
|
+ &:hover { border-color: rgba(249,115,22,0.3); }
|
|
|
+}
|
|
|
+.hint-sentence-body { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; }
|
|
|
+.hint-sentence-main { flex: 1; }
|
|
|
+.hint-en { font-size: 14px; color: #1f2937; margin: 0 0 4px; }
|
|
|
+.hint-zh { font-size: 12px; color: #6b7280; margin: 0; }
|
|
|
+.hint-keywords { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 8px; }
|
|
|
+.hint-keyword {
|
|
|
+ padding: 2px 6px;
|
|
|
+ background: rgba(249,115,22,0.1);
|
|
|
+ color: #f97316;
|
|
|
+ font-size: 10px;
|
|
|
+ border-radius: 4px;
|
|
|
+}
|
|
|
+.hint-play-btn {
|
|
|
+ width: 32px; height: 32px;
|
|
|
+ border-radius: 8px;
|
|
|
+ background: #fff7ed;
|
|
|
+ border: 1px solid rgba(249,115,22,0.2);
|
|
|
+ color: #f97316;
|
|
|
+ display: flex; align-items: center; justify-content: center;
|
|
|
+ flex-shrink: 0;
|
|
|
+ cursor: pointer;
|
|
|
+ &:hover { background: #fed7aa; }
|
|
|
+}
|
|
|
+
|
|
|
+.hint-vocab-list { display: flex; flex-direction: column; gap: 8px; }
|
|
|
+.hint-vocab-item {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: space-between;
|
|
|
+ padding: 10px;
|
|
|
+ background: #f9fafb;
|
|
|
+ border: 1px solid #e5e7eb;
|
|
|
+ border-radius: 8px;
|
|
|
+}
|
|
|
+.vocab-info { display: flex; align-items: center; gap: 8px; }
|
|
|
+.vocab-word { font-weight: 500; font-size: 14px; color: #1f2937; }
|
|
|
+.vocab-phonetic { font-size: 12px; color: #9ca3af; }
|
|
|
+.vocab-meaning { font-size: 12px; color: #6b7280; }
|
|
|
+.vocab-play-btn {
|
|
|
+ width: 24px; height: 24px;
|
|
|
+ border-radius: 6px;
|
|
|
+ background: #fff;
|
|
|
+ border: 1px solid #e5e7eb;
|
|
|
+ color: #6b7280;
|
|
|
+ display: flex; align-items: center; justify-content: center;
|
|
|
+ cursor: pointer;
|
|
|
+ &:hover { color: #f97316; border-color: rgba(249,115,22,0.3); }
|
|
|
+}
|
|
|
+
|
|
|
+.hint-footer { text-align: center; font-size: 12px; color: #9ca3af; margin-top: 20px; }
|
|
|
+
|
|
|
+// 音素弹窗
|
|
|
+.phoneme-modal {
|
|
|
+ background: #fff;
|
|
|
+ border-radius: 16px;
|
|
|
+ width: 100%;
|
|
|
+ max-width: 384px;
|
|
|
+ margin: 0 16px;
|
|
|
+ padding: 16px;
|
|
|
+ box-shadow: 0 20px 60px rgba(0,0,0,0.15);
|
|
|
+}
|
|
|
+.phoneme-word { text-align: center; font-size: 24px; font-weight: 600; color: #111827; margin-bottom: 16px; }
|
|
|
+.phoneme-cards { display: flex; flex-direction: column; gap: 12px; }
|
|
|
+.phoneme-card { padding: 12px; border-radius: 12px; }
|
|
|
+.phoneme-card-inner { display: flex; align-items: center; justify-content: space-between; }
|
|
|
+.phoneme-user { background: #fffbeb; border: 1px solid #fde68a; }
|
|
|
+.phoneme-standard { background: #f0fdf4; border: 1px solid #bbf7d0; }
|
|
|
+.phoneme-tip { background: #eff6ff; border: 1px solid #bfdbfe; }
|
|
|
+.phoneme-card-label { font-size: 10px; margin: 0 0 4px; }
|
|
|
+.phoneme-user .phoneme-card-label { color: #d97706; }
|
|
|
+.phoneme-standard .phoneme-card-label { color: #16a34a; }
|
|
|
+.phoneme-tip .phoneme-card-label { color: #2563eb; }
|
|
|
+.phoneme-card-value { font-size: 14px; font-family: monospace; color: #92400e; margin: 0; }
|
|
|
+.phoneme-value-green { color: #166534; }
|
|
|
+.phoneme-tip-text { font-size: 12px; color: #1e40af; margin: 0; }
|
|
|
+
|
|
|
+.phoneme-play-btn {
|
|
|
+ width: 32px; height: 32px;
|
|
|
+ border-radius: 8px;
|
|
|
+ border: 1px solid;
|
|
|
+ display: flex; align-items: center; justify-content: center;
|
|
|
+ cursor: pointer;
|
|
|
+}
|
|
|
+.phoneme-play-user { background: #fef3c7; border-color: #fde68a; color: #d97706; &:hover { background: #fde68a; } }
|
|
|
+.phoneme-play-standard { background: #dcfce7; border-color: #bbf7d0; color: #16a34a; &:hover { background: #bbf7d0; } }
|
|
|
+
|
|
|
+// 徽章弹出
|
|
|
+.badge-popup {
|
|
|
+ position: fixed;
|
|
|
+ top: 16px;
|
|
|
+ right: 16px;
|
|
|
+ z-index: 50;
|
|
|
+}
|
|
|
+.badge-card {
|
|
|
+ background: linear-gradient(to right, #f97316, #f59e0b);
|
|
|
+ border-radius: 16px;
|
|
|
+ padding: 12px 16px;
|
|
|
+ box-shadow: 0 8px 24px rgba(249,115,22,0.3);
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 12px;
|
|
|
+ animation: bounce 1s ease-in-out infinite;
|
|
|
+}
|
|
|
+.badge-icon { font-size: 30px; }
|
|
|
+.badge-name { font-size: 14px; font-weight: 600; color: #fff; margin: 0; }
|
|
|
+.badge-desc { font-size: 10px; color: rgba(255,255,255,0.8); margin: 0; }
|
|
|
+</style>
|