553 lines
16 KiB
HTML
553 lines
16 KiB
HTML
|
|
<!DOCTYPE html>
|
|||
|
|
<html lang="zh-CN">
|
|||
|
|
<head>
|
|||
|
|
<meta charset="UTF-8">
|
|||
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|||
|
|
<title>局域网文字转语音工具</title>
|
|||
|
|
<!-- 引入Element UI样式 -->
|
|||
|
|
<link rel="stylesheet" href="https://unpkg.com/element-ui/lib/theme-chalk/index.css">
|
|||
|
|
<style>
|
|||
|
|
* {
|
|||
|
|
margin: 0;
|
|||
|
|
padding: 0;
|
|||
|
|
box-sizing: border-box;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
body {
|
|||
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
|||
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|||
|
|
min-height: 100vh;
|
|||
|
|
padding: 20px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.container {
|
|||
|
|
max-width: 1200px;
|
|||
|
|
margin: 0 auto;
|
|||
|
|
background: white;
|
|||
|
|
border-radius: 12px;
|
|||
|
|
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
|
|||
|
|
padding: 30px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.header {
|
|||
|
|
text-align: center;
|
|||
|
|
margin-bottom: 30px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.header h1 {
|
|||
|
|
color: #333;
|
|||
|
|
font-size: 28px;
|
|||
|
|
margin-bottom: 10px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.header p {
|
|||
|
|
color: #666;
|
|||
|
|
font-size: 14px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.input-section {
|
|||
|
|
margin-bottom: 30px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.input-section label {
|
|||
|
|
display: block;
|
|||
|
|
margin-bottom: 8px;
|
|||
|
|
color: #333;
|
|||
|
|
font-weight: 500;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.input-section textarea {
|
|||
|
|
width: 100%;
|
|||
|
|
min-height: 120px;
|
|||
|
|
padding: 12px;
|
|||
|
|
border: 2px solid #e0e0e0;
|
|||
|
|
border-radius: 6px;
|
|||
|
|
font-size: 14px;
|
|||
|
|
resize: vertical;
|
|||
|
|
transition: border-color 0.3s;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.input-section textarea:focus {
|
|||
|
|
outline: none;
|
|||
|
|
border-color: #667eea;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.preset-buttons {
|
|||
|
|
display: flex;
|
|||
|
|
flex-wrap: wrap;
|
|||
|
|
gap: 10px;
|
|||
|
|
margin-top: 10px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.preset-btn {
|
|||
|
|
padding: 8px 16px;
|
|||
|
|
background: #f0f0f0;
|
|||
|
|
border: 1px solid #ddd;
|
|||
|
|
border-radius: 4px;
|
|||
|
|
cursor: pointer;
|
|||
|
|
transition: all 0.3s;
|
|||
|
|
font-size: 13px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.preset-btn:hover {
|
|||
|
|
background: #667eea;
|
|||
|
|
color: white;
|
|||
|
|
border-color: #667eea;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.control-section {
|
|||
|
|
display: grid;
|
|||
|
|
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
|||
|
|
gap: 20px;
|
|||
|
|
margin-bottom: 30px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.control-item {
|
|||
|
|
background: #f8f9fa;
|
|||
|
|
padding: 20px;
|
|||
|
|
border-radius: 8px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.control-item label {
|
|||
|
|
display: block;
|
|||
|
|
margin-bottom: 10px;
|
|||
|
|
color: #333;
|
|||
|
|
font-weight: 500;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.control-item .slider-container {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 15px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.slider {
|
|||
|
|
flex: 1;
|
|||
|
|
height: 6px;
|
|||
|
|
background: #e0e0e0;
|
|||
|
|
border-radius: 3px;
|
|||
|
|
position: relative;
|
|||
|
|
cursor: pointer;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.slider-track {
|
|||
|
|
height: 100%;
|
|||
|
|
background: #667eea;
|
|||
|
|
border-radius: 3px;
|
|||
|
|
transition: width 0.2s;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.slider-thumb {
|
|||
|
|
width: 18px;
|
|||
|
|
height: 18px;
|
|||
|
|
background: white;
|
|||
|
|
border: 2px solid #667eea;
|
|||
|
|
border-radius: 50%;
|
|||
|
|
position: absolute;
|
|||
|
|
top: 50%;
|
|||
|
|
transform: translate(-50%, -50%);
|
|||
|
|
cursor: grab;
|
|||
|
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.slider-thumb:active {
|
|||
|
|
cursor: grabbing;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.value-display {
|
|||
|
|
min-width: 60px;
|
|||
|
|
text-align: right;
|
|||
|
|
color: #666;
|
|||
|
|
font-size: 14px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.button-group {
|
|||
|
|
display: flex;
|
|||
|
|
gap: 15px;
|
|||
|
|
justify-content: center;
|
|||
|
|
margin-top: 30px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.btn {
|
|||
|
|
padding: 12px 30px;
|
|||
|
|
border: none;
|
|||
|
|
border-radius: 6px;
|
|||
|
|
font-size: 16px;
|
|||
|
|
cursor: pointer;
|
|||
|
|
transition: all 0.3s;
|
|||
|
|
font-weight: 500;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.btn-primary {
|
|||
|
|
background: #667eea;
|
|||
|
|
color: white;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.btn-primary:hover:not(:disabled) {
|
|||
|
|
background: #5568d3;
|
|||
|
|
transform: translateY(-2px);
|
|||
|
|
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.btn-danger {
|
|||
|
|
background: #f56565;
|
|||
|
|
color: white;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.btn-danger:hover:not(:disabled) {
|
|||
|
|
background: #e53e3e;
|
|||
|
|
transform: translateY(-2px);
|
|||
|
|
box-shadow: 0 4px 12px rgba(245, 101, 101, 0.4);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.btn:disabled {
|
|||
|
|
opacity: 0.5;
|
|||
|
|
cursor: not-allowed;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.status {
|
|||
|
|
text-align: center;
|
|||
|
|
margin-top: 20px;
|
|||
|
|
padding: 15px;
|
|||
|
|
border-radius: 6px;
|
|||
|
|
font-size: 14px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.status.info {
|
|||
|
|
background: #e3f2fd;
|
|||
|
|
color: #1976d2;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.status.warning {
|
|||
|
|
background: #fff3e0;
|
|||
|
|
color: #f57c00;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.status.success {
|
|||
|
|
background: #e8f5e9;
|
|||
|
|
color: #388e3c;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.status.error {
|
|||
|
|
background: #ffebee;
|
|||
|
|
color: #d32f2f;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@media (max-width: 768px) {
|
|||
|
|
.container {
|
|||
|
|
padding: 20px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.control-section {
|
|||
|
|
grid-template-columns: 1fr;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.button-group {
|
|||
|
|
flex-direction: column;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.btn {
|
|||
|
|
width: 100%;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
</style>
|
|||
|
|
</head>
|
|||
|
|
<body>
|
|||
|
|
<div class="container">
|
|||
|
|
<div class="header">
|
|||
|
|
<h1>🎤 局域网文字转语音工具</h1>
|
|||
|
|
<p>基于浏览器 Web Speech API,无需后端服务,纯前端实现</p>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="input-section">
|
|||
|
|
<label for="textInput">📝 输入要朗读的文字:</label>
|
|||
|
|
<textarea id="textInput" placeholder="请输入要转换为语音的文字内容..."></textarea>
|
|||
|
|
<div class="preset-buttons">
|
|||
|
|
<button class="preset-btn" onclick="setPresetText('欢迎使用AI心理健康测评系统,请仔细阅读题目并选择您的答案。')">
|
|||
|
|
欢迎语
|
|||
|
|
</button>
|
|||
|
|
<button class="preset-btn" onclick="setPresetText('请根据您的实际情况,选择最符合的选项。')">
|
|||
|
|
答题提示
|
|||
|
|
</button>
|
|||
|
|
<button class="preset-btn" onclick="setPresetText('感谢您的参与,测评已完成,请查看您的测评报告。')">
|
|||
|
|
完成提示
|
|||
|
|
</button>
|
|||
|
|
<button class="preset-btn" onclick="setPresetText('请注意,本次测评结果仅供参考,如有疑问请咨询专业心理医生。')">
|
|||
|
|
免责声明
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="control-section">
|
|||
|
|
<div class="control-item">
|
|||
|
|
<label>🔊 音量:<span id="volumeValue">100%</span></label>
|
|||
|
|
<div class="slider-container">
|
|||
|
|
<div class="slider" id="volumeSlider">
|
|||
|
|
<div class="slider-track" id="volumeTrack"></div>
|
|||
|
|
<div class="slider-thumb" id="volumeThumb"></div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="control-item">
|
|||
|
|
<label>⚡ 语速:<span id="rateValue">1.0x</span></label>
|
|||
|
|
<div class="slider-container">
|
|||
|
|
<div class="slider" id="rateSlider">
|
|||
|
|
<div class="slider-track" id="rateTrack"></div>
|
|||
|
|
<div class="slider-thumb" id="rateThumb"></div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="control-item">
|
|||
|
|
<label>🎵 音调:<span id="pitchValue">1.0</span></label>
|
|||
|
|
<div class="slider-container">
|
|||
|
|
<div class="slider" id="pitchSlider">
|
|||
|
|
<div class="slider-track" id="pitchTrack"></div>
|
|||
|
|
<div class="slider-thumb" id="pitchThumb"></div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="button-group">
|
|||
|
|
<button class="btn btn-primary" id="speakBtn" onclick="speakText()">
|
|||
|
|
▶️ 开始朗读
|
|||
|
|
</button>
|
|||
|
|
<button class="btn btn-danger" id="stopBtn" onclick="stopSpeaking()" disabled>
|
|||
|
|
⏹️ 停止朗读
|
|||
|
|
</button>
|
|||
|
|
<button class="btn btn-primary" onclick="clearText()">
|
|||
|
|
🗑️ 清空文本
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="status info" id="status">
|
|||
|
|
💡 提示:请在 Chrome、Edge 或 Safari 浏览器中使用以获得最佳体验
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<script>
|
|||
|
|
// 全局变量
|
|||
|
|
let synth = null;
|
|||
|
|
let utterance = null;
|
|||
|
|
let isSpeaking = false;
|
|||
|
|
|
|||
|
|
// 参数值
|
|||
|
|
let volume = 1.0; // 0-1
|
|||
|
|
let rate = 1.0; // 0.5-2
|
|||
|
|
let pitch = 1.0; // 0.5-2
|
|||
|
|
|
|||
|
|
// 初始化
|
|||
|
|
window.onload = function() {
|
|||
|
|
initTts();
|
|||
|
|
initSliders();
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 初始化 TTS
|
|||
|
|
*/
|
|||
|
|
function initTts() {
|
|||
|
|
if ('speechSynthesis' in window) {
|
|||
|
|
synth = window.speechSynthesis;
|
|||
|
|
updateStatus('success', '✅ 语音合成功能已就绪');
|
|||
|
|
} else {
|
|||
|
|
updateStatus('error', '❌ 您的浏览器不支持语音合成功能,请使用 Chrome、Edge 或 Safari 浏览器');
|
|||
|
|
document.getElementById('speakBtn').disabled = true;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 初始化滑块
|
|||
|
|
*/
|
|||
|
|
function initSliders() {
|
|||
|
|
initSlider('volume', 0, 1, 1.0, updateVolume);
|
|||
|
|
initSlider('rate', 0.5, 2, 1.0, updateRate);
|
|||
|
|
initSlider('pitch', 0.5, 2, 1.0, updatePitch);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 初始化单个滑块
|
|||
|
|
*/
|
|||
|
|
function initSlider(name, min, max, value, callback) {
|
|||
|
|
const slider = document.getElementById(name + 'Slider');
|
|||
|
|
const thumb = document.getElementById(name + 'Thumb');
|
|||
|
|
const track = document.getElementById(name + 'Track');
|
|||
|
|
const valueDisplay = document.getElementById(name + 'Value');
|
|||
|
|
|
|||
|
|
let isDragging = false;
|
|||
|
|
|
|||
|
|
function updateSlider(clientX) {
|
|||
|
|
const rect = slider.getBoundingClientRect();
|
|||
|
|
let percent = (clientX - rect.left) / rect.width;
|
|||
|
|
percent = Math.max(0, Math.min(1, percent));
|
|||
|
|
|
|||
|
|
const newValue = min + percent * (max - min);
|
|||
|
|
const displayValue = name === 'volume'
|
|||
|
|
? Math.round(newValue * 100) + '%'
|
|||
|
|
: name === 'rate'
|
|||
|
|
? newValue.toFixed(1) + 'x'
|
|||
|
|
: newValue.toFixed(1);
|
|||
|
|
|
|||
|
|
valueDisplay.textContent = displayValue;
|
|||
|
|
thumb.style.left = percent * 100 + '%';
|
|||
|
|
track.style.width = percent * 100 + '%';
|
|||
|
|
|
|||
|
|
callback(newValue);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
slider.addEventListener('mousedown', (e) => {
|
|||
|
|
isDragging = true;
|
|||
|
|
updateSlider(e.clientX);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
document.addEventListener('mousemove', (e) => {
|
|||
|
|
if (isDragging) {
|
|||
|
|
updateSlider(e.clientX);
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
document.addEventListener('mouseup', () => {
|
|||
|
|
isDragging = false;
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 初始化位置
|
|||
|
|
const percent = (value - min) / (max - min);
|
|||
|
|
thumb.style.left = percent * 100 + '%';
|
|||
|
|
track.style.width = percent * 100 + '%';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 更新音量
|
|||
|
|
*/
|
|||
|
|
function updateVolume(value) {
|
|||
|
|
volume = value;
|
|||
|
|
if (utterance) {
|
|||
|
|
utterance.volume = value;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 更新语速
|
|||
|
|
*/
|
|||
|
|
function updateRate(value) {
|
|||
|
|
rate = value;
|
|||
|
|
if (utterance) {
|
|||
|
|
utterance.rate = value;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 更新音调
|
|||
|
|
*/
|
|||
|
|
function updatePitch(value) {
|
|||
|
|
pitch = value;
|
|||
|
|
if (utterance) {
|
|||
|
|
utterance.pitch = value;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 朗读文本
|
|||
|
|
*/
|
|||
|
|
function speakText() {
|
|||
|
|
const text = document.getElementById('textInput').value.trim();
|
|||
|
|
|
|||
|
|
if (!text) {
|
|||
|
|
updateStatus('warning', '⚠️ 请输入要朗读的文字');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!synth) {
|
|||
|
|
updateStatus('error', '❌ 语音合成功能不可用');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 停止之前的朗读
|
|||
|
|
stopSpeaking();
|
|||
|
|
|
|||
|
|
// 创建语音合成对象
|
|||
|
|
utterance = new SpeechSynthesisUtterance(text);
|
|||
|
|
|
|||
|
|
// 设置参数
|
|||
|
|
utterance.lang = 'zh-CN';
|
|||
|
|
utterance.volume = volume;
|
|||
|
|
utterance.rate = rate;
|
|||
|
|
utterance.pitch = pitch;
|
|||
|
|
|
|||
|
|
// 选择中文语音
|
|||
|
|
const voices = synth.getVoices();
|
|||
|
|
const chineseVoice = voices.find(voice =>
|
|||
|
|
voice.lang.includes('zh') || voice.lang.includes('CN')
|
|||
|
|
);
|
|||
|
|
if (chineseVoice) {
|
|||
|
|
utterance.voice = chineseVoice;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 监听事件
|
|||
|
|
utterance.onstart = () => {
|
|||
|
|
isSpeaking = true;
|
|||
|
|
document.getElementById('speakBtn').disabled = true;
|
|||
|
|
document.getElementById('stopBtn').disabled = false;
|
|||
|
|
updateStatus('success', '🔊 正在朗读...');
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
utterance.onend = () => {
|
|||
|
|
isSpeaking = false;
|
|||
|
|
document.getElementById('speakBtn').disabled = false;
|
|||
|
|
document.getElementById('stopBtn').disabled = true;
|
|||
|
|
updateStatus('info', '✅ 朗读完成');
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
utterance.onerror = (event) => {
|
|||
|
|
isSpeaking = false;
|
|||
|
|
document.getElementById('speakBtn').disabled = false;
|
|||
|
|
document.getElementById('stopBtn').disabled = true;
|
|||
|
|
updateStatus('error', '❌ 朗读失败: ' + event.error);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 开始朗读
|
|||
|
|
synth.speak(utterance);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 停止朗读
|
|||
|
|
*/
|
|||
|
|
function stopSpeaking() {
|
|||
|
|
if (synth && synth.speaking) {
|
|||
|
|
synth.cancel();
|
|||
|
|
isSpeaking = false;
|
|||
|
|
document.getElementById('speakBtn').disabled = false;
|
|||
|
|
document.getElementById('stopBtn').disabled = true;
|
|||
|
|
updateStatus('info', '⏹️ 已停止朗读');
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 清空文本
|
|||
|
|
*/
|
|||
|
|
function clearText() {
|
|||
|
|
document.getElementById('textInput').value = '';
|
|||
|
|
stopSpeaking();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 设置预设文本
|
|||
|
|
*/
|
|||
|
|
function setPresetText(text) {
|
|||
|
|
document.getElementById('textInput').value = text;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 更新状态提示
|
|||
|
|
*/
|
|||
|
|
function updateStatus(type, message) {
|
|||
|
|
const statusEl = document.getElementById('status');
|
|||
|
|
statusEl.className = 'status ' + type;
|
|||
|
|
statusEl.textContent = message;
|
|||
|
|
}
|
|||
|
|
</script>
|
|||
|
|
</body>
|
|||
|
|
</html>
|
|||
|
|
|