|
@@ -1,55 +1,168 @@
|
|
|
<template>
|
|
<template>
|
|
|
<div class="dialogue-chat-view">
|
|
<div class="dialogue-chat-view">
|
|
|
- <!-- ── HEADER ── -->
|
|
|
|
|
- <div class="chat-header">
|
|
|
|
|
- <div class="header-left">
|
|
|
|
|
- <div
|
|
|
|
|
- class="ai-avatar"
|
|
|
|
|
- :class="{ breathing: state === 'idle' || state === 'ai_thinking' }"
|
|
|
|
|
- >{{ aiAvatar }}</div>
|
|
|
|
|
- <span class="ai-name">{{ aiName }}</span>
|
|
|
|
|
-
|
|
|
|
|
- <span v-if="state === 'idle' && showIdleHint" class="idle-hint fade-in">
|
|
|
|
|
- 在等你的回答...
|
|
|
|
|
- </span>
|
|
|
|
|
- <span
|
|
|
|
|
- v-else-if="state !== 'idle' && state !== 'ai_thinking'"
|
|
|
|
|
- class="online-dot"
|
|
|
|
|
- title="在线"
|
|
|
|
|
- />
|
|
|
|
|
|
|
+ <!-- ── 结束态全屏 loading ── -->
|
|
|
|
|
+ <div v-if="reportFetchInflight" class="finalizing-screen">
|
|
|
|
|
+ <div class="finalizing-spinner">
|
|
|
|
|
+ <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
|
|
|
+ stroke-width="2" stroke-linecap="round">
|
|
|
|
|
+ <path d="M21 12a9 9 0 1 1-6.219-8.56" />
|
|
|
|
|
+ </svg>
|
|
|
</div>
|
|
</div>
|
|
|
-
|
|
|
|
|
- <div class="header-right">
|
|
|
|
|
- <span class="round-indicator">{{ currentRound }} / {{ totalRounds }} 轮</span>
|
|
|
|
|
- <span v-if="engine.countdownSeconds.value != null" class="total-time">
|
|
|
|
|
- {{ formatSeconds(engine.countdownSeconds.value) }}
|
|
|
|
|
- </span>
|
|
|
|
|
- <button class="icon-btn" title="更多操作" @click="showExitConfirm = true">
|
|
|
|
|
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
|
|
|
- stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
|
|
|
- <circle cx="12" cy="12" r="1" /><circle cx="19" cy="12" r="1" /><circle cx="5" cy="12" r="1" />
|
|
|
|
|
- </svg>
|
|
|
|
|
- </button>
|
|
|
|
|
|
|
+ <p class="finalizing-title">AI 正在为你整理报告...</p>
|
|
|
|
|
+ <div class="finalizing-stats-card">
|
|
|
|
|
+ <span class="finalizing-stat">{{ finalizingStats.rounds }} 轮</span>
|
|
|
|
|
+ <span class="finalizing-stats-sep">·</span>
|
|
|
|
|
+ <span class="finalizing-stat">{{ finalizingStats.sentences }} 句</span>
|
|
|
|
|
+ <span class="finalizing-stats-sep">·</span>
|
|
|
|
|
+ <span class="finalizing-stat">累计 {{ finalizingStats.durationText }}</span>
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
- <!-- 麦克风权限引导 -->
|
|
|
|
|
- <div v-if="recorder.permissionState.value === 'denied'" class="permission-banner">
|
|
|
|
|
- <span class="permission-icon">🎤</span>
|
|
|
|
|
- <span>麦克风权限已被拒绝,请在浏览器设置中开启后刷新页面</span>
|
|
|
|
|
- </div>
|
|
|
|
|
|
|
+ <template v-else>
|
|
|
|
|
+ <!-- ── HEADER ── -->
|
|
|
|
|
+ <div class="chat-header">
|
|
|
|
|
+ <div class="header-left">
|
|
|
|
|
+ <div
|
|
|
|
|
+ class="ai-avatar"
|
|
|
|
|
+ :class="{ breathing: state === 'idle' || state === 'ai_thinking' }"
|
|
|
|
|
+ >{{ aiAvatar }}</div>
|
|
|
|
|
+ <span class="ai-name">{{ aiName }}</span>
|
|
|
|
|
+
|
|
|
|
|
+ <span v-if="state === 'idle' && showIdleHint" class="idle-hint fade-in">
|
|
|
|
|
+ 在等你的回答...
|
|
|
|
|
+ </span>
|
|
|
|
|
+ <span
|
|
|
|
|
+ v-else-if="state !== 'idle' && state !== 'ai_thinking'"
|
|
|
|
|
+ class="online-dot"
|
|
|
|
|
+ title="在线"
|
|
|
|
|
+ />
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div class="header-right">
|
|
|
|
|
+ <span class="round-indicator">{{ currentRound }} / {{ totalRounds }} 轮</span>
|
|
|
|
|
+ <span v-if="engine.countdownSeconds.value != null" class="total-time">
|
|
|
|
|
+ {{ formatSeconds(engine.countdownSeconds.value) }}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ <button class="icon-btn" title="更多操作" @click="showExitConfirm = true">
|
|
|
|
|
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
|
|
|
+ stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
|
|
|
+ <circle cx="12" cy="12" r="1" /><circle cx="19" cy="12" r="1" /><circle cx="5" cy="12" r="1" />
|
|
|
|
|
+ </svg>
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 麦克风权限引导 -->
|
|
|
|
|
+ <div v-if="recorder.permissionState.value === 'denied'" class="permission-banner">
|
|
|
|
|
+ <span class="permission-icon">🎤</span>
|
|
|
|
|
+ <span>麦克风权限已被拒绝,请在浏览器设置中开启后刷新页面</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- ── CHAT AREA ── -->
|
|
|
|
|
+ <div ref="chatContainerRef" class="chat-area">
|
|
|
|
|
+ <template v-for="message in engine.messages.value" :key="message.id">
|
|
|
|
|
+ <!-- AI 消息 -->
|
|
|
|
|
+ <div v-if="message.role === 'ai'" class="msg-row msg-ai fade-in">
|
|
|
|
|
+ <div class="avatar-sm">{{ aiAvatar }}</div>
|
|
|
|
|
+ <div class="msg-col">
|
|
|
|
|
+ <!-- 音频条 -->
|
|
|
|
|
+ <div v-if="message.content || message.status === 'done'" class="voice-bar voice-ai">
|
|
|
|
|
+ <button
|
|
|
|
|
+ class="play-btn play-ai"
|
|
|
|
|
+ :class="{ 'play-btn-error': player.errorId.value === message.id }"
|
|
|
|
|
+ @click="togglePlay(message.id)"
|
|
|
|
|
+ >
|
|
|
|
|
+ <svg
|
|
|
|
|
+ v-if="player.loadingId.value === message.id"
|
|
|
|
|
+ class="play-spinner"
|
|
|
|
|
+ width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
|
|
|
+ stroke-width="2" stroke-linecap="round"
|
|
|
|
|
+ >
|
|
|
|
|
+ <path d="M21 12a9 9 0 1 1-6.219-8.56" />
|
|
|
|
|
+ </svg>
|
|
|
|
|
+ <svg
|
|
|
|
|
+ v-else-if="player.playingId.value === message.id"
|
|
|
|
|
+ width="12" height="12" viewBox="0 0 24 24" fill="currentColor"
|
|
|
|
|
+ >
|
|
|
|
|
+ <rect x="6" y="4" width="4" height="16" /><rect x="14" y="4" width="4" height="16" />
|
|
|
|
|
+ </svg>
|
|
|
|
|
+ <svg
|
|
|
|
|
+ v-else-if="player.errorId.value === message.id"
|
|
|
|
|
+ width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
|
|
|
+ stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
|
|
|
|
+ >
|
|
|
|
|
+ <path d="M12 9v4" />
|
|
|
|
|
+ <path d="M12 17h.01" />
|
|
|
|
|
+ <path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
|
|
|
|
|
+ </svg>
|
|
|
|
|
+ <svg
|
|
|
|
|
+ v-else
|
|
|
|
|
+ width="12" height="12" viewBox="0 0 24 24" fill="currentColor"
|
|
|
|
|
+ >
|
|
|
|
|
+ <polygon points="5 3 19 12 5 21 5 3" />
|
|
|
|
|
+ </svg>
|
|
|
|
|
+ </button>
|
|
|
|
|
+ <div class="wave-bar-group">
|
|
|
|
|
+ <div
|
|
|
|
|
+ v-for="i in 14"
|
|
|
|
|
+ :key="i"
|
|
|
|
|
+ class="wave-bar wave-ai"
|
|
|
|
|
+ :style="{ height: `${Math.abs(Math.sin(i * 0.7)) * 8 + 3}px` }"
|
|
|
|
|
+ />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <span
|
|
|
|
|
+ v-if="player.errorId.value === message.id"
|
|
|
|
|
+ class="play-error-hint"
|
|
|
|
|
+ >点击重试</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 英文文本 -->
|
|
|
|
|
+ <div v-if="showEnglishText && message.content" class="bubble bubble-ai">
|
|
|
|
|
+ {{ message.content }}
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 流式加载中 -->
|
|
|
|
|
+ <div v-if="message.status === 'loading' && !message.content" class="typing-bubble">
|
|
|
|
|
+ <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>
|
|
|
|
|
|
|
|
- <!-- ── CHAT AREA ── -->
|
|
|
|
|
- <div ref="chatContainerRef" class="chat-area">
|
|
|
|
|
- <template v-for="message in engine.messages.value" :key="message.id">
|
|
|
|
|
- <!-- AI 消息 -->
|
|
|
|
|
- <div v-if="message.role === 'ai'" class="msg-row msg-ai fade-in">
|
|
|
|
|
- <div class="avatar-sm">{{ aiAvatar }}</div>
|
|
|
|
|
- <div class="msg-col">
|
|
|
|
|
- <!-- 音频条 -->
|
|
|
|
|
- <div v-if="message.content || message.status === 'done'" class="voice-bar voice-ai">
|
|
|
|
|
|
|
+ <!-- AI 错误 -->
|
|
|
|
|
+ <div v-if="message.status === 'error'" class="error-card">
|
|
|
|
|
+ <span class="error-text">{{ message.error || '生成失败' }}</span>
|
|
|
|
|
+ <button
|
|
|
|
|
+ v-if="hasRetryButton(message)"
|
|
|
|
|
+ class="retry-btn"
|
|
|
|
|
+ :disabled="engine.greetingInflight.value"
|
|
|
|
|
+ @click="handleRetry(message)"
|
|
|
|
|
+ >{{ retryButtonLabel(message) }}</button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 学生消息 -->
|
|
|
|
|
+ <div v-else class="msg-row msg-student fade-in">
|
|
|
|
|
+ <!-- 音频条(橙色) -->
|
|
|
|
|
+ <div v-if="message.content || message.status !== 'loading'" class="voice-bar voice-student">
|
|
|
|
|
+ <span
|
|
|
|
|
+ v-if="player.errorId.value === message.id"
|
|
|
|
|
+ class="play-error-hint play-error-hint-student"
|
|
|
|
|
+ >点击重试</span>
|
|
|
|
|
+ <span
|
|
|
|
|
+ v-else
|
|
|
|
|
+ class="voice-duration voice-duration-student"
|
|
|
|
|
+ >{{ formatDuration(message.audioDuration) }}</span>
|
|
|
|
|
+ <div class="wave-bar-group">
|
|
|
|
|
+ <div
|
|
|
|
|
+ v-for="i in 14"
|
|
|
|
|
+ :key="i"
|
|
|
|
|
+ class="wave-bar wave-student"
|
|
|
|
|
+ :style="{ height: `${Math.abs(Math.sin(i * 0.7)) * 8 + 3}px` }"
|
|
|
|
|
+ />
|
|
|
|
|
+ </div>
|
|
|
<button
|
|
<button
|
|
|
- class="play-btn play-ai"
|
|
|
|
|
|
|
+ class="play-btn play-student"
|
|
|
:class="{ 'play-btn-error': player.errorId.value === message.id }"
|
|
:class="{ 'play-btn-error': player.errorId.value === message.id }"
|
|
|
@click="togglePlay(message.id)"
|
|
@click="togglePlay(message.id)"
|
|
|
>
|
|
>
|
|
@@ -83,451 +196,358 @@
|
|
|
<polygon points="5 3 19 12 5 21 5 3" />
|
|
<polygon points="5 3 19 12 5 21 5 3" />
|
|
|
</svg>
|
|
</svg>
|
|
|
</button>
|
|
</button>
|
|
|
- <div class="wave-bar-group">
|
|
|
|
|
- <div
|
|
|
|
|
- v-for="i in 14"
|
|
|
|
|
- :key="i"
|
|
|
|
|
- class="wave-bar wave-ai"
|
|
|
|
|
- :style="{ height: `${Math.abs(Math.sin(i * 0.7)) * 8 + 3}px` }"
|
|
|
|
|
- />
|
|
|
|
|
- </div>
|
|
|
|
|
- <span
|
|
|
|
|
- v-if="player.errorId.value === message.id"
|
|
|
|
|
- class="play-error-hint"
|
|
|
|
|
- >点击重试</span>
|
|
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
- <!-- 英文文本 -->
|
|
|
|
|
- <div v-if="showEnglishText && message.content" class="bubble bubble-ai">
|
|
|
|
|
- {{ message.content }}
|
|
|
|
|
- </div>
|
|
|
|
|
-
|
|
|
|
|
- <!-- 流式加载中 -->
|
|
|
|
|
- <div v-if="message.status === 'loading' && !message.content" class="typing-bubble">
|
|
|
|
|
|
|
+ <!-- 学生 STT 加载中(与 AI typing-bubble 对称,避免按完成后 student 侧空白) -->
|
|
|
|
|
+ <div v-if="message.status === 'loading' && !message.content" class="typing-bubble typing-bubble-student">
|
|
|
<span class="typing-dot" style="animation-delay: 0ms" />
|
|
<span class="typing-dot" style="animation-delay: 0ms" />
|
|
|
<span class="typing-dot" style="animation-delay: 150ms" />
|
|
<span class="typing-dot" style="animation-delay: 150ms" />
|
|
|
<span class="typing-dot" style="animation-delay: 300ms" />
|
|
<span class="typing-dot" style="animation-delay: 300ms" />
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
- <!-- AI 错误 -->
|
|
|
|
|
|
|
+ <!-- 英文识别文本(带高亮) -->
|
|
|
|
|
+ <div v-if="showEnglishText && message.content" class="bubble bubble-student">
|
|
|
|
|
+ <template v-if="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="openPhonemeDetail(getWordAnalysis(message, word)!)"
|
|
|
|
|
+ >{{ word }}</span>
|
|
|
|
|
+ <span v-else>{{ word }}</span>
|
|
|
|
|
+ {{ ' ' }}
|
|
|
|
|
+ </template>
|
|
|
|
|
+ </template>
|
|
|
|
|
+ <template v-else>{{ message.content }}</template>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 学生错误 -->
|
|
|
<div v-if="message.status === 'error'" class="error-card">
|
|
<div v-if="message.status === 'error'" class="error-card">
|
|
|
- <span class="error-text">{{ message.error || '生成失败' }}</span>
|
|
|
|
|
|
|
+ <span class="error-text">{{ message.error || '发送失败' }}</span>
|
|
|
<button
|
|
<button
|
|
|
v-if="hasRetryButton(message)"
|
|
v-if="hasRetryButton(message)"
|
|
|
class="retry-btn"
|
|
class="retry-btn"
|
|
|
- :disabled="engine.greetingInflight.value"
|
|
|
|
|
@click="handleRetry(message)"
|
|
@click="handleRetry(message)"
|
|
|
>{{ retryButtonLabel(message) }}</button>
|
|
>{{ retryButtonLabel(message) }}</button>
|
|
|
|
|
+ <button
|
|
|
|
|
+ v-if="hasRerecordButton(message)"
|
|
|
|
|
+ class="rerecord-btn"
|
|
|
|
|
+ @click="handleRerecord(message)"
|
|
|
|
|
+ >重录</button>
|
|
|
</div>
|
|
</div>
|
|
|
- </div>
|
|
|
|
|
- </div>
|
|
|
|
|
-
|
|
|
|
|
- <!-- 学生消息 -->
|
|
|
|
|
- <div v-else class="msg-row msg-student fade-in">
|
|
|
|
|
- <!-- 音频条(橙色) -->
|
|
|
|
|
- <div v-if="message.content || message.status !== 'loading'" class="voice-bar voice-student">
|
|
|
|
|
- <span
|
|
|
|
|
- v-if="player.errorId.value === message.id"
|
|
|
|
|
- class="play-error-hint play-error-hint-student"
|
|
|
|
|
- >点击重试</span>
|
|
|
|
|
- <span
|
|
|
|
|
- v-else
|
|
|
|
|
- class="voice-duration voice-duration-student"
|
|
|
|
|
- >{{ formatDuration(message.audioDuration) }}</span>
|
|
|
|
|
- <div class="wave-bar-group">
|
|
|
|
|
- <div
|
|
|
|
|
- v-for="i in 14"
|
|
|
|
|
- :key="i"
|
|
|
|
|
- class="wave-bar wave-student"
|
|
|
|
|
- :style="{ height: `${Math.abs(Math.sin(i * 0.7)) * 8 + 3}px` }"
|
|
|
|
|
- />
|
|
|
|
|
- </div>
|
|
|
|
|
- <button
|
|
|
|
|
- class="play-btn play-student"
|
|
|
|
|
- :class="{ 'play-btn-error': player.errorId.value === message.id }"
|
|
|
|
|
- @click="togglePlay(message.id)"
|
|
|
|
|
- >
|
|
|
|
|
- <svg
|
|
|
|
|
- v-if="player.loadingId.value === message.id"
|
|
|
|
|
- class="play-spinner"
|
|
|
|
|
- width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
|
|
|
- stroke-width="2" stroke-linecap="round"
|
|
|
|
|
- >
|
|
|
|
|
- <path d="M21 12a9 9 0 1 1-6.219-8.56" />
|
|
|
|
|
- </svg>
|
|
|
|
|
- <svg
|
|
|
|
|
- v-else-if="player.playingId.value === message.id"
|
|
|
|
|
- width="12" height="12" viewBox="0 0 24 24" fill="currentColor"
|
|
|
|
|
- >
|
|
|
|
|
- <rect x="6" y="4" width="4" height="16" /><rect x="14" y="4" width="4" height="16" />
|
|
|
|
|
- </svg>
|
|
|
|
|
- <svg
|
|
|
|
|
- v-else-if="player.errorId.value === message.id"
|
|
|
|
|
- width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
|
|
|
- stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
|
|
|
|
- >
|
|
|
|
|
- <path d="M12 9v4" />
|
|
|
|
|
- <path d="M12 17h.01" />
|
|
|
|
|
- <path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
|
|
|
|
|
- </svg>
|
|
|
|
|
- <svg
|
|
|
|
|
- v-else
|
|
|
|
|
- width="12" height="12" viewBox="0 0 24 24" fill="currentColor"
|
|
|
|
|
- >
|
|
|
|
|
- <polygon points="5 3 19 12 5 21 5 3" />
|
|
|
|
|
- </svg>
|
|
|
|
|
- </button>
|
|
|
|
|
- </div>
|
|
|
|
|
-
|
|
|
|
|
- <!-- 学生 STT 加载中(与 AI typing-bubble 对称,避免按完成后 student 侧空白) -->
|
|
|
|
|
- <div v-if="message.status === 'loading' && !message.content" class="typing-bubble typing-bubble-student">
|
|
|
|
|
- <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 v-if="showEnglishText && message.content" class="bubble bubble-student">
|
|
|
|
|
- <template v-if="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="openPhonemeDetail(getWordAnalysis(message, word)!)"
|
|
|
|
|
- >{{ word }}</span>
|
|
|
|
|
- <span v-else>{{ word }}</span>
|
|
|
|
|
- {{ ' ' }}
|
|
|
|
|
- </template>
|
|
|
|
|
- </template>
|
|
|
|
|
- <template v-else>{{ message.content }}</template>
|
|
|
|
|
- </div>
|
|
|
|
|
-
|
|
|
|
|
- <!-- 学生错误 -->
|
|
|
|
|
- <div v-if="message.status === 'error'" class="error-card">
|
|
|
|
|
- <span class="error-text">{{ message.error || '发送失败' }}</span>
|
|
|
|
|
- <button
|
|
|
|
|
- v-if="hasRetryButton(message)"
|
|
|
|
|
- class="retry-btn"
|
|
|
|
|
- @click="handleRetry(message)"
|
|
|
|
|
- >{{ retryButtonLabel(message) }}</button>
|
|
|
|
|
- <button
|
|
|
|
|
- v-if="hasRerecordButton(message)"
|
|
|
|
|
- class="rerecord-btn"
|
|
|
|
|
- @click="handleRerecord(message)"
|
|
|
|
|
- >重录</button>
|
|
|
|
|
- </div>
|
|
|
|
|
|
|
|
|
|
- <!-- L1 评分卡 -->
|
|
|
|
|
- <div v-if="message.evaluation" class="eval-card">
|
|
|
|
|
- <div class="eval-l1">
|
|
|
|
|
- <div class="dim-row">
|
|
|
|
|
- <span class="dim-label">准确</span>
|
|
|
|
|
- <DimBadge :level="message.evaluation.dimensions.accuracy" />
|
|
|
|
|
- <span class="dim-sep">|</span>
|
|
|
|
|
- <span class="dim-label">流畅</span>
|
|
|
|
|
- <DimBadge :level="message.evaluation.dimensions.fluency" />
|
|
|
|
|
- <span class="dim-sep">|</span>
|
|
|
|
|
- <span class="dim-label">完整</span>
|
|
|
|
|
- <DimBadge :level="message.evaluation.dimensions.completeness" />
|
|
|
|
|
- <span class="dim-sep">|</span>
|
|
|
|
|
- <span class="dim-label">节奏</span>
|
|
|
|
|
- <DimBadge :level="message.evaluation.dimensions.rhythm" />
|
|
|
|
|
- </div>
|
|
|
|
|
- <div class="sugg-row">
|
|
|
|
|
- <p class="sugg-text">
|
|
|
|
|
- <span class="sugg-icon">💡</span>
|
|
|
|
|
- <span class="truncate">{{ message.evaluation.suggestion }}</span>
|
|
|
|
|
- </p>
|
|
|
|
|
- <button class="detail-toggle" @click="toggleExpand(message.id)">
|
|
|
|
|
- {{ expandedMessageId === message.id ? '收起' : '详情' }}
|
|
|
|
|
- <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
|
|
|
- stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
|
|
|
|
- :class="{ 'chev-up': expandedMessageId === message.id }">
|
|
|
|
|
- <polyline points="6 9 12 15 18 9" />
|
|
|
|
|
- </svg>
|
|
|
|
|
- </button>
|
|
|
|
|
|
|
+ <!-- L1 评分卡 -->
|
|
|
|
|
+ <div v-if="message.evaluation" class="eval-card">
|
|
|
|
|
+ <div class="eval-l1">
|
|
|
|
|
+ <div class="dim-row">
|
|
|
|
|
+ <span class="dim-label">准确</span>
|
|
|
|
|
+ <DimBadge :level="message.evaluation.dimensions.accuracy" />
|
|
|
|
|
+ <span class="dim-sep">|</span>
|
|
|
|
|
+ <span class="dim-label">流畅</span>
|
|
|
|
|
+ <DimBadge :level="message.evaluation.dimensions.fluency" />
|
|
|
|
|
+ <span class="dim-sep">|</span>
|
|
|
|
|
+ <span class="dim-label">完整</span>
|
|
|
|
|
+ <DimBadge :level="message.evaluation.dimensions.completeness" />
|
|
|
|
|
+ <span class="dim-sep">|</span>
|
|
|
|
|
+ <span class="dim-label">节奏</span>
|
|
|
|
|
+ <DimBadge :level="message.evaluation.dimensions.rhythm" />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="sugg-row">
|
|
|
|
|
+ <p class="sugg-text">
|
|
|
|
|
+ <span class="sugg-icon">💡</span>
|
|
|
|
|
+ <span class="truncate">{{ message.evaluation.suggestion }}</span>
|
|
|
|
|
+ </p>
|
|
|
|
|
+ <button class="detail-toggle" @click="toggleExpand(message.id)">
|
|
|
|
|
+ {{ expandedMessageId === message.id ? '收起' : '详情' }}
|
|
|
|
|
+ <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
|
|
|
+ stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
|
|
|
|
+ :class="{ 'chev-up': expandedMessageId === message.id }">
|
|
|
|
|
+ <polyline points="6 9 12 15 18 9" />
|
|
|
|
|
+ </svg>
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
</div>
|
|
</div>
|
|
|
- </div>
|
|
|
|
|
|
|
|
|
|
- <div v-if="expandedMessageId === message.id" class="eval-l2 fade-in">
|
|
|
|
|
- <div v-if="message.evaluation.betterExpression" class="better-exp">
|
|
|
|
|
- <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="(w, i) in message.evaluation.suggestedWords" :key="i" class="word-tag">{{ w }}</span>
|
|
|
|
|
|
|
+ <div v-if="expandedMessageId === message.id" class="eval-l2 fade-in">
|
|
|
|
|
+ <div v-if="message.evaluation.betterExpression" class="better-exp">
|
|
|
|
|
+ <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="(w, i) in message.evaluation.suggestedWords" :key="i" class="word-tag">{{ w }}</span>
|
|
|
|
|
+ </div>
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
- </div>
|
|
|
|
|
- </template>
|
|
|
|
|
|
|
+ </template>
|
|
|
|
|
|
|
|
- </div>
|
|
|
|
|
-
|
|
|
|
|
- <!-- 沉默提示浮层 -->
|
|
|
|
|
- <div v-if="state === 'recording' && silenceHintText" class="silence-hint-wrap">
|
|
|
|
|
- <div class="silence-hint fade-in">
|
|
|
|
|
- <span class="silence-icon">💡</span>
|
|
|
|
|
- <p class="silence-text">{{ silenceHintText }}</p>
|
|
|
|
|
- <button class="silence-close" @click="silenceHintText = ''">
|
|
|
|
|
- <svg width="12" height="12" 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>
|
|
|
- </div>
|
|
|
|
|
|
|
|
|
|
- <!-- ── CONTROL ZONE ── -->
|
|
|
|
|
- <div class="control-zone">
|
|
|
|
|
- <!-- 进度条(仅录音时可见) -->
|
|
|
|
|
- <div class="progress-wrap">
|
|
|
|
|
- <div
|
|
|
|
|
- class="progress-track"
|
|
|
|
|
- :style="{ background: state === 'recording' ? '#f3f4f6' : 'transparent' }"
|
|
|
|
|
- >
|
|
|
|
|
- <div
|
|
|
|
|
- class="progress-fill"
|
|
|
|
|
- :class="{ 'near-limit': isNearLimit }"
|
|
|
|
|
- :style="{
|
|
|
|
|
- width: state === 'recording' ? `${progressPct}%` : '0%',
|
|
|
|
|
- opacity: state === 'recording' ? 1 : 0,
|
|
|
|
|
- }"
|
|
|
|
|
- />
|
|
|
|
|
- </div>
|
|
|
|
|
- </div>
|
|
|
|
|
-
|
|
|
|
|
- <!-- 状态叠放区 -->
|
|
|
|
|
- <div class="state-stack">
|
|
|
|
|
- <!-- idle -->
|
|
|
|
|
- <div
|
|
|
|
|
- class="state-layer state-idle"
|
|
|
|
|
- :style="stateStyle('idle')"
|
|
|
|
|
- >
|
|
|
|
|
- <button class="hint-btn" @click="openTaskHint">
|
|
|
|
|
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
|
|
|
|
|
+ <!-- 沉默提示浮层 -->
|
|
|
|
|
+ <div v-if="state === 'recording' && silenceHintText" class="silence-hint-wrap">
|
|
|
|
|
+ <div class="silence-hint fade-in">
|
|
|
|
|
+ <span class="silence-icon">💡</span>
|
|
|
|
|
+ <p class="silence-text">{{ silenceHintText }}</p>
|
|
|
|
|
+ <button class="silence-close" @click="silenceHintText = ''">
|
|
|
|
|
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
|
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
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" />
|
|
|
|
|
|
|
+ <line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
|
|
|
</svg>
|
|
</svg>
|
|
|
- 提示
|
|
|
|
|
</button>
|
|
</button>
|
|
|
- <button
|
|
|
|
|
- class="mic-btn"
|
|
|
|
|
- :disabled="!engine.canRecord.value"
|
|
|
|
|
- @click="handleStartRecording"
|
|
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- ── CONTROL ZONE ── -->
|
|
|
|
|
+ <div class="control-zone">
|
|
|
|
|
+ <!-- 进度条(仅录音时可见) -->
|
|
|
|
|
+ <div class="progress-wrap">
|
|
|
|
|
+ <div
|
|
|
|
|
+ class="progress-track"
|
|
|
|
|
+ :style="{ background: state === 'recording' ? '#f3f4f6' : 'transparent' }"
|
|
|
>
|
|
>
|
|
|
- <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
|
|
|
|
|
+ class="progress-fill"
|
|
|
|
|
+ :class="{ 'near-limit': isNearLimit }"
|
|
|
|
|
+ :style="{
|
|
|
|
|
+ width: state === 'recording' ? `${progressPct}%` : '0%',
|
|
|
|
|
+ opacity: state === 'recording' ? 1 : 0,
|
|
|
|
|
+ }"
|
|
|
|
|
+ />
|
|
|
|
|
+ </div>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
- <!-- starting -->
|
|
|
|
|
- <div class="state-layer state-starting" :style="stateStyle('starting')">
|
|
|
|
|
- <div class="record-capsule">
|
|
|
|
|
- <button class="cancel-btn" @click="handleCancelStarting">
|
|
|
|
|
- <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
|
|
|
|
|
+ <!-- 状态叠放区 -->
|
|
|
|
|
+ <div class="state-stack">
|
|
|
|
|
+ <!-- idle -->
|
|
|
|
|
+ <div
|
|
|
|
|
+ class="state-layer state-idle"
|
|
|
|
|
+ :style="stateStyle('idle')"
|
|
|
|
|
+ >
|
|
|
|
|
+ <button class="hint-btn" @click="openTaskHint">
|
|
|
|
|
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
|
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
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" />
|
|
|
|
|
|
|
+ <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>
|
|
</svg>
|
|
|
- 取消
|
|
|
|
|
|
|
+ 提示
|
|
|
</button>
|
|
</button>
|
|
|
- <div class="record-meter">
|
|
|
|
|
- <svg class="spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
|
|
|
- stroke-width="2" stroke-linecap="round">
|
|
|
|
|
- <path d="M21 12a9 9 0 1 1-6.219-8.56" />
|
|
|
|
|
- </svg>
|
|
|
|
|
- <span class="record-time">准备录音中...</span>
|
|
|
|
|
- </div>
|
|
|
|
|
- <button class="finish-btn" disabled>
|
|
|
|
|
- <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
|
|
|
|
|
+ <button
|
|
|
|
|
+ class="mic-btn"
|
|
|
|
|
+ :disabled="!engine.canRecord.value"
|
|
|
|
|
+ @click="handleStartRecording"
|
|
|
|
|
+ >
|
|
|
|
|
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
|
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
|
- <path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
|
|
|
|
|
- <polyline points="22 4 12 14.01 9 11.01" />
|
|
|
|
|
|
|
+ <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>
|
|
</svg>
|
|
|
- 完成
|
|
|
|
|
|
|
+ 开始录音
|
|
|
</button>
|
|
</button>
|
|
|
</div>
|
|
</div>
|
|
|
- </div>
|
|
|
|
|
|
|
|
|
|
- <!-- recording -->
|
|
|
|
|
- <div class="state-layer state-recording" :style="stateStyle('recording')">
|
|
|
|
|
- <div class="record-capsule">
|
|
|
|
|
- <button class="cancel-btn" @click="handleCancelRecording">
|
|
|
|
|
- <svg width="12" height="12" 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 class="record-meter">
|
|
|
|
|
- <div class="animated-wave">
|
|
|
|
|
- <div
|
|
|
|
|
- v-for="i in 7"
|
|
|
|
|
- :key="i"
|
|
|
|
|
- class="aw-bar"
|
|
|
|
|
- :class="{ 'near-limit': isNearLimit }"
|
|
|
|
|
- :style="{
|
|
|
|
|
- height: `${Math.abs(Math.sin(i * 0.9)) * 9 + 3}px`,
|
|
|
|
|
- animationDelay: `${(i - 1) * 0.1}s`,
|
|
|
|
|
- }"
|
|
|
|
|
- />
|
|
|
|
|
|
|
+ <!-- starting -->
|
|
|
|
|
+ <div class="state-layer state-starting" :style="stateStyle('starting')">
|
|
|
|
|
+ <div class="record-capsule">
|
|
|
|
|
+ <button class="cancel-btn" @click="handleCancelStarting">
|
|
|
|
|
+ <svg width="12" height="12" 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 class="record-meter">
|
|
|
|
|
+ <svg class="spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
|
|
|
+ stroke-width="2" stroke-linecap="round">
|
|
|
|
|
+ <path d="M21 12a9 9 0 1 1-6.219-8.56" />
|
|
|
|
|
+ </svg>
|
|
|
|
|
+ <span class="record-time">准备录音中...</span>
|
|
|
</div>
|
|
</div>
|
|
|
- <span class="record-time" :class="{ 'near-limit': isNearLimit }">
|
|
|
|
|
- {{ formatSeconds(recorder.recordingDuration.value) }}
|
|
|
|
|
- </span>
|
|
|
|
|
- <span class="record-time-max">/ {{ formatSeconds(MAX_RECORDING_SECONDS) }}</span>
|
|
|
|
|
|
|
+ <button class="finish-btn" disabled>
|
|
|
|
|
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
|
|
|
+ stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
|
|
|
+ <path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
|
|
|
|
|
+ <polyline points="22 4 12 14.01 9 11.01" />
|
|
|
|
|
+ </svg>
|
|
|
|
|
+ 完成
|
|
|
|
|
+ </button>
|
|
|
</div>
|
|
</div>
|
|
|
- <button class="finish-btn" @click="handleFinishRecording">
|
|
|
|
|
- <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
|
|
|
- stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
|
|
|
- <path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
|
|
|
|
|
- <polyline points="22 4 12 14.01 9 11.01" />
|
|
|
|
|
- </svg>
|
|
|
|
|
- 完成
|
|
|
|
|
- </button>
|
|
|
|
|
</div>
|
|
</div>
|
|
|
- </div>
|
|
|
|
|
|
|
|
|
|
- <!-- stt -->
|
|
|
|
|
- <div class="state-layer state-center" :style="stateStyle('stt')">
|
|
|
|
|
- <svg class="spinner" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
|
|
|
- stroke-width="2" stroke-linecap="round">
|
|
|
|
|
- <path d="M21 12a9 9 0 1 1-6.219-8.56" />
|
|
|
|
|
- </svg>
|
|
|
|
|
- <span class="center-text">正在识别语音...</span>
|
|
|
|
|
- </div>
|
|
|
|
|
-
|
|
|
|
|
- <!-- ai_thinking -->
|
|
|
|
|
- <div class="state-layer state-center" :style="stateStyle('ai_thinking')">
|
|
|
|
|
- <div class="mini-avatar">{{ aiAvatar }}</div>
|
|
|
|
|
- <span class="center-text">{{ aiName }} 正在回复...</span>
|
|
|
|
|
- </div>
|
|
|
|
|
|
|
+ <!-- recording -->
|
|
|
|
|
+ <div class="state-layer state-recording" :style="stateStyle('recording')">
|
|
|
|
|
+ <div class="record-capsule">
|
|
|
|
|
+ <button class="cancel-btn" @click="handleCancelRecording">
|
|
|
|
|
+ <svg width="12" height="12" 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 class="record-meter">
|
|
|
|
|
+ <div class="animated-wave">
|
|
|
|
|
+ <div
|
|
|
|
|
+ v-for="i in 7"
|
|
|
|
|
+ :key="i"
|
|
|
|
|
+ class="aw-bar"
|
|
|
|
|
+ :class="{ 'near-limit': isNearLimit }"
|
|
|
|
|
+ :style="{
|
|
|
|
|
+ height: `${Math.abs(Math.sin(i * 0.9)) * 9 + 3}px`,
|
|
|
|
|
+ animationDelay: `${(i - 1) * 0.1}s`,
|
|
|
|
|
+ }"
|
|
|
|
|
+ />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <span class="record-time" :class="{ 'near-limit': isNearLimit }">
|
|
|
|
|
+ {{ formatSeconds(recorder.recordingDuration.value) }}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ <span class="record-time-max">/ {{ formatSeconds(MAX_RECORDING_SECONDS) }}</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <button class="finish-btn" @click="handleFinishRecording">
|
|
|
|
|
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
|
|
|
+ stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
|
|
|
+ <path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
|
|
|
|
|
+ <polyline points="22 4 12 14.01 9 11.01" />
|
|
|
|
|
+ </svg>
|
|
|
|
|
+ 完成
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
|
|
|
- <!-- error -->
|
|
|
|
|
- <div class="state-layer state-error" :style="stateStyle('error')">
|
|
|
|
|
- <div class="error-info">
|
|
|
|
|
- <span class="warn-icon">⚠️</span>
|
|
|
|
|
- <span class="warn-text">{{ lastErrorText }}</span>
|
|
|
|
|
|
|
+ <!-- stt -->
|
|
|
|
|
+ <div class="state-layer state-center" :style="stateStyle('stt')">
|
|
|
|
|
+ <svg class="spinner" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
|
|
|
+ stroke-width="2" stroke-linecap="round">
|
|
|
|
|
+ <path d="M21 12a9 9 0 1 1-6.219-8.56" />
|
|
|
|
|
+ </svg>
|
|
|
|
|
+ <span class="center-text">正在识别语音...</span>
|
|
|
</div>
|
|
</div>
|
|
|
- <button
|
|
|
|
|
- v-if="lastErroredMessage && hasRetryButton(lastErroredMessage)"
|
|
|
|
|
- class="retry-pill"
|
|
|
|
|
- @click="handleRetry(lastErroredMessage)"
|
|
|
|
|
- >{{ retryButtonLabel(lastErroredMessage) }}</button>
|
|
|
|
|
- </div>
|
|
|
|
|
|
|
|
|
|
- <!-- finalizing -->
|
|
|
|
|
- <div class="state-layer state-center" :style="stateStyle('finalizing')">
|
|
|
|
|
- <svg class="spinner" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
|
|
|
- stroke-width="2" stroke-linecap="round">
|
|
|
|
|
- <path d="M21 12a9 9 0 1 1-6.219-8.56" />
|
|
|
|
|
- </svg>
|
|
|
|
|
- <span class="center-text">正在生成你的本次对话报告...</span>
|
|
|
|
|
- </div>
|
|
|
|
|
- </div>
|
|
|
|
|
- </div>
|
|
|
|
|
|
|
+ <!-- ai_thinking -->
|
|
|
|
|
+ <div class="state-layer state-center" :style="stateStyle('ai_thinking')">
|
|
|
|
|
+ <div class="mini-avatar">{{ aiAvatar }}</div>
|
|
|
|
|
+ <span class="center-text">{{ aiName }} 正在回复...</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
|
|
|
- <!-- ─────── OVERLAYS ─────── -->
|
|
|
|
|
-
|
|
|
|
|
- <!-- 任务提示弹窗 -->
|
|
|
|
|
- <TaskHintModal
|
|
|
|
|
- :visible="showHintModal"
|
|
|
|
|
- :loading="taskHintLoading"
|
|
|
|
|
- :error="taskHintError"
|
|
|
|
|
- :hint="taskHint"
|
|
|
|
|
- :ai-name="aiName"
|
|
|
|
|
- @close="showHintModal = false"
|
|
|
|
|
- @retry="loadTaskHint"
|
|
|
|
|
- />
|
|
|
|
|
-
|
|
|
|
|
- <!-- 音素详情弹窗 -->
|
|
|
|
|
- <div v-if="phonemeDetail" class="modal-mask" @click.self="phonemeDetail = null">
|
|
|
|
|
- <div class="modal phoneme-modal scale-in">
|
|
|
|
|
- <div class="modal-head">
|
|
|
|
|
- <div>
|
|
|
|
|
- <h3 class="phoneme-word">{{ phonemeDetail.word }}</h3>
|
|
|
|
|
- <p class="phoneme-sub">发音详情</p>
|
|
|
|
|
|
|
+ <!-- error -->
|
|
|
|
|
+ <div class="state-layer state-error" :style="stateStyle('error')">
|
|
|
|
|
+ <div class="error-info">
|
|
|
|
|
+ <span class="warn-icon">⚠️</span>
|
|
|
|
|
+ <span class="warn-text">{{ lastErrorText }}</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <button
|
|
|
|
|
+ v-if="lastErroredMessage && hasRetryButton(lastErroredMessage)"
|
|
|
|
|
+ class="retry-pill"
|
|
|
|
|
+ @click="handleRetry(lastErroredMessage)"
|
|
|
|
|
+ >{{ retryButtonLabel(lastErroredMessage) }}</button>
|
|
|
</div>
|
|
</div>
|
|
|
- <button class="close-btn" @click="phonemeDetail = null">
|
|
|
|
|
- <svg width="14" height="14" 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" />
|
|
|
|
|
|
|
+
|
|
|
|
|
+ <!-- finalizing -->
|
|
|
|
|
+ <div class="state-layer state-center" :style="stateStyle('finalizing')">
|
|
|
|
|
+ <svg class="spinner" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
|
|
|
+ stroke-width="2" stroke-linecap="round">
|
|
|
|
|
+ <path d="M21 12a9 9 0 1 1-6.219-8.56" />
|
|
|
</svg>
|
|
</svg>
|
|
|
- </button>
|
|
|
|
|
|
|
+ <span class="center-text">正在生成你的本次对话报告...</span>
|
|
|
|
|
+ </div>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
|
|
|
- <div class="phoneme-body">
|
|
|
|
|
- <div class="pho-card pho-user">
|
|
|
|
|
|
|
+ <!-- ─────── OVERLAYS ─────── -->
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 任务提示弹窗 -->
|
|
|
|
|
+ <TaskHintModal
|
|
|
|
|
+ :visible="showHintModal"
|
|
|
|
|
+ :loading="taskHintLoading"
|
|
|
|
|
+ :error="taskHintError"
|
|
|
|
|
+ :hint="taskHint"
|
|
|
|
|
+ :ai-name="aiName"
|
|
|
|
|
+ @close="showHintModal = false"
|
|
|
|
|
+ @retry="loadTaskHint"
|
|
|
|
|
+ />
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 音素详情弹窗 -->
|
|
|
|
|
+ <div v-if="phonemeDetail" class="modal-mask" @click.self="phonemeDetail = null">
|
|
|
|
|
+ <div class="modal phoneme-modal scale-in">
|
|
|
|
|
+ <div class="modal-head">
|
|
|
<div>
|
|
<div>
|
|
|
- <p class="pho-label">你的发音</p>
|
|
|
|
|
- <p class="pho-value">{{ phonemeDetail.userPronunciation }}</p>
|
|
|
|
|
|
|
+ <h3 class="phoneme-word">{{ phonemeDetail.word }}</h3>
|
|
|
|
|
+ <p class="phoneme-sub">发音详情</p>
|
|
|
</div>
|
|
</div>
|
|
|
- <button class="pho-play pho-play-user">
|
|
|
|
|
- <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3" /></svg>
|
|
|
|
|
|
|
+ <button class="close-btn" @click="phonemeDetail = null">
|
|
|
|
|
+ <svg width="14" height="14" 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>
|
|
</button>
|
|
|
</div>
|
|
</div>
|
|
|
- <div class="pho-card pho-standard">
|
|
|
|
|
- <div>
|
|
|
|
|
- <p class="pho-label">标准发音</p>
|
|
|
|
|
- <p class="pho-value pho-value-green">{{ phonemeDetail.standardPronunciation }}</p>
|
|
|
|
|
|
|
+
|
|
|
|
|
+ <div class="phoneme-body">
|
|
|
|
|
+ <div class="pho-card pho-user">
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <p class="pho-label">你的发音</p>
|
|
|
|
|
+ <p class="pho-value">{{ phonemeDetail.userPronunciation }}</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <button class="pho-play pho-play-user">
|
|
|
|
|
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3" /></svg>
|
|
|
|
|
+ </button>
|
|
|
</div>
|
|
</div>
|
|
|
- <button class="pho-play pho-play-standard">
|
|
|
|
|
- <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3" /></svg>
|
|
|
|
|
|
|
+ <div class="pho-card pho-standard">
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <p class="pho-label">标准发音</p>
|
|
|
|
|
+ <p class="pho-value pho-value-green">{{ phonemeDetail.standardPronunciation }}</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <button class="pho-play pho-play-standard">
|
|
|
|
|
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3" /></svg>
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div v-if="phonemeDetail.tip" class="pho-card pho-tip">
|
|
|
|
|
+ <p class="pho-label">小提示</p>
|
|
|
|
|
+ <p class="pho-tip-text">{{ phonemeDetail.tip }}</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <button class="pho-practice-btn" @click="practiceThisWord">
|
|
|
|
|
+ 针对这个词重练一次
|
|
|
</button>
|
|
</button>
|
|
|
</div>
|
|
</div>
|
|
|
- <div v-if="phonemeDetail.tip" class="pho-card pho-tip">
|
|
|
|
|
- <p class="pho-label">小提示</p>
|
|
|
|
|
- <p class="pho-tip-text">{{ phonemeDetail.tip }}</p>
|
|
|
|
|
- </div>
|
|
|
|
|
-
|
|
|
|
|
- <button class="pho-practice-btn" @click="practiceThisWord">
|
|
|
|
|
- 针对这个词重练一次
|
|
|
|
|
- </button>
|
|
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
- </div>
|
|
|
|
|
|
|
|
|
|
- <!-- 退出/重开确认弹窗 -->
|
|
|
|
|
- <div v-if="showExitConfirm" class="modal-mask" @click.self="showExitConfirm = false">
|
|
|
|
|
- <div class="modal exit-modal scale-in">
|
|
|
|
|
- <div class="modal-head">
|
|
|
|
|
- <h3 class="modal-title">选择操作</h3>
|
|
|
|
|
- <button class="close-btn" @click="showExitConfirm = false">
|
|
|
|
|
- <svg width="14" height="14" 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>
|
|
|
|
|
- <p class="exit-hint">请选择你的操作:</p>
|
|
|
|
|
- <div class="exit-actions">
|
|
|
|
|
- <button class="exit-secondary" @click="showExitConfirm = false">继续练习</button>
|
|
|
|
|
- <button class="exit-secondary" @click="handleRestart">重新开始</button>
|
|
|
|
|
- <button class="exit-primary" @click="handleExitConfirm">结束并查看报告</button>
|
|
|
|
|
|
|
+ <!-- 退出/重开确认弹窗 -->
|
|
|
|
|
+ <div v-if="showExitConfirm" class="modal-mask" @click.self="showExitConfirm = false">
|
|
|
|
|
+ <div class="modal exit-modal scale-in">
|
|
|
|
|
+ <div class="modal-head">
|
|
|
|
|
+ <h3 class="modal-title">选择操作</h3>
|
|
|
|
|
+ <button class="close-btn" @click="showExitConfirm = false">
|
|
|
|
|
+ <svg width="14" height="14" 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>
|
|
|
|
|
+ <p class="exit-hint">请选择你的操作:</p>
|
|
|
|
|
+ <div class="exit-actions">
|
|
|
|
|
+ <button class="exit-secondary" @click="showExitConfirm = false">继续练习</button>
|
|
|
|
|
+ <button class="exit-secondary" @click="handleRestart">重新开始</button>
|
|
|
|
|
+ <button class="exit-primary" @click="handleExitConfirm">结束并查看报告</button>
|
|
|
|
|
+ </div>
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
- </div>
|
|
|
|
|
|
|
|
|
|
- <!-- 徽章弹窗 -->
|
|
|
|
|
- <div v-if="showBadge" class="badge-popup scale-in">
|
|
|
|
|
- <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 v-if="showBadge" class="badge-popup scale-in">
|
|
|
|
|
+ <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>
|
|
</div>
|
|
|
- </div>
|
|
|
|
|
|
|
+ </template>
|
|
|
</div>
|
|
</div>
|
|
|
</template>
|
|
</template>
|
|
|
|
|
|
|
@@ -672,6 +692,20 @@ const lastErrorText = computed(() => {
|
|
|
return last?.error || '请求异常,请稍后再试'
|
|
return last?.error || '请求异常,请稍后再试'
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
|
|
+const finalizingStats = computed(() => {
|
|
|
|
|
+ const studentMsgs = engine.messages.value.filter(m => m.role === 'student')
|
|
|
|
|
+ const sentenceCount = studentMsgs.filter(m => m.status !== 'error').length
|
|
|
|
|
+ const totalDurationSec = studentMsgs.reduce(
|
|
|
|
|
+ (sum, m) => sum + (m.audioDuration ?? 0),
|
|
|
|
|
+ 0,
|
|
|
|
|
+ )
|
|
|
|
|
+ return {
|
|
|
|
|
+ rounds: engine.currentRound.value,
|
|
|
|
|
+ sentences: sentenceCount,
|
|
|
|
|
+ durationText: formatSeconds(Math.round(totalDurationSec)),
|
|
|
|
|
+ }
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
// ─────────────────────────────────────────────
|
|
// ─────────────────────────────────────────────
|
|
|
// Sub-Component: DimBadge
|
|
// Sub-Component: DimBadge
|
|
|
// ─────────────────────────────────────────────
|
|
// ─────────────────────────────────────────────
|
|
@@ -1866,4 +1900,45 @@ onUnmounted(() => {
|
|
|
to { opacity: 1; transform: scale(1); }
|
|
to { opacity: 1; transform: scale(1); }
|
|
|
}
|
|
}
|
|
|
.scale-in { animation: scale-in-frames 0.22s ease-out; }
|
|
.scale-in { animation: scale-in-frames 0.22s ease-out; }
|
|
|
|
|
+
|
|
|
|
|
+// ─────────────────────────────────────────────
|
|
|
|
|
+// Finalizing screen (full-view loading while fetching report)
|
|
|
|
|
+// ─────────────────────────────────────────────
|
|
|
|
|
+.finalizing-screen {
|
|
|
|
|
+ flex: 1;
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ flex-direction: column;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ justify-content: center;
|
|
|
|
|
+ gap: 16px;
|
|
|
|
|
+ padding: 24px;
|
|
|
|
|
+ background: #fff;
|
|
|
|
|
+}
|
|
|
|
|
+.finalizing-spinner {
|
|
|
|
|
+ color: #fb923c;
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ justify-content: center;
|
|
|
|
|
+ svg { animation: spin 1s linear infinite; }
|
|
|
|
|
+}
|
|
|
|
|
+.finalizing-title {
|
|
|
|
|
+ font-size: 14px;
|
|
|
|
|
+ color: #374151;
|
|
|
|
|
+ margin: 0;
|
|
|
|
|
+ font-weight: 500;
|
|
|
|
|
+}
|
|
|
|
|
+.finalizing-stats-card {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ gap: 8px;
|
|
|
|
|
+ padding: 12px 18px;
|
|
|
|
|
+ background: #f9fafb;
|
|
|
|
|
+ border: 1px solid #f3f4f6;
|
|
|
|
|
+ border-radius: 12px;
|
|
|
|
|
+ font-size: 12px;
|
|
|
|
|
+ color: #6b7280;
|
|
|
|
|
+ font-variant-numeric: tabular-nums;
|
|
|
|
|
+}
|
|
|
|
|
+.finalizing-stat { white-space: nowrap; }
|
|
|
|
|
+.finalizing-stats-sep { color: #d1d5db; }
|
|
|
</style>
|
|
</style>
|