1493 lines
36 KiB
Vue
1493 lines
36 KiB
Vue
<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="请输入要听写的单词,每行一个单词 例如: apple 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>
|