|
|
@@ -8,24 +8,32 @@
|
|
|
<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>
|
|
|
+ <span v-if="recorder.isRecording.value" class="recording-duration">{{ recorder.recordingDuration.value }}s</span>
|
|
|
+ <span v-if="engine.countdownSeconds.value != null" class="countdown">
|
|
|
+ {{ formatCountdown(engine.countdownSeconds.value) }}
|
|
|
+ </span>
|
|
|
+ <span class="round-indicator">{{ engine.currentRound.value }}/{{ totalRounds }}</span>
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
+ <!-- 麦克风权限引导 -->
|
|
|
+ <div v-if="recorder.permissionState.value === 'denied'" class="permission-banner">
|
|
|
+ <span class="permission-icon">🎤</span>
|
|
|
+ <span class="permission-text">麦克风权限已被拒绝,请在浏览器设置中开启后刷新页面</span>
|
|
|
+ </div>
|
|
|
+
|
|
|
<!-- 对话消息区 -->
|
|
|
<div ref="chatContainerRef" class="chat-messages">
|
|
|
<div
|
|
|
- v-for="message in messages"
|
|
|
+ v-for="message in engine.messages.value"
|
|
|
: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'">
|
|
|
+ <div v-if="message.content" 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" />
|
|
|
@@ -43,8 +51,15 @@
|
|
|
<span class="duration" :class="message.role === 'ai' ? 'duration-ai' : 'duration-student'">0:03</span>
|
|
|
</div>
|
|
|
|
|
|
+ <!-- 流式输出中的 typing 动画 -->
|
|
|
+ <div v-if="message.status === 'loading' && message.role === 'ai'" 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 class="text-bubble" :class="message.role === 'ai' ? 'text-ai' : 'text-student'">
|
|
|
+ <div v-if="message.content" 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
|
|
|
@@ -59,6 +74,21 @@
|
|
|
<template v-else>{{ message.content }}</template>
|
|
|
</div>
|
|
|
|
|
|
+ <!-- 错误状态 UI -->
|
|
|
+ <div v-if="message.status === 'error'" class="error-card">
|
|
|
+ <span class="error-text">{{ message.error || '发送失败' }}</span>
|
|
|
+ <button
|
|
|
+ v-if="message.role === 'student'"
|
|
|
+ class="retry-btn"
|
|
|
+ @click="engine.retryMessage(message.id)"
|
|
|
+ >重试</button>
|
|
|
+ <button
|
|
|
+ v-if="message.role === 'ai'"
|
|
|
+ class="retry-btn"
|
|
|
+ @click="engine.regenerateAiMessage(message.id)"
|
|
|
+ >重新生成</button>
|
|
|
+ </div>
|
|
|
+
|
|
|
<!-- L1 即时反馈 -->
|
|
|
<div v-if="message.role === 'student' && message.evaluation" class="feedback-card">
|
|
|
<div class="feedback-l1">
|
|
|
@@ -82,7 +112,6 @@
|
|
|
</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>
|
|
|
@@ -106,23 +135,14 @@
|
|
|
</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 v-if="recorder.isRecording.value && recorder.silenceDetected.value" class="silence-hint">
|
|
|
<div class="silence-hint-card">
|
|
|
<p class="silence-hint-text">
|
|
|
<span class="hint-icon">💡</span>
|
|
|
- {{ silenceHint }}
|
|
|
+ Try saying something! Don't be shy.
|
|
|
</p>
|
|
|
</div>
|
|
|
</div>
|
|
|
@@ -133,10 +153,9 @@
|
|
|
<!-- 提示按钮 -->
|
|
|
<button
|
|
|
class="side-btn"
|
|
|
- :disabled="isWaiting || isRecording"
|
|
|
+ :disabled="!engine.canRecord.value"
|
|
|
@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" />
|
|
|
@@ -148,11 +167,10 @@
|
|
|
<div class="record-group">
|
|
|
<button
|
|
|
class="record-btn"
|
|
|
- :class="{ recording: isRecording, waiting: isWaiting }"
|
|
|
- :disabled="isWaiting"
|
|
|
+ :class="{ recording: recorder.isRecording.value, waiting: engine.isProcessing.value }"
|
|
|
+ :disabled="!engine.canRecord.value && !recorder.isRecording.value"
|
|
|
@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" />
|
|
|
@@ -160,18 +178,17 @@
|
|
|
</svg>
|
|
|
</button>
|
|
|
<div class="record-status">
|
|
|
- <div v-if="isRecording" class="pulse-bars">
|
|
|
+ <div v-if="recorder.isRecording.value" 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 class="record-label" :class="{ 'label-recording': recorder.isRecording.value, 'label-waiting': engine.isProcessing.value }">
|
|
|
+ {{ recorder.isRecording.value ? '录音中' : engine.isProcessing.value ? '等待中...' : '点击说话' }}
|
|
|
</span>
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
<!-- 重录按钮 -->
|
|
|
- <button class="side-btn" :disabled="isWaiting || isRecording">
|
|
|
- <!-- RefreshIcon SVG -->
|
|
|
+ <button class="side-btn" :disabled="!engine.canRecord.value">
|
|
|
<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>
|
|
|
@@ -198,14 +215,6 @@
|
|
|
</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>
|
|
|
@@ -313,8 +322,10 @@
|
|
|
</template>
|
|
|
|
|
|
<script lang="ts" setup>
|
|
|
-import { ref, watch, onMounted, onUnmounted, nextTick } from 'vue'
|
|
|
+import { ref, watch, onMounted, nextTick } from 'vue'
|
|
|
import type { PreviewChatMessage, BadgeAchievement } from '@/types/englishSpeaking'
|
|
|
+import { useDialogueEngine } from '../composables/useDialogueEngine'
|
|
|
+import { useAudioRecorder } from '../composables/useAudioRecorder'
|
|
|
|
|
|
interface Props {
|
|
|
topic?: string
|
|
|
@@ -322,6 +333,7 @@ interface Props {
|
|
|
aiName?: string
|
|
|
aiAvatar?: string
|
|
|
totalRounds?: number
|
|
|
+ mode?: 'preview' | 'real'
|
|
|
}
|
|
|
|
|
|
const props = withDefaults(defineProps<Props>(), {
|
|
|
@@ -330,24 +342,18 @@ const props = withDefaults(defineProps<Props>(), {
|
|
|
aiName: 'Tom',
|
|
|
aiAvatar: '😊',
|
|
|
totalRounds: 3,
|
|
|
+ mode: 'preview',
|
|
|
})
|
|
|
|
|
|
const emit = defineEmits<{
|
|
|
complete: []
|
|
|
}>()
|
|
|
|
|
|
-const messages = ref<PreviewChatMessage[]>([])
|
|
|
-const currentRound = ref(1)
|
|
|
-const isRecording = ref(false)
|
|
|
-const isWaiting = ref(false)
|
|
|
+const engine = useDialogueEngine(props.mode)
|
|
|
+const recorder = useAudioRecorder()
|
|
|
+
|
|
|
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<{
|
|
|
@@ -368,13 +374,7 @@ const BADGE_CONFIG: Record<string, BadgeAchievement> = {
|
|
|
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'] },
|
|
|
@@ -390,37 +390,55 @@ const vocabHints = [
|
|
|
{ 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.' },
|
|
|
-]
|
|
|
+// 初始化对话 session
|
|
|
+onMounted(() => {
|
|
|
+ engine.initSession({
|
|
|
+ topic: props.topic,
|
|
|
+ roleId: 'tom',
|
|
|
+ totalRounds: props.totalRounds,
|
|
|
+ vocabulary: props.keywords,
|
|
|
+ })
|
|
|
+})
|
|
|
+
|
|
|
+// 自动滚动
|
|
|
+watch(
|
|
|
+ () => engine.messages.value.map(m => m.content).join(''),
|
|
|
+ () => {
|
|
|
+ nextTick(() => {
|
|
|
+ if (chatContainerRef.value) {
|
|
|
+ chatContainerRef.value.scrollTop = chatContainerRef.value.scrollHeight
|
|
|
+ }
|
|
|
+ })
|
|
|
+ },
|
|
|
+)
|
|
|
+
|
|
|
+// 对话完成 → 通知父组件
|
|
|
+watch(
|
|
|
+ () => engine.isComplete.value,
|
|
|
+ (complete) => {
|
|
|
+ if (complete) emit('complete')
|
|
|
+ },
|
|
|
+)
|
|
|
|
|
|
-// 生成模拟评估数据
|
|
|
-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,
|
|
|
+// 录音切换
|
|
|
+async function handleToggleRecording() {
|
|
|
+ if (recorder.isRecording.value) {
|
|
|
+ try {
|
|
|
+ const audioBlob = await recorder.stopRecording()
|
|
|
+ await engine.sendStudentMessage(audioBlob)
|
|
|
+ } catch (err) {
|
|
|
+ console.error('Recording/send failed:', err)
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ try {
|
|
|
+ await recorder.startRecording()
|
|
|
+ } catch (err) {
|
|
|
+ console.error('Failed to start recording:', err)
|
|
|
+ }
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-// 检查并触发徽章
|
|
|
+// 徽章检查
|
|
|
function checkAndTriggerBadge(evaluation: PreviewChatMessage['evaluation']) {
|
|
|
if (!evaluation) return
|
|
|
|
|
|
@@ -453,89 +471,23 @@ function checkAndTriggerBadge(evaluation: PreviewChatMessage['evaluation']) {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-// 录音计时与沉默检测
|
|
|
-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)
|
|
|
+// Watch for new student messages with evaluations
|
|
|
+watch(
|
|
|
+ () => engine.messages.value.filter(m => m.role === 'student' && m.evaluation).length,
|
|
|
+ () => {
|
|
|
+ const studentMsgs = engine.messages.value.filter(m => m.role === 'student' && m.evaluation)
|
|
|
+ const last = studentMsgs[studentMsgs.length - 1]
|
|
|
+ if (last?.evaluation) checkAndTriggerBadge(last.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 formatCountdown(seconds: number): string {
|
|
|
+ const m = Math.floor(seconds / 60)
|
|
|
+ const s = seconds % 60
|
|
|
+ return `${m}:${s.toString().padStart(2, '0')}`
|
|
|
}
|
|
|
|
|
|
-// 工具函数
|
|
|
function toggleExpand(id: string) {
|
|
|
expandedMessageId.value = expandedMessageId.value === id ? null : id
|
|
|
}
|
|
|
@@ -601,8 +553,23 @@ function showPhonemeDetail(analysis: NonNullable<NonNullable<PreviewChatMessage[
|
|
|
.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; }
|
|
|
+.countdown { color: #6b7280; font-variant-numeric: tabular-nums; }
|
|
|
.round-indicator { color: #f97316; font-weight: 500; }
|
|
|
|
|
|
+// 权限引导
|
|
|
+.permission-banner {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 8px;
|
|
|
+ padding: 8px 12px;
|
|
|
+ background: #fef3c7;
|
|
|
+ border-bottom: 1px solid #fde68a;
|
|
|
+ font-size: 11px;
|
|
|
+ color: #92400e;
|
|
|
+}
|
|
|
+.permission-icon { font-size: 14px; }
|
|
|
+.permission-text { flex: 1; }
|
|
|
+
|
|
|
// 消息区域
|
|
|
.chat-messages {
|
|
|
flex: 1;
|
|
|
@@ -667,6 +634,31 @@ function showPhonemeDetail(analysis: NonNullable<NonNullable<PreviewChatMessage[
|
|
|
&:hover { background: #fef3c7; }
|
|
|
}
|
|
|
|
|
|
+// 错误状态
|
|
|
+.error-card {
|
|
|
+ margin-top: 6px;
|
|
|
+ padding: 8px 12px;
|
|
|
+ background: #fef2f2;
|
|
|
+ border: 1px solid #fecaca;
|
|
|
+ border-radius: 8px;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: space-between;
|
|
|
+ gap: 8px;
|
|
|
+}
|
|
|
+.error-text { font-size: 11px; color: #dc2626; }
|
|
|
+.retry-btn {
|
|
|
+ padding: 4px 12px;
|
|
|
+ background: #fff;
|
|
|
+ border: 1px solid #fecaca;
|
|
|
+ border-radius: 999px;
|
|
|
+ font-size: 11px;
|
|
|
+ color: #dc2626;
|
|
|
+ cursor: pointer;
|
|
|
+ white-space: nowrap;
|
|
|
+ &:hover { background: #fef2f2; border-color: #f87171; }
|
|
|
+}
|
|
|
+
|
|
|
// 即时反馈
|
|
|
.feedback-card {
|
|
|
margin-top: 8px;
|
|
|
@@ -763,7 +755,7 @@ function showPhonemeDetail(analysis: NonNullable<NonNullable<PreviewChatMessage[
|
|
|
border-radius: 12px;
|
|
|
border-top-left-radius: 4px;
|
|
|
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
|
|
|
- display: flex;
|
|
|
+ display: inline-flex;
|
|
|
align-items: center;
|
|
|
gap: 4px;
|
|
|
}
|
|
|
@@ -845,6 +837,7 @@ function showPhonemeDetail(analysis: NonNullable<NonNullable<PreviewChatMessage[
|
|
|
&:hover { background: #ea580c; }
|
|
|
&.recording { background: #ef4444; }
|
|
|
&.waiting { background: #d1d5db; cursor: not-allowed; }
|
|
|
+ &:disabled { background: #d1d5db; cursor: not-allowed; }
|
|
|
}
|
|
|
|
|
|
.record-status { display: flex; align-items: center; gap: 6px; }
|
|
|
@@ -914,13 +907,6 @@ function showPhonemeDetail(analysis: NonNullable<NonNullable<PreviewChatMessage[
|
|
|
// 提示弹窗内容
|
|
|
.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 {
|