测试版
This commit is contained in:
parent
598c589c69
commit
af259fa946
317
app.py
Normal file
317
app.py
Normal file
@ -0,0 +1,317 @@
|
|||||||
|
from flask import Flask, render_template, request, jsonify
|
||||||
|
import subprocess
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
import json
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
CACHE_DIR = "cache"
|
||||||
|
MAX_CACHE_FILES = 10
|
||||||
|
METADATA_FILE = os.path.join(CACHE_DIR, 'metadata.json')
|
||||||
|
|
||||||
|
voices = {
|
||||||
|
"晓晓(温暖女声)": "zh-CN-XiaoxiaoNeural",
|
||||||
|
"晓伊(活泼女声)": "zh-CN-XiaoyiNeural",
|
||||||
|
"云健(激情男声)": "zh-CN-YunjianNeural",
|
||||||
|
"云希(阳光男声)": "zh-CN-YunxiNeural",
|
||||||
|
"云夏(可爱男声)": "zh-CN-YunxiaNeural",
|
||||||
|
"云扬(新闻男声)": "zh-CN-YunyangNeural",
|
||||||
|
|
||||||
|
"辽宁-小贝(方言)": "zh-CN-liaoning-XiaobeiNeural",
|
||||||
|
"陕西-小妮(方言)": "zh-CN-shaanxi-XiaoniNeural",
|
||||||
|
|
||||||
|
"香港-晓佳": "zh-HK-HiuGaaiNeural",
|
||||||
|
"香港-晓曼": "zh-HK-HiuMaanNeural",
|
||||||
|
"香港-云龙": "zh-HK-WanLungNeural",
|
||||||
|
|
||||||
|
"台湾-晓陈": "zh-TW-HsiaoChenNeural",
|
||||||
|
"台湾-晓雨": "zh-TW-HsiaoYuNeural",
|
||||||
|
"台湾-云哲": "zh-TW-YunJheNeural"
|
||||||
|
}
|
||||||
|
|
||||||
|
current_process = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_metadata():
|
||||||
|
if not os.path.exists(METADATA_FILE):
|
||||||
|
return {}
|
||||||
|
with open(METADATA_FILE, 'r', encoding='utf-8') as f:
|
||||||
|
return json.load(f)
|
||||||
|
|
||||||
|
|
||||||
|
def save_metadata(metadata):
|
||||||
|
with open(METADATA_FILE, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(metadata, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
def clean_cache():
|
||||||
|
if not os.path.exists(CACHE_DIR):
|
||||||
|
os.makedirs(CACHE_DIR)
|
||||||
|
return
|
||||||
|
|
||||||
|
files = []
|
||||||
|
metadata = get_metadata()
|
||||||
|
for f in os.listdir(CACHE_DIR):
|
||||||
|
filepath = os.path.join(CACHE_DIR, f)
|
||||||
|
if os.path.isfile(filepath) and f.endswith('.mp3'):
|
||||||
|
files.append({
|
||||||
|
'filepath': filepath,
|
||||||
|
'filename': f,
|
||||||
|
'created_at': os.path.getctime(filepath),
|
||||||
|
'is_favorite': metadata.get(f, {}).get('is_favorite', False)
|
||||||
|
})
|
||||||
|
|
||||||
|
total_files = len(files)
|
||||||
|
if total_files >= MAX_CACHE_FILES:
|
||||||
|
files.sort(key=lambda x: x['created_at'])
|
||||||
|
|
||||||
|
files_to_delete = []
|
||||||
|
non_fav_files = [f for f in files if not f['is_favorite']]
|
||||||
|
|
||||||
|
num_files_to_delete = total_files - MAX_CACHE_FILES + 1
|
||||||
|
|
||||||
|
if len(non_fav_files) >= num_files_to_delete:
|
||||||
|
files_to_delete = non_fav_files[:num_files_to_delete]
|
||||||
|
elif len(non_fav_files) > 0:
|
||||||
|
files_to_delete = non_fav_files
|
||||||
|
|
||||||
|
for file_info in files_to_delete:
|
||||||
|
filename = file_info['filename']
|
||||||
|
if filename in metadata:
|
||||||
|
del metadata[filename]
|
||||||
|
os.remove(file_info['filepath'])
|
||||||
|
|
||||||
|
if files_to_delete:
|
||||||
|
save_metadata(metadata)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/')
|
||||||
|
def index():
|
||||||
|
return render_template('index.html', voices=voices)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/speak', methods=['POST'])
|
||||||
|
def speak():
|
||||||
|
global current_process
|
||||||
|
|
||||||
|
text = request.form.get('text')
|
||||||
|
voice = request.form.get('voice')
|
||||||
|
loop = request.form.get('loop') == 'true'
|
||||||
|
interval = int(request.form.get('interval', 5))
|
||||||
|
|
||||||
|
if not text:
|
||||||
|
return jsonify({'status': 'error', 'message': '请输入广播内容'})
|
||||||
|
|
||||||
|
clean_cache()
|
||||||
|
|
||||||
|
filename = f"{uuid.uuid4()}.mp3"
|
||||||
|
filepath = os.path.join(CACHE_DIR, filename)
|
||||||
|
|
||||||
|
voice_name = voices.get(voice, 'zh-CN-YunxiNeural')
|
||||||
|
|
||||||
|
subprocess.run([
|
||||||
|
'python3',
|
||||||
|
'-m',
|
||||||
|
'edge_tts',
|
||||||
|
'--voice',
|
||||||
|
voice_name,
|
||||||
|
'--text',
|
||||||
|
text,
|
||||||
|
'--write-media',
|
||||||
|
filepath
|
||||||
|
])
|
||||||
|
|
||||||
|
metadata = get_metadata()
|
||||||
|
display_name = text[:30] + '...' if len(text) > 30 else text
|
||||||
|
metadata[filename] = {
|
||||||
|
'display_name': display_name,
|
||||||
|
'text': text,
|
||||||
|
'voice': voice,
|
||||||
|
'is_favorite': False
|
||||||
|
}
|
||||||
|
save_metadata(metadata)
|
||||||
|
|
||||||
|
if current_process:
|
||||||
|
current_process.kill()
|
||||||
|
|
||||||
|
subprocess.run(['pkill', '-9', 'mpg123'])
|
||||||
|
|
||||||
|
if loop:
|
||||||
|
current_process = subprocess.Popen([
|
||||||
|
'bash',
|
||||||
|
'player.sh',
|
||||||
|
filepath,
|
||||||
|
str(interval)
|
||||||
|
])
|
||||||
|
else:
|
||||||
|
current_process = subprocess.Popen([
|
||||||
|
'mpg123',
|
||||||
|
'-o',
|
||||||
|
'alsa',
|
||||||
|
'-a',
|
||||||
|
'plughw:audiocodec,0',
|
||||||
|
filepath
|
||||||
|
])
|
||||||
|
|
||||||
|
return jsonify({'status': 'ok', 'filename': filename})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/stop')
|
||||||
|
def stop():
|
||||||
|
global current_process
|
||||||
|
|
||||||
|
if current_process:
|
||||||
|
current_process.kill()
|
||||||
|
current_process = None
|
||||||
|
|
||||||
|
subprocess.run(['pkill', '-9', 'mpg123'])
|
||||||
|
|
||||||
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/files')
|
||||||
|
def list_files():
|
||||||
|
metadata = get_metadata()
|
||||||
|
files = []
|
||||||
|
|
||||||
|
for f in os.listdir(CACHE_DIR):
|
||||||
|
if f.endswith('.mp3'):
|
||||||
|
filepath = os.path.join(CACHE_DIR, f)
|
||||||
|
if os.path.isfile(filepath):
|
||||||
|
info = metadata.get(f, {'display_name': f, 'text': '', 'voice': ''})
|
||||||
|
files.append({
|
||||||
|
'filename': f,
|
||||||
|
'display_name': info['display_name'],
|
||||||
|
'text': info.get('text', ''),
|
||||||
|
'voice': info.get('voice', ''),
|
||||||
|
'is_favorite': info.get('is_favorite', False),
|
||||||
|
'created_at': os.path.getctime(filepath)
|
||||||
|
})
|
||||||
|
|
||||||
|
files.sort(key=lambda x: (not x['is_favorite'], -x['created_at']))
|
||||||
|
return jsonify({'files': files})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/play-file', methods=['POST'])
|
||||||
|
def play_file():
|
||||||
|
global current_process
|
||||||
|
|
||||||
|
filename = request.form.get('filename')
|
||||||
|
loop = request.form.get('loop') == 'true'
|
||||||
|
interval = int(request.form.get('interval', 5))
|
||||||
|
|
||||||
|
if not filename:
|
||||||
|
return jsonify({'status': 'error', 'message': '请选择文件'})
|
||||||
|
|
||||||
|
filepath = os.path.join(CACHE_DIR, filename)
|
||||||
|
|
||||||
|
if not os.path.exists(filepath):
|
||||||
|
return jsonify({'status': 'error', 'message': '文件不存在'})
|
||||||
|
|
||||||
|
if current_process:
|
||||||
|
current_process.kill()
|
||||||
|
|
||||||
|
subprocess.run(['pkill', '-9', 'mpg123'])
|
||||||
|
|
||||||
|
if loop:
|
||||||
|
current_process = subprocess.Popen([
|
||||||
|
'bash',
|
||||||
|
'player.sh',
|
||||||
|
filepath,
|
||||||
|
str(interval)
|
||||||
|
])
|
||||||
|
else:
|
||||||
|
current_process = subprocess.Popen([
|
||||||
|
'mpg123',
|
||||||
|
'-o',
|
||||||
|
'alsa',
|
||||||
|
'-a',
|
||||||
|
'plughw:audiocodec,0',
|
||||||
|
filepath
|
||||||
|
])
|
||||||
|
|
||||||
|
metadata = get_metadata()
|
||||||
|
display_name = metadata.get(filename, {}).get('display_name', filename)
|
||||||
|
|
||||||
|
return jsonify({'status': 'ok', 'message': f'正在播放: {display_name}'})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/rename-file', methods=['POST'])
|
||||||
|
def rename_file():
|
||||||
|
filename = request.form.get('filename')
|
||||||
|
new_name = request.form.get('new_name')
|
||||||
|
|
||||||
|
if not filename or not new_name:
|
||||||
|
return jsonify({'status': 'error', 'message': '参数错误'})
|
||||||
|
|
||||||
|
metadata = get_metadata()
|
||||||
|
|
||||||
|
if filename not in metadata:
|
||||||
|
return jsonify({'status': 'error', 'message': '文件不存在'})
|
||||||
|
|
||||||
|
metadata[filename]['display_name'] = new_name
|
||||||
|
save_metadata(metadata)
|
||||||
|
|
||||||
|
return jsonify({'status': 'ok', 'message': '重命名成功'})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/toggle-favorite', methods=['POST'])
|
||||||
|
def toggle_favorite():
|
||||||
|
filename = request.form.get('filename')
|
||||||
|
|
||||||
|
if not filename:
|
||||||
|
return jsonify({'status': 'error', 'message': '参数错误'})
|
||||||
|
|
||||||
|
metadata = get_metadata()
|
||||||
|
|
||||||
|
if filename not in metadata:
|
||||||
|
return jsonify({'status': 'error', 'message': '文件不存在'})
|
||||||
|
|
||||||
|
metadata[filename]['is_favorite'] = not metadata[filename].get('is_favorite', False)
|
||||||
|
save_metadata(metadata)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'ok',
|
||||||
|
'is_favorite': metadata[filename]['is_favorite'],
|
||||||
|
'message': '收藏成功' if metadata[filename]['is_favorite'] else '取消收藏成功'
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/delete-file', methods=['POST'])
|
||||||
|
def delete_file():
|
||||||
|
global current_process
|
||||||
|
|
||||||
|
filename = request.form.get('filename')
|
||||||
|
|
||||||
|
if not filename:
|
||||||
|
return jsonify({'status': 'error', 'message': '请选择文件'})
|
||||||
|
|
||||||
|
filepath = os.path.join(CACHE_DIR, filename)
|
||||||
|
|
||||||
|
if not os.path.exists(filepath):
|
||||||
|
return jsonify({'status': 'error', 'message': '文件不存在'})
|
||||||
|
|
||||||
|
if current_process:
|
||||||
|
try:
|
||||||
|
current_process.kill()
|
||||||
|
current_process = None
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
subprocess.run(['pkill', '-9', 'mpg123'])
|
||||||
|
|
||||||
|
os.remove(filepath)
|
||||||
|
|
||||||
|
metadata = get_metadata()
|
||||||
|
if filename in metadata:
|
||||||
|
del metadata[filename]
|
||||||
|
save_metadata(metadata)
|
||||||
|
|
||||||
|
return jsonify({'status': 'ok', 'message': '删除成功'})
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
if not os.path.exists(CACHE_DIR):
|
||||||
|
os.makedirs(CACHE_DIR)
|
||||||
|
app.run(host='0.0.0.0', port=5000)
|
||||||
7
cache/metadata.json
vendored
Normal file
7
cache/metadata.json
vendored
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"13dd99c6-415a-4ac0-9cec-ed2246224a81.mp3": {
|
||||||
|
"display_name": "12345",
|
||||||
|
"text": "12345",
|
||||||
|
"voice": "晓晓(温暖女声)"
|
||||||
|
}
|
||||||
|
}
|
||||||
10
player.sh
Normal file
10
player.sh
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
FILE=$1
|
||||||
|
INTERVAL=${2:-5}
|
||||||
|
|
||||||
|
while true
|
||||||
|
do
|
||||||
|
mpg123 -o alsa -a plughw:audiocodec,0 "$FILE"
|
||||||
|
sleep "$INTERVAL"
|
||||||
|
done
|
||||||
652
static/style.css
Normal file
652
static/style.css
Normal file
@ -0,0 +1,652 @@
|
|||||||
|
:root {
|
||||||
|
--primary-color: #0078ff;
|
||||||
|
--primary-hover: #0066d4;
|
||||||
|
--danger-color: #ff3b30;
|
||||||
|
--danger-hover: #e6352a;
|
||||||
|
--secondary-color: #6b7280;
|
||||||
|
--secondary-hover: #4b5563;
|
||||||
|
|
||||||
|
--bg-primary: #101010;
|
||||||
|
--bg-secondary: #1b1b1b;
|
||||||
|
--bg-tertiary: #252525;
|
||||||
|
--bg-input: #252525;
|
||||||
|
|
||||||
|
--text-primary: #ffffff;
|
||||||
|
--text-secondary: #a0a0a0;
|
||||||
|
--text-muted: #6b7280;
|
||||||
|
|
||||||
|
--border-color: #333333;
|
||||||
|
--border-radius: 12px;
|
||||||
|
--border-radius-lg: 16px;
|
||||||
|
|
||||||
|
--shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||||
|
--shadow-lg: 0 8px 30px rgba(0, 0, 0, 0.4);
|
||||||
|
|
||||||
|
--transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 20px;
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.light-theme {
|
||||||
|
--bg-primary: #f5f5f5;
|
||||||
|
--bg-secondary: #ffffff;
|
||||||
|
--bg-tertiary: #f0f0f0;
|
||||||
|
--bg-input: #ffffff;
|
||||||
|
|
||||||
|
--text-primary: #1a1a1a;
|
||||||
|
--text-secondary: #4a4a4a;
|
||||||
|
--text-muted: #888888;
|
||||||
|
|
||||||
|
--border-color: #e0e0e0;
|
||||||
|
--shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
|
||||||
|
--shadow-lg: 0 8px 30px rgba(0, 0, 0, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h1 {
|
||||||
|
font-size: 1.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
background: linear-gradient(135deg, var(--primary-color), #00d4ff);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-toggle {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-toggle:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: var(--border-radius-lg);
|
||||||
|
padding: 24px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
height: 140px;
|
||||||
|
background: var(--bg-input);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
padding: 14px;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
resize: none;
|
||||||
|
transition: var(--transition);
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
box-shadow: 0 0 0 3px rgba(0, 120, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea::placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.char-count {
|
||||||
|
display: block;
|
||||||
|
text-align: right;
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--bg-input);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
padding: 12px 14px;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition);
|
||||||
|
appearance: none;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20' viewBox='0 0 24 24' fill='none' stroke='%236b7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right 12px center;
|
||||||
|
}
|
||||||
|
|
||||||
|
select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
box-shadow: 0 0 0 3px rgba(0, 120, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
select option {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 12px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label:hover {
|
||||||
|
background: var(--bg-input);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label input[type="checkbox"] {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
accent-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label span {
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.interval-group {
|
||||||
|
animation: fadeIn 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.interval-input {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.interval-input input[type="range"] {
|
||||||
|
flex: 1;
|
||||||
|
height: 8px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: 4px;
|
||||||
|
outline: none;
|
||||||
|
cursor: pointer;
|
||||||
|
appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.interval-input input[type="range"]::-webkit-slider-thumb {
|
||||||
|
appearance: none;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
background: var(--primary-color);
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.interval-input input[type="range"]::-webkit-slider-thumb:hover {
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.interval-input span#interval-value {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--primary-color);
|
||||||
|
min-width: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.interval-input .unit {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: 14px 20px;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: linear-gradient(135deg, var(--primary-color), #0099ff);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: linear-gradient(135deg, var(--primary-hover), #0088e6);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 15px rgba(0, 120, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: linear-gradient(135deg, var(--danger-color), #ff5a50);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover {
|
||||||
|
background: linear-gradient(135deg, var(--danger-hover), #ff4a40);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 15px rgba(255, 59, 48, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: var(--secondary-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-small {
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-container {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: var(--border-radius-lg);
|
||||||
|
padding: 20px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-btn {
|
||||||
|
padding: 8px;
|
||||||
|
min-width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-header h3 {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-list {
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-list::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-list::-webkit-scrollbar-track {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-list::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--border-color);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 30px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state p {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
padding: 12px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item-favorite {
|
||||||
|
border: 2px solid #fbbf24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item:hover {
|
||||||
|
background: var(--bg-input);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item-favorite:hover {
|
||||||
|
border-color: #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-icon {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-details {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-name-container {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-name-input {
|
||||||
|
width: 100%;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 4px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-name-input:hover {
|
||||||
|
background: var(--bg-input);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-name-input:focus {
|
||||||
|
background: var(--bg-input);
|
||||||
|
box-shadow: 0 0 0 2px var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-voice {
|
||||||
|
padding: 2px 6px;
|
||||||
|
background: var(--bg-input);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-btn {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-input);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-btn:hover {
|
||||||
|
background: var(--border-color);
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-btn-play:hover {
|
||||||
|
background: #10b981;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-btn-loop:hover {
|
||||||
|
background: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-btn-favorite {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-btn-favorite:hover {
|
||||||
|
background: #fbbf24;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-btn-favorite.active {
|
||||||
|
background: #fbbf24;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-btn-favorite.active:hover {
|
||||||
|
background: #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-btn-delete:hover {
|
||||||
|
background: var(--danger-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-container {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: var(--border-radius-lg);
|
||||||
|
padding: 20px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-header h3 {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-btn {
|
||||||
|
padding: 8px;
|
||||||
|
min-width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-content {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
padding: 14px;
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
font-family: 'Consolas', 'Monaco', monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-content::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-content::-webkit-scrollbar-track {
|
||||||
|
background: var(--bg-input);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-content::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--border-color);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-content p {
|
||||||
|
margin: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-time {
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-info {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-success {
|
||||||
|
color: #10b981;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-error {
|
||||||
|
color: var(--danger-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
body {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h1 {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-toggle {
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
height: 120px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-group {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-content {
|
||||||
|
max-height: 150px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.container {
|
||||||
|
max-width: 700px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
padding: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
height: 180px;
|
||||||
|
}
|
||||||
|
}
|
||||||
452
templates/index.html
Normal file
452
templates/index.html
Normal file
@ -0,0 +1,452 @@
|
|||||||
|
<!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>
|
||||||
Loading…
x
Reference in New Issue
Block a user