452 lines
17 KiB
HTML
452 lines
17 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>
|
|
<link rel="stylesheet" href="/static/style.css">
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<div class="header">
|
|
<h1>📢 Web广播系统</h1>
|
|
<button class="theme-toggle" onclick="toggleTheme()" title="切换主题">
|
|
<span id="theme-icon">🌙</span>
|
|
</button>
|
|
</div>
|
|
|
|
<div class="main-content">
|
|
<div class="form-group">
|
|
<textarea
|
|
id="text"
|
|
placeholder="输入广播内容..."
|
|
maxlength="500"
|
|
></textarea>
|
|
<span class="char-count">0/500</span>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="voice">选择声音</label>
|
|
<select id="voice">
|
|
{% for k,v in voices.items() %}
|
|
<option>{{ k }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label class="checkbox-label">
|
|
<input type="checkbox" id="loop" onchange="toggleInterval()">
|
|
<span>循环播放</span>
|
|
</label>
|
|
</div>
|
|
|
|
<div class="form-group interval-group" id="interval-group" style="display: none;">
|
|
<label for="interval">循环间隔时间</label>
|
|
<div class="interval-input">
|
|
<input
|
|
type="range"
|
|
id="interval"
|
|
min="1"
|
|
max="60"
|
|
value="5"
|
|
>
|
|
<span id="interval-value">5</span>
|
|
<span class="unit">秒</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="button-group">
|
|
<button class="btn btn-primary" onclick="speak()">
|
|
<span class="btn-icon">▶️</span>
|
|
开始广播
|
|
</button>
|
|
<button class="btn btn-danger" onclick="stopPlay()">
|
|
<span class="btn-icon">⏹️</span>
|
|
停止播放
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="files-container">
|
|
<div class="files-header">
|
|
<button class="btn btn-secondary btn-small refresh-btn" onclick="loadFiles()" title="刷新列表">
|
|
🔄刷新列表
|
|
</button>
|
|
<h3>语音文件列表</h3>
|
|
</div>
|
|
<div id="files-list" class="files-list">
|
|
<div class="empty-state">
|
|
<span class="empty-icon">📭</span>
|
|
<p>暂无语音文件</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="logs-container">
|
|
<div class="logs-header">
|
|
<button class="btn btn-secondary btn-small clear-btn" onclick="clearLogs()" title="清空日志">
|
|
🗑️清空日志
|
|
</button>
|
|
<h3>运行日志</h3>
|
|
</div>
|
|
<div id="logs" class="logs-content">
|
|
<p class="log-info">系统已就绪,等待操作...</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
const themeKey = 'broadcast-theme';
|
|
const darkIcon = '🌙';
|
|
const lightIcon = '☀️';
|
|
|
|
function initTheme() {
|
|
const savedTheme = localStorage.getItem(themeKey);
|
|
if (savedTheme === 'light' || (!savedTheme && window.matchMedia('(prefers-color-scheme: light)').matches)) {
|
|
setLightTheme();
|
|
} else {
|
|
setDarkTheme();
|
|
}
|
|
}
|
|
|
|
function setDarkTheme() {
|
|
document.body.classList.add('dark-theme');
|
|
document.body.classList.remove('light-theme');
|
|
document.getElementById('theme-icon').textContent = darkIcon;
|
|
localStorage.setItem(themeKey, 'dark');
|
|
}
|
|
|
|
function setLightTheme() {
|
|
document.body.classList.add('light-theme');
|
|
document.body.classList.remove('dark-theme');
|
|
document.getElementById('theme-icon').textContent = lightIcon;
|
|
localStorage.setItem(themeKey, 'light');
|
|
}
|
|
|
|
function toggleTheme() {
|
|
if (document.body.classList.contains('dark-theme')) {
|
|
setLightTheme();
|
|
} else {
|
|
setDarkTheme();
|
|
}
|
|
}
|
|
|
|
function toggleInterval() {
|
|
const intervalGroup = document.getElementById('interval-group');
|
|
const loopChecked = document.getElementById('loop').checked;
|
|
intervalGroup.style.display = loopChecked ? 'block' : 'none';
|
|
}
|
|
|
|
function addLog(message, type = 'info') {
|
|
const logsContainer = document.getElementById('logs');
|
|
const logEntry = document.createElement('p');
|
|
logEntry.className = `log-${type}`;
|
|
const timestamp = new Date().toLocaleTimeString('zh-CN');
|
|
logEntry.innerHTML = `<span class="log-time">[${timestamp}]</span> ${message}`;
|
|
logsContainer.appendChild(logEntry);
|
|
logsContainer.scrollTop = logsContainer.scrollHeight;
|
|
}
|
|
|
|
function clearLogs() {
|
|
const logsContainer = document.getElementById('logs');
|
|
logsContainer.innerHTML = '<p class="log-info">系统已就绪,等待操作...</p>';
|
|
}
|
|
|
|
function updateCharCount() {
|
|
const textarea = document.getElementById('text');
|
|
const count = textarea.value.length;
|
|
document.querySelector('.char-count').textContent = `${count}/500`;
|
|
}
|
|
|
|
async function speak() {
|
|
const text = document.getElementById('text').value.trim();
|
|
const voice = document.getElementById('voice').value;
|
|
const loop = document.getElementById('loop').checked;
|
|
const interval = document.getElementById('interval').value;
|
|
|
|
if (!text) {
|
|
addLog('请输入广播内容', 'error');
|
|
return;
|
|
}
|
|
|
|
addLog('开始处理广播请求...', 'info');
|
|
|
|
const formData = new FormData();
|
|
formData.append('text', text);
|
|
formData.append('voice', voice);
|
|
formData.append('loop', loop);
|
|
formData.append('interval', interval);
|
|
|
|
addLog(`正在生成语音,使用声音: ${voice}`, 'info');
|
|
|
|
if (loop) {
|
|
addLog(`循环模式已启用,间隔时间: ${interval}秒`, 'info');
|
|
}
|
|
|
|
try {
|
|
addLog('正在调用语音合成服务...', 'info');
|
|
const response = await fetch('/speak', {
|
|
method: 'POST',
|
|
body: formData
|
|
});
|
|
|
|
const result = await response.json();
|
|
|
|
if (result.status === 'ok') {
|
|
addLog('语音合成成功,开始播放...', 'success');
|
|
loadFiles();
|
|
} else {
|
|
addLog(`播放失败: ${result.message || '未知错误'}`, 'error');
|
|
}
|
|
} catch (error) {
|
|
addLog(`请求失败: ${error.message}`, 'error');
|
|
}
|
|
}
|
|
|
|
async function stopPlay() {
|
|
addLog('正在停止播放...', 'info');
|
|
try {
|
|
await fetch('/stop');
|
|
addLog('播放已停止', 'success');
|
|
} catch (error) {
|
|
addLog(`停止失败: ${error.message}`, 'error');
|
|
}
|
|
}
|
|
|
|
async function loadFiles() {
|
|
try {
|
|
const response = await fetch('/files');
|
|
const result = await response.json();
|
|
renderFiles(result.files);
|
|
} catch (error) {
|
|
addLog(`加载文件列表失败: ${error.message}`, 'error');
|
|
}
|
|
}
|
|
|
|
function renderFiles(files) {
|
|
const container = document.getElementById('files-list');
|
|
|
|
if (!files || files.length === 0) {
|
|
container.innerHTML = `
|
|
<div class="empty-state">
|
|
<span class="empty-icon">📭</span>
|
|
<p>暂无语音文件</p>
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = files.map(file => `
|
|
<div class="file-item ${file.is_favorite ? 'file-item-favorite' : ''}">
|
|
<div class="file-info">
|
|
<div class="file-icon">🎵</div>
|
|
<div class="file-details">
|
|
<div class="file-name-container">
|
|
<input
|
|
type="text"
|
|
class="file-name-input"
|
|
value="${escapeHtml(file.display_name)}"
|
|
data-filename="${file.filename}"
|
|
onblur="saveFileName(this)"
|
|
onkeyup="handleFileNameKeyup(event, this)"
|
|
/>
|
|
</div>
|
|
<div class="file-meta">
|
|
<span class="file-voice">${file.voice || '未知声音'}</span>
|
|
<span class="file-date">${formatDate(file.created_at)}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="file-actions">
|
|
<button class="file-btn file-btn-favorite ${file.is_favorite ? 'active' : ''}" onclick="toggleFavorite('${file.filename}')" title="${file.is_favorite ? '取消收藏' : '收藏'}">
|
|
${file.is_favorite ? '⭐' : '☆'}
|
|
</button>
|
|
<button class="file-btn file-btn-play" onclick="playFile('${file.filename}')" title="播放">
|
|
▶️
|
|
</button>
|
|
<button class="file-btn file-btn-loop" onclick="playFileLoop('${file.filename}')" title="循环播放">
|
|
🔄
|
|
</button>
|
|
<button class="file-btn file-btn-delete" onclick="deleteFile('${file.filename}', '${escapeHtml(file.display_name)}')" title="删除">
|
|
🗑️
|
|
</button>
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
function escapeHtml(str) {
|
|
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
}
|
|
|
|
function formatDate(timestamp) {
|
|
const date = new Date(timestamp * 1000);
|
|
return date.toLocaleString('zh-CN', {
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
hour: '2-digit',
|
|
minute: '2-digit'
|
|
});
|
|
}
|
|
|
|
async function playFile(filename) {
|
|
const loop = document.getElementById('loop').checked;
|
|
const interval = document.getElementById('interval').value;
|
|
|
|
addLog(`正在播放已保存的语音...`, 'info');
|
|
|
|
const formData = new FormData();
|
|
formData.append('filename', filename);
|
|
formData.append('loop', loop);
|
|
formData.append('interval', interval);
|
|
|
|
try {
|
|
const response = await fetch('/play-file', {
|
|
method: 'POST',
|
|
body: formData
|
|
});
|
|
const result = await response.json();
|
|
|
|
if (result.status === 'ok') {
|
|
addLog(result.message, 'success');
|
|
} else {
|
|
addLog(`播放失败: ${result.message}`, 'error');
|
|
}
|
|
} catch (error) {
|
|
addLog(`播放失败: ${error.message}`, 'error');
|
|
}
|
|
}
|
|
|
|
async function playFileLoop(filename) {
|
|
const interval = document.getElementById('interval').value;
|
|
|
|
addLog(`正在循环播放已保存的语音...`, 'info');
|
|
|
|
const formData = new FormData();
|
|
formData.append('filename', filename);
|
|
formData.append('loop', true);
|
|
formData.append('interval', interval);
|
|
|
|
try {
|
|
const response = await fetch('/play-file', {
|
|
method: 'POST',
|
|
body: formData
|
|
});
|
|
const result = await response.json();
|
|
|
|
if (result.status === 'ok') {
|
|
addLog(`${result.message},间隔时间: ${interval}秒`, 'success');
|
|
} else {
|
|
addLog(`播放失败: ${result.message}`, 'error');
|
|
}
|
|
} catch (error) {
|
|
addLog(`播放失败: ${error.message}`, 'error');
|
|
}
|
|
}
|
|
|
|
async function saveFileName(input) {
|
|
const filename = input.dataset.filename;
|
|
const newName = input.value.trim();
|
|
|
|
if (!newName) {
|
|
input.value = input.oldValue || filename;
|
|
return;
|
|
}
|
|
|
|
if (newName === input.oldValue) return;
|
|
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append('filename', filename);
|
|
formData.append('new_name', newName);
|
|
|
|
const response = await fetch('/rename-file', {
|
|
method: 'POST',
|
|
body: formData
|
|
});
|
|
const result = await response.json();
|
|
|
|
if (result.status === 'ok') {
|
|
addLog(`文件已重命名为: ${newName}`, 'success');
|
|
} else {
|
|
addLog(`重命名失败: ${result.message}`, 'error');
|
|
input.value = input.oldValue;
|
|
}
|
|
} catch (error) {
|
|
addLog(`重命名失败: ${error.message}`, 'error');
|
|
input.value = input.oldValue;
|
|
}
|
|
}
|
|
|
|
function handleFileNameKeyup(event, input) {
|
|
if (!input.oldValue) {
|
|
input.oldValue = input.value;
|
|
}
|
|
|
|
if (event.key === 'Enter') {
|
|
input.blur();
|
|
} else if (event.key === 'Escape') {
|
|
input.value = input.oldValue;
|
|
input.blur();
|
|
}
|
|
}
|
|
|
|
async function toggleFavorite(filename) {
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append('filename', filename);
|
|
|
|
const response = await fetch('/toggle-favorite', {
|
|
method: 'POST',
|
|
body: formData
|
|
});
|
|
const result = await response.json();
|
|
|
|
if (result.status === 'ok') {
|
|
addLog(result.message, 'success');
|
|
loadFiles();
|
|
} else {
|
|
addLog(`操作失败: ${result.message}`, 'error');
|
|
}
|
|
} catch (error) {
|
|
addLog(`操作失败: ${error.message}`, 'error');
|
|
}
|
|
}
|
|
|
|
async function deleteFile(filename, displayName) {
|
|
if (!confirm(`确定要删除文件 "${displayName}" 吗?`)) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append('filename', filename);
|
|
|
|
const response = await fetch('/delete-file', {
|
|
method: 'POST',
|
|
body: formData
|
|
});
|
|
const result = await response.json();
|
|
|
|
if (result.status === 'ok') {
|
|
addLog(`文件已删除: ${displayName}`, 'success');
|
|
loadFiles();
|
|
} else {
|
|
addLog(`删除失败: ${result.message}`, 'error');
|
|
}
|
|
} catch (error) {
|
|
addLog(`删除失败: ${error.message}`, 'error');
|
|
}
|
|
}
|
|
|
|
document.getElementById('text').addEventListener('input', updateCharCount);
|
|
document.getElementById('interval').addEventListener('input', function() {
|
|
document.getElementById('interval-value').textContent = this.value;
|
|
});
|
|
|
|
initTheme();
|
|
loadFiles();
|
|
</script>
|
|
</body>
|
|
</html> |