AI.Demo/src/views/SpellPractice.vue

1493 lines
36 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup>
import { ref, computed, onUnmounted, reactive } from "vue";
import { useRouter } from "vue-router";
import {
DOUBAO_APP_ID,
DOUBAO_ACCESS_TOKEN,
DOUBAO_RESOURCE_ID,
DOUBAO_TTS_API_PATH,
DOUBAO_AUDIO_FORMAT,
DOUBAO_SAMPLE_RATE,
} from "@/config/index.js";
const router = useRouter();
// 兼容不支持 crypto.randomUUID 的浏览器
const generateUUID = () => {
if (typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
};
// 单词输入
const wordInput = ref("");
const wordList = computed(() => {
return wordInput.value
.split("\n")
.map((w) => w.trim())
.filter((w) => w.length > 0);
});
// 示例单词列表
const exampleWords = [
"apple", "banana", "orange", "grape", "strawberry",
"computer", "program", "keyboard", "screen", "mouse",
"book", "paper", "pencil", "school", "student",
"house", "window", "door", "table", "chair"
];
// 加载示例单词
const loadExampleWords = () => {
wordInput.value = exampleWords.join("\n");
};
// 发音配置
const selectedVoice = ref("4");
const repeatCount = ref(2);
const speechRate = ref(1.0);
const wordInterval = ref(2.0); // 每个单词朗读间隔时间(秒)
const repeatInterval = ref(1.0); // 单个单词重复朗读间隔时间(秒)
// 英语音色列表
const englishVoices = ref([
{
id: "1",
name: "Dacey (美音)",
avatar: "👩🏼",
voice_type: "en_female_dacey_uranus_bigtts",
},
{
id: "2",
name: "Tim (美音)",
avatar: "👨🏼",
voice_type: "en_male_tim_uranus_bigtts",
},
{
id: "3",
name: "Stokie (美音)",
avatar: "👩🏻",
voice_type: "en_female_stokie_uranus_bigtts",
},
{
id: "4",
name: "Tina (英音)",
avatar: "👩‍🏫",
voice_type: "zh_female_yingyujiaoxue_uranus_bigtts",
},
]);
// 听写状态
const dictationState = ref("idle"); // idle | playing | paused | completed
const currentWordIndex = ref(0);
const currentRepeatIndex = ref(0);
const completedWords = ref(0);
let audioInstance = null;
let audioCache = new Map(); // 缓存已生成的音频
// 长按状态
const longPressState = reactive({
pause: false, // 暂停按钮是否正在长按
replay: false, // 重播按钮是否正在长按
});
// 长按定时器
let longPressTimer = null;
// 长按开始
const handleLongPressStart = (buttonType) => {
// 清除之前的定时器
if (longPressTimer) {
clearTimeout(longPressTimer);
}
longPressState[buttonType] = true;
// 设置长按触发时间800ms
longPressTimer = setTimeout(() => {
if (buttonType === "pause" && dictationState.value === "playing") {
// 长按暂停按钮 = 停止听写
stopDictation();
} else if (buttonType === "replay") {
// 长按重播按钮 = 显示当前单词
showCurrentWord();
}
longPressState[buttonType] = false;
}, 800);
};
// 长按结束
const handleLongPressEnd = (buttonType) => {
if (longPressTimer) {
clearTimeout(longPressTimer);
longPressTimer = null;
}
longPressState[buttonType] = false;
};
// 显示当前单词(用于听写时查看答案)
const showCurrentWord = () => {
if (!currentWord.value) return;
// 如果正在播放,先暂停
const wasPlaying = dictationState.value === "playing";
if (wasPlaying) {
pauseDictation();
}
// 创建临时显示元素
const wordDisplay = document.createElement("div");
wordDisplay.className = "word-tooltip";
wordDisplay.textContent = currentWord.value;
wordDisplay.style.cssText = `
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
padding: 2rem 3rem;
background: linear-gradient(135deg, var(--accent-5), #d97706);
color: white;
font-size: 3rem;
font-weight: 700;
border-radius: 20px;
box-shadow: 0 10px 40px rgba(245, 158, 11, 0.5);
z-index: 1000;
animation: fadeIn 0.3s ease;
`;
document.body.appendChild(wordDisplay);
// 3秒后自动消失
setTimeout(() => {
wordDisplay.style.animation = "fadeOut 0.3s ease";
setTimeout(() => {
document.body.removeChild(wordDisplay);
}, 300);
}, 2000);
};
// 进度计算
const progress = computed(() => {
if (wordList.value.length === 0) return 0;
return Math.round((completedWords.value / wordList.value.length) * 100);
});
const currentWord = computed(() => {
if (wordList.value.length === 0) return "";
return wordList.value[currentWordIndex.value] || "";
});
const statusText = computed(() => {
switch (dictationState.value) {
case "idle":
return wordList.value.length > 0
? `准备就绪,共 ${wordList.value.length} 个单词`
: "请输入单词列表";
case "playing":
return `正在朗读: ${currentWord.value}`;
case "paused":
return `已暂停: ${currentWord.value}`;
case "completed":
return `听写完成!共 ${wordList.value.length} 个单词`;
default:
return "";
}
});
// 预加载音频
const preloadAudio = async (word) => {
const cacheKey = `${word}_${selectedVoice.value}`;
if (audioCache.has(cacheKey)) {
return audioCache.get(cacheKey);
}
try {
const voice = englishVoices.value.find((v) => v.id === selectedVoice.value);
const payload = {
user: { uid: "spell_" + Date.now() },
req_params: {
text: word,
speaker: voice.voice_type,
audio_params: {
format: DOUBAO_AUDIO_FORMAT,
sample_rate: DOUBAO_SAMPLE_RATE,
},
},
};
const response = await fetch(DOUBAO_TTS_API_PATH, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Api-App-Id": DOUBAO_APP_ID,
"X-Api-Access-Key": DOUBAO_ACCESS_TOKEN,
"X-Api-Resource-Id": DOUBAO_RESOURCE_ID,
"X-Api-Request-Id": generateUUID(),
},
body: JSON.stringify(payload),
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let audioChunks = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop();
for (const line of lines) {
if (!line.trim()) continue;
try {
const resData = JSON.parse(line);
if (resData.code === 0 && resData.data) {
const binaryString = window.atob(resData.data);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
audioChunks.push(bytes);
}
} catch (e) {}
}
}
if (buffer.trim()) {
try {
const resData = JSON.parse(buffer);
if (resData.code === 0 && resData.data) {
const binaryString = window.atob(resData.data);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
audioChunks.push(bytes);
}
} catch (e) {}
}
if (audioChunks.length > 0) {
const totalLength = audioChunks.reduce((acc, val) => acc + val.length, 0);
const allBytes = new Uint8Array(totalLength);
let offset = 0;
for (const chunk of audioChunks) {
allBytes.set(chunk, offset);
offset += chunk.length;
}
const blob = new Blob([allBytes], { type: "audio/mp3" });
const url = URL.createObjectURL(blob);
audioCache.set(cacheKey, url);
return url;
}
} catch (error) {
console.error("Preload audio error:", error);
}
return null;
};
// 播放音频
const playAudio = (url) => {
if (audioInstance) {
audioInstance.pause();
audioInstance = null;
}
audioInstance = new Audio(url);
audioInstance.playbackRate = speechRate.value;
audioInstance.onended = handleAudioEnded;
audioInstance.onerror = handleAudioError;
audioInstance.play().catch(handleAudioError);
};
// 处理音频播放结束
const handleAudioEnded = () => {
// 检查是否已停止
if (dictationState.value !== "playing") return;
currentRepeatIndex.value++;
if (currentRepeatIndex.value < repeatCount.value) {
// 继续播放同一单词的下一次,间隔 repeatInterval 秒
setTimeout(() => {
if (dictationState.value !== "playing") return;
const cacheKey = `${currentWord.value}_${selectedVoice.value}`;
const url = audioCache.get(cacheKey);
if (url) {
playAudio(url);
}
}, repeatInterval.value * 1000);
} else {
// 进入下一个单词
completedWords.value++;
currentRepeatIndex.value = 0;
currentWordIndex.value++;
if (currentWordIndex.value < wordList.value.length) {
// 间隔 wordInterval 秒后播放下一个单词
setTimeout(() => {
if (dictationState.value !== "playing") return;
playNextWord();
}, wordInterval.value * 1000);
} else {
// 全部完成
dictationState.value = "completed";
}
}
};
const handleAudioError = () => {
// 出错时跳过当前单词
currentRepeatIndex.value++;
if (currentRepeatIndex.value < repeatCount.value) {
const cacheKey = `${currentWord.value}_${selectedVoice.value}`;
const url = audioCache.get(cacheKey);
if (url) playAudio(url);
} else {
completedWords.value++;
currentRepeatIndex.value = 0;
currentWordIndex.value++;
if (currentWordIndex.value < wordList.value.length) {
playNextWord();
} else {
dictationState.value = "completed";
}
}
};
// 播放下一个单词
const playNextWord = async () => {
const word = currentWord.value;
if (!word) return;
const url = await preloadAudio(word);
if (url) {
playAudio(url);
} else {
// 生成失败,跳过
handleAudioError();
}
};
// 开始听写
const startDictation = async () => {
if (wordList.value.length === 0) return;
// 重置状态
dictationState.value = "playing";
currentWordIndex.value = 0;
currentRepeatIndex.value = 0;
completedWords.value = 0;
audioCache.clear();
// 预加载第一个单词并播放
const firstWord = wordList.value[0];
const url = await preloadAudio(firstWord);
if (url) {
playAudio(url);
}
};
// 暂停听写
const pauseDictation = () => {
if (audioInstance) {
audioInstance.pause();
}
dictationState.value = "paused";
};
// 继续听写
const resumeDictation = () => {
if (audioInstance) {
audioInstance.play();
}
dictationState.value = "playing";
};
// 重播当前单词
const replayCurrentWord = async () => {
if (!currentWord.value) return;
currentRepeatIndex.value = 0;
const url = await preloadAudio(currentWord.value);
if (url) {
playAudio(url);
dictationState.value = "playing";
}
};
// 停止听写
const stopDictation = () => {
if (audioInstance) {
audioInstance.pause();
audioInstance = null;
}
dictationState.value = "idle";
currentWordIndex.value = 0;
currentRepeatIndex.value = 0;
completedWords.value = 0;
};
// 重置听写
const resetDictation = () => {
stopDictation();
wordInput.value = "";
};
onUnmounted(() => {
if (audioInstance) {
audioInstance.pause();
audioInstance = null;
}
// 清理缓存的音频 URL
audioCache.forEach((url) => URL.revokeObjectURL(url));
audioCache.clear();
// 清理长按定时器
if (longPressTimer) {
clearTimeout(longPressTimer);
longPressTimer = null;
}
});
</script>
<template>
<div class="page-container">
<nav class="nav-bar">
<button @click="router.push('/')" class="back-btn">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18"
/>
</svg>
返回首页
</button>
<div class="nav-title">单词听写</div>
</nav>
<div class="content-grid">
<!-- 左侧单词输入区 + 听写控制区上下排列 -->
<div class="left-column">
<!-- 单词输入区 -->
<div class="panel input-panel">
<div class="panel-header">
<h3>单词列表</h3>
<div class="header-actions">
<button
class="example-btn"
@click="loadExampleWords"
:disabled="dictationState === 'playing' || dictationState === 'paused'"
>
示例单词
</button>
<span class="word-count">{{ wordList.length }} 个单词</span>
</div>
</div>
<div class="word-input-wrapper">
<textarea
v-model="wordInput"
class="word-input"
placeholder="请输入要听写的单词,每行一个单词&#10;例如:&#10;apple&#10;banana"
:disabled="
dictationState === 'playing' || dictationState === 'paused'
"
></textarea>
</div>
</div>
<!-- 听写控制区 -->
<div class="panel control-panel">
<!-- 状态显示 -->
<div class="status-area">
<div class="status-text">{{ statusText }}</div>
<!-- 进度条 -->
<div class="progress-container" v-if="wordList.length > 0">
<div class="progress-bar">
<div
class="progress-fill"
:style="{ width: progress + '%' }"
></div>
</div>
<span class="progress-text"
>{{ completedWords }} / {{ wordList.length }}</span
>
</div>
</div>
<!-- 当前单词显示 -->
<div class="current-word-area" v-if="dictationState === 'completed'">
<div class="current-word">{{ currentWord }}</div>
<div class="repeat-indicator">
第 {{ currentRepeatIndex + 1 }} / {{ repeatCount }} 遍
</div>
</div>
<!-- 波形动画 -->
<div class="waveform-area" v-if="dictationState === 'playing'">
<div class="waveform">
<span
v-for="i in 12"
:key="i"
class="wave-bar"
:style="{ animationDelay: i * 0.1 + 's' }"
></span>
</div>
</div>
<!-- 完成图标 -->
<div class="complete-icon" v-if="dictationState === 'completed'">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<!-- 控制按钮 -->
<div class="control-buttons">
<!-- 空闲状态 -->
<template v-if="dictationState === 'idle'">
<button
class="primary-btn"
:disabled="wordList.length === 0"
@click="startDictation"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 010 1.972l-11.54 6.347a1.125 1.125 0 01-1.667-.986V5.653Z"
/>
</svg>
开始听写
</button>
</template>
<!-- 播放中 -->
<template v-else-if="dictationState === 'playing'">
<button
class="control-btn pause-btn"
:class="{ pressing: longPressState.pause }"
@click="pauseDictation"
@mousedown="handleLongPressStart('pause')"
@mouseup="handleLongPressEnd('pause')"
@mouseleave="handleLongPressEnd('pause')"
@touchstart.prevent="handleLongPressStart('pause')"
@touchend="handleLongPressEnd('pause')"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15.75 5.25v13.5m-7.5-13.5v13.5"
/>
</svg>
</button>
<button
class="control-btn replay-btn"
:class="{ pressing: longPressState.replay }"
@click="replayCurrentWord"
@mousedown="handleLongPressStart('replay')"
@mouseup="handleLongPressEnd('replay')"
@mouseleave="handleLongPressEnd('replay')"
@touchstart.prevent="handleLongPressStart('replay')"
@touchend="handleLongPressEnd('replay')"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
/>
</svg>
</button>
<button class="control-btn stop-btn" @click="stopDictation">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M5.25 7.5A2.25 2.25 0 017.5 5.25h9a2.25 2.25 0 012.25 2.25v9a2.25 2.25 0 01-2.25 2.25h-9a2.25 2.25 0 01-2.25-2.25v-9z"
/>
</svg>
</button>
</template>
<!-- 暂停状态 -->
<template v-else-if="dictationState === 'paused'">
<button class="control-btn resume-btn" @click="resumeDictation">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 010 1.972l-11.54 6.347a1.125 1.125 0 01-1.667-.986V5.653Z"
/>
</svg>
</button>
<button
class="control-btn replay-btn"
:class="{ pressing: longPressState.replay }"
@click="replayCurrentWord"
@mousedown="handleLongPressStart('replay')"
@mouseup="handleLongPressEnd('replay')"
@mouseleave="handleLongPressEnd('replay')"
@touchstart.prevent="handleLongPressStart('replay')"
@touchend="handleLongPressEnd('replay')"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
/>
</svg>
</button>
<button class="control-btn stop-btn" @click="stopDictation">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M5.25 7.5A2.25 2.25 0 017.5 5.25h9a2.25 2.25 0 012.25 2.25v9a2.25 2.25 0 01-2.25 2.25h-9a2.25 2.25 0 01-2.25-2.25v-9z"
/>
</svg>
</button>
</template>
<!-- 完成状态 -->
<template v-else-if="dictationState === 'completed'">
<button class="primary-btn" @click="startDictation">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
/>
</svg>
重新听写
</button>
<button class="secondary-btn" @click="resetDictation">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9 15L3 9m0 0l6-6M3 9h12a6 6 0 010 12h-3"
/>
</svg>
重置
</button>
</template>
</div>
</div>
</div>
<!-- 右侧:发音配置区 -->
<div class="panel config-panel">
<div class="panel-header">
<h3>发音配置</h3>
</div>
<!-- 发音人选择 -->
<div class="config-section">
<label class="config-label">选择发音人</label>
<div class="voice-list">
<div
v-for="voice in englishVoices"
:key="voice.id"
class="voice-item"
:class="{ selected: selectedVoice === voice.id }"
@click="selectedVoice = voice.id"
>
<span class="voice-avatar">{{ voice.avatar }}</span>
<span class="voice-name">{{ voice.name }}</span>
</div>
</div>
</div>
<!-- 朗读次数 -->
<div class="config-section">
<label class="config-label">
朗读次数
<span class="config-value">{{ repeatCount }} 次</span>
</label>
<input
type="range"
v-model.number="repeatCount"
min="1"
max="5"
step="1"
class="range-slider"
/>
<div class="range-labels">
<span>1</span>
<span>3</span>
<span>5</span>
</div>
</div>
<!-- 语速调节 -->
<div class="config-section">
<label class="config-label">
朗读语速
<span class="config-value">{{ speechRate.toFixed(1) }}x</span>
</label>
<input
type="range"
v-model.number="speechRate"
min="0.5"
max="2.0"
step="0.1"
class="range-slider"
/>
<div class="range-labels">
<span>0.5x</span>
<span>1.0x</span>
<span>2.0x</span>
</div>
</div>
<!-- 单词间隔 -->
<div class="config-section">
<label class="config-label">
单词间隔
<span class="config-value">{{ wordInterval.toFixed(1) }}s</span>
</label>
<input
type="range"
v-model.number="wordInterval"
min="0.5"
max="5"
step="0.5"
class="range-slider"
/>
<div class="range-labels">
<span>0.5s</span>
<span>2.5s</span>
<span>5s</span>
</div>
</div>
<!-- 重复朗读间隔 -->
<div class="config-section">
<label class="config-label">
重复朗读间隔
<span class="config-value">{{ repeatInterval.toFixed(1) }}s</span>
</label>
<input
type="range"
v-model.number="repeatInterval"
min="0"
max="3"
step="0.5"
class="range-slider"
/>
<div class="range-labels">
<span>0s</span>
<span>1.5s</span>
<span>3s</span>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
* {
box-sizing: border-box;
}
.page-container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
min-height: 100vh;
display: flex;
flex-direction: column;
}
.nav-bar {
display: flex;
align-items: center;
margin-bottom: 2rem;
}
.back-btn {
display: flex;
align-items: center;
gap: 0.5rem;
background: none;
border: none;
color: var(--text-secondary);
font-size: 1rem;
cursor: pointer;
padding: 0.5rem 1rem;
border-radius: 8px;
transition: all 0.2s;
}
.back-btn:hover {
background: var(--card-bg);
color: var(--text-primary);
}
.back-btn svg {
width: 20px;
height: 20px;
}
.nav-title {
font-size: 1.5rem;
font-weight: 600;
margin-left: auto;
margin-right: auto;
transform: translateX(-40px);
}
.content-grid {
display: grid;
grid-template-columns: 1fr 400px;
gap: 1.5rem;
flex: 1;
}
.left-column {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.input-panel {
min-height: 200px;
}
.config-panel {
min-height: auto;
}
.control-panel {
flex: 1;
min-height: 300px;
}
.panel {
background: var(--card-bg);
border: 1px solid var(--card-border);
border-radius: 20px;
backdrop-filter: blur(12px);
padding: 1.5rem;
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.panel-header h3 {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
}
.word-count {
color: var(--text-secondary);
font-size: 0.875rem;
}
.header-actions {
display: flex;
align-items: center;
gap: 0.75rem;
}
.example-btn {
background: rgba(255, 255, 255, 0.05);
color: var(--accent-5);
border: 1px solid var(--accent-5);
padding: 0.375rem 0.75rem;
border-radius: 8px;
font-size: 0.75rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.example-btn:hover:not(:disabled) {
background: rgba(245, 158, 11, 0.1);
}
.example-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* 输入区 */
.word-input-wrapper {
overflow: hidden;
border-radius: 12px;
border: 1px solid var(--card-border);
transition: border-color 0.3s;
}
.word-input-wrapper:focus-within {
border-color: var(--accent-5);
}
.word-input {
width: 100%;
height: 200px;
background: rgba(0, 0, 0, 0.2);
border: none;
border-radius: 12px;
padding: 1rem;
color: var(--text-primary);
font-family: inherit;
font-size: 1rem;
line-height: 1.8;
resize: none;
outline: none;
transition: border-color 0.3s;
}
.word-input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.word-input::placeholder {
color: var(--text-secondary);
}
/* 滚动条美化 */
.word-input {
scrollbar-width: thin;
scrollbar-color: rgba(245, 158, 11, 0.5) rgba(245, 158, 11, 0.1);
}
.word-input::-webkit-scrollbar {
width: 6px;
}
.word-input::-webkit-scrollbar-track {
background: rgba(245, 158, 11, 0.1);
border-radius: 3px;
}
.word-input::-webkit-scrollbar-thumb {
background: rgba(245, 158, 11, 0.5);
border-radius: 3px;
}
.word-input::-webkit-scrollbar-thumb:hover {
background: rgba(245, 158, 11, 0.7);
}
/* 配置区 */
.config-section {
margin-bottom: 1.5rem;
}
.config-section:last-child {
margin-bottom: 0;
}
.config-label {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.875rem;
color: var(--text-secondary);
margin-bottom: 0.75rem;
}
.config-value {
color: var(--accent-5);
font-weight: 600;
}
.voice-list {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.75rem;
}
.voice-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: rgba(0, 0, 0, 0.2);
border: 1px solid var(--card-border);
border-radius: 10px;
cursor: pointer;
transition: all 0.2s;
}
.voice-item:hover {
background: rgba(255, 255, 255, 0.05);
}
.voice-item.selected {
border-color: var(--accent-5);
background: linear-gradient(
to right,
rgba(245, 158, 11, 0.1),
rgba(0, 0, 0, 0.2)
);
}
.voice-avatar {
font-size: 1.5rem;
}
.voice-name {
font-size: 0.875rem;
font-weight: 500;
}
.range-slider {
width: 100%;
height: 6px;
-webkit-appearance: none;
appearance: none;
background: rgba(255, 255, 255, 0.1);
border-radius: 3px;
outline: none;
cursor: pointer;
}
.range-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 18px;
height: 18px;
background: linear-gradient(135deg, var(--accent-5), #d97706);
border-radius: 50%;
cursor: pointer;
box-shadow: 0 2px 8px rgba(245, 158, 11, 0.4);
transition: transform 0.2s;
}
.range-slider::-webkit-slider-thumb:hover {
transform: scale(1.1);
}
.range-labels {
display: flex;
justify-content: space-between;
margin-top: 0.5rem;
font-size: 0.75rem;
color: var(--text-secondary);
}
.toggle-switch {
width: 52px;
height: 28px;
background: rgba(255, 255, 255, 0.1);
border: none;
border-radius: 14px;
cursor: pointer;
position: relative;
transition: background 0.3s;
}
.toggle-switch.active {
background: linear-gradient(135deg, var(--accent-5), #d97706);
}
.toggle-knob {
position: absolute;
top: 3px;
left: 3px;
width: 22px;
height: 22px;
background: white;
border-radius: 50%;
transition: transform 0.3s;
}
.toggle-switch.active .toggle-knob {
transform: translateX(24px);
}
/* 控制面板 */
.control-panel {
display: flex;
flex-direction: column;
min-height: 280px;
}
.status-area {
text-align: center;
margin-bottom: 2rem;
}
.status-text {
font-size: 1rem;
color: var(--text-secondary);
margin-bottom: 1rem;
}
.progress-container {
display: flex;
align-items: center;
gap: 1rem;
}
.progress-bar {
flex: 1;
height: 8px;
background: rgba(255, 255, 255, 0.1);
border-radius: 4px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--accent-5), #d97706);
border-radius: 4px;
transition: width 0.3s ease;
}
.progress-text {
font-size: 0.875rem;
color: var(--text-secondary);
font-variant-numeric: tabular-nums;
}
/* 当前单词显示 */
.current-word-area {
text-align: center;
margin-bottom: 2rem;
}
.current-word {
font-size: 3rem;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 0.5rem;
text-shadow: 0 0 30px rgba(245, 158, 11, 0.3);
}
.repeat-indicator {
font-size: 0.875rem;
color: var(--accent-5);
}
/* 波形动画 */
.waveform-area {
display: flex;
justify-content: center;
align-items: center;
height: 100px;
margin-bottom: 2rem;
}
.waveform {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
height: 60px;
}
.wave-bar {
width: 4px;
background: linear-gradient(to top, var(--accent-5), #d97706);
border-radius: 2px;
animation: wave 0.8s ease-in-out infinite;
}
@keyframes wave {
0%,
100% {
height: 20px;
}
50% {
height: 50px;
}
}
/* 完成图标 */
.complete-icon {
display: flex;
justify-content: center;
margin-bottom: 2rem;
}
.complete-icon svg {
width: 80px;
height: 80px;
color: var(--accent-4);
animation: scaleIn 0.5s ease-out;
}
@keyframes scaleIn {
0% {
transform: scale(0);
opacity: 0;
}
100% {
transform: scale(1);
opacity: 1;
}
}
/* 控制按钮 */
.control-buttons {
display: flex;
justify-content: center;
gap: 1rem;
margin-top: auto;
}
.primary-btn {
display: flex;
align-items: center;
gap: 0.5rem;
background: linear-gradient(135deg, var(--accent-5), #d97706);
color: white;
border: none;
padding: 1rem 2rem;
border-radius: 12px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
box-shadow: 0 4px 15px rgba(245, 158, 11, 0.3);
}
.primary-btn:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(245, 158, 11, 0.4);
}
.primary-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.primary-btn svg {
width: 20px;
height: 20px;
}
.secondary-btn {
display: flex;
align-items: center;
gap: 0.5rem;
background: rgba(255, 255, 255, 0.05);
color: var(--text-secondary);
border: 1px solid var(--card-border);
padding: 1rem 1.5rem;
border-radius: 12px;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: all 0.3s;
}
.secondary-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: var(--text-primary);
}
.secondary-btn svg {
width: 20px;
height: 20px;
}
.control-btn {
width: 56px;
height: 56px;
border-radius: 50%;
border: none;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s;
}
.control-btn svg {
width: 24px;
height: 24px;
}
.pause-btn {
background: linear-gradient(135deg, var(--accent-5), #d97706);
color: white;
box-shadow: 0 4px 15px rgba(245, 158, 11, 0.3);
}
.pause-btn:hover {
transform: scale(1.05);
box-shadow: 0 6px 20px rgba(245, 158, 11, 0.4);
}
.resume-btn {
background: linear-gradient(135deg, var(--accent-4), #0d9488);
color: white;
box-shadow: 0 4px 15px rgba(20, 184, 166, 0.3);
}
.resume-btn:hover {
transform: scale(1.05);
box-shadow: 0 6px 20px rgba(20, 184, 166, 0.4);
}
.replay-btn {
background: rgba(255, 255, 255, 0.1);
color: var(--text-primary);
}
.replay-btn:hover {
background: rgba(255, 255, 255, 0.15);
transform: scale(1.05);
}
.stop-btn {
background: rgba(239, 68, 68, 0.2);
color: #ef4444;
}
.stop-btn:hover {
background: rgba(239, 68, 68, 0.3);
transform: scale(1.05);
}
/* 长按状态 */
.control-btn.pressing {
transform: scale(0.95);
opacity: 0.8;
}
.pause-btn.pressing {
background: linear-gradient(135deg, #dc2626, #b91c1c);
box-shadow: 0 2px 10px rgba(239, 68, 68, 0.4);
}
.replay-btn.pressing {
background: linear-gradient(135deg, var(--accent-5), #d97706);
color: white;
box-shadow: 0 4px 15px rgba(245, 158, 11, 0.3);
}
/* 动画效果 */
@keyframes fadeIn {
from {
opacity: 0;
transform: translate(-50%, -50%) scale(0.8);
}
to {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
}
@keyframes fadeOut {
from {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
to {
opacity: 0;
transform: translate(-50%, -50%) scale(0.8);
}
}
@media (max-width: 900px) {
.content-grid {
grid-template-columns: 1fr;
}
.left-column {
gap: 1rem;
}
.page-container {
padding: 1rem;
}
.voice-list {
grid-template-columns: 1fr;
}
}
</style>