feat: complete local version to overwrite remote

This commit is contained in:
DelLevin-Home
2026-06-16 03:30:57 +08:00
parent 1735c19f48
commit 3c78293f4d
129 changed files with 22814 additions and 0 deletions

View File

@@ -0,0 +1,47 @@
{
"permissions": {
"allow": [
"Bash(curl -L -o webfonts/fa-solid-900.woff2 \"https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/webfonts/fa-solid-900.woff2\")",
"Bash(curl -L -o webfonts/fa-solid-900.ttf \"https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/webfonts/fa-solid-900.ttf\")",
"Bash(curl -L -o fa-solid-900.ttf \"https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/webfonts/fa-solid-900.ttf\")",
"Bash(mv static/webfonts/static/webfonts/* static/webfonts/)",
"Bash(rm -rf static/webfonts/static)",
"Bash(cd /d/UserData/Desktop/my_proj/py_demo/python_script/flask-dev-api && ls -la static/webfonts/)",
"Bash(python -c ' *)",
"Bash(curl -s -X POST \"http://127.0.0.1:8080/v1/chat/completions\" -H \"Content-Type: application/json\" -d '{\"model\":\"qwen3:1.7b\",\"messages\":[{\"role\":\"user\",\"content\":\"你好,回复两个字\"}],\"max_tokens\":50}' --connect-timeout 5)",
"Bash(curl -L -o echarts.min.js \"https://cdn.jsdelivr.net/npm/echarts@5.5.1/dist/echarts.min.js\")",
"mcp__searxng__searxng_web_search",
"WebFetch(domain:raw.githubusercontent.com)",
"WebFetch(domain:www.oschina.net)",
"Bash(ls \"D:/UserData/Desktop/my_proj/it-tools\")",
"Bash(ls \"D:/UserData/Desktop/my_proj/it-tools/src\" 2>/dev/null || echo \"No src directory\")",
"Bash(curl -sL \"https://cdn.jsdelivr.net/npm/qrcode-generator@1.4.4/qrcode.min.js\" -o \"D:/UserData/Desktop/my_proj/py_demo/python_script/flask-dev-api/static/qrcode.min.js\")",
"Bash(ls -la \"D:/UserData/Desktop/xiazai/\" 2>/dev/null | head -20)",
"Bash(python -c \"import py_compile; py_compile.compile\\('blueprints/down_image.py', doraise=True\\); print\\('OK'\\)\")",
"Bash(python -c \"import py_compile; py_compile.compile\\('app.py', doraise=True\\); py_compile.compile\\('blueprints/__init__.py', doraise=True\\); print\\('OK'\\)\")",
"Bash(pip install *)",
"Bash(python -c \"import py_compile; py_compile.compile\\('app.py', doraise=True\\); py_compile.compile\\('config.py', doraise=True\\); py_compile.compile\\('utils/stats_db.py', doraise=True\\); py_compile.compile\\('blueprints/sovits_tts.py', doraise=True\\); print\\('All OK'\\)\")",
"Bash(python -c \"from app import create_app; app = create_app\\(\\); print\\('Flask app OK, routes:', len\\(app.url_map._rules\\)\\)\")",
"Bash(python -c \"import webview; print\\('pywebview', webview.__version__, 'OK'\\)\")",
"Bash(python -c \"import webview; print\\('pywebview OK'\\); print\\(dir\\(webview\\)[:10]\\)\")",
"Bash(python -c \"from config import BASE_DIR; print\\('BASE_DIR:', BASE_DIR\\)\")",
"Bash(python -c \"import run_desktop; print\\('run_desktop imports OK'\\)\")",
"Bash(python -c \"import py_compile; py_compile.compile\\('blueprints/sovits_tts.py', doraise=True\\); print\\('OK'\\)\")",
"Bash(python -c \"import ctranslate2; print\\('version:', ctranslate2.__version__\\); print\\('has get_cuda_device_count:', hasattr\\(ctranslate2, 'get_cuda_device_count'\\)\\)\")",
"Bash(where nvcc *)",
"Bash(dir \"C:\\\\Program Files\\\\NVIDIA GPU Computing Toolkit\\\\CUDA\")",
"Bash(python -c \"import torch; print\\('torch OK:', torch.__version__, torch.cuda.is_available\\(\\)\\)\")",
"Bash(python -c \"import torchaudio; print\\('torchaudio OK:', torchaudio.__version__\\)\")",
"Bash(python -c \"import torchaudio; print\\(torchaudio.__version__\\)\")",
"Bash(pip show *)",
"Bash(findstr \"Version\")",
"Bash(cd /d \"E:\\\\AI\\\\GPT-SoVITS-v4\")",
"Bash(python -c \"from GPT_SoVITS.TTS_infer_pack.TTS import TTS, TTS_Config; print\\('OK'\\)\")",
"Bash(dir \"E:\\\\AI\\\\GPT-SoVITS-v4\\\\requirements*.txt\")",
"Bash(where python *)",
"Bash(conda env *)",
"Bash(\"E:/AI/GPT-SoVITS-v4/runtime/python.exe\" \"D:/UserData/Desktop/my_proj/py_demo/python_script/flask-dev-api/utils/sovits_worker.py\" \"E:/AI/GPT-SoVITS-v4\" \"GPT_SoVITS/configs/tts_infer.yaml\")",
"Bash(echo \"EXIT CODE: $?\")"
]
}
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,22 @@
# blueprints package
from .pin_tu import bp as pin_tu_bp
from .base64_codec import bp as base64_bp
from .down_video import bp as down_video_bp
from .fen_ci import bp as fen_ci_bp
from .content_tag import bp as content_tag_bp
from .chmod_calc import bp as chmod_calc_bp
from .json_format import bp as json_format_bp
from .qr_code import bp as qr_code_bp
from .http_status import bp as http_status_bp
from .url_parser import bp as url_parser_bp
from .token_gen import bp as token_gen_bp
from .sovits_tts import bp as sovits_tts_bp
from .stt import bp as stt_bp
from .ai_dubbing import bp as ai_dubbing_bp
from .rvc import bp as rvc_bp
__all__ = [
'pin_tu_bp', 'base64_bp', 'down_video_bp', 'fen_ci_bp', 'content_tag_bp',
'chmod_calc_bp', 'json_format_bp', 'qr_code_bp', 'http_status_bp', 'url_parser_bp', 'token_gen_bp',
'sovits_tts_bp', 'stt_bp', 'ai_dubbing_bp', 'rvc_bp',
]

View File

@@ -0,0 +1,440 @@
# -*- coding: utf-8 -*-
"""
ai-dubbing AI 配音蓝图
通过 subprocess 调用 GPT-SoVITS 自带的 runtime\python.exe 实现文本转语音
"""
import os
import sys
import json
import uuid
import glob
import struct
import subprocess
import threading
import tempfile
from flask import Blueprint, render_template, request, jsonify, send_file, after_this_request
bp = Blueprint('ai_dubbing', __name__, url_prefix='/ai-dubbing')
try:
from config import BASE_DIR
except ImportError:
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
CONFIG_PATH = os.path.join(BASE_DIR, 'config', 'ai_dubbing_config.json')
WORKER_SCRIPT = os.path.join(BASE_DIR, 'utils', 'sovits_worker.py')
# 子进程管理
_proc = None
_proc_lock = threading.Lock()
_proc_stderr = []
_engine_status = {'initialized': False, 'loading': False, 'error': None, 'version': None}
# 合成请求锁
_synth_lock = threading.Lock()
# 任务结果缓存
_task_results_local = {}
TEXT_LANGUAGES = {
'zh': '中文', 'en': '英文', 'ja': '日文', 'ko': '韩文',
'yue': '粤语', 'all_zh': '全部中文', 'all_ja': '全部日文',
'all_yue': '全部粤语', 'all_ko': '全部韩文',
'auto': '多语种混合', 'auto_yue': '多语种混合(粤语)',
}
CUT_METHODS = ['cut0', 'cut1', 'cut2', 'cut3', 'cut4', 'cut5']
CUT_METHOD_NAMES = {
'cut0': '不切分', 'cut1': '凑四句一切', 'cut2': '凑50字一切',
'cut3': '按中文句号。切', 'cut4': '按英文句号.切', 'cut5': '按标点符号切',
}
def _default_config():
return {
'sovits_project_path': r'E:\AI\GPT-SoVITS-v4',
'sovits_config_yaml': r'GPT_SoVITS\configs\tts_infer.yaml',
'last_prompt_text': '', 'last_prompt_lang': 'zh',
'last_text_lang': 'zh', 'last_text_split_method': 'cut5',
'top_k': 5, 'top_p': 1.0, 'temperature': 1.0,
'batch_size': 1, 'speed_factor': 1.0, 'seed': -1,
'gpt_model_dir': '', 'sovits_model_dir': '',
'last_gpt_model': '', 'last_sovits_model': '',
'refer_audio_folder': '',
}
def _load_config():
cfg = _default_config()
if os.path.exists(CONFIG_PATH):
with open(CONFIG_PATH, 'r', encoding='utf-8') as f:
saved = json.load(f)
cfg.update(saved)
return cfg
def _save_config(cfg):
with open(CONFIG_PATH, 'w', encoding='utf-8') as f:
json.dump(cfg, f, ensure_ascii=False, indent=2)
def _scan_models(directory, pattern):
"""扫描目录下指定格式的模型文件,返回文件名列表"""
if not directory or not os.path.isdir(directory):
return []
files = glob.glob(os.path.join(directory, pattern))
files.sort(key=lambda x: os.path.basename(x).lower())
return [os.path.basename(f) for f in files]
def _send_msg(proc, obj):
data = json.dumps(obj, ensure_ascii=False).encode('utf-8')
proc.stdin.write(struct.pack('<I', len(data)))
proc.stdin.write(data)
proc.stdin.flush()
def _recv_msg(proc):
raw_len = proc.stdout.read(4)
if not raw_len:
return None
msg_len = struct.unpack('<I', raw_len)[0]
data = proc.stdout.read(msg_len)
return json.loads(data.decode('utf-8'))
def _init_engine():
global _proc, _engine_status
if _engine_status['initialized'] and _proc and _proc.poll() is None:
return True, None
with _proc_lock:
if _engine_status['initialized'] and _proc and _proc.poll() is None:
return True, None
if _engine_status['loading']:
return False, '引擎正在加载中,请稍候...'
_engine_status['loading'] = True
_engine_status['error'] = None
cfg = _load_config()
sovits_path = cfg.get('sovits_project_path', '').strip()
sovits_config_yaml = cfg.get('sovits_config_yaml', r'GPT_SoVITS\configs\tts_infer.yaml')
if not sovits_path or not os.path.isdir(sovits_path):
_engine_status['loading'] = False
_engine_status['error'] = f'GPT-SoVITS 项目路径不存在: {sovits_path}'
return False, _engine_status['error']
runtime_python = os.path.join(sovits_path, 'runtime', 'python.exe')
if not os.path.exists(runtime_python):
_engine_status['loading'] = False
_engine_status['error'] = f'找不到 GPT-SoVITS 运行时: {runtime_python}'
return False, _engine_status['error']
try:
creationflags = 0x08000000 if os.name == 'nt' else 0
_proc = subprocess.Popen(
[runtime_python, WORKER_SCRIPT, sovits_path, sovits_config_yaml],
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
creationflags=creationflags,
)
_proc_stderr.clear()
def _read_stderr():
try:
for line in _proc.stderr:
_proc_stderr.append(line.decode('utf-8', errors='replace').rstrip())
except Exception:
pass
threading.Thread(target=_read_stderr, daemon=True).start()
msg = _recv_msg(_proc)
if msg and msg.get('type') == 'ready':
_engine_status['initialized'] = True
_engine_status['version'] = msg.get('version', 'unknown')
_engine_status['error'] = None
return True, None
else:
err = msg.get('error', '未知错误') if msg else '子进程无响应'
if _proc_stderr:
err += '\n--- 日志 ---\n' + '\n'.join(_proc_stderr[-10:])
_engine_status['error'] = err
_kill_proc()
return False, err
except Exception as e:
_engine_status['error'] = str(e)
_kill_proc()
return False, str(e)
finally:
_engine_status['loading'] = False
def _kill_proc():
global _proc
if _proc:
try:
_proc.kill()
except Exception:
pass
_proc = None
def _reload_model(model_type, path):
"""向子进程发送模型重载请求model_type: 'reload_gpt''reload_sovits'"""
with _synth_lock:
if not _proc or _proc.poll() is not None:
return False, '子进程未运行'
try:
_send_msg(_proc, {'type': model_type, 'path': path})
msg = _recv_msg(_proc)
if msg and msg.get('type') == 'done':
return True, None
else:
return False, msg.get('error', '未知错误') if msg else '子进程无响应'
except Exception as e:
return False, str(e)
def _synthesize(inputs, output_path):
with _synth_lock:
if not _proc or _proc.poll() is not None:
return False, '子进程未运行'
try:
_send_msg(_proc, {
'type': 'synthesize',
'inputs': inputs,
'output_path': output_path,
})
msg = _recv_msg(_proc)
if msg and msg.get('type') == 'done':
return True, msg.get('output_path')
else:
return False, msg.get('error', '未知错误') if msg else '子进程无响应'
except Exception as e:
return False, str(e)
# ==================== 路由 ====================
@bp.route('/')
def page():
cfg = _load_config()
return render_template('ai_dubbing.html',
config=cfg, languages=TEXT_LANGUAGES,
cut_methods=CUT_METHODS, cut_method_names=CUT_METHOD_NAMES)
@bp.route('/config', methods=['GET'])
def get_config():
cfg = _load_config()
cfg['engine_status'] = _engine_status
# 扫描模型列表
gpt_dir = cfg.get('gpt_model_dir', '')
sovits_dir = cfg.get('sovits_model_dir', '')
cfg['gpt_models'] = _scan_models(gpt_dir, '*.ckpt')
cfg['sovits_models'] = _scan_models(sovits_dir, '*.pth')
# 扫描参考音频
refer_folder = cfg.get('refer_audio_folder', '')
if refer_folder and os.path.isdir(refer_folder):
AUDIO_EXTS = ('.wav', '.mp3', '.flac', '.ogg', '.aac', '.m4a', '.wma')
files = [os.path.join(refer_folder, f) for f in os.listdir(refer_folder)
if os.path.isfile(os.path.join(refer_folder, f)) and f.lower().endswith(AUDIO_EXTS)]
files.sort(key=lambda x: os.path.basename(x).lower())
cfg['audio_files'] = files
else:
cfg['audio_files'] = []
return jsonify(cfg)
@bp.route('/config', methods=['POST'])
def save_config():
data = request.get_json()
cfg = _load_config()
for key in ('sovits_project_path', 'sovits_config_yaml',
'last_prompt_text', 'last_prompt_lang',
'last_text_lang', 'last_text_split_method',
'top_k', 'top_p', 'temperature', 'batch_size', 'speed_factor', 'seed',
'gpt_model_dir', 'sovits_model_dir',
'last_gpt_model', 'last_sovits_model', 'refer_audio_folder'):
if key in data:
cfg[key] = data[key]
_save_config(cfg)
return jsonify({'success': True})
@bp.route('/scan-audio-folder', methods=['POST'])
def scan_audio_folder():
data = request.get_json()
folder = (data.get('folder') or '').strip()
if not folder or not os.path.isdir(folder):
return jsonify({'success': False, 'error': '文件夹不存在'})
AUDIO_EXTS = ('.wav', '.mp3', '.flac', '.ogg', '.aac', '.m4a', '.wma')
files = [os.path.join(folder, f) for f in os.listdir(folder)
if os.path.isfile(os.path.join(folder, f)) and f.lower().endswith(AUDIO_EXTS)]
files.sort(key=lambda x: os.path.basename(x).lower())
return jsonify({'success': True, 'files': files, 'count': len(files)})
@bp.route('/engine-status', methods=['GET'])
def engine_status():
status = dict(_engine_status)
status['logs'] = _proc_stderr[-50:]
return jsonify(status)
@bp.route('/clear-logs', methods=['POST'])
def clear_logs():
_proc_stderr.clear()
return jsonify({'success': True})
@bp.route('/init-engine', methods=['POST'])
def init_engine():
ok, err = _init_engine()
if ok:
return jsonify({'success': True, 'version': _engine_status.get('version')})
return jsonify({'success': False, 'error': err})
@bp.route('/reload-engine', methods=['POST'])
def reload_engine():
global _engine_status
_kill_proc()
_engine_status = {'initialized': False, 'loading': False, 'error': None, 'version': None}
ok, err = _init_engine()
if ok:
return jsonify({'success': True, 'version': _engine_status.get('version')})
return jsonify({'success': False, 'error': err})
@bp.route('/stop-engine', methods=['POST'])
def stop_engine():
global _engine_status
_kill_proc()
_engine_status = {'initialized': False, 'loading': False, 'error': None, 'version': None}
return jsonify({'success': True})
@bp.route('/switch-model', methods=['POST'])
def switch_model():
"""切换 GPT 或 SoVITS 模型"""
if not _engine_status['initialized']:
return jsonify({'success': False, 'error': '引擎未初始化'}), 400
data = request.get_json()
model_type = data.get('type') # 'gpt' 或 'sovits'
filename = data.get('filename')
if not model_type or not filename:
return jsonify({'success': False, 'error': '缺少参数'}), 400
cfg = _load_config()
if model_type == 'gpt':
model_dir = cfg.get('gpt_model_dir', '')
full_path = os.path.join(model_dir, filename) if model_dir else filename
ok, err = _reload_model('reload_gpt', full_path)
if ok:
cfg['last_gpt_model'] = filename
_save_config(cfg)
return jsonify({'success': True, 'message': f'GPT 模型已切换: {filename}'})
return jsonify({'success': False, 'error': err})
elif model_type == 'sovits':
model_dir = cfg.get('sovits_model_dir', '')
full_path = os.path.join(model_dir, filename) if model_dir else filename
ok, err = _reload_model('reload_sovits', full_path)
if ok:
cfg['last_sovits_model'] = filename
_save_config(cfg)
return jsonify({'success': True, 'message': f'SoVITS 模型已切换: {filename}'})
return jsonify({'success': False, 'error': err})
else:
return jsonify({'success': False, 'error': '未知模型类型'}), 400
@bp.route('/synthesize', methods=['POST'])
def synthesize():
if not _engine_status['initialized']:
return jsonify({'success': False, 'error': '引擎未初始化,请先初始化'}), 400
data = request.get_json()
text = (data.get('text') or '').strip()
if not text:
return jsonify({'success': False, 'error': '请输入要合成的文本'}), 400
ref_audio_path = (data.get('ref_audio_path') or '').strip()
if not ref_audio_path:
return jsonify({'success': False, 'error': '请选择参考音频'}), 400
cfg = _load_config()
for key in ('last_prompt_text', 'last_prompt_lang',
'last_text_lang', 'last_text_split_method',
'top_k', 'top_p', 'temperature', 'batch_size', 'speed_factor', 'seed'):
if key in data:
cfg[key] = data[key]
_save_config(cfg)
task_id = uuid.uuid4().hex
output_path = os.path.join(tempfile.gettempdir(), f'ai_dub_{task_id}.wav')
inputs = {
'text': text,
'text_lang': data.get('text_lang', 'zh'),
'ref_audio_path': ref_audio_path,
'prompt_text': data.get('prompt_text', ''),
'prompt_lang': data.get('prompt_lang', 'zh'),
'top_k': data.get('top_k', 5),
'top_p': data.get('top_p', 1.0),
'temperature': data.get('temperature', 1.0),
'text_split_method': data.get('text_split_method', 'cut5'),
'batch_size': data.get('batch_size', 1),
'speed_factor': data.get('speed_factor', 1.0),
'seed': data.get('seed', -1),
'parallel_infer': True,
'repetition_penalty': 1.35,
}
def _do_synth():
ok, result = _synthesize(inputs, output_path)
if ok:
_task_results_local[task_id] = {'status': 'done', 'file': output_path}
else:
_task_results_local[task_id] = {'status': 'error', 'error': result}
_task_results_local[task_id] = {'status': 'synthesizing'}
t = threading.Thread(target=_do_synth, daemon=True)
t.start()
return jsonify({'success': True, 'task_id': task_id})
@bp.route('/status/<task_id>')
def task_status(task_id):
info = _task_results_local.get(task_id)
if not info:
return jsonify({'success': False, 'error': '任务不存在'}), 404
resp = {'success': True, 'progress': info}
if info['status'] == 'done':
resp['audio_url'] = f'/ai-dubbing/audio/{task_id}'
elif info['status'] == 'error':
resp['progress'] = {'status': 'error', 'error': info.get('error', '未知错误')}
_task_results_local.pop(task_id, None)
return jsonify(resp)
@bp.route('/audio/<task_id>')
def get_audio(task_id):
info = _task_results_local.pop(task_id, None)
audio_path = info['file'] if info else os.path.join(tempfile.gettempdir(), f'ai_dub_{task_id}.wav')
if not os.path.exists(audio_path):
return jsonify({'error': '音频文件不存在'}), 404
@after_this_request
def cleanup(resp):
try:
os.remove(audio_path)
except OSError:
pass
return resp
return send_file(audio_path, mimetype='audio/wav')

View File

@@ -0,0 +1,60 @@
# -*- coding: utf-8 -*-
"""
base64-de-in-code 编解码蓝图
"""
import base64
from flask import Blueprint, render_template, request, jsonify
bp = Blueprint('base64_codec', __name__, url_prefix='/base64-de-in-code')
def _fallback_decode(data_bytes):
"""降级解码:当 encoding_utils 不可用时使用"""
for enc in ['utf-8', 'gbk', 'gb2312', 'cp936']:
try:
return data_bytes.decode(enc)
except UnicodeDecodeError:
continue
return "解码失败,无法识别编码。"
@bp.route('/')
def page():
return render_template('base64.html')
@bp.route('/decode', methods=['POST'])
def decode():
data = request.get_json()
base64_input = data.get('input_text', '').strip()
if not base64_input:
return jsonify({'success': False, 'error': '输入不能为空。'})
try:
decoded_bytes = base64.b64decode(base64_input)
except Exception as e:
return jsonify({'success': False, 'error': f'Base64 解码失败: {str(e)}'})
try:
from utils.encoding_utils import detect_and_decode
decoded_result = detect_and_decode(decoded_bytes)
except Exception:
decoded_result = _fallback_decode(decoded_bytes)
return jsonify({'success': True, 'result': decoded_result})
@bp.route('/encode', methods=['POST'])
def encode():
data = request.get_json()
text_to_encode = data.get('text_to_encode', '')
encoding = data.get('encoding', 'utf-8')
if text_to_encode is None:
return jsonify({'success': False, 'error': '输入不能为空。'})
try:
encoded_bytes = text_to_encode.encode(encoding)
except LookupError:
return jsonify({'success': False, 'error': f'未知的编码格式: {encoding}'})
except UnicodeEncodeError as e:
return jsonify({'success': False, 'error': f'编码失败: {str(e)}'})
encoded_string = base64.b64encode(encoded_bytes).decode('ascii')
return jsonify({'success': True, 'result': encoded_string})

View File

@@ -0,0 +1,9 @@
# -*- coding: utf-8 -*-
from flask import Blueprint, render_template
bp = Blueprint('chmod_calc', __name__, url_prefix='/chmod-calc')
@bp.route('/')
def page():
return render_template('chmod_calc.html')

View File

@@ -0,0 +1,196 @@
# -*- coding: utf-8 -*-
"""
content-tag 文章标签生成蓝图
支持 Ollama (/api/generate) 和 llama.cpp/OpenAI 兼容 (/v1/chat/completions)
"""
import requests
import re
import time
import json
from flask import Blueprint, render_template, request, jsonify
from utils.stats_db import get_content_tag_settings, update_content_tag_settings
bp = Blueprint('content_tag', __name__, url_prefix='/content-tag')
@bp.route('/')
def page():
return render_template('content_tag.html')
@bp.route('/settings', methods=["GET"])
def get_settings():
return jsonify({"success": True, "settings": get_content_tag_settings()})
@bp.route('/settings', methods=["POST"])
def save_settings():
data = request.get_json()
if not data:
return jsonify({"success": False, "error": "缺少数据"}), 400
update_content_tag_settings(data)
return jsonify({"success": True, "settings": get_content_tag_settings()})
@bp.route('/models', methods=["POST"])
def list_models():
"""获取 Ollama 可用模型列表"""
data = request.get_json()
api_url = (data.get("api_url") or "").strip()
if not api_url:
return jsonify({"success": False, "error": "请填写 API 地址"})
# /api/generate -> base url -> /api/tags
base_url = api_url.rsplit('/api/', 1)[0]
tags_url = base_url + '/api/tags'
try:
r = requests.get(tags_url, timeout=10)
if r.status_code == 200:
models = [m["name"] for m in r.json().get("models", [])]
return jsonify({"success": True, "models": models})
else:
return jsonify({"success": False, "error": f"获取模型失败: {r.status_code}"})
except requests.exceptions.ConnectionError:
return jsonify({"success": False, "error": f"无法连接到: {tags_url}"})
except Exception as e:
return jsonify({"success": False, "error": str(e)})
@bp.route('/test-api', methods=["POST"])
def test_api():
data = request.get_json()
api_url = (data.get("api_url") or "").strip()
model = (data.get("model") or "").strip()
software = data.get("software", "llamacpp")
if not api_url:
return jsonify({"success": False, "error": "请填写 API 地址"})
try:
if software == "ollama":
base_url = api_url.rsplit('/api/', 1)[0]
r = requests.get(base_url, timeout=10)
if r.status_code != 200 or 'Ollama is running' not in r.text:
return jsonify({"success": False, "error": f"Ollama 未运行: {base_url}"})
tags_url = base_url + '/api/tags'
r2 = requests.get(tags_url, timeout=10)
if r2.status_code != 200:
return jsonify({"success": False, "error": "Ollama 获取模型列表失败"})
models = [m["name"] for m in r2.json().get("models", [])]
return jsonify({"success": True, "message": f"连接成功,共 {len(models)} 个模型", "models": models})
else:
payload = {"model": model or "test", "messages": [{"role": "user", "content": "hi"}], "max_tokens": 5}
r = requests.post(api_url, json=payload, timeout=10)
if r.status_code == 200:
result = r.json()
name = result.get("model", model)
return jsonify({"success": True, "message": f"连接成功,模型: {name}"})
else:
return jsonify({"success": False, "error": f"API 返回 {r.status_code}: {r.text[:200]}"})
except requests.exceptions.ConnectionError:
return jsonify({"success": False, "error": f"无法连接到: {api_url}"})
except Exception as e:
return jsonify({"success": False, "error": str(e)})
@bp.route('/generate', methods=["POST"])
def generate_tags():
data = request.get_json()
api_url = (data.get("api_url") or "").strip()
model = (data.get("model") or "").strip()
software = data.get("software", "llamacpp")
content = (data.get("content") or "").strip()
max_tags = data.get("max_tags", 10)
min_length = data.get("min_length", 2)
max_length = data.get("max_length", 6)
if not api_url:
return jsonify({"success": False, "error": "请填写 API 地址"})
if software == "ollama" and not model:
return jsonify({"success": False, "error": "Ollama 需要填写模型名称"})
if not content:
return jsonify({"success": False, "error": "请输入文章内容"})
custom_system = (data.get("system_msg") or "").strip()
custom_user = (data.get("user_msg") or "").strip()
system_msg = custom_system if custom_system else "你是一个专门生成文章标签的助手,请你根据我给你的文章的内容总结并生成一系列的标签,格式可以参考[关键词1, 关键词2, 关键词3].你只需要给我生成这种形式的标签即可,其他分析内容无需输出."
user_template = custom_user if custom_user else "请严格按照以下要求,从提供的文章内容中提取关键词。\n\n文章内容:\n{content}\n\n要求:\n- 提取最多 {max_tags} 个最能概括文章主旨和核心概念的关键词。\n- 关键词必须来源于文章内容,准确反映文章主题。\n- 每个关键词的长度必须在 {min_length}{max_length} 个字符之间。\n- 输出格式为关键词1, 关键词2, 关键词3, ...\n- 只输出关键词列表,不要有任何其他解释或前缀。"
user_msg = user_template.format(content=content, max_tags=max_tags, min_length=min_length, max_length=max_length)
if software == "ollama":
payload = {
"model": model,
"prompt": user_msg,
"system": system_msg,
"stream": False,
"think": False,
"options": {"top_p": 0.9, "temperature": 0.1, "num_predict": 2048}
}
else:
payload = {
"messages": [
{"role": "system", "content": system_msg},
{"role": "user", "content": user_msg}
],
"stream": False,
"temperature": 0.1,
"top_p": 0.9,
"max_tokens": 2048,
}
if model:
payload["model"] = model
start_time = time.time()
try:
response = requests.post(api_url, json=payload, timeout=120)
if response.status_code != 200:
return jsonify({"success": False, "error": f"API 返回错误: {response.status_code} - {response.text[:200]}"})
result = response.json()
if software == "ollama":
llm_output = result.get("response", "").strip()
actual_model = result.get("model", model)
else:
choices = result.get("choices", [])
if not choices:
return jsonify({"success": False, "error": f"API 响应无 choices: {json.dumps(result, ensure_ascii=False)[:200]}"})
llm_output = choices[0].get("message", {}).get("content", "").strip()
actual_model = result.get("model", model)
think_match = re.search(r'<think>(.*?)</think>', llm_output, re.DOTALL)
ai_think = think_match.group(1).strip() if think_match else ""
clean_output = re.sub(r'<think>.*?</think>', '', llm_output, count=1, flags=re.DOTALL).strip()
raw_tags = [tag.strip() for tag in re.split(r'[,;\n]+', clean_output) if tag.strip()]
seen = set()
tags = []
for tag in raw_tags:
if tag not in seen:
seen.add(tag)
tags.append(tag)
elapsed = round(time.time() - start_time, 2)
return jsonify({
"success": True,
"tags": tags,
"think": ai_think,
"model": actual_model,
"consume": elapsed
})
except requests.exceptions.ConnectionError:
return jsonify({"success": False, "error": f"无法连接到 API 地址: {api_url}"})
except requests.exceptions.Timeout:
return jsonify({"success": False, "error": "API 请求超时120秒"})
except Exception as e:
return jsonify({"success": False, "error": f"请求失败: {str(e)}"})

View File

@@ -0,0 +1,184 @@
# -*- coding: utf-8 -*-
"""
down-video 视频下载蓝图
"""
import os
import json
import queue
from flask import Blueprint, render_template, request, jsonify, send_file, Response
from config import DEFAULT_OUTPUT_DIR, PROXY_URL
from utils.down_video_utils import (
check_ffmpeg,
check_deno,
is_twitter_url,
is_bilibili_url,
is_instagram_url,
is_youtube_url,
download_video,
)
from utils.stats_db import add_recent_path, get_recent_paths, delete_recent_path, clear_recent_paths
bp = Blueprint('dv_cookies', __name__, url_prefix='/down-video')
# 用于存储下载进度的队列
progress_queues = {}
@bp.route('/')
def page():
ffmpeg_ok = check_ffmpeg()
deno_ok = check_deno()
return render_template('down_video.html', ffmpeg_ok=ffmpeg_ok, deno_ok=deno_ok)
@bp.route('/file')
def serve_file():
filepath = request.args.get("path", "")
as_download = request.args.get("download", "0") == "1"
if not filepath:
return "Missing path", 400
filepath = os.path.abspath(filepath)
if not os.path.exists(filepath) or not os.path.isfile(filepath):
return "File not found", 404
ext = os.path.splitext(filepath)[1].lower()
if ext not in ('.mp4', '.m4a', '.webm', '.mkv', '.mov', '.avi'):
return "File type not allowed", 403
mime_types = {
'.mp4': 'video/mp4',
'.m4a': 'audio/mp4',
'.webm': 'video/webm',
'.mkv': 'video/x-matroska',
'.mov': 'video/quicktime',
'.avi': 'video/x-msvideo',
}
mimetype = mime_types.get(ext, 'application/octet-stream')
response = send_file(
filepath,
mimetype=mimetype,
conditional=True,
)
response.headers['Accept-Ranges'] = 'bytes'
if not as_download:
response.headers['Content-Disposition'] = 'inline'
return response
@bp.route('/download', methods=["POST"])
def download():
data = request.get_json()
url = data.get("url", "").strip() if data else ""
output_dir = data.get("output_dir", DEFAULT_OUTPUT_DIR).strip() if data else DEFAULT_OUTPUT_DIR
download_id = data.get("download_id", "") if data else ""
use_proxy = data.get("use_proxy", False) if data else False
proxy_url_input = (data.get("proxy_url") or "").strip() if data else ""
if not url:
return jsonify({"success": False, "message": "请输入视频链接"}), 400
if is_twitter_url(url):
platform = 'twitter'
elif is_bilibili_url(url):
platform = 'bilibili'
elif is_instagram_url(url):
platform = 'instagram'
elif is_youtube_url(url):
platform = 'youtube'
else:
return jsonify({"success": False, "message": "仅支持 Twitter/X、Bilibili 和 Instagram 视频链接"}), 400
if not output_dir:
output_dir = DEFAULT_OUTPUT_DIR
# 确定使用的代理地址
proxy_url = proxy_url_input if use_proxy and proxy_url_input else (PROXY_URL if use_proxy else None)
# 创建进度队列
progress_queue = queue.Queue()
if download_id:
progress_queues[download_id] = progress_queue
def progress_callback(data):
progress_queue.put(data)
try:
# 发送开始合并的消息
def send_merge_status():
progress_queue.put({
'type': 'merging',
'message': '正在合并视频和音频...'
})
success, message, title, filepath = download_video(url, output_dir, platform, progress_callback, proxy_url=proxy_url)
# 如果是bilibili或youtube可能有合并过程发送合并状态
if platform in ('bilibili', 'youtube') and filepath and '_merged' in filepath:
send_merge_status()
return jsonify({"success": success, "message": message, "title": title, "filepath": filepath})
finally:
# 清理队列
if download_id and download_id in progress_queues:
del progress_queues[download_id]
@bp.route('/progress/<download_id>')
def progress_stream(download_id):
"""SSE端点 - 实时推送下载进度"""
def generate():
progress_queue = progress_queues.get(download_id)
if not progress_queue:
yield f"data: {json.dumps({'type': 'error', 'message': '下载任务不存在'})}\n\n"
return
while True:
try:
data = progress_queue.get(timeout=30)
yield f"data: {json.dumps(data)}\n\n"
if data.get('type') == 'progress' and data.get('percent', 0) >= 100:
break
except queue.Empty:
# 发送心跳保持连接
yield f":\n\n"
continue
return Response(
generate(),
mimetype='text/event-stream',
headers={
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no'
}
)
# ===== 保存路径历史 =====
@bp.route('/save-paths', methods=['GET'])
def get_save_paths():
paths = get_recent_paths(limit=10)
return jsonify({'paths': paths})
@bp.route('/save-paths', methods=['POST'])
def add_save_path():
data = request.get_json()
path = data.get('path', '').strip() if data else ''
if not path:
return jsonify({'success': False, 'message': '路径不能为空'}), 400
add_recent_path(path)
return jsonify({'success': True})
@bp.route('/save-paths/delete', methods=['POST'])
def delete_save_path():
data = request.get_json()
path = data.get('path', '').strip() if data else ''
if not path:
return jsonify({'success': False, 'message': '路径不能为空'}), 400
delete_recent_path(path)
return jsonify({'success': True})

View File

@@ -0,0 +1,49 @@
# -*- coding: utf-8 -*-
"""
fen-ci 分词蓝图
"""
from flask import Blueprint, render_template, request, jsonify
from utils.fen_ci_utils import tokenizer, token_auth
bp = Blueprint('fen_ci', __name__, url_prefix='/fen-ci')
def _authenticate():
"""检查请求头中的 Authorization token"""
auth_header = request.headers.get('Authorization')
if not auth_header or not auth_header.startswith('Bearer '):
return None, "缺少或格式错误的Authorization头。请使用 'Authorization: Bearer <your_token>'"
token = auth_header.split(" ", 1)[1]
user = token_auth.get_user(token)
if not user:
return None, "无效的Token"
return user, None
@bp.route('/')
def page():
return render_template('fen_ci.html')
@bp.route('/tokenize', methods=['POST'])
def tokenize():
user, error_msg = _authenticate()
if not user:
return jsonify({'success': False, 'error': error_msg}), 401
data = request.get_json()
input_text = data.get('input_text', '')
try:
tokens = tokenizer.tokenize(input_text)
stats = tokenizer.get_stats(tokens)
return jsonify({
'success': True,
'tokens': tokens,
'stats': stats,
'original_text': input_text,
'requested_by': user
})
except Exception as e:
return jsonify({'success': False, 'error': f'分词处理失败: {str(e)}'}), 500

View File

@@ -0,0 +1,9 @@
# -*- coding: utf-8 -*-
from flask import Blueprint, render_template
bp = Blueprint('http_status', __name__, url_prefix='/http-status')
@bp.route('/')
def page():
return render_template('http_status.html')

View File

@@ -0,0 +1,9 @@
# -*- coding: utf-8 -*-
from flask import Blueprint, render_template
bp = Blueprint('json_format', __name__, url_prefix='/json-format')
@bp.route('/')
def page():
return render_template('json_format.html')

View File

@@ -0,0 +1,447 @@
# -*- coding: utf-8 -*-
"""
pin-tu 图片浏览蓝图
用户自行指定浏览路径root 用 URL-safe base64 编码放在 URL 中)
"""
import os
import shutil
import base64
from flask import Blueprint, render_template, send_from_directory, request, abort, jsonify
from utils.pin_tu_utils import (
find_media_in_folder,
get_subfolders,
build_breadcrumbs,
is_safe_path,
)
from utils.stats_db import (
add_search, get_search_history, delete_search_history, clear_search_history,
add_recent_path, get_recent_paths, delete_recent_path, clear_recent_paths,
)
bp = Blueprint('pin_tu', __name__, url_prefix='/pin-tu')
def _decode_root(b64_str):
"""URL-safe base64 解码为原始路径"""
try:
# URL-safe → standard base64
padded = b64_str.replace('-', '+').replace('_', '/')
# 补齐 =
pad = len(padded) % 4
if pad:
padded += '=' * (4 - pad)
return base64.b64decode(padded).decode('utf-8')
except Exception:
return None
def _encode_root(path_str):
"""路径编码为 URL-safe base64 字符串"""
b = base64.b64encode(path_str.encode('utf-8')).decode('ascii')
return b.replace('+', '-').replace('/', '_').rstrip('=')
@bp.route('/')
def index():
return render_template('pin_tu.html', mode='index', root='', root_b64='')
@bp.route('/browse/<string:root_b64>')
def browse(root_b64):
root_path = _decode_root(root_b64)
if not root_path:
abort(400)
# 记录最近访问路径
add_recent_path(root_path)
subpath = request.args.get('p', '')
mode = request.args.get('mode', 'list')
if mode not in ['list', 'grid', 'manga']:
mode = 'list'
root_abs = os.path.normpath(root_path)
if subpath:
current_path = os.path.normpath(os.path.join(root_abs, subpath))
else:
current_path = root_abs
if not os.path.isdir(root_abs):
abort(404)
if not is_safe_path(root_abs, current_path):
abort(403)
image_files, video_files, other_files = find_media_in_folder(current_path)
subfolders = get_subfolders(current_path)
breadcrumbs = build_breadcrumbs(subpath, base_url=f'/pin-tu/browse/{root_b64}')
current_path_name = os.path.basename(current_path) if current_path != root_abs else os.path.basename(root_abs)
if mode == 'manga':
return render_template(
'pin_tu_manga.html',
images=image_files,
current_path_name=current_path_name,
current_subpath=subpath + '/' if subpath else '',
root_b64=root_b64,
)
return render_template(
'pin_tu.html',
mode='browse',
images=image_files,
videos=video_files,
files=other_files,
subfolders=subfolders,
current_path_name=current_path_name,
current_subpath=subpath + '/' if subpath else '',
breadcrumbs=breadcrumbs,
view_mode=mode,
is_manga_path='漫画' in subpath,
root_b64=root_b64,
root_display=root_path,
)
@bp.route('/file-info/<string:root_b64>')
def file_info(root_b64):
root_path = _decode_root(root_b64)
if not root_path:
abort(400)
file_path = request.args.get('p', '')
if not file_path:
abort(400)
root_abs = os.path.normpath(root_path)
full_file = os.path.normpath(os.path.join(root_abs, file_path))
if not is_safe_path(root_abs, full_file) or not os.path.isfile(full_file):
abort(404)
stat = os.stat(full_file)
size = stat.st_size
if size < 1024:
size_str = f'{size} B'
elif size < 1024 * 1024:
size_str = f'{size / 1024:.1f} KB'
elif size < 1024 * 1024 * 1024:
size_str = f'{size / (1024 * 1024):.1f} MB'
else:
size_str = f'{size / (1024 * 1024 * 1024):.2f} GB'
from datetime import datetime
modified = datetime.fromtimestamp(stat.st_mtime).strftime('%Y-%m-%d %H:%M:%S')
ext = os.path.splitext(file_path)[1].lstrip('.').upper()
return {
'name': os.path.basename(file_path),
'size': size_str,
'size_bytes': size,
'type': ext or '未知',
'modified': modified,
'path': file_path,
}
@bp.route('/media/<string:root_b64>')
def serve_media(root_b64):
root_path = _decode_root(root_b64)
if not root_path:
abort(400)
file_path = request.args.get('p', '')
if not file_path:
abort(400)
root_abs = os.path.normpath(root_path)
folder = os.path.dirname(file_path)
filename = os.path.basename(file_path)
base_dir = os.path.join(root_abs, folder)
full_file = os.path.normpath(os.path.join(base_dir, filename))
if not is_safe_path(root_abs, full_file):
abort(403)
return send_from_directory(base_dir, filename)
@bp.route('/rename/<string:root_b64>', methods=['POST'])
def rename(root_b64):
root_path = _decode_root(root_b64)
if not root_path:
abort(400)
data = request.get_json() or {}
old_path = data.get('path', '')
new_name = data.get('new_name', '').strip()
if not old_path or not new_name:
return {'success': False, 'error': '参数不完整'}, 400
# 校验新文件名
invalid = set(r'\/:*?"<>|')
if any(c in invalid for c in new_name):
return {'success': False, 'error': '文件名包含非法字符'}, 400
root_abs = os.path.normpath(root_path)
old_full = os.path.normpath(os.path.join(root_abs, old_path))
if not is_safe_path(root_abs, old_full) or not os.path.exists(old_full):
return {'success': False, 'error': '文件不存在'}, 404
new_full = os.path.join(os.path.dirname(old_full), new_name)
if os.path.exists(new_full) and os.path.normcase(new_full) != os.path.normcase(old_full):
return {'success': False, 'error': '同名文件已存在'}, 400
try:
os.rename(old_full, new_full)
return {'success': True, 'new_name': new_name}
except Exception as e:
return {'success': False, 'error': str(e)}, 500
@bp.route('/search-page/<string:root_b64>')
def search_page(root_b64):
root_path = _decode_root(root_b64)
if not root_path:
abort(400)
return render_template('pin_tu_search.html', root_b64=root_b64, root_display=root_path, query=request.args.get('q', ''))
@bp.route('/search/<string:root_b64>')
def search(root_b64):
root_path = _decode_root(root_b64)
if not root_path:
abort(400)
query = request.args.get('q', '').strip().lower()
if not query or len(query) < 1:
return {'results': []}
root_abs = os.path.normpath(root_path)
if not os.path.isdir(root_abs):
abort(404)
results = []
max_results = 50
for dirpath, dirnames, filenames in os.walk(root_abs):
# 搜索文件夹名
for d in list(dirnames):
if query in d.lower():
rel = os.path.relpath(os.path.join(dirpath, d), root_abs).replace('\\', '/')
results.append({'name': d, 'path': rel, 'type': 'folder'})
if len(results) >= max_results:
return {'results': results}
# 搜索文件名
for f in filenames:
if query in f.lower():
rel = os.path.relpath(os.path.join(dirpath, f), root_abs).replace('\\', '/')
ext = os.path.splitext(f)[1].lstrip('.').upper()
results.append({'name': f, 'path': rel, 'type': ext or 'file'})
if len(results) >= max_results:
return {'results': results}
return {'results': results}
# ===== 搜索历史 API =====
@bp.route('/api/search-history/<string:root_b64>')
def api_get_search_history(root_b64):
root_path = _decode_root(root_b64)
if not root_path:
abort(400)
records = get_search_history(root_path, limit=20)
return jsonify({'history': records})
@bp.route('/api/search-history/<string:root_b64>', methods=['POST'])
def api_add_search(root_b64):
root_path = _decode_root(root_b64)
if not root_path:
abort(400)
data = request.get_json() or {}
keyword = data.get('keyword', '').strip()
result_count = data.get('result_count', 0)
if keyword:
add_search(keyword, root_path, result_count)
return jsonify({'success': True})
@bp.route('/api/search-history/<string:root_b64>', methods=['DELETE'])
def api_delete_search_history(root_b64):
root_path = _decode_root(root_b64)
if not root_path:
abort(400)
data = request.get_json() or {}
if data.get('clear'):
clear_search_history(root_path)
else:
ids = data.get('ids', [])
if ids:
delete_search_history(ids)
return jsonify({'success': True})
# ===== 最近访问路径 API =====
@bp.route('/api/recent-paths')
def api_get_recent_paths():
records = get_recent_paths(limit=8)
return jsonify({'paths': records})
@bp.route('/api/recent-paths', methods=['POST'])
def api_add_recent_path():
data = request.get_json() or {}
path = data.get('path', '').strip()
if path:
add_recent_path(path)
return jsonify({'success': True})
@bp.route('/api/recent-paths', methods=['DELETE'])
def api_delete_recent_path():
data = request.get_json() or {}
if data.get('clear'):
clear_recent_paths()
else:
path = data.get('path', '')
if path:
delete_recent_path(path)
return jsonify({'success': True})
# ===== 新建文件夹 =====
@bp.route('/create-folder/<string:root_b64>', methods=['POST'])
def create_folder(root_b64):
root_path = _decode_root(root_b64)
if not root_path:
abort(400)
data = request.get_json() or {}
subpath = data.get('path', '')
name = data.get('name', '').strip()
if not name:
return {'success': False, 'error': '名称不能为空'}, 400
invalid = set(r'\/:*?"<>|')
if any(c in invalid for c in name):
return {'success': False, 'error': '名称包含非法字符'}, 400
root_abs = os.path.normpath(root_path)
if subpath:
parent = os.path.normpath(os.path.join(root_abs, subpath))
else:
parent = root_abs
if not is_safe_path(root_abs, parent) or not os.path.isdir(parent):
return {'success': False, 'error': '父目录不存在'}, 400
target = os.path.join(parent, name)
if os.path.exists(target):
return {'success': False, 'error': '同名文件夹已存在'}, 400
try:
os.makedirs(target)
return {'success': True, 'name': name}
except Exception as e:
return {'success': False, 'error': str(e)}, 500
# ===== 移动文件/文件夹 =====
@bp.route('/move/<string:root_b64>', methods=['POST'])
def move_item(root_b64):
root_path = _decode_root(root_b64)
if not root_path:
abort(400)
data = request.get_json() or {}
source = data.get('source', '')
dest = data.get('dest', '') # 目标文件夹的相对路径,空字符串表示根目录
if not source:
return {'success': False, 'error': '缺少源路径'}, 400
root_abs = os.path.normpath(root_path)
src_full = os.path.normpath(os.path.join(root_abs, source))
if not is_safe_path(root_abs, src_full) or not os.path.exists(src_full):
return {'success': False, 'error': '源文件不存在'}, 404
if dest:
dest_dir = os.path.normpath(os.path.join(root_abs, dest))
else:
dest_dir = root_abs
if not is_safe_path(root_abs, dest_dir) or not os.path.isdir(dest_dir):
return {'success': False, 'error': '目标目录不存在'}, 400
# 不能移动到自身或自身子目录
norm_src = os.path.normcase(src_full)
norm_dest = os.path.normcase(dest_dir)
if norm_dest.startswith(norm_src + os.sep) or norm_dest == norm_src:
return {'success': False, 'error': '不能移动到自身或子目录'}, 400
item_name = os.path.basename(src_full)
new_full = os.path.join(dest_dir, item_name)
if os.path.exists(new_full) and os.path.normcase(new_full) != norm_src:
return {'success': False, 'error': '目标位置已存在同名项目'}, 400
try:
shutil.move(src_full, new_full)
return {'success': True, 'name': item_name}
except Exception as e:
return {'success': False, 'error': str(e)}, 500
# ===== 删除文件/文件夹 =====
@bp.route('/delete/<string:root_b64>', methods=['POST'])
def delete_item(root_b64):
root_path = _decode_root(root_b64)
if not root_path:
abort(400)
data = request.get_json() or {}
source = data.get('source', '')
if not source:
return {'success': False, 'error': '缺少路径'}, 400
root_abs = os.path.normpath(root_path)
full = os.path.normpath(os.path.join(root_abs, source))
if not is_safe_path(root_abs, full) or not os.path.exists(full):
return {'success': False, 'error': '文件不存在'}, 404
name = os.path.basename(full)
try:
if os.path.isdir(full):
shutil.rmtree(full)
else:
os.remove(full)
return {'success': True, 'name': name}
except Exception as e:
return {'success': False, 'error': str(e)}, 500
# ===== 列出子文件夹(供移动弹窗使用)=====
@bp.route('/list-folders/<string:root_b64>')
def list_folders(root_b64):
root_path = _decode_root(root_b64)
if not root_path:
abort(400)
subpath = request.args.get('p', '')
root_abs = os.path.normpath(root_path)
if subpath:
current = os.path.normpath(os.path.join(root_abs, subpath))
else:
current = root_abs
if not is_safe_path(root_abs, current) or not os.path.isdir(current):
abort(404)
subfolders = get_subfolders(current)
# 父目录
parent = ''
if subpath:
parts = subpath.rstrip('/').split('/')
parent = '/'.join(parts[:-1]) if len(parts) > 1 else ''
return jsonify({
'current': subpath,
'parent': parent,
'folders': [{'name': f, 'path': (subpath + '/' + f).strip('/')} for f in subfolders]
})

View File

@@ -0,0 +1,9 @@
# -*- coding: utf-8 -*-
from flask import Blueprint, render_template
bp = Blueprint('qr_code', __name__, url_prefix='/qr-code')
@bp.route('/')
def page():
return render_template('qr_code.html')

View File

@@ -0,0 +1,415 @@
# -*- coding: utf-8 -*-
"""
rvc RVC 变声蓝图
通过 subprocess 调用 RVC 自带的 runtime\python.exe 实现语音变声
"""
import os
import json
import uuid
import glob
import struct
import subprocess
import threading
import tempfile
from flask import Blueprint, render_template, request, jsonify, send_file, after_this_request
bp = Blueprint('rvc', __name__, url_prefix='/rvc')
try:
from config import BASE_DIR
except ImportError:
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
CONFIG_PATH = os.path.join(BASE_DIR, 'config', 'rvc_config.json')
WORKER_SCRIPT = os.path.join(BASE_DIR, 'utils', 'rvc_worker.py')
# 子进程管理
_proc = None
_proc_lock = threading.Lock()
_proc_stderr = []
_engine_status = {'initialized': False, 'loading': False, 'error': None, 'device': None, 'model_loaded': False}
# 变声请求锁
_synth_lock = threading.Lock()
# 任务结果缓存
_task_results_local = {}
F0_METHODS = ['pm', 'harvest', 'crepe', 'rmvpe']
F0_METHOD_NAMES = {
'pm': 'PM (最快)', 'harvest': 'Harvest (高质量)',
'crepe': 'Crepe (神经网络)', 'rmvpe': 'RMVPE (推荐)',
}
def _default_config():
return {
'rvc_project_path': r'E:\AI\RVC\RVC1006Nvidia',
'model_dir': 'assets/weights',
'model_dir_mode': 'relative',
'index_dir': 'logs',
'index_dir_mode': 'relative',
'last_model': '',
'last_index': '',
'f0_up_key': 0,
'f0_method': 'rmvpe',
'index_rate': 0.75,
'filter_radius': 3,
'resample_sr': 0,
'rms_mix_rate': 0.25,
'protect': 0.33,
}
def _load_config():
cfg = _default_config()
if os.path.exists(CONFIG_PATH):
with open(CONFIG_PATH, 'r', encoding='utf-8') as f:
saved = json.load(f)
cfg.update(saved)
# 空值回退到默认值
if not cfg.get('model_dir'):
cfg['model_dir'] = 'assets/weights'
if not cfg.get('index_dir'):
cfg['index_dir'] = 'logs'
if not cfg.get('model_dir_mode'):
cfg['model_dir_mode'] = 'relative'
if not cfg.get('index_dir_mode'):
cfg['index_dir_mode'] = 'relative'
return cfg
def _save_config(cfg):
with open(CONFIG_PATH, 'w', encoding='utf-8') as f:
json.dump(cfg, f, ensure_ascii=False, indent=2)
def _scan_pth_models(directory):
"""扫描 .pth 模型文件"""
if not directory or not os.path.isdir(directory):
return []
files = glob.glob(os.path.join(directory, '*.pth'))
files.sort(key=lambda x: os.path.basename(x).lower())
return [os.path.basename(f) for f in files]
def _scan_index_files(directory):
"""递归扫描 .index 文件,排除 trained"""
if not directory or not os.path.isdir(directory):
return []
results = []
for root, dirs, files in os.walk(directory):
for f in files:
if f.endswith('.index') and 'trained' not in f.lower():
results.append(os.path.join(root, f))
results.sort(key=lambda x: os.path.basename(x).lower())
return results
def _send_msg(proc, obj):
data = json.dumps(obj, ensure_ascii=False).encode('utf-8')
proc.stdin.write(struct.pack('<I', len(data)))
proc.stdin.write(data)
proc.stdin.flush()
def _recv_msg(proc):
raw_len = proc.stdout.read(4)
if not raw_len:
return None
msg_len = struct.unpack('<I', raw_len)[0]
data = proc.stdout.read(msg_len)
return json.loads(data.decode('utf-8'))
def _init_engine():
global _proc, _engine_status
if _engine_status['initialized'] and _proc and _proc.poll() is None:
return True, None
with _proc_lock:
if _engine_status['initialized'] and _proc and _proc.poll() is None:
return True, None
if _engine_status['loading']:
return False, '引擎正在加载中,请稍候...'
_engine_status['loading'] = True
_engine_status['error'] = None
cfg = _load_config()
rvc_path = cfg.get('rvc_project_path', '').strip()
if not rvc_path or not os.path.isdir(rvc_path):
_engine_status['loading'] = False
_engine_status['error'] = f'RVC 项目路径不存在: {rvc_path}'
return False, _engine_status['error']
runtime_python = os.path.join(rvc_path, 'runtime', 'python.exe')
if not os.path.exists(runtime_python):
_engine_status['loading'] = False
_engine_status['error'] = f'找不到 RVC 运行时: {runtime_python}'
return False, _engine_status['error']
try:
creationflags = 0x08000000 if os.name == 'nt' else 0
_proc = subprocess.Popen(
[runtime_python, WORKER_SCRIPT, rvc_path],
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
creationflags=creationflags,
)
_proc_stderr.clear()
def _read_stderr():
try:
for line in _proc.stderr:
_proc_stderr.append(line.decode('utf-8', errors='replace').rstrip())
except Exception:
pass
threading.Thread(target=_read_stderr, daemon=True).start()
msg = _recv_msg(_proc)
if msg and msg.get('type') == 'ready':
_engine_status['initialized'] = True
_engine_status['device'] = msg.get('device', 'unknown')
_engine_status['error'] = None
return True, None
else:
err = msg.get('error', '未知错误') if msg else '子进程无响应'
if _proc_stderr:
err += '\n--- 日志 ---\n' + '\n'.join(_proc_stderr[-10:])
_engine_status['error'] = err
_kill_proc()
return False, err
except Exception as e:
_engine_status['error'] = str(e)
_kill_proc()
return False, str(e)
finally:
_engine_status['loading'] = False
def _kill_proc():
global _proc
if _proc:
try:
_proc.kill()
except Exception:
pass
_proc = None
def _send_command(obj):
with _synth_lock:
if not _proc or _proc.poll() is not None:
return False, '子进程未运行'
try:
_send_msg(_proc, obj)
msg = _recv_msg(_proc)
if msg and msg.get('type') == 'done':
return True, msg
else:
return False, msg.get('error', '未知错误') if msg else '子进程无响应'
except Exception as e:
return False, str(e)
# ==================== 路由 ====================
@bp.route('/')
def page():
cfg = _load_config()
return render_template('rvc.html',
config=cfg, f0_methods=F0_METHODS,
f0_method_names=F0_METHOD_NAMES)
@bp.route('/config', methods=['GET'])
def get_config():
cfg = _load_config()
cfg['engine_status'] = _engine_status
# 扫描模型
rvc_path = cfg.get('rvc_project_path', '')
model_dir_raw = cfg.get('model_dir', 'assets/weights')
model_dir = model_dir_raw if cfg.get('model_dir_mode') == 'absolute' else os.path.join(rvc_path, model_dir_raw)
cfg['models'] = _scan_pth_models(model_dir)
# 扫描 index
index_dir_raw = cfg.get('index_dir', 'logs')
index_dir = index_dir_raw if cfg.get('index_dir_mode') == 'absolute' else os.path.join(rvc_path, index_dir_raw)
cfg['index_files'] = _scan_index_files(index_dir)
return jsonify(cfg)
@bp.route('/config', methods=['POST'])
def save_config():
data = request.get_json()
cfg = _load_config()
for key in ('rvc_project_path', 'model_dir', 'model_dir_mode', 'index_dir', 'index_dir_mode',
'last_model', 'last_index',
'f0_up_key', 'f0_method', 'index_rate',
'filter_radius', 'resample_sr', 'rms_mix_rate', 'protect'):
if key in data:
cfg[key] = data[key]
_save_config(cfg)
return jsonify({'success': True})
@bp.route('/upload-audio', methods=['POST'])
def upload_audio():
if 'audio' not in request.files:
return jsonify({'success': False, 'error': '请上传音频文件'}), 400
audio_file = request.files['audio']
if not audio_file.filename:
return jsonify({'success': False, 'error': '未选择文件'}), 400
ext = os.path.splitext(audio_file.filename)[1].lower()
if ext not in ('.wav', '.mp3', '.flac', '.ogg', '.m4a', '.aac'):
return jsonify({'success': False, 'error': f'不支持的音频格式: {ext}'}), 400
orig_filename = audio_file.filename
filename = uuid.uuid4().hex + ext
save_path = os.path.join(tempfile.gettempdir(), filename)
audio_file.save(save_path)
return jsonify({'success': True, 'path': save_path, 'filename': orig_filename})
@bp.route('/engine-status', methods=['GET'])
def engine_status():
status = dict(_engine_status)
status['logs'] = _proc_stderr[-50:]
return jsonify(status)
@bp.route('/clear-logs', methods=['POST'])
def clear_logs():
_proc_stderr.clear()
return jsonify({'success': True})
@bp.route('/init-engine', methods=['POST'])
def init_engine():
ok, err = _init_engine()
if ok:
return jsonify({'success': True, 'device': _engine_status.get('device')})
return jsonify({'success': False, 'error': err})
@bp.route('/reload-engine', methods=['POST'])
def reload_engine():
global _engine_status
_kill_proc()
_engine_status = {'initialized': False, 'loading': False, 'error': None, 'device': None, 'model_loaded': False}
ok, err = _init_engine()
if ok:
return jsonify({'success': True, 'device': _engine_status.get('device')})
return jsonify({'success': False, 'error': err})
@bp.route('/stop-engine', methods=['POST'])
def stop_engine():
global _engine_status
_kill_proc()
_engine_status = {'initialized': False, 'loading': False, 'error': None, 'device': None, 'model_loaded': False}
return jsonify({'success': True})
@bp.route('/switch-model', methods=['POST'])
def switch_model():
if not _engine_status['initialized']:
return jsonify({'success': False, 'error': '引擎未初始化'}), 400
data = request.get_json()
model_name = data.get('model_name')
if not model_name:
return jsonify({'success': False, 'error': '缺少模型名称'}), 400
ok, result = _send_command({'type': 'load_model', 'model_name': model_name})
if ok:
_engine_status['model_loaded'] = True
cfg = _load_config()
cfg['last_model'] = model_name
_save_config(cfg)
return jsonify({'success': True, 'message': f'模型已加载: {model_name}'})
return jsonify({'success': False, 'error': result})
@bp.route('/convert', methods=['POST'])
def convert():
if not _engine_status['initialized']:
return jsonify({'success': False, 'error': '引擎未初始化,请先初始化'}), 400
if not _engine_status.get('model_loaded'):
return jsonify({'success': False, 'error': '请先加载语音模型'}), 400
data = request.get_json()
input_path = (data.get('input_path') or '').strip()
if not input_path or not os.path.exists(input_path):
return jsonify({'success': False, 'error': '请上传有效的音频文件'}), 400
cfg = _load_config()
for key in ('last_model', 'last_index', 'f0_up_key', 'f0_method',
'index_rate', 'filter_radius', 'resample_sr', 'rms_mix_rate', 'protect'):
if key in data:
cfg[key] = data[key]
_save_config(cfg)
task_id = uuid.uuid4().hex
output_path = os.path.join(tempfile.gettempdir(), f'rvc_{task_id}.wav')
convert_params = {
'type': 'convert',
'input_path': input_path,
'output_path': output_path,
'f0_up_key': data.get('f0_up_key', cfg['f0_up_key']),
'f0_method': data.get('f0_method', cfg['f0_method']),
'file_index': data.get('file_index', ''),
'index_rate': data.get('index_rate', cfg['index_rate']),
'filter_radius': data.get('filter_radius', cfg['filter_radius']),
'resample_sr': data.get('resample_sr', cfg['resample_sr']),
'rms_mix_rate': data.get('rms_mix_rate', cfg['rms_mix_rate']),
'protect': data.get('protect', cfg['protect']),
}
def _do_convert():
ok, result = _send_command(convert_params)
if ok:
_task_results_local[task_id] = {'status': 'done', 'file': output_path}
else:
_task_results_local[task_id] = {'status': 'error', 'error': result}
_task_results_local[task_id] = {'status': 'converting'}
t = threading.Thread(target=_do_convert, daemon=True)
t.start()
return jsonify({'success': True, 'task_id': task_id})
@bp.route('/status/<task_id>')
def task_status(task_id):
info = _task_results_local.get(task_id)
if not info:
return jsonify({'success': False, 'error': '任务不存在'}), 404
resp = {'success': True, 'progress': info}
if info['status'] == 'done':
resp['audio_url'] = f'/rvc/audio/{task_id}'
elif info['status'] == 'error':
resp['progress'] = {'status': 'error', 'error': info.get('error', '未知错误')}
_task_results_local.pop(task_id, None)
return jsonify(resp)
@bp.route('/audio/<task_id>')
def get_audio(task_id):
info = _task_results_local.pop(task_id, None)
audio_path = info['file'] if info else os.path.join(tempfile.gettempdir(), f'rvc_{task_id}.wav')
if not os.path.exists(audio_path):
return jsonify({'error': '音频文件不存在'}), 404
@after_this_request
def cleanup(resp):
try:
os.remove(audio_path)
except OSError:
pass
return resp
return send_file(audio_path, mimetype='audio/wav')

View File

@@ -0,0 +1,342 @@
# -*- coding: utf-8 -*-
"""
sovits-tts GPT-SoVITS v2 配音蓝图
代理转发请求到 GPT-SoVITS v2 API 服务
"""
import os
import json
import glob
import platform
import subprocess
import requests
from flask import Blueprint, render_template, request, jsonify, Response
bp = Blueprint('sovits_tts', __name__, url_prefix='/sovits-tts')
CONFIG_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'config', 'sovits_config.json')
try:
from config import BASE_DIR
CONFIG_PATH = os.path.join(BASE_DIR, 'config', 'sovits_config.json')
except ImportError:
pass
_sovits_proc = None
@bp.route('/')
def page():
return render_template('sovits_tts.html')
def _load_config():
if os.path.exists(CONFIG_PATH):
with open(CONFIG_PATH, 'r', encoding='utf-8') as f:
return json.load(f)
return {
'api_url': 'http://127.0.0.1:9880',
'gpt_model_dir': '', 'sovits_model_dir': '',
'last_gpt_model': '', 'last_sovits_model': '',
'sovits_path': '', 'start_cmd': 'start api_v2.bat',
'refer_audio_history': [],
'refer_audio_folder': '',
'top_k': 5, 'top_p': 1.0, 'temperature': 1.0,
'batch_size': 32, 'speed': 1.0, 'text_split_method': 'cut5'
}
def _save_config(cfg):
with open(CONFIG_PATH, 'w', encoding='utf-8') as f:
json.dump(cfg, f, ensure_ascii=False, indent=2)
def _scan_models(model_dir, ext):
if not model_dir or not os.path.isdir(model_dir):
return []
files = glob.glob(os.path.join(model_dir, '**', f'*{ext}'), recursive=True)
files.sort(key=lambda f: os.path.getmtime(f), reverse=True)
return files
@bp.route('/config', methods=['GET'])
def get_config():
cfg = _load_config()
cfg['gpt_models'] = _scan_models(cfg.get('gpt_model_dir', ''), '.ckpt')
cfg['sovits_models'] = _scan_models(cfg.get('sovits_model_dir', ''), '.pth')
return jsonify(cfg)
@bp.route('/config', methods=['POST'])
def save_config():
data = request.get_json()
cfg = _load_config()
for key in ('api_url', 'gpt_model_dir', 'sovits_model_dir', 'last_gpt_model', 'last_sovits_model', 'sovits_path', 'start_cmd',
'top_k', 'top_p', 'temperature', 'batch_size', 'speed', 'text_split_method', 'refer_audio_folder'):
if key in data:
cfg[key] = data[key]
_save_config(cfg)
return jsonify({'success': True})
@bp.route('/refer-audio-history', methods=['POST'])
def save_refer_audio():
data = request.get_json()
path = (data.get('path') or '').strip()
if not path:
return jsonify({'success': False, 'error': '路径不能为空'})
cfg = _load_config()
history = cfg.get('refer_audio_history', [])
if path in history:
history.remove(path)
history.insert(0, path)
cfg['refer_audio_history'] = history
_save_config(cfg)
return jsonify({'success': True, 'history': history})
@bp.route('/refer-audio-history', methods=['DELETE'])
def delete_refer_audio():
data = request.get_json()
path = (data.get('path') or '').strip()
cfg = _load_config()
history = cfg.get('refer_audio_history', [])
if path in history:
history.remove(path)
cfg['refer_audio_history'] = history
_save_config(cfg)
return jsonify({'success': True, 'history': history})
@bp.route('/refer-audio-history', methods=['PUT'])
def update_refer_audio():
data = request.get_json()
old_path = (data.get('old_path') or '').strip()
new_path = (data.get('new_path') or '').strip()
if not old_path or not new_path:
return jsonify({'success': False, 'error': '路径不能为空'})
cfg = _load_config()
history = cfg.get('refer_audio_history', [])
if old_path not in history:
return jsonify({'success': False, 'error': '原路径不存在'})
if new_path in history and new_path != old_path:
return jsonify({'success': False, 'error': '新路径已存在'})
idx = history.index(old_path)
history[idx] = new_path
cfg['refer_audio_history'] = history
_save_config(cfg)
return jsonify({'success': True, 'history': history})
@bp.route('/scan-audio-folder', methods=['POST'])
def scan_audio_folder():
"""扫描文件夹下的音频文件"""
data = request.get_json()
folder = (data.get('folder') or '').strip()
if not folder:
return jsonify({'success': False, 'error': '请输入文件夹路径'})
if not os.path.isdir(folder):
return jsonify({'success': False, 'error': '文件夹不存在'})
AUDIO_EXTS = ('.wav', '.mp3', '.flac', '.ogg', '.aac', '.m4a', '.wma')
files = []
for f in os.listdir(folder):
full = os.path.join(folder, f)
if os.path.isfile(full) and f.lower().endswith(AUDIO_EXTS):
files.append(full)
files.sort(key=lambda x: os.path.basename(x).lower())
# 保存最后使用的文件夹
cfg = _load_config()
cfg['refer_audio_folder'] = folder
_save_config(cfg)
return jsonify({'success': True, 'files': files, 'count': len(files)})
@bp.route('/test-connection', methods=['POST'])
def test_connection():
data = request.get_json()
api_url = (data.get('api_url') or '').strip().rstrip('/')
if not api_url:
return jsonify({'success': False, 'error': '请填写 GPT-SoVITS 服务地址'})
try:
r = requests.get(api_url + '/test', timeout=10)
if r.status_code == 200:
data = r.json()
return jsonify({'success': True, 'message': data.get('message', '连接成功')})
return jsonify({'success': False, 'error': f'服务返回状态码 {r.status_code}'})
except requests.exceptions.ConnectionError:
return jsonify({'success': False, 'error': f'无法连接到: {api_url}'})
except requests.exceptions.Timeout:
return jsonify({'success': False, 'error': '连接超时'})
except Exception as e:
return jsonify({'success': False, 'error': str(e)})
@bp.route('/run', methods=['POST'])
def run_api():
global _sovits_proc
if platform.system() != 'Windows':
return jsonify({'success': False, 'error': '仅支持 Windows 系统'})
cfg = _load_config()
sovits_path = (cfg.get('sovits_path') or '').strip()
start_cmd = (cfg.get('start_cmd') or '').strip()
if not sovits_path:
return jsonify({'success': False, 'error': '请先配置 GPT-SoVITS 路径'})
if not os.path.isdir(sovits_path):
return jsonify({'success': False, 'error': f'路径不存在: {sovits_path}'})
if not start_cmd:
return jsonify({'success': False, 'error': '请先配置启动命令'})
try:
_sovits_proc = subprocess.Popen(start_cmd, shell=True, cwd=sovits_path)
return jsonify({'success': True, 'message': f'已启动: {start_cmd}'})
except Exception as e:
return jsonify({'success': False, 'error': f'启动失败: {str(e)}'})
@bp.route('/status', methods=['GET'])
def check_status():
cfg = _load_config()
api_url = (cfg.get('api_url') or '').strip().rstrip('/')
if not api_url:
return jsonify({'running': False})
try:
r = requests.get(api_url + '/test', timeout=3)
if r.status_code == 200:
data = r.json()
return jsonify({'running': True, 'message': data.get('message', '')})
except Exception:
pass
return jsonify({'running': False})
@bp.route('/kill', methods=['POST'])
def kill_api():
global _sovits_proc
if _sovits_proc and _sovits_proc.poll() is None:
try:
subprocess.call(['taskkill', '/F', '/T', '/PID', str(_sovits_proc.pid)],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
except Exception:
pass
_sovits_proc = None
else:
cfg = _load_config()
api_url = (cfg.get('api_url') or '').strip().rstrip('/')
if api_url:
try:
r = requests.get(api_url + '/control?command=exit', timeout=5)
except Exception:
pass
return jsonify({'success': True, 'message': '已发送停止信号'})
@bp.route('/synthesize', methods=['POST'])
def synthesize():
data = request.get_json()
api_url = (data.get('api_url') or '').strip().rstrip('/')
text = (data.get('text') or '').strip()
text_lang = (data.get('text_lang') or 'zh').strip()
if not api_url:
return jsonify({'success': False, 'error': '请填写 GPT-SoVITS 服务地址'})
if not text:
return jsonify({'success': False, 'error': '请输入要合成的文本'})
ref_audio_path = (data.get('ref_audio_path') or '').strip()
prompt_text = (data.get('prompt_text') or '').strip()
prompt_lang = (data.get('prompt_lang') or 'zh').strip()
if not ref_audio_path:
return jsonify({'success': False, 'error': '请填写参考音频路径'})
payload = {
'text': text,
'text_lang': text_lang,
'ref_audio_path': ref_audio_path,
'prompt_text': prompt_text,
'prompt_lang': prompt_lang,
'text_split_method': (data.get('text_split_method') or 'cut5').strip(),
'batch_size': data.get('batch_size', 1),
'top_k': data.get('top_k', 5),
'top_p': data.get('top_p', 1.0),
'temperature': data.get('temperature', 1.0),
'speed_factor': data.get('speed_factor', 1.0),
'seed': data.get('seed', -1),
'media_type': 'wav',
'streaming_mode': False,
}
try:
r = requests.post(api_url + '/tts', json=payload, timeout=300)
content_type = r.headers.get('Content-Type', '')
if r.status_code == 200 and 'audio' in content_type:
return Response(r.content, mimetype='audio/wav',
headers={'Content-Disposition': 'inline'})
else:
try:
err = r.json()
msg = err.get('message', r.text[:200])
except Exception:
msg = r.text[:200]
return jsonify({'success': False, 'error': f'合成失败: {msg}'})
except requests.exceptions.ConnectionError:
return jsonify({'success': False, 'error': f'无法连接到: {api_url}'})
except requests.exceptions.Timeout:
return jsonify({'success': False, 'error': '合成超时300秒文本可能过长'})
except Exception as e:
return jsonify({'success': False, 'error': f'请求失败: {str(e)}'})
@bp.route('/set-gpt-model', methods=['POST'])
def set_gpt_model():
data = request.get_json()
api_url = (data.get('api_url') or '').strip().rstrip('/')
weights_path = (data.get('weights_path') or '').strip()
if not api_url:
return jsonify({'success': False, 'error': '请填写 GPT-SoVITS 服务地址'})
if not weights_path:
return jsonify({'success': False, 'error': '请选择 GPT 模型'})
try:
r = requests.get(api_url + '/set_gpt_weights', params={'weights_path': weights_path}, timeout=60)
if r.status_code == 200:
return jsonify({'success': True, 'message': 'GPT 模型切换成功'})
try:
err = r.json()
msg = err.get('message', r.text[:200])
except Exception:
msg = r.text[:200]
return jsonify({'success': False, 'error': f'切换失败: {msg}'})
except requests.exceptions.ConnectionError:
return jsonify({'success': False, 'error': f'无法连接到: {api_url}'})
except Exception as e:
return jsonify({'success': False, 'error': str(e)})
@bp.route('/set-sovits-model', methods=['POST'])
def set_sovits_model():
data = request.get_json()
api_url = (data.get('api_url') or '').strip().rstrip('/')
weights_path = (data.get('weights_path') or '').strip()
if not api_url:
return jsonify({'success': False, 'error': '请填写 GPT-SoVITS 服务地址'})
if not weights_path:
return jsonify({'success': False, 'error': '请选择 SoVITS 模型'})
try:
r = requests.get(api_url + '/set_sovits_weights', params={'weights_path': weights_path}, timeout=60)
if r.status_code == 200:
return jsonify({'success': True, 'message': 'SoVITS 模型切换成功'})
try:
err = r.json()
msg = err.get('message', r.text[:200])
except Exception:
msg = r.text[:200]
return jsonify({'success': False, 'error': f'切换失败: {msg}'})
except requests.exceptions.ConnectionError:
return jsonify({'success': False, 'error': f'无法连接到: {api_url}'})
except Exception as e:
return jsonify({'success': False, 'error': str(e)})

View File

@@ -0,0 +1,338 @@
# -*- coding: utf-8 -*-
"""
STT 语音识别蓝图
使用 faster-whisper 实现音频转文字
"""
import os
import json
import uuid
import subprocess
import tempfile
import threading
import time
from datetime import timedelta
from flask import Blueprint, render_template, request, jsonify
bp = Blueprint('stt', __name__, url_prefix='/stt')
# HuggingFace 镜像
os.environ['HF_ENDPOINT'] = 'https://hf-mirror.com'
# OpenCC 繁体转简体
try:
from opencc import OpenCC
_cc_t2s = OpenCC('t2s')
except Exception:
_cc_t2s = None
try:
from config import BASE_DIR
except ImportError:
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
CONFIG_PATH = os.path.join(BASE_DIR, 'config', 'stt_config.json')
# 模型缓存
_model_cache = {}
_task_queue = []
_task_progress = {}
_task_results = {}
_worker_started = False
_worker_lock = threading.Lock()
LANGUAGES = {
'auto': '自动检测',
'zh': '中文', 'en': '英语', 'ja': '日语', 'ko': '韩语',
'fr': '法语', 'de': '德语', 'es': '西班牙语', 'ru': '俄语',
'th': '泰语', 'it': '意大利语', 'pt': '葡萄牙语', 'vi': '越南语',
'ar': '阿拉伯语', 'tr': '土耳其语',
}
def _load_config():
cfg = {'model_dir': ''}
if os.path.exists(CONFIG_PATH):
with open(CONFIG_PATH, 'r', encoding='utf-8') as f:
cfg.update(json.load(f))
return cfg
def _save_config(cfg):
with open(CONFIG_PATH, 'w', encoding='utf-8') as f:
json.dump(cfg, f, ensure_ascii=False, indent=2)
def _scan_models(directory):
"""扫描模型目录,提取已下载的模型名称"""
models = []
if not directory or not os.path.isdir(directory):
return models
prefix = 'models--Systran--faster-whisper-'
for name in os.listdir(directory):
full = os.path.join(directory, name)
if os.path.isdir(full) and name.startswith(prefix):
model_name = name[len(prefix):]
if model_name:
models.append(model_name)
return sorted(models)
@bp.route('/')
def page():
cfg = _load_config()
return render_template('stt.html', models=_scan_models(cfg['model_dir']), languages=LANGUAGES)
@bp.route('/config', methods=['GET'])
def get_config():
cfg = _load_config()
cfg['models'] = _scan_models(cfg['model_dir'])
return jsonify(cfg)
@bp.route('/config', methods=['POST'])
def save_config():
data = request.get_json()
cfg = _load_config()
if 'model_dir' in data:
cfg['model_dir'] = data['model_dir'].strip()
_save_config(cfg)
return jsonify({'success': True})
@bp.route('/models')
def list_models():
cfg = _load_config()
return jsonify({'models': _scan_models(cfg['model_dir'])})
@bp.route('/cuda-check')
def cuda_check():
"""检查 CUDA 是否可用ctranslate2 优先PyTorch 兜底)"""
# 方法1: ctranslate2faster-whisper 的实际后端)
try:
import ctranslate2
count = ctranslate2.get_cuda_device_count()
if count > 0:
name = 'CUDA Device'
try:
name = ctranslate2.get_cuda_device_name(0) or name
except Exception:
pass
return jsonify({'cuda': True, 'device_count': count, 'name': name})
except Exception:
pass
# 方法2: PyTorch
try:
import torch
if torch.cuda.is_available():
return jsonify({'cuda': True, 'device_count': torch.cuda.device_count(),
'name': torch.cuda.get_device_name(0)})
except Exception:
pass
return jsonify({'cuda': False})
def _ms_to_srt_time(ms):
td = timedelta(milliseconds=ms)
h, rem = divmod(td.seconds, 3600)
m, s = divmod(rem, 60)
ms_part = td.microseconds // 1000
return f'{h:02d}:{m:02d}:{s:02d},{ms_part:03d}'
def _convert_to_wav(input_path):
"""用 FFmpeg 转为 16kHz 单声道 WAV返回 wav 路径或 None"""
wav_path = os.path.join(tempfile.gettempdir(), uuid.uuid4().hex + '.wav')
cmd = ['ffmpeg', '-y', '-i', input_path, '-ar', '16000', '-ac', '1', wav_path]
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=300,
creationflags=0x08000000 if os.name == 'nt' else 0)
if result.returncode == 0 and os.path.exists(wav_path):
return wav_path
except Exception:
pass
return None
def _get_model(model_name, device='cpu'):
"""获取或加载模型CUDA 失败自动回退 CPU"""
cfg = _load_config()
model_dir = cfg['model_dir']
cache_key = f'{model_name}_{device}_{model_dir}'
if cache_key not in _model_cache:
from faster_whisper import WhisperModel
try:
_model_cache[cache_key] = WhisperModel(
model_name, device=device, download_root=model_dir
)
except Exception as e:
if device == 'cuda':
# CUDA 加载失败,回退 CPU
cpu_key = f'{model_name}_cpu_{model_dir}'
if cpu_key not in _model_cache:
_model_cache[cpu_key] = WhisperModel(
model_name, device='cpu', download_root=model_dir
)
return _model_cache[cpu_key], f'CUDA 加载失败({e}),已回退到 CPU'
raise
return _model_cache[cache_key], None
def _process_task(task):
"""处理单个转录任务"""
task_id = task['task_id']
fmt = task.get('format', 'text')
device = task.get('device', 'cpu')
try:
_task_progress[task_id] = {'percent': 0, 'status': 'loading', 'format': fmt}
model, warn = _get_model(task['model'], device)
if warn:
_task_progress[task_id] = {'percent': 0, 'status': 'transcribing', 'format': fmt, 'warning': warn}
else:
_task_progress[task_id] = {'percent': 0, 'status': 'transcribing', 'format': fmt}
lang = task['language'] if task['language'] != 'auto' else None
segments, info = model.transcribe(
task['wav_path'],
beam_size=5, best_of=5,
vad_filter=True,
language=lang,
)
total_duration = max(info.duration, 0.01)
results = []
for seg in segments:
_task_progress[task_id] = {
'percent': round(seg.end / total_duration, 2),
'status': 'transcribing',
'format': fmt,
}
text = seg.text.strip()
if not text or len(text) <= 1:
continue
if _cc_t2s:
text = _cc_t2s.convert(text)
start_ms = int(seg.start * 1000)
end_ms = int(seg.end * 1000)
results.append({
'start': start_ms,
'end': end_ms,
'start_time': _ms_to_srt_time(start_ms),
'end_time': _ms_to_srt_time(end_ms),
'text': text,
})
_task_results[task_id] = results
_task_progress[task_id] = {'percent': 1, 'status': 'done', 'format': fmt}
except Exception as e:
_task_progress[task_id] = {'percent': 0, 'status': 'error', 'error': str(e), 'format': fmt}
finally:
# 清理临时文件
try:
if os.path.exists(task['wav_path']):
os.remove(task['wav_path'])
except Exception:
pass
def _worker():
"""后台 worker 线程"""
while True:
if not _task_queue:
time.sleep(1)
continue
task = _task_queue.pop(0)
_process_task(task)
def _ensure_worker():
global _worker_started
if not _worker_started:
with _worker_lock:
if not _worker_started:
t = threading.Thread(target=_worker, daemon=True)
t.start()
_worker_started = True
@bp.route('/transcribe', methods=['POST'])
def transcribe():
if 'audio' not in request.files:
return jsonify({'success': False, 'error': '请上传音频文件'}), 400
audio_file = request.files['audio']
if not audio_file.filename:
return jsonify({'success': False, 'error': '未选择文件'}), 400
model_name = request.form.get('model', 'base')
language = request.form.get('language', 'auto')
device = request.form.get('device', 'cpu')
output_format = request.form.get('format', 'text')
if model_name not in _scan_models(_load_config()['model_dir']):
return jsonify({'success': False, 'error': f'模型 {model_name} 不存在,请先下载到 stt_models 目录'}), 400
# 保存原始文件
ext = os.path.splitext(audio_file.filename)[1].lower()
original_path = os.path.join(tempfile.gettempdir(), uuid.uuid4().hex + ext)
audio_file.save(original_path)
# 转 WAV
wav_path = _convert_to_wav(original_path)
try:
os.remove(original_path)
except Exception:
pass
if not wav_path:
return jsonify({'success': False, 'error': '音频转换失败,请确保 FFmpeg 已安装'}), 500
# 创建任务
task_id = uuid.uuid4().hex
_task_progress[task_id] = {'percent': 0, 'status': 'queued', 'format': output_format}
_task_queue.append({
'task_id': task_id,
'wav_path': wav_path,
'model': model_name,
'language': language,
'device': device,
'format': output_format,
})
_ensure_worker()
return jsonify({'success': True, 'task_id': task_id})
@bp.route('/status/<task_id>')
def task_status(task_id):
progress = _task_progress.get(task_id)
if not progress:
return jsonify({'success': False, 'error': '任务不存在'}), 404
resp = {'success': True, 'progress': progress}
if progress['status'] == 'done':
results = _task_results.get(task_id, [])
fmt = progress.get('format', 'text')
if fmt == 'srt':
lines = []
for i, r in enumerate(results):
lines.append(f"{i+1}\n{r['start_time']} --> {r['end_time']}\n{r['text']}\n")
resp['result'] = '\n'.join(lines)
elif fmt == 'json':
resp['result'] = results
else:
resp['result'] = '\n'.join(r['text'] for r in results)
# 清理
_task_progress.pop(task_id, None)
_task_results.pop(task_id, None)
return jsonify(resp)

View File

@@ -0,0 +1,9 @@
# -*- coding: utf-8 -*-
from flask import Blueprint, render_template
bp = Blueprint('token_gen', __name__, url_prefix='/token-gen')
@bp.route('/')
def page():
return render_template('token_gen.html')

View File

@@ -0,0 +1,9 @@
# -*- coding: utf-8 -*-
from flask import Blueprint, render_template
bp = Blueprint('url_parser', __name__, url_prefix='/url-parser')
@bp.route('/')
def page():
return render_template('url_parser.html')

23
flask-dev-api/config.py Normal file
View File

@@ -0,0 +1,23 @@
# -*- coding: utf-8 -*-
"""
全局配置集中管理
"""
import os
# 基础目录
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
# ==================== down-video 视频下载 ====================
TWITTER_COOKIE = os.path.join(BASE_DIR, "config", "dv_cookies", "x.com_cookies.txt")
BILIBILI_COOKIE = os.path.join(BASE_DIR, "config", "dv_cookies", "bilibili.com_cookies.txt")
INSTAGRAM_COOKIE = os.path.join(BASE_DIR, "config", "dv_cookies", "instagram.com_cookies.txt")
YOUTUBE_COOKIE = os.path.join(BASE_DIR, "config", "dv_cookies", "youtube.com_cookies.txt")
# 默认下载目录
DEFAULT_OUTPUT_DIR = r"D:\UserData\Desktop\xiazai"
# 默认代理端口
PROXY_URL = "socks5://127.0.0.1:10808"
# ==================== fen-ci 分词 ====================
JIEBA_DICT_FILE = os.path.join(BASE_DIR, 'config', 'fen_ci', 'jieba.txt')
TOKEN_FILE_PATH = os.path.join(BASE_DIR, 'config', 'fen_ci', 'usertoken.json')
# ==================== 服务端口 ====================
PORT = 5000

View File

@@ -0,0 +1,20 @@
{
"sovits_project_path": "E:\\AI\\GPT-SoVITS-v4",
"sovits_config_yaml": "GPT_SoVITS\\configs\\tts_infer.yaml",
"last_prompt_text": "",
"last_prompt_lang": "zh",
"last_text_lang": "zh",
"last_text_split_method": "cut5",
"top_k": 5,
"top_p": 1,
"temperature": 1,
"batch_size": 16,
"speed_factor": 0.85,
"seed": -1,
"gpt_model_dir": "E:\\AI\\GPT-SoVITS-v4\\GPT_weights_v2",
"sovits_model_dir": "E:\\AI\\GPT-SoVITS-v4\\SoVITS_weights_v2",
"last_gpt_model": "bubu-e15.ckpt",
"last_sovits_model": "bubu_e4_s68.pth",
"refer_audio_folder": "D:\\UserData\\Desktop\\声音素材\\参考音频",
"config_yaml": "GPT_SoVITS/configs/tts_infer.yaml"
}

View File

@@ -0,0 +1,49 @@
# Netscape HTTP Cookie File
# This file is generated by yt-dlp. Do not edit.
live.bilibili.com FALSE / FALSE 1782229863 GIFT_BLOCK_COOKIE GIFT_BLOCK_COOKIE
.live.bilibili.com TRUE / FALSE 1809050447 GIFT_BLOCK_COOKIE GIFT_BLOCK_COOKIE
.live.bilibili.com TRUE / FALSE 1809050447 LIVE_PK_GUARD 1
.www.bilibili.com TRUE / FALSE 1809050447 bmg_af_switch 1
.www.bilibili.com TRUE / FALSE 1809050447 bmg_src_def_domain i0.hdslb.com
.bilibili.com TRUE / FALSE 1809050253 buvid3 0237DF2E-9521-872E-651B-19B301DAB90D62499infoc
.bilibili.com TRUE / FALSE 1809050253 b_nut 1746844662
.bilibili.com TRUE / FALSE 1809050253 _uuid FB7F39E5-73C2-1086D-366F-F7635C521056A61874infoc
.bilibili.com TRUE / FALSE 1809050253 enable_web_push DISABLE
.bilibili.com TRUE / FALSE 1809050253 enable_feed_channel ENABLE
.bilibili.com TRUE / FALSE 1809050253 hit-dyn-v2 1
.bilibili.com TRUE / FALSE 1809050253 rpdid 0zbfvRTtA1|uIiOHoVI|mcP|3w1UdA6S
.bilibili.com TRUE / FALSE 1809050253 opus-goback 1
.bilibili.com TRUE / FALSE 1809050253 fingerprint acb00c50626cb1195b761be931da19c5
.bilibili.com TRUE / FALSE 1809050253 buvid_fp_plain undefined
.bilibili.com TRUE / FALSE 1809050253 buvid_fp acb00c50626cb1195b761be931da19c5
.bilibili.com TRUE / FALSE 1809050253 header_theme_version OPEN
.bilibili.com TRUE / FALSE 1812979749 theme-tip-show SHOWED
.bilibili.com TRUE / FALSE 1809050253 LIVE_BUVID AUTO8417538829055118
.bilibili.com TRUE / FALSE 1809050253 theme-avatar-tip-show SHOWED
.bilibili.com TRUE / FALSE 1809050253 theme-switch-show SHOWED
.bilibili.com TRUE / FALSE 1809050253 theme_style light
.bilibili.com TRUE / TRUE 1796809280 DedeUserID 403551212
.bilibili.com TRUE / TRUE 1796809280 DedeUserID__ckMd5 168d56fdf378bcfc
.bilibili.com TRUE / FALSE 1809050253 PVID 3
.bilibili.com TRUE / FALSE 1816004451 buvid4 D3170BA8-2910-9D9B-A608-52B60F1437AB62978-025051010-2j8K1NCxW9nraGJ2vOJv2vg3LhbmydYJolrkxpng+JQJZmNXaAoya/H8tItJHUfy
.bilibili.com TRUE / FALSE 1812974025 home_feed_column 5
.bilibili.com TRUE / FALSE 1812018942 CURRENT_QUALITY 64
.bilibili.com TRUE / FALSE 1781516479 bili_ticket eyJhbGciOiJIUzI1NiIsImtpZCI6InMwMyIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3ODE1MTY0ODIsImlhdCI6MTc4MTI1NzIyMiwicGx0IjotMX0.eEl-svs_DNL9hArempTC7OjEOmwU5zkzh4z5lvc2JGU
.bilibili.com TRUE / FALSE 1781516479 bili_ticket_expires 1781516422
.bilibili.com TRUE / TRUE 1796809280 SESSDATA e981c0f8%2C1796809283%2C53cbf%2A61CjDSekND1lFe5TiJgnRjBVV1dsBTaBY0JP5uDZhtM2bI4WZ9FnbNW1xAQXYO7eBAH7cSVk1jUG0zLU1SWUprSFZxb0VZNl9HM05WckVCZHpYRTVqM3dFUkMwbEVjai1DWF9MbERjUDZVVExzZ2JLMjBnbFhfSjA0MUZWTThBb2tRaDZDQ1d6UHpBIIEC
.bilibili.com TRUE / TRUE 1796809280 bili_jct ef487b5774b781e6ab9c18d90213d327
.bilibili.com TRUE / FALSE 0 sid 8gro2gn0
.bilibili.com TRUE / FALSE 1812974025 browser_resolution 1851-897
.bilibili.com TRUE / FALSE 0 bsource search_google
.bilibili.com TRUE / FALSE 1812979748 CURRENT_FNVAL 4048
.bilibili.com TRUE / FALSE 1784030027 bp_t_offset_403551212 1213756160121765888
.bilibili.com TRUE / FALSE 0 b_lsid E00D64CD_19EC65D6A1F
www.bilibili.com FALSE / FALSE 0 bmg_src_def_domain i0.hdslb.com
www.bilibili.com FALSE / FALSE 0 bmg_af_sc {"none":{"on":1,"def":"i0.hdslb.com"},"sgp":{"on":1,"def":"i0-sgp.hdslb.com"}}
space.bilibili.com FALSE / FALSE 0 bmg_af_switch 1
space.bilibili.com FALSE / FALSE 0 bmg_src_def_domain i0.hdslb.com
space.bilibili.com FALSE / FALSE 0 bmg_af_sc {"none":{"on":1,"def":"i0.hdslb.com"},"sgp":{"on":1,"def":"i0-sgp.hdslb.com"}}
t.bilibili.com FALSE / FALSE 0 bmg_af_switch 1
t.bilibili.com FALSE / FALSE 0 bmg_src_def_domain i0.hdslb.com
t.bilibili.com FALSE / FALSE 0 bmg_af_sc {"none":{"on":1,"def":"i0.hdslb.com"},"sgp":{"on":1,"def":"i0-sgp.hdslb.com"}}

View File

@@ -0,0 +1,14 @@
# Netscape HTTP Cookie File
# This file is generated by yt-dlp. Do not edit.
.instagram.com TRUE / TRUE 1809050447 datr 7VpRaNFZCVtH94meCwNyGN67
.instagram.com TRUE / TRUE 1809050447 ig_did 4C8BEDB9-BD5A-4578-BE5E-8041FEBB5CFA
.instagram.com TRUE / TRUE 1809050447 ig_nrcb 1
.instagram.com TRUE / TRUE 1809050447 mid aIa_JwALAAFIl_ySVVi41gHHYsqr
.instagram.com TRUE / TRUE 1811647082 ps_l 1
.instagram.com TRUE / TRUE 1811647082 ps_n 1
.instagram.com TRUE / TRUE 1816106662 csrftoken qk83wpj5fjcPiaqmQt7IxK5ja194QaUS
.instagram.com TRUE / TRUE 1789322662 ds_user_id 66789174757
.instagram.com TRUE / TRUE 1782074190 wd 1920x911
.instagram.com TRUE / TRUE 1813005305 sessionid 66789174757%3APbdj3uLKZJD0EN%3A19%3AAYjwKOXobrrd72LKe9vlFJUmTuCya1VXDMgtvuFcRA
.instagram.com TRUE / TRUE 0 rur "CCO\05466789174757\0541813082662:01ff69c7f16b43f762b5e295cdd619d102b106e20d02842bc516c60ddca07b33dacd7514"

View File

@@ -0,0 +1,61 @@
# Netscape HTTP Cookie File
# This file is generated by yt-dlp. Do not edit.
zhutix.com FALSE / FALSE 1782047186 X_CACHE_KEY 0291346395e7ad60497281e3e38d4231
.x.com TRUE / FALSE 1815998942 __cuid b00c4a6e73cf471e95c093aec1d1c0b0
.x.com TRUE / TRUE 1809050447 personalization_id "v1_X+OVgjnn20lE7ppYGHrXzQ=="
.x.com TRUE / TRUE 1809050447 guest_id v1%3A177285432573681062
.x.com TRUE / TRUE 1809050447 kdt aYTMkoEta2sR2Cbrf9TCU2vL6NQrNTk6xP26HzJA
.x.com TRUE / TRUE 1809050447 auth_token e0a16f1e58541f609e0b26bfd2759c5508ad67c6
.x.com TRUE / TRUE 1809050447 ct0 0699cddc947b9fa7e4b226e70d7fc69fdd46a9d4623aea18b98cf8ed9d99fd308a01fec0e531ae1bb50b602a0ba9e87691d694888c39440c7bc8bdc3e35afd1d0757903d5bc702b71fa81c648a0108e7
.x.com TRUE / TRUE 1815998952 guest_id_ads v1%3A177285432573681062
.x.com TRUE / TRUE 1815998952 guest_id_marketing v1%3A177285432573681062
.x.com TRUE / TRUE 1812974953 twid u%3D1051785185509822466
.x.com TRUE / FALSE 1809050447 g_state {"i_l":0,"i_ll":1772854333755,"i_e":{"enable_itp_optimization":0}}
.x.com TRUE / TRUE 1781551671 __cf_bm Y8Rce.E3Yxoh5EYX50LfajfNrmXT685C2ouYXp15erI-1781549871.0334666-1.0.1.1-O1jKivmiYHSUXZNwxtl9tFYsJ.e9bqR5h0Ct_LDNTxmqa6dbjMJz6k1M18IDYzJCL0sQNGf44f7.xfAHvDB1WsZzToX5s7J1.NlMYz5eWG5L1aJ9.ABmgZ1dOkjOQQE6
www.dropbox.com FALSE / TRUE 1790090452 gvc MTk3NzU0NTUwMjg0MDYzMDYzMzA4NTQ0MjEyNzAzODM1MzUxNTYz
www.dropbox.com FALSE / TRUE 1787066452 __Host-js_csrf ee7vG1QZzX2hhBoptjGNMkhg
.dropbox.com TRUE / TRUE 1809050447 t ee7vG1QZzX2hhBoptjGNMkhg
.netflix.com TRUE / TRUE 1809050447 SecureNetflixId v%3D3%26mac%3DAQEAEQABABSSBFH4KvE6qPl_-6UI_re1LAdlfAy3rZk.%26dt%3D1758380348892
.netflix.com TRUE / TRUE 1809050447 NetflixId v%3D3%26ct%3DBgjHlOvcAxLAAb-lG8nEmlB-x4EMpdZG6gG0_ZGe7HgQvUbBUgbe6kPr6Or-1cC94UaN0RbSzxx_OYBaaHLg2MvdKRvw0dKlWwzRJyychkiKImYRxA1PHETG8KjG86tx5smLKn9QcETtqecvyrKl04PUFZ6KuO8Y3LrCO8posa-VMj5U3gonTjruh63uy_zFQYQygkvrJIShQ1yQjNxCropJEzK536vs46y7MYjt_KPCIK9E8tpT80whmRDQ7DgBniSOyGTtiKKyFhgGIg4KDEsJyQjnvm9ZXXKI7g..
.yandex.com TRUE / TRUE 1809050447 yuidss 3388071441761318311
.yandex.com TRUE / TRUE 1809050447 is_gdpr 0
.yandex.com TRUE / TRUE 1809050447 yandexuid 3388071441761318311
.yandex.com TRUE / TRUE 1813426903 my YwA=
.yandex.com TRUE / TRUE 1809050447 is_gdpr_b CMWxMBD63gIoAg==
.yandex.com TRUE / TRUE 1809050447 yandex_gid 11514
.yandex.com TRUE / TRUE 1812418540 i MLohYmrN97KycAe1+2vR9wM3je12zjJpgekoxMDo63OIc1ksAnHK5JG1LZ6vg8avafP/Ui1NvMFstSjVa+DYFB2ckZ8=
.yandex.com TRUE / TRUE 1813426899 bh EkEiQ2hyb21pdW0iO3Y9IjE0MCIsICJOb3Q9QT9CcmFuZCI7dj0iMjQiLCAiR29vZ2xlIENocm9tZSI7dj0iMTQwIhoFIng4NiIiECIxNDAuMC43MzM5LjEyOCIqAj8wMgIiIjoJIldpbmRvd3MiQggiMTkuMC4wIkoEIjY0IlJdIkNocm9taXVtIjt2PSIxNDAuMC43MzM5LjEyOCIsICJOb3Q9QT9CcmFuZCI7dj0iMjQuMC4wLjAiLCAiR29vZ2xlIENocm9tZSI7dj0iMTQwLjAuNzMzOS4xMjgiWgI/MGDTtZ3QBmoe3Mrh/wiS2KGxA5/P4eoD+/rw5w3r//32D/68z4cI
.yandex.com TRUE / TRUE 1813426972 _yasc 4DZrQ4PuAkz3cI5tvgcS3TdKymWz/jxU6srXJpYRokrZt0eokHzSrKn2v7KsfYytjDGkmp1UMV0VXNifmUQwdWqIMLxwDNaq+9UtboqXu0dpnEf0IRhN5ERLjg6xTaPyHEzInkZw+GgAYJ77lLveUOZDY7pGF+rt2AKxULjAvLCXyd6lKmGxeU8a1qdMV+wI2fa7bQ4bZIcvHOgwa9D5Ig==.MTc3ODg2Njk3MzU5Ng==
.yandex.com TRUE / TRUE 1813427034 yp 2094227035.pcs.0#1792854544.swntab.0#1794634904.szm.1%3A1920x1080%3A1368x748%3A15#1779730970.dlp.2
www.kninebox.com FALSE / FALSE 1800025226 SITE_TOTAL_ID c9a65e54de60ac4fe330c0ff3782502c
www.zabbix.com FALSE / TRUE 1798733253 bblastvisit 1767197255
www.zabbix.com FALSE / TRUE 1798733263 bblastactivity 1767197264
x.com FALSE / FALSE 1788406355 g_state {"i_l":0,"i_ll":1772854333755,"i_e":{"enable_itp_optimization":0}}
x.com FALSE / FALSE 1815998942 __cuid b00c4a6e73cf471e95c093aec1d1c0b0
x.com FALSE / FALSE 0 lang zh-cn
www.wmmflix.com FALSE / FALSE 1805768886 lastvisit 229%091774232884%09%2Fapp-index-run%3Fapp%3Dsearch%26keywords%3D%25E6%25A5%259A%25E9%2597%25A8%25E7%259A%2584%25E4%25B8%2596%25E7%2595%258C
.zhutix.com TRUE / FALSE 1809050447 X_CACHE_KEY 0291346395e7ad60497281e3e38d4231
.www.dropbox.com TRUE / TRUE 1809050447 gvc MTk3NzU0NTUwMjg0MDYzMDYzMzA4NTQ0MjEyNzAzODM1MzUxNTYz
.www.kninebox.com TRUE / FALSE 1809050447 SITE_TOTAL_ID c9a65e54de60ac4fe330c0ff3782502c
.www.zabbix.com TRUE / TRUE 1809050447 bblastvisit 1767197255
.www.zabbix.com TRUE / TRUE 1809050447 bblastactivity 1767197264
.www.wmmflix.com TRUE / FALSE 1809050447 lastvisit 229%091774232884%09%2Fapp-index-run%3Fapp%3Dsearch%26keywords%3D%25E6%25A5%259A%25E9%2597%25A8%25E7%259A%2584%25E4%25B8%2596%25E7%2595%258C
.apifox.com TRUE / FALSE 1807354712 projectCid E7kxt1QP-suZw-rUCR-y7Yt-GkpVyJCohmWN
.apifox.com TRUE / TRUE 1807278859 Authorization Bearer UXhlvujyRSawMlpxDml0cqNhAJ2PuUdV
.apifox.com TRUE / TRUE 1807278859 Authorization.sig RL9wbeTJaCY-0qUIbw5CjV9i82cwYlHFJnCRdc_uxpk
fetcherx.com FALSE / TRUE 1813402355 use_lang zh_CN
www.imgix.com FALSE / FALSE 1815214365 fa-consent-ad_personalization false
www.imgix.com FALSE / FALSE 1815214365 fa-consent-ad_storage false
www.imgix.com FALSE / FALSE 1815214365 fa-consent-ad_user_data false
www.imgix.com FALSE / FALSE 1815214365 fa-consent-analytics_storage false
www.imgix.com FALSE / FALSE 1815214365 fa-consent-functionality_storage false
www.imgix.com FALSE / FALSE 1815214365 fa-consent-personalization_storage false
www.imgix.com FALSE / FALSE 1815214365 fa-consent-security_storage true
.imgix.com TRUE / FALSE 1812190443 ajs_anonymous_id 3176670a-5510-4e47-b8c7-979d71a934a8
.imgix.com TRUE / TRUE 1812190444 ph_phc_gqau377j9mZCjDlmVX7QNJjez7Hhs2oI4Nr1sVeoN1s_posthog %7B%22distinct_id%22%3A%22z18631839859%40gmail.com%22%2C%22%24sesid%22%3A%5B1780654444253%2C%22019e9745-0edb-7b1b-8bb4-a1225a2d81d7%22%2C1780654345947%5D%2C%22%24epp%22%3Atrue%2C%22%24initial_person_info%22%3A%7B%22r%22%3A%22https%3A%2F%2Fimages.unsplash.com%2F%22%2C%22u%22%3A%22https%3A%2F%2Fdashboard.imgix.com%2Fauth0%22%7D%7D
login.imgix.com FALSE / TRUE 1812212006 did s%3Av0%3Aaa7f0e2a-1e71-409b-bcac-f23550f526c4%3A5fc289c9a2613ad7d1265ed3322b4c08a448577c0411b63dcfc7dd5852bf6fb5.iPtufEf%2FRRan4MjyN9IjbGoVZVic%2FNfhvfO4A4pa%2FKE
login.imgix.com FALSE / TRUE 1812212006 did_compat s%3Av0%3Aaa7f0e2a-1e71-409b-bcac-f23550f526c4%3A5fc289c9a2613ad7d1265ed3322b4c08a448577c0411b63dcfc7dd5852bf6fb5.iPtufEf%2FRRan4MjyN9IjbGoVZVic%2FNfhvfO4A4pa%2FKE
.dashboard.imgix.com TRUE / TRUE 1812190441 __stripe_mid b060c0ef-41b1-4251-a5c0-36a79f8faddb478cf7
.itzmx.com TRUE / TRUE 1783532085 UpTn_8a61_saltkey rxA6t5qz
.itzmx.com TRUE / TRUE 1783532085 UpTn_8a61_lastvisit 1780936481

View File

@@ -0,0 +1,16 @@
# Netscape HTTP Cookie File
# This file is generated by yt-dlp. Do not edit.
.youtube.com TRUE / TRUE 1816029836 __Secure-3PAPISID p29j5udXvFjH9L_P/A4m-3vMtyP9bCpciZ
.youtube.com TRUE / FALSE 0 PREF tz=UTC&f7=100&f5=30000&hl=en
.youtube.com TRUE / TRUE 1791638492 __Secure-BUCKET CJcC
.youtube.com TRUE / TRUE 1816029836 __Secure-3PSID g.a000_AiLiZZyVuWnpeP6WLBqx9nnIkdJz0G8LSlciB4U2rPpWmEhGfEookTFipBTqTvkL5yU6QACgYKAaMSARYSFQHGX2Mi41GqINM93br4T0lKt6eM6RoVAUF8yKo1k5Y44q-OMZ8AgRBion9i0076
.youtube.com TRUE / TRUE 1813040907 __Secure-1PSIDTS sidts-CjUByojQU6hZiaockSCRL9COCSwcVE3utW1Gk0YS748BVlAiZRRGpquov4L-aRe_Ujl-yVZ0EhAA
.youtube.com TRUE / TRUE 1813040907 __Secure-3PSIDTS sidts-CjUByojQU6hZiaockSCRL9COCSwcVE3utW1Gk0YS748BVlAiZRRGpquov4L-aRe_Ujl-yVZ0EhAA
.youtube.com TRUE / TRUE 1813041037 __Secure-3PSIDCC AKEyXzWNSlDRYYtYIc42aXuLmNjNcIBF1-bqadoifU39MaUz7SILVaK-_agLrwGmN4J5Jel9FQ
.youtube.com TRUE / TRUE 1797056997 __Secure-YNID 19.YT=H7AKSY-Ar7b3FOBF9AYf5spCeonS3PxM0s8Nk5bFq_4vygoqW6Iu82bB7zV8_gK58XsKop2IcZex7mRltci3dUT8fQpK4tI5oQ_CJbyl3VI3bINRU_YixqjlojeVg3XmKehImhhe4g4_IYck3bXa8K68MZqedx3ED6IpOSud6LTMiBFpW63w0FGZDq2FbkFO3novFiLrhCRhomSPB6AAUtCHLCb9bIsEjF_2k560CnkSa4GzBKMfC6jkLpJs5HsoTtKfw4VeiQ4ARTv4Uh6ErKgiWw1CTot-QzYT8PiZOqlZAyFu42cBDuze-bTxskTj1yi_YYwB6o9jmqgVVD3dHQ
.youtube.com TRUE / TRUE 1797060085 VISITOR_INFO1_LIVE 2iHv55tsnuw
.youtube.com TRUE / TRUE 1797060085 VISITOR_PRIVACY_METADATA CgJKUBIEGgAgQA%3D%3D
.youtube.com TRUE / TRUE 1797057037 __Secure-ROLLOUT_TOKEN CJix3Jv29fHvQxDRpqykz4iVAxiiwqy3z4iVAw%3D%3D
.youtube.com TRUE / TRUE 1781508337 GPS 1
.youtube.com TRUE / TRUE 0 SOCS CAI

View File

@@ -0,0 +1,37 @@
MemoFlow
MookNote
VoceChat
WireGuard
SyncClipboard
floccus
GitNex
Bitwarden
Immich
牛逼
安益强
张昊洋
王美丽
李冰素
清华大学
人工智能学院
自然语言处理
机器学习算法
康嘉超
王震瀛
张钰
贝利亚
朱益广
好的
小程序
问一下
曹婷婷
好滴
鲁桂娟
安兰兰
李春雨
王娜娜
telegram
Telegram
v2rayNG
tool
root

View File

@@ -0,0 +1,6 @@
[
{
"user": "admin",
"token": "DdzBgb8GEkBpA8gtCJXP24hGJz9bXpEOi6z91fHm25X59q5968XqtLxPi1MfiTHJ"
}
]

View File

@@ -0,0 +1,16 @@
{
"rvc_project_path": "E:\\AI\\RVC\\RVC1006Nvidia",
"model_dir": "E:\\AI\\RVC\\rvc_con\\weights",
"model_dir_mode": "absolute",
"index_dir": "E:\\AI\\RVC\\rvc_con\\index",
"index_dir_mode": "absolute",
"last_model": "",
"last_index": "",
"f0_up_key": 0,
"f0_method": "rmvpe",
"index_rate": 0.75,
"filter_radius": 3,
"resample_sr": 22050,
"rms_mix_rate": 0.25,
"protect": 0.33
}

View File

@@ -0,0 +1,16 @@
{
"api_url": "http://127.0.0.1:9880",
"gpt_model_dir": "E:\\AI\\GPT-SoVITS-v4\\GPT_weights_v2",
"sovits_model_dir": "E:\\AI\\GPT-SoVITS-v4\\SoVITS_weights_v2",
"last_gpt_model": "E:\\AI\\GPT-SoVITS-v4\\GPT_weights_v2\\bubu-e15.ckpt",
"last_sovits_model": "E:\\AI\\GPT-SoVITS-v4\\SoVITS_weights_v2\\bubu_e4_s68.pth",
"sovits_path": "E:\\AI\\GPT-SoVITS-v4",
"start_cmd": "start api_v2.bat",
"top_k": 5,
"top_p": 1,
"temperature": 1,
"batch_size": 16,
"speed": 1,
"text_split_method": "cut5",
"refer_audio_folder": "D:\\UserData\\Desktop\\声音素材\\参考音频"
}

View File

@@ -0,0 +1,3 @@
{
"model_dir": "E:\\AI\\stt\\models"
}

View File

@@ -0,0 +1,10 @@
Flask>=2.0.0
jieba>=0.42.1
yt-dlp>=2023.0.0
chardet>=5.0.0
Werkzeug>=2.0.0
faster-whisper>=1.0.0
opencc-python-reimplemented>=0.1.7
# AI 配音 (GPT-SoVITS 依赖)
soundfile>=0.12.0
numpy>=1.20.0

View File

@@ -0,0 +1 @@
python "D:\UserData\Desktop\my_proj\py_demo\python_script\flask-dev-api\app.py"

45
flask-dev-api/static/echarts.min.js vendored Normal file

File diff suppressed because one or more lines are too long

68
flask-dev-api/static/font-awesome.css vendored Normal file
View File

@@ -0,0 +1,68 @@
/* Font Awesome 6.5.0 - Local Subset */
@font-face {
font-family: 'Font Awesome 6 Free';
font-style: normal;
font-weight: 900;
font-display: auto;
src: url('webfonts/fa-solid-900.woff2') format('woff2'),
url('webfonts/fa-solid-900.ttf') format('truetype');
}
.fas {
font-family: 'Font Awesome 6 Free';
font-weight: 900;
-moz-osx-font-smoothing: grayscale;
-webkit-font-smoothing: antialiased;
display: inline-block;
font-style: normal;
font-variant: normal;
text-rendering: auto;
line-height: 1;
}
/* 图标定义 */
.fa-images:before { content: "\f302"; }
.fa-image:before { content: "\f03e"; }
.fa-lock:before { content: "\f023"; }
.fa-video:before { content: "\f03d"; }
.fa-scissors:before { content: "\f0c4"; }
.fa-arrow-left:before { content: "\f060"; }
.fa-search:before { content: "\f002"; }
.fa-home:before { content: "\f015"; }
.fa-list:before { content: "\f03a"; }
.fa-th:before { content: "\f00a"; }
.fa-book:before { content: "\f02d"; }
.fa-folder:before { content: "\f07b"; }
.fa-chart-pie:before { content: "\f200"; }
.fa-link:before { content: "\f0c1"; }
.fa-folder-open:before { content: "\f07c"; }
.fa-folder-plus:before { content: "\f65e"; }
.fa-check-circle:before { content: "\f058"; }
.fa-times-circle:before { content: "\f057"; }
.fa-info-circle:before { content: "\f05a"; }
.fa-refresh:before { content: "\f021"; }
.fa-tags:before { content: "\f02c"; }
.fa-robot:before { content: "\f544"; }
.fa-clock:before { content: "\f017"; }
.fa-copy:before { content: "\f0c5"; }
.fa-check:before { content: "\f00c"; }
.fa-brain:before { content: "\f5dc"; }
.fa-chart-bar:before { content: "\f080"; }
.fa-wrench:before { content: "\f0ad"; }
.fa-calendar:before { content: "\f073"; }
.fa-database:before { content: "\f1c0"; }
.fa-file:before { content: "\f15b"; }
.fa-play:before { content: "\f04b"; }
.fa-pen:before { content: "\f303"; }
.fa-fire:before { content: "\f06d"; }
.fa-tachometer-alt:before { content: "\f3fd"; }
.fa-trophy:before { content: "\f091"; }
.fa-exchange-alt:before { content: "\f362"; }
.fa-level-up-alt:before { content: "\f3bf"; }
.fa-chevron-right:before { content: "\f054"; }
.fa-chevron-left:before { content: "\f053"; }
.fa-trash:before { content: "\f1f8"; }
.fa-qrcode:before { content: "\f029"; }
.fa-code:before { content: "\f121"; }
.fa-key:before { content: "\f084"; }
.fa-microphone:before { content: "\f130"; }

8
flask-dev-api/static/qrcode.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,104 @@
/* static/style.css */
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
margin: 0;
padding: 20px;
background-color: #f5f5f5;
color: #333;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
}
.page-header {
text-align: center;
margin-bottom: 20px;
width: 100%;
}
.page-header h2 {
margin: 0 0 8px 0;
color: #333;
font-size: 24px;
}
.page-header p {
margin: 4px 0;
color: #666;
font-size: 14px;
}
.container {
display: flex;
width: 90%;
max-width: 1600px;
gap: 20px;
}
.panel {
flex: 1;
display: flex;
flex-direction: column;
background-color: #fff;
border-radius: 6px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
border: 1px solid #e0e0e0;
overflow: hidden;
}
.panel h2 {
margin: 0;
padding: 12px 16px;
font-size: 16px;
font-weight: 600;
background-color: #f5f5f5;
border-bottom: 1px solid #e0e0e0;
color: #666;
}
textarea {
flex: 1;
padding: 16px;
border: none;
resize: none;
font-size: 14px;
line-height: 1.5;
background-color: #fff;
color: #333;
outline: none;
font-family: inherit;
min-height: 500px;
max-height: calc(100vh - 200px);
height: 60vh;
}
.controls {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 12px 16px;
background-color: #f5f5f5;
border-top: 1px solid #e0e0e0;
}
button {
padding: 6px 12px;
font-size: 12px;
font-weight: 500;
color: #666;
background-color: #fff;
border: 1px solid #ddd;
border-radius: 6px;
cursor: pointer;
transition: background-color 0.2s ease;
}
button:hover {
background-color: #f0f0f0;
color: #333;
}
button:active {
background-color: #e5e5e5;
}

Binary file not shown.

Binary file not shown.

BIN
flask-dev-api/stats.db Normal file

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,232 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="{{ url_for('static', filename='font-awesome.css') }}">
<title>Base64 编码/解码</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: #f5f5f5;
color: #333;
height: 100vh;
overflow: hidden;
display: flex;
}
/* 左侧面板 */
.left-panel {
width: 50%; flex-shrink: 0;
display: flex; flex-direction: column;
border-right: 1px solid #e8e8e8;
background: #fff; height: 100vh;
overflow-y: auto;
}
.left-panel::-webkit-scrollbar { width: 4px; }
.left-panel::-webkit-scrollbar-thumb { background: #ddd; border-radius: 2px; }
.input-section { padding: 20px 24px 28px; flex: 1; display: flex; flex-direction: column; }
/* 头部 */
.header { display: flex; align-items: center; margin-bottom: 2px; }
.header h1 { font-size: 18px; font-weight: 700; color: #1a1a1a; letter-spacing: -0.01em; }
.subtitle { color: #aaa; font-size: 12px; margin-bottom: 16px; }
/* 分组卡片 */
.section-card {
background: #fafafa; border: 1px solid #f0f0f0;
border-radius: 10px; padding: 14px 16px; margin-bottom: 12px;
}
.section-title {
font-size: 11px; font-weight: 600; color: #999;
text-transform: uppercase; letter-spacing: 0.06em;
margin-bottom: 12px; display: flex; align-items: center; gap: 6px;
}
.section-title i { font-size: 11px; }
/* textarea */
.text-input {
width: 100%; padding: 10px 12px;
border: 1px solid #e0e0e0; border-radius: 6px;
font-size: 13px; outline: none; resize: vertical;
font-family: inherit; transition: border-color 0.15s;
line-height: 1.6;
}
.text-input:focus { border-color: #0078d4; box-shadow: 0 0 0 2px rgba(0,120,212,0.06); }
.text-input[readonly] { background: #f9f9f9; color: #555; }
/* 按钮 */
.btn {
padding: 7px 14px; border: none; border-radius: 6px;
font-size: 12px; font-weight: 600; cursor: pointer;
transition: all 0.12s ease;
display: inline-flex; align-items: center; gap: 5px;
}
.btn-primary { background: #0078d4; color: #fff; }
.btn-primary:hover { background: #006cbd; }
.btn-success { background: #0a7a1a; color: #fff; }
.btn-success:hover { background: #096b16; }
.btn-outline { background: #fff; color: #666; border: 1px solid #e0e0e0; }
.btn-outline:hover { background: #f5f5f5; color: #333; }
.btn-row { display: flex; gap: 8px; }
/* 操作按钮 */
.action-btn {
width: 100%; padding: 10px 18px;
border: none; border-radius: 8px;
font-size: 14px; font-weight: 600; cursor: pointer;
transition: all 0.15s ease;
display: flex; align-items: center; justify-content: center; gap: 8px;
box-shadow: 0 2px 8px rgba(0,120,212,0.2);
}
.action-btn:hover { transform: translateY(-1px); }
.action-btn:active { transform: translateY(0); }
.action-btn.encode {
background: linear-gradient(135deg, #0078d4, #005fa3); color: #fff;
}
.action-btn.encode:hover { box-shadow: 0 4px 12px rgba(0,120,212,0.3); }
.action-btn.decode {
background: linear-gradient(135deg, #0a7a1a, #065f12); color: #fff;
}
.action-btn.decode:hover { box-shadow: 0 4px 12px rgba(10,122,26,0.3); }
/* 右侧面板 */
.right-panel {
flex: 1; background: #fff;
display: flex; flex-direction: column;
height: 100vh;
}
.right-header {
padding: 20px 24px 16px;
display: flex; align-items: center; justify-content: space-between;
}
.right-header h2 { font-size: 15px; font-weight: 600; color: #333; }
.result-body {
flex: 1; display: flex; flex-direction: column;
padding: 0 24px 24px; gap: 12px;
}
.result-body textarea { flex: 1; min-height: 0; }
/* Toast */
.toast {
position: fixed; top: 20px; left: 50%;
transform: translateX(-50%);
background: #0a7a1a; color: #fff;
padding: 10px 24px; border-radius: 8px;
font-size: 13px; font-weight: 500;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
opacity: 0; transition: opacity 0.3s;
z-index: 9999; pointer-events: none;
}
.toast.show { opacity: 1; }
</style>
</head>
<body>
<div class="toast" id="toast"></div>
<!-- 左侧:输入 -->
<div class="left-panel">
<div class="input-section">
<div class="header">
<h1><i class="fas fa-lock" style="color:#0078d4;font-size:16px;margin-right:6px;"></i>Base64 编码/解码</h1>
</div>
<p class="subtitle">文本与 Base64 互转</p>
<div class="section-card" style="flex:1;display:flex;flex-direction:column;">
<div class="section-title"><i class="fas fa-pen"></i> 输入文本</div>
<textarea id="inputText" class="text-input" placeholder="在此粘贴待处理的文本..." style="flex:1;min-height:200px;"></textarea>
<div class="btn-row" style="margin-top:10px;">
<button class="btn btn-outline" onclick="clearEl('inputText')"><i class="fas fa-times-circle"></i> 清空</button>
</div>
<div style="display:flex;gap:8px;margin-top:12px;">
<button class="action-btn encode" onclick="doProcess('encode')" style="flex:1;">
<i class="fas fa-lock"></i> 编码
</button>
<button class="action-btn decode" onclick="doProcess('decode')" style="flex:1;">
<i class="fas fa-lock-open"></i> 解码
</button>
</div>
</div>
</div>
</div>
<!-- 右侧:输出 -->
<div class="right-panel">
<div class="right-header">
<h2><i class="fas fa-check-circle" style="color:#0078d4;margin-right:6px;"></i>输出结果</h2>
<button class="btn btn-outline" onclick="copyResult()"><i class="fas fa-copy"></i> 复制</button>
</div>
<div class="result-body">
<textarea id="outputResult" class="text-input" readonly placeholder="处理结果将显示在这里..."></textarea>
<div class="btn-row">
<button class="btn btn-outline" onclick="clearEl('outputResult')"><i class="fas fa-times-circle"></i> 清空</button>
<button class="btn btn-outline" onclick="swapIO()"><i class="fas fa-arrow-right-arrow-left"></i> 结果互换到输入</button>
</div>
</div>
</div>
<script>
function showToast(msg) {
var t = document.getElementById('toast');
t.textContent = msg;
t.className = 'toast show';
setTimeout(function() { t.className = 'toast'; }, 1500);
}
function clearEl(id) { document.getElementById(id).value = ''; }
function swapIO() {
var out = document.getElementById('outputResult').value;
if (!out) { showToast('没有内容可互换'); return; }
document.getElementById('inputText').value = out;
document.getElementById('outputResult').value = '';
showToast('已移到输入框');
}
async function doProcess(mode) {
var text = document.getElementById('inputText').value.trim();
if (!text) { showToast('请输入文本'); return; }
var endpoint, payload;
if (mode === 'encode') {
endpoint = '/base64-de-in-code/encode';
payload = { text_to_encode: text, encoding: 'utf-8' };
} else {
endpoint = '/base64-de-in-code/decode';
payload = { input_text: text };
}
try {
var res = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
var data = await res.json();
document.getElementById('outputResult').value = data.success ? data.result : '错误: ' + data.error;
} catch (e) {
document.getElementById('outputResult').value = '请求失败: ' + e.message;
}
}
function copyResult() {
var text = document.getElementById('outputResult').value;
if (!text) { showToast('没有内容可复制'); return; }
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(function() { showToast('已复制'); });
} else {
var ta = document.createElement('textarea');
ta.value = text;
ta.style.cssText = 'position:fixed;left:-9999px';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
showToast('已复制');
}
}
document.getElementById('inputText').focus();
</script>
</body>
</html>

View File

@@ -0,0 +1,174 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="{{ url_for('static', filename='font-awesome.css') }}">
<title>Chmod 计算器</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: #f5f5f5; color: #333;
height: 100vh; overflow-y: auto;
}
.container { max-width: 700px; margin: 0 auto; padding: 24px 28px 40px; }
.header { display: flex; align-items: center; margin-bottom: 2px; }
.header h1 { font-size: 18px; font-weight: 700; color: #1a1a1a; }
.subtitle { color: #aaa; font-size: 12px; margin-bottom: 16px; }
.section-card {
background: #fafafa; border: 1px solid #f0f0f0;
border-radius: 10px; padding: 14px 16px; margin-bottom: 12px;
}
.section-title {
font-size: 11px; font-weight: 600; color: #999;
text-transform: uppercase; letter-spacing: 0.06em;
margin-bottom: 12px; display: flex; align-items: center; gap: 6px;
}
.section-title i { font-size: 11px; }
/* 权限网格 */
.perm-grid {
display: grid; grid-template-columns: 72px 1fr 1fr 1fr;
gap: 0; border: 1px solid #e8e8e8; border-radius: 8px; overflow: hidden;
background: #fff;
}
.perm-grid .gh {
padding: 8px; font-size: 11px; font-weight: 600; color: #999;
text-transform: uppercase; letter-spacing: 0.04em; text-align: center;
background: #fafafa; border-bottom: 1px solid #e8e8e8;
}
.perm-grid .gh:first-child { text-align: right; }
.perm-grid .rh {
padding: 8px 10px; font-size: 12px; font-weight: 600; color: #666;
text-align: right; background: #fafafa;
border-right: 1px solid #e8e8e8; border-bottom: 1px solid #f0f0f0;
display: flex; align-items: center; justify-content: flex-end; gap: 4px;
}
.perm-grid .rh:last-of-type { border-bottom: none; }
.perm-grid .rh .val { font-size: 10px; color: #bbb; font-weight: 400; }
.perm-grid .cell {
padding: 8px; text-align: center;
border-bottom: 1px solid #f0f0f0;
display: flex; align-items: center; justify-content: center;
}
.perm-grid .row-last .cell { border-bottom: none; }
.perm-grid input[type="checkbox"] { display: none; }
.perm-grid .cb {
width: 30px; height: 30px; border: 2px solid #ddd; border-radius: 5px;
display: flex; align-items: center; justify-content: center;
cursor: pointer; transition: all 0.15s;
}
.perm-grid .cb i { font-size: 13px; color: #fff; opacity: 0; transition: opacity 0.15s; }
.perm-grid input:checked + .cb { background: #0078d4; border-color: #0078d4; }
.perm-grid input:checked + .cb i { opacity: 1; }
/* 结果区 */
.result-card {
background: #fff; border: 1px solid #f0f0f0;
border-radius: 10px; padding: 24px;
text-align: center;
}
.octal-display {
font-size: 64px; font-weight: 700; font-family: "SFMono-Regular", Consolas, monospace;
color: #0078d4; letter-spacing: 0.08em; margin-bottom: 4px;
}
.symbolic-display {
font-size: 22px; font-family: "SFMono-Regular", Consolas, monospace;
color: #555; letter-spacing: 0.15em; margin-bottom: 20px;
}
.cmd-wrap {
display: inline-flex; align-items: center; gap: 10px;
background: #fafafa; border: 1px solid #e0e0e0; border-radius: 8px;
padding: 10px 16px;
}
.cmd-wrap code {
font-family: "SFMono-Regular", Consolas, monospace;
font-size: 14px; color: #333;
}
.btn-cp {
padding: 5px 12px; background: #0078d4; color: #fff; border: none;
border-radius: 5px; font-size: 12px; font-weight: 600; cursor: pointer;
transition: all 0.15s; white-space: nowrap;
}
.btn-cp:hover { background: #006cbd; }
.btn-cp.copied { background: #2e7d32; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1><i class="fas fa-shield-halved" style="color:#0078d4;font-size:16px;margin-right:6px;"></i>Chmod 计算器</h1>
</div>
<p class="subtitle">Linux 文件权限速算</p>
<div class="section-card">
<div class="section-title"><i class="fas fa-check-square"></i> 勾选权限</div>
<div class="perm-grid">
<div class="gh"></div>
<div class="gh">Owner</div>
<div class="gh">Group</div>
<div class="gh">Public</div>
<div class="rh">Read <span class="val">(4)</span></div>
<div class="cell"><label><input type="checkbox" data-g="owner" data-s="read" onchange="calc()"><span class="cb"><i class="fas fa-check"></i></span></label></div>
<div class="cell"><label><input type="checkbox" data-g="group" data-s="read" onchange="calc()"><span class="cb"><i class="fas fa-check"></i></span></label></div>
<div class="cell"><label><input type="checkbox" data-g="public" data-s="read" onchange="calc()"><span class="cb"><i class="fas fa-check"></i></span></label></div>
<div class="rh">Write <span class="val">(2)</span></div>
<div class="cell"><label><input type="checkbox" data-g="owner" data-s="write" onchange="calc()"><span class="cb"><i class="fas fa-check"></i></span></label></div>
<div class="cell"><label><input type="checkbox" data-g="group" data-s="write" onchange="calc()"><span class="cb"><i class="fas fa-check"></i></span></label></div>
<div class="cell"><label><input type="checkbox" data-g="public" data-s="write" onchange="calc()"><span class="cb"><i class="fas fa-check"></i></span></label></div>
<div class="rh row-last">Exec <span class="val">(1)</span></div>
<div class="cell"><label><input type="checkbox" data-g="owner" data-s="execute" onchange="calc()"><span class="cb"><i class="fas fa-check"></i></span></label></div>
<div class="cell"><label><input type="checkbox" data-g="group" data-s="execute" onchange="calc()"><span class="cb"><i class="fas fa-check"></i></span></label></div>
<div class="cell"><label><input type="checkbox" data-g="public" data-s="execute" onchange="calc()"><span class="cb"><i class="fas fa-check"></i></span></label></div>
</div>
</div>
<div class="result-card">
<div class="octal-display" id="octal">000</div>
<div class="symbolic-display" id="symbolic">---------</div>
<div class="cmd-wrap">
<code id="cmd">chmod 000 path</code>
<button class="btn-cp" id="copyBtn" onclick="copyCmd()">复制</button>
</div>
</div>
</div>
<script>
var vals={read:4,write:2,execute:1};
var chars={read:'r',write:'w',execute:'x'};
function calc(){
var groups=['owner','group','public'];
var octal='',symbolic='';
groups.forEach(function(g){
var sum=0,sym='';
['read','write','execute'].forEach(function(s){
var checked=document.querySelector('input[data-g="'+g+'"][data-s="'+s+'"]').checked;
if(checked){sum+=vals[s];sym+=chars[s];}else{sym+='-';}
});
octal+=sum;symbolic+=sym;
});
document.getElementById('octal').textContent=octal;
document.getElementById('symbolic').textContent=symbolic;
document.getElementById('cmd').textContent='chmod '+octal+' path';
}
function copyCmd(){
var text=document.getElementById('cmd').textContent;
var el=document.createElement('textarea');
el.value=text;el.style.cssText='position:fixed;left:-9999px';
document.body.appendChild(el);el.select();document.execCommand('copy');document.body.removeChild(el);
var btn=document.getElementById('copyBtn');
btn.textContent='已复制';btn.classList.add('copied');
setTimeout(function(){btn.textContent='复制';btn.classList.remove('copied');},1500);
}
calc();
</script>
</body>
</html>

View File

@@ -0,0 +1,753 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="{{ url_for('static', filename='font-awesome.css') }}">
<title>文章标签生成</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: #f5f5f5;
color: #333;
height: 100vh;
overflow: hidden;
display: flex;
}
.left-panel {
width: 45%;
flex-shrink: 0;
display: flex;
flex-direction: column;
border-right: 1px solid #e8e8e8;
background: #fff;
height: 100vh;
overflow-y: auto;
}
.left-panel::-webkit-scrollbar { width: 4px; }
.left-panel::-webkit-scrollbar-thumb { background: #ddd; border-radius: 2px; }
.input-section { padding: 20px 24px 28px; flex-shrink: 0; }
.content-section { padding: 0 24px 24px; flex: 1; display: flex; flex-direction: column; min-height: 0; }
/* 头部 */
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 2px;
}
.header h1 {
font-size: 18px;
font-weight: 700;
color: #1a1a1a;
letter-spacing: -0.01em;
}
.settings-icon {
width: 32px; height: 32px;
border: none; background: transparent;
color: #999; font-size: 15px;
cursor: pointer; border-radius: 8px;
display: flex; align-items: center; justify-content: center;
transition: all 0.15s;
}
.settings-icon:hover { background: #f5f5f5; color: #333; }
.subtitle { color: #aaa; font-size: 12px; margin-bottom: 16px; }
/* section-card */
.section-card {
background: #fafafa;
border: 1px solid #f0f0f0;
border-radius: 10px;
padding: 14px 16px;
margin-bottom: 12px;
}
.section-title {
font-size: 11px;
font-weight: 600;
color: #999;
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 6px;
}
.section-title i { font-size: 11px; }
/* 输入 */
.input-group { margin-bottom: 10px; }
.input-group:last-child { margin-bottom: 0; }
.input-group label {
display: block; font-size: 12px; font-weight: 500;
margin-bottom: 4px; color: #555;
}
.input-group .input-wrap { position: relative; }
.input-group .input-wrap i {
position: absolute; left: 10px; top: 50%; transform: translateY(-50%);
color: #c0c0c0; font-size: 12px; pointer-events: none;
}
.input-group input,
.input-group select {
width: 100%; padding: 7px 10px 7px 32px;
border: 1px solid #e0e0e0; border-radius: 6px;
background: #fff; color: #333; font-size: 13px;
outline: none; transition: all 0.15s ease;
}
.input-group input::placeholder { color: #c0c0c0; }
.input-group input:focus,
.input-group select:focus {
border-color: #0078d4;
box-shadow: 0 0 0 2px rgba(0,120,212,0.06);
}
.input-group.no-icon input,
.input-group.no-icon select { padding-left: 10px; }
.input-group select {
-webkit-appearance: none; -moz-appearance: none; appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 10 10'%3E%3Cpath fill='%23999' d='M5 7L1 3h8z'/%3E%3C/svg%3E");
background-repeat: no-repeat; background-position: right 10px center;
padding-right: 28px; cursor: pointer;
}
.input-row { display: flex; gap: 8px; }
.input-row .input-group { flex: 1; }
/* API 行 */
.api-row { display: flex; gap: 8px; align-items: flex-end; }
.api-row .input-group { flex: 1; margin-bottom: 0; }
/* 按钮 */
.btn {
padding: 7px 14px; border: none; border-radius: 6px;
font-size: 12px; font-weight: 600; cursor: pointer;
transition: all 0.12s ease;
display: inline-flex; align-items: center; gap: 5px;
white-space: nowrap;
}
.btn-primary { background: #0078d4; color: #fff; }
.btn-primary:hover:not(:disabled) { background: #006cbd; }
.btn-primary:disabled { opacity: 0.4; cursor: not-allowed; }
.btn-outline { background: #fff; color: #666; border: 1px solid #e0e0e0; }
.btn-outline:hover { background: #f5f5f5; color: #333; }
.btn-sm { padding: 5px 10px; font-size: 11px; }
.btn-block { width: 100%; justify-content: center; }
.btn-row { display: flex; gap: 8px; margin-top: 12px; }
/* textarea */
.text-input {
width: 100%; padding: 10px 12px;
border: 1px solid #e0e0e0; border-radius: 6px;
font-size: 13px; outline: none; resize: none;
font-family: inherit; transition: border-color 0.15s;
line-height: 1.6;
}
.text-input:focus { border-color: #0078d4; box-shadow: 0 0 0 2px rgba(0,120,212,0.06); }
.text-input::placeholder { color: #c0c0c0; }
/* 生成按钮 */
.generate-btn {
width: 100%; padding: 10px 18px;
background: linear-gradient(135deg, #0078d4, #005fa3);
color: #fff; border: none; border-radius: 8px;
font-size: 14px; font-weight: 600;
cursor: pointer; transition: all 0.15s ease;
display: flex; align-items: center; justify-content: center; gap: 8px;
box-shadow: 0 2px 8px rgba(0,120,212,0.2);
}
.generate-btn:hover:not(:disabled) {
background: linear-gradient(135deg, #006cbd, #004e8a);
box-shadow: 0 4px 12px rgba(0,120,212,0.3);
transform: translateY(-1px);
}
.generate-btn:active:not(:disabled) { transform: translateY(0); }
.generate-btn:disabled { opacity: 0.4; cursor: not-allowed; transform: none; }
/* 右侧面板 */
.right-panel {
flex: 1; display: flex; flex-direction: column;
padding: 28px 32px; overflow-y: auto;
background: #f5f5f5;
}
.right-panel::-webkit-scrollbar { width: 4px; }
.right-panel::-webkit-scrollbar-thumb { background: #ddd; border-radius: 2px; }
/* placeholder */
.result-placeholder {
flex: 1; display: flex; flex-direction: column;
align-items: center; justify-content: center;
text-align: center; color: #bbb;
}
.result-placeholder .ph-icon {
width: 72px; height: 72px; border-radius: 18px;
background: #fff; border: 1px solid #e0e0e0;
display: flex; align-items: center; justify-content: center;
margin-bottom: 16px;
}
.result-placeholder .ph-icon i { font-size: 28px; color: #ddd; }
.result-placeholder p { font-size: 14px; color: #999; }
.result-placeholder .ph-hint { font-size: 12px; color: #ccc; margin-top: 4px; }
/* 右侧结果 */
.result-header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 16px;
}
.result-header h2 { font-size: 16px; font-weight: 700; color: #1a1a1a; }
.meta {
display: flex; gap: 16px; font-size: 12px; color: #999;
margin-bottom: 20px; flex-wrap: wrap;
}
.meta span { display: flex; align-items: center; gap: 4px; }
.meta .value { font-weight: 600; color: #555; }
.tag-list {
display: flex; flex-wrap: wrap; gap: 8px;
margin-bottom: 20px;
}
.tag {
display: inline-flex; align-items: center; gap: 5px;
padding: 6px 14px;
background: #e8f0fe; color: #0078d4;
border: 1px solid #b3d9ff; border-radius: 20px;
font-size: 13px; font-weight: 500;
cursor: pointer; transition: all 0.15s;
user-select: all;
}
.tag:hover { background: #d0e4fd; }
.tag .copy-hint { font-size: 10px; opacity: 0; transition: opacity 0.15s; }
.tag:hover .copy-hint { opacity: 0.6; }
.tag-text-box {
padding: 12px 14px;
background: #fff; border: 1px solid #e0e0e0; border-radius: 8px;
display: flex; align-items: center; gap: 12px;
}
.tag-text-box .tag-text {
flex: 1; font-size: 13px; color: #333;
word-break: break-all; line-height: 1.6; user-select: all;
}
.think-card {
margin-top: 16px; background: #fff; border: 1px solid #e0e0e0;
border-radius: 8px; overflow: hidden;
}
.think-header {
padding: 10px 14px; font-size: 12px; font-weight: 600;
color: #999; background: #fafafa;
border-bottom: 1px solid #f0f0f0;
display: flex; align-items: center; gap: 6px;
text-transform: uppercase; letter-spacing: 0.05em;
}
.think-box {
padding: 14px; font-size: 13px; line-height: 1.6;
color: #666; white-space: pre-wrap; word-break: break-all;
max-height: 200px; overflow-y: auto;
}
/* loading */
.loading-wrap {
flex: 1; display: flex; flex-direction: column;
align-items: center; justify-content: center;
color: #999;
}
.loading-wrap .spinner {
width: 32px; height: 32px; margin-bottom: 16px;
border: 3px solid rgba(0,120,212,0.15);
border-top-color: #0078d4; border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.loading-wrap p { font-size: 13px; }
@keyframes spin { to { transform: rotate(360deg); } }
.spinner-sm {
display: inline-block; width: 14px; height: 14px;
border: 2px solid rgba(255,255,255,0.3);
border-top-color: #fff; border-radius: 50%;
animation: spin 0.8s linear infinite;
}
/* error bar */
.error-bar {
padding: 12px 16px; background: #fde8e8; color: #c62828;
border: 1px solid #f5b3b3; border-radius: 8px;
font-size: 13px; display: flex; align-items: center; gap: 8px;
}
/* Toast */
.toast {
position: fixed; top: 20px; left: 50%; transform: translateX(-50%);
background: #0a7a1a; color: #fff;
padding: 10px 24px; border-radius: 8px;
font-size: 14px; font-weight: 500;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
opacity: 0; transition: opacity 0.3s;
z-index: 9999; pointer-events: none;
}
.toast.show { opacity: 1; }
.toast.error { background: #c62828; }
/* 设置弹窗 */
.modal-overlay {
display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.45); z-index: 1000;
align-items: center; justify-content: center;
}
.modal-overlay.show { display: flex; }
.modal {
background: #fff; border-radius: 12px; width: 520px;
max-height: 80vh; overflow: hidden;
box-shadow: 0 20px 60px rgba(0,0,0,0.25);
display: flex; flex-direction: column;
}
.modal-header {
display: flex; align-items: center; justify-content: space-between;
padding: 18px 22px; border-bottom: 1px solid #eee;
}
.modal-header h2 { font-size: 15px; font-weight: 600; color: #333; display: flex; align-items: center; gap: 8px; }
.modal-close {
width: 26px; height: 26px; border: none; background: transparent;
color: #999; font-size: 18px; cursor: pointer; border-radius: 6px;
display: flex; align-items: center; justify-content: center;
}
.modal-close:hover { background: #f5f5f5; color: #333; }
.modal-body { padding: 18px 22px; overflow-y: auto; flex: 1; }
.modal-footer {
display: flex; justify-content: flex-end; gap: 10px;
padding: 14px 22px; border-top: 1px solid #eee; background: #fafafa;
}
.hint-box {
padding: 10px 14px; background: #f0f7ff;
border: 1px solid #b3d9ff; border-radius: 6px;
font-size: 12px; color: #555; line-height: 1.8;
margin-bottom: 16px;
}
.hint-box strong { color: #0078d4; }
.hint-box code {
background: #e3f2fd; padding: 1px 6px; border-radius: 3px;
font-family: Consolas, "Courier New", monospace; font-size: 11px; color: #1565c0;
}
.prompt-tabs {
display: flex; gap: 0; margin-bottom: 12px;
border-bottom: 2px solid #eee;
}
.prompt-tab {
padding: 8px 16px; font-size: 12px; cursor: pointer;
border: none; background: none; color: #999; font-weight: 600;
border-bottom: 2px solid transparent; margin-bottom: -2px;
transition: all 0.15s;
}
.prompt-tab.active { color: #0078d4; border-bottom-color: #0078d4; }
.prompt-content { display: none; }
.prompt-content.active { display: block; }
.prompt-textarea {
width: 100%; height: 200px;
padding: 10px 12px; border: 1px solid #e0e0e0; border-radius: 6px;
font-size: 12px; line-height: 1.6; resize: vertical; outline: none;
font-family: Consolas, "Courier New", monospace;
background: #fff; color: #333; transition: border-color 0.15s;
}
.prompt-textarea:focus { border-color: #0078d4; box-shadow: 0 0 0 2px rgba(0,120,212,0.06); }
</style>
</head>
<body>
<div class="toast" id="toast"></div>
<!-- 设置弹窗 -->
<div class="modal-overlay" id="promptModal">
<div class="modal">
<div class="modal-header">
<h2><i class="fas fa-wrench"></i> 高级设置</h2>
<button class="modal-close" onclick="closePromptModal()">&times;</button>
</div>
<div class="modal-body">
<div class="input-row" style="margin-bottom:16px;">
<div class="input-group no-icon">
<label for="maxTags">最大标签数</label>
<input type="number" id="maxTags" value="10" min="1" max="50">
</div>
<div class="input-group no-icon">
<label for="minLength">最短字数</label>
<input type="number" id="minLength" value="2" min="1" max="20">
</div>
<div class="input-group no-icon">
<label for="maxLength">最长字数</label>
<input type="number" id="maxLength" value="6" min="1" max="50">
</div>
</div>
<div class="hint-box">
<strong>可用变量:</strong><br>
<code>{content}</code> 文章内容 &nbsp;
<code>{max_tags}</code> 最大标签数 &nbsp;
<code>{min_length}</code> 最短字数 &nbsp;
<code>{max_length}</code> 最长字数
</div>
<div class="prompt-tabs">
<button class="prompt-tab active" onclick="switchPromptTab('system')">系统提示词</button>
<button class="prompt-tab" onclick="switchPromptTab('user')">用户提示词</button>
</div>
<div class="prompt-content active" id="systemPromptPane">
<textarea class="prompt-textarea" id="systemMsg"></textarea>
</div>
<div class="prompt-content" id="userPromptPane">
<textarea class="prompt-textarea" id="userMsg"></textarea>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-outline" onclick="resetPrompts()">恢复默认</button>
<button class="btn btn-primary" onclick="savePrompts()"><i class="fas fa-check"></i> 保存</button>
</div>
</div>
</div>
<!-- 左侧面板 -->
<div class="left-panel">
<div class="input-section">
<div class="header">
<h1>文章标签 AI 生成</h1>
<button class="settings-icon" onclick="openPromptModal()" title="高级设置">
<i class="fas fa-wrench"></i>
</button>
</div>
<p class="subtitle">接入 LLM 模型,自动提取文章关键词标签</p>
<!-- 连接设置 -->
<div class="section-card">
<div class="section-title"><i class="fas fa-link"></i> 连接设置</div>
<div class="input-row">
<div class="input-group no-icon" style="max-width:120px;">
<label for="software">软件类型</label>
<select id="software" onchange="onSoftwareChange()">
<option value="ollama">Ollama</option>
<option value="llamacpp" selected>llama.cpp</option>
</select>
</div>
<div class="input-group" id="modelGroup" style="display:none;">
<label for="model">模型</label>
<div class="input-wrap">
<i class="fas fa-robot"></i>
<select id="model">
<option value="">请先获取模型列表</option>
</select>
</div>
</div>
</div>
<div class="api-row" style="margin-top:8px;">
<div class="input-group">
<label for="apiUrl">API 地址</label>
<div class="input-wrap">
<i class="fas fa-link"></i>
<input type="text" id="apiUrl" placeholder="http://127.0.0.1:8080/v1/chat/completions" value="http://127.0.0.1:8080/v1/chat/completions">
</div>
</div>
<button class="btn btn-outline" id="testBtn" onclick="testApi()"><i class="fas fa-refresh"></i> 测试</button>
</div>
</div>
</div>
<!-- 文章输入 -->
<div class="content-section">
<div class="section-card" style="flex:1; display:flex; flex-direction:column; margin-bottom:0;">
<div class="section-title"><i class="fas fa-pen"></i> 文章内容</div>
<textarea class="text-input" id="content" placeholder="在此粘贴文章内容…" style="flex:1; min-height:0;"></textarea>
<div class="btn-row">
<button class="btn btn-outline" onclick="clearAll()"><i class="fas fa-trash"></i> 清空</button>
<button class="generate-btn" id="generateBtn" onclick="generate()" style="flex:1;" disabled>
<i class="fas fa-tags"></i> 请先测试连接
</button>
</div>
</div>
</div>
</div>
<!-- 右侧面板 -->
<div class="right-panel" id="rightPanel">
<div class="result-placeholder" id="resultPlaceholder">
<div class="ph-icon"><i class="fas fa-tags"></i></div>
<p>粘贴文章内容,点击「生成标签」</p>
<span class="ph-hint">Ctrl + Enter 快速生成</span>
</div>
<div id="resultContent" style="display:none; width:100%;"></div>
</div>
<script>
var softwareSelect = document.getElementById('software');
var apiUrl = document.getElementById('apiUrl');
var modelSelect = document.getElementById('model');
var modelGroup = document.getElementById('modelGroup');
var maxTagsInput = document.getElementById('maxTags');
var minLengthInput = document.getElementById('minLength');
var maxLengthInput = document.getElementById('maxLength');
var contentInput = document.getElementById('content');
var generateBtn = document.getElementById('generateBtn');
var rightPanel = document.getElementById('rightPanel');
var resultPlaceholder = document.getElementById('resultPlaceholder');
var resultContent = document.getElementById('resultContent');
var currentTags = [];
var DEFAULT_SYSTEM_MSG = "你是一个专门生成文章标签的助手,请你根据我给你的文章的内容总结并生成一系列的标签,格式可以参考[关键词1, 关键词2, 关键词3].你只需要给我生成这种形式的标签即可,其他分析内容无需输出.";
var DEFAULT_USER_MSG = "请严格按照以下要求,从提供的文章内容中提取关键词。\n\n文章内容:\n{content}\n\n要求:\n- 提取最多 {max_tags} 个最能概括文章主旨和核心概念的关键词。\n- 关键词必须来源于文章内容,准确反映文章主题。\n- 每个关键词的长度必须在 {min_length} 到 {max_length} 个字符之间。\n- 输出格式为关键词1, 关键词2, 关键词3, ...\n- 只输出关键词列表,不要有任何其他解释或前缀。";
var defaultUrls = {
ollama: 'http://127.0.0.1:11434/api/generate',
llamacpp: 'http://127.0.0.1:8080/v1/chat/completions'
};
/* 设置弹窗 */
function openPromptModal() { document.getElementById('promptModal').classList.add('show'); }
function closePromptModal() { document.getElementById('promptModal').classList.remove('show'); }
function switchPromptTab(tab) {
document.querySelectorAll('.prompt-tab').forEach(function(t, i) {
t.classList.toggle('active', tab === 'system' ? i === 0 : i === 1);
});
document.getElementById('systemPromptPane').classList.toggle('active', tab === 'system');
document.getElementById('userPromptPane').classList.toggle('active', tab === 'user');
}
function resetPrompts() {
document.getElementById('systemMsg').value = DEFAULT_SYSTEM_MSG;
document.getElementById('userMsg').value = DEFAULT_USER_MSG;
maxTagsInput.value = 10;
minLengthInput.value = 2;
maxLengthInput.value = 6;
}
function savePrompts() {
fetch('/content-tag/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
system_msg: document.getElementById('systemMsg').value,
user_msg: document.getElementById('userMsg').value,
max_tags: parseInt(maxTagsInput.value) || 10,
min_length: parseInt(minLengthInput.value) || 2,
max_length: parseInt(maxLengthInput.value) || 6
})
}).then(function(r) { return r.json(); }).then(function(d) {
if (d.success) { closePromptModal(); showToast('设置已保存'); }
else showToast(d.error || '保存失败', 'error');
}).catch(function(e) { showToast('保存失败', 'error'); });
}
function loadSettings() {
fetch('/content-tag/settings').then(function(r) { return r.json(); }).then(function(d) {
if (d.success && d.settings) {
var s = d.settings;
document.getElementById('systemMsg').value = s.system_msg || DEFAULT_SYSTEM_MSG;
document.getElementById('userMsg').value = s.user_msg || DEFAULT_USER_MSG;
maxTagsInput.value = s.max_tags || 10;
minLengthInput.value = s.min_length || 2;
maxLengthInput.value = s.max_length || 6;
}
}).catch(function() {});
}
loadSettings();
[maxTagsInput, minLengthInput, maxLengthInput].forEach(function(el) {
el.addEventListener('change', function() {
fetch('/content-tag/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
max_tags: parseInt(maxTagsInput.value) || 10,
min_length: parseInt(minLengthInput.value) || 2,
max_length: parseInt(maxLengthInput.value) || 6
})
});
});
});
/* 软件切换 */
function onSoftwareChange() {
var sw = softwareSelect.value;
apiUrl.value = defaultUrls[sw] || '';
modelGroup.style.display = sw === 'ollama' ? '' : 'none';
document.getElementById('testBtn').innerHTML = '<i class="fas fa-refresh"></i> 测试';
}
/* 测试连接 */
async function testApi() {
var testBtn = document.getElementById('testBtn');
testBtn.disabled = true;
testBtn.innerHTML = '<span class="spinner-sm" style="border-color:rgba(0,0,0,0.15);border-top-color:#333;"></span>';
try {
var sw = softwareSelect.value;
var res = await fetch('/content-tag/test-api', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ api_url: apiUrl.value.trim(), model: modelSelect.value, software: sw })
});
var data = await res.json();
if (data.success) {
testBtn.innerHTML = '<i class="fas fa-check" style="color:#0a7a1a;"></i>';
generateBtn.disabled = false;
generateBtn.innerHTML = '<i class="fas fa-tags"></i> 生成标签';
if (sw === 'ollama' && data.models) {
modelSelect.innerHTML = data.models.map(function(m) { return '<option value="' + m + '">' + m + '</option>'; }).join('');
modelGroup.style.display = '';
}
showToast('连接成功');
} else {
testBtn.innerHTML = '<i class="fas fa-times-circle" style="color:#c62828;"></i>';
if (data.error) showToast(data.error, 'error');
}
} catch (err) {
testBtn.innerHTML = '<i class="fas fa-times-circle" style="color:#c62828;"></i>';
showToast('请求失败: ' + err.message, 'error');
} finally {
testBtn.disabled = false;
}
}
/* 清空 */
function clearAll() {
contentInput.value = '';
resultContent.style.display = 'none';
resultContent.innerHTML = '';
resultPlaceholder.style.display = 'flex';
currentTags = [];
}
/* 生成 */
async function generate() {
var url = apiUrl.value.trim();
var model = modelSelect.value;
var content = contentInput.value.trim();
var software = softwareSelect.value;
if (!url) { showToast('请填写 API 地址', 'error'); return; }
if (software === 'ollama' && !model) { showToast('请选择模型', 'error'); return; }
if (!content) { showToast('请输入文章内容', 'error'); return; }
var TIMEOUT_SEC = 120;
var elapsed = 0;
generateBtn.disabled = true;
generateBtn.innerHTML = '<span class="spinner-sm"></span> 分析中 0s';
resultPlaceholder.style.display = 'none';
resultContent.style.display = 'block';
resultContent.innerHTML =
'<div class="loading-wrap"><div class="spinner"></div><p>正在调用 AI 模型分析文章…</p></div>';
var timer = setInterval(function() {
elapsed++;
generateBtn.innerHTML = '<span class="spinner-sm"></span> 分析中 ' + elapsed + 's / ' + TIMEOUT_SEC + 's';
}, 1000);
var controller = new AbortController();
var timeoutId = setTimeout(function() { controller.abort(); }, TIMEOUT_SEC * 1000);
try {
var res = await fetch('/content-tag/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
api_url: url, model: model, software: software,
content: content,
max_tags: parseInt(maxTagsInput.value) || 10,
min_length: parseInt(minLengthInput.value) || 2,
max_length: parseInt(maxLengthInput.value) || 6,
system_msg: document.getElementById('systemMsg').value,
user_msg: document.getElementById('userMsg').value
}),
signal: controller.signal
});
var data = await res.json();
if (data.success) {
currentTags = data.tags;
renderResult(data);
} else {
resultContent.innerHTML = '<div class="error-bar"><i class="fas fa-times-circle"></i> ' + data.error + '</div>';
}
} catch (err) {
var msg = err.name === 'AbortError' ? '请求超时(' + TIMEOUT_SEC + '秒)' : '请求失败: ' + err.message;
resultContent.innerHTML = '<div class="error-bar"><i class="fas fa-times-circle"></i> ' + msg + '</div>';
} finally {
clearTimeout(timeoutId);
clearInterval(timer);
generateBtn.disabled = false;
generateBtn.innerHTML = '<i class="fas fa-tags"></i> 生成标签';
}
}
/* 渲染结果 */
function renderResult(data) {
var tagsHtml = data.tags.map(function(t) {
return '<span class="tag" onclick="copyTag(this, \'' + t.replace(/'/g, "\\'") + '\')">' +
t + '<span class="copy-hint">复制</span></span>';
}).join('');
var tagsText = data.tags.join(', ');
var html = '' +
'<div class="result-header">' +
'<h2>生成结果</h2>' +
'<button class="btn btn-outline btn-sm" id="copyAllBtn" onclick="copyAllTags()"><i class="fas fa-copy"></i> 复制全部</button>' +
'</div>' +
'<div class="meta">' +
'<span><i class="fas fa-robot"></i> <span class="value">' + data.model + '</span></span>' +
'<span><i class="fas fa-clock"></i> <span class="value">' + data.consume + 's</span></span>' +
'<span><i class="fas fa-tags"></i> <span class="value">' + data.tags.length + ' 个标签</span></span>' +
'</div>' +
'<div class="tag-list">' + tagsHtml + '</div>' +
'<div class="tag-text-box">' +
'<span class="tag-text">' + tagsText + '</span>' +
'<button class="btn btn-outline btn-sm" onclick="copyTagText(this)"><i class="fas fa-copy"></i> 复制</button>' +
'</div>';
if (data.think) {
html += '<div class="think-card"><div class="think-header"><i class="fas fa-brain"></i> AI 思考过程</div>' +
'<div class="think-box"></div></div>';
}
resultContent.innerHTML = html;
if (data.think) {
resultContent.querySelector('.think-box').textContent = data.think;
}
}
/* Toast */
function showToast(msg, type) {
var toast = document.getElementById('toast');
toast.textContent = msg;
toast.className = 'toast show' + (type === 'error' ? ' error' : '');
setTimeout(function() { toast.className = 'toast'; }, 2000);
}
/* 复制 */
function copyToClipboard(text) {
if (navigator.clipboard && navigator.clipboard.writeText) {
return navigator.clipboard.writeText(text);
}
return new Promise(function(resolve, reject) {
var ta = document.createElement('textarea');
ta.value = text;
ta.style.cssText = 'position:fixed;left:-9999px;top:-9999px';
document.body.appendChild(ta);
ta.select();
try { document.execCommand('copy') ? resolve() : reject(); }
catch (e) { reject(e); }
finally { document.body.removeChild(ta); }
});
}
function copyTag(el, text) {
copyToClipboard(text).then(function() {
showToast('已复制');
el.style.background = '#d1fae5'; el.style.borderColor = '#a7f3d0'; el.style.color = '#065f46';
setTimeout(function() { el.style.background = ''; el.style.borderColor = ''; el.style.color = ''; }, 600);
});
}
function copyAllTags() {
if (!currentTags.length) return;
var btn = document.getElementById('copyAllBtn');
copyToClipboard(currentTags.join(', ')).then(function() {
showToast('已复制');
btn.innerHTML = '<i class="fas fa-check"></i> 已复制';
setTimeout(function() { btn.innerHTML = '<i class="fas fa-copy"></i> 复制全部'; }, 1500);
});
}
function copyTagText(btn) {
var text = btn.parentElement.querySelector('.tag-text').textContent;
copyToClipboard(text).then(function() {
showToast('已复制');
btn.innerHTML = '<i class="fas fa-check"></i> 已复制';
setTimeout(function() { btn.innerHTML = '<i class="fas fa-copy"></i> 复制'; }, 1500);
});
}
/* 快捷键 */
contentInput.addEventListener('keydown', function(e) {
if (e.ctrlKey && e.key === 'Enter') generate();
});
</script>
</body>
</html>

View File

@@ -0,0 +1,529 @@
<!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="{{ url_for('static', filename='font-awesome.css') }}">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "Segoe UI", Roboto, sans-serif;
background: #f5f5f5;
color: #333;
padding: 16px;
}
.db-section {
background: #fff;
border: 1px solid #e0e0e0;
border-radius: 10px;
}
.db-tabs {
display: flex;
gap: 0;
border-bottom: 2px solid #e0e0e0;
padding: 0 16px;
background: #fafafa;
border-radius: 10px 10px 0 0;
}
.db-tab {
padding: 10px 18px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
border: none;
background: none;
color: #666;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
transition: all 0.15s;
}
.db-tab.active { color: #0078d4; border-bottom-color: #0078d4; }
.db-tab:hover { color: #333; }
.db-tab .tab-count {
display: inline-block;
background: #e0e0e0;
color: #666;
font-size: 10px;
padding: 1px 6px;
border-radius: 8px;
margin-left: 4px;
}
.db-tab.active .tab-count { background: #e8f0fe; color: #0078d4; }
.db-toolbar {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
border-bottom: 1px solid #f0f0f0;
flex-wrap: wrap;
}
.db-search {
display: flex;
align-items: center;
background: #f5f5f5;
border: 1px solid #e0e0e0;
border-radius: 6px;
padding: 5px 10px;
gap: 6px;
flex: 1;
min-width: 180px;
max-width: 300px;
}
.db-search:focus-within { border-color: #0078d4; background: #fff; }
.db-search i { color: #bbb; font-size: 12px; }
.db-search input {
border: none;
background: none;
outline: none;
font-size: 13px;
width: 100%;
color: #333;
}
.db-btn {
padding: 5px 12px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
background: #fff;
color: #666;
transition: all 0.15s;
display: inline-flex;
align-items: center;
gap: 4px;
}
.db-btn:hover { background: #f5f5f5; color: #333; }
.db-btn.danger { color: #c62828; border-color: #f5b3b3; }
.db-btn.danger:hover { background: #fde8e8; }
.db-btn.primary { background: #0078d4; color: #fff; border-color: #0078d4; }
.db-btn.primary:hover { background: #006cbd; }
.db-table-wrap { overflow-x: auto; }
.db-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.db-table th {
text-align: left;
padding: 8px 12px;
color: #999;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
font-size: 11px;
border-bottom: 1px solid #e0e0e0;
background: #fafafa;
white-space: nowrap;
}
.db-table td {
padding: 7px 12px;
border-bottom: 1px solid #f0f0f0;
color: #555;
max-width: 280px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.db-table tr:hover td { background: #f8f9fa; }
.db-table .col-check { width: 36px; text-align: center; }
.db-table .col-actions { width: 80px; text-align: center; white-space: nowrap; }
.db-table .action-btn {
background: none;
border: none;
cursor: pointer;
color: #999;
font-size: 13px;
padding: 2px 6px;
border-radius: 4px;
transition: all 0.15s;
}
.db-table .action-btn:hover { color: #0078d4; background: #e8f0fe; }
.db-table .action-btn.del:hover { color: #c62828; background: #fde8e8; }
.db-pagination {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 16px;
font-size: 12px;
color: #999;
border-top: 1px solid #f0f0f0;
}
.db-pagination .page-btns { display: flex; gap: 4px; }
.db-pagination .page-btn {
padding: 4px 10px;
border: 1px solid #ddd;
border-radius: 4px;
background: #fff;
color: #666;
cursor: pointer;
font-size: 12px;
}
.db-pagination .page-btn:hover { background: #f0f0f0; }
.db-pagination .page-btn.active { background: #0078d4; color: #fff; border-color: #0078d4; }
.db-pagination .page-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.db-empty {
text-align: center;
padding: 40px;
color: #ccc;
font-size: 14px;
}
/* 弹窗 */
.modal-overlay {
display: none;
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.3);
z-index: 1000;
align-items: center;
justify-content: center;
}
.modal-overlay.show { display: flex; }
.modal-box {
background: #fff;
border-radius: 12px;
padding: 24px;
width: 480px;
max-width: 90vw;
max-height: 80vh;
overflow-y: auto;
box-shadow: 0 8px 30px rgba(0,0,0,0.12);
}
.modal-box h3 {
font-size: 16px;
margin-bottom: 16px;
}
.modal-form .form-row {
display: flex;
align-items: center;
margin-bottom: 10px;
}
.modal-form .form-row label {
width: 100px;
font-size: 13px;
color: #666;
text-align: right;
padding-right: 12px;
flex-shrink: 0;
}
.modal-form .form-row input,
.modal-form .form-row select {
flex: 1;
padding: 7px 10px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 13px;
outline: none;
}
.modal-form .form-row input:focus,
.modal-form .form-row select:focus { border-color: #0078d4; }
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 16px;
}
</style>
</head>
<body>
<div class="db-section">
<div class="db-tabs">
<button class="db-tab active" data-table="api_calls" onclick="switchDbTab('api_calls', this)">
<i class="fas fa-chart-bar"></i> 接口调用记录 <span class="tab-count" id="cnt-api_calls">0</span>
</button>
<button class="db-tab" data-table="search_history" onclick="switchDbTab('search_history', this)">
<i class="fas fa-search"></i> 搜索历史 <span class="tab-count" id="cnt-search_history">0</span>
</button>
<button class="db-tab" data-table="recent_paths" onclick="switchDbTab('recent_paths', this)">
<i class="fas fa-folder"></i> 最近路径 <span class="tab-count" id="cnt-recent_paths">0</span>
</button>
</div>
<div class="db-toolbar">
<div class="db-search">
<i class="fas fa-search"></i>
<input type="text" id="dbSearchInput" placeholder="搜索..." oninput="onDbSearch()">
</div>
<button class="db-btn primary" onclick="openAddModal()"><i class="fas fa-folder-plus"></i> 新增</button>
<button class="db-btn danger" onclick="batchDelete()"><i class="fas fa-trash"></i> 删除选中</button>
<button class="db-btn danger" onclick="clearAll()"><i class="fas fa-trash"></i> 清空</button>
</div>
<div class="db-table-wrap">
<table class="db-table" id="db-table">
<thead id="db-thead"></thead>
<tbody id="db-tbody"></tbody>
</table>
</div>
<div class="db-empty" id="db-empty" style="display:none;">暂无数据</div>
<div class="db-pagination">
<span id="db-page-info">共 0 条</span>
<div class="page-btns" id="db-page-btns"></div>
</div>
</div>
<!-- 编辑弹窗 -->
<div class="modal-overlay" id="editModal">
<div class="modal-box">
<h3 id="modalTitle">新增记录</h3>
<div class="modal-form" id="modalForm"></div>
<div class="modal-actions">
<button class="db-btn" onclick="closeModal()">取消</button>
<button class="db-btn primary" onclick="saveRecord()">保存</button>
</div>
</div>
</div>
<script>
var dbCurrentTable = 'api_calls';
var dbCurrentPage = 1;
var dbPageSize = 20;
var dbSearchTimer = null;
var dbEditingId = null;
var TABLE_CONFIG = {
api_calls: {
label: '接口调用记录',
columns: [
{ key: 'id', label: 'ID', width: '50px', editable: false },
{ key: 'endpoint', label: '接口', editable: true },
{ key: 'method', label: '方法', editable: true, type: 'select', options: ['GET','POST','PUT','DELETE'] },
{ key: 'status_code', label: '状态码', editable: true, type: 'number' },
{ key: 'duration_ms', label: '耗时(ms)', editable: true, type: 'number' },
{ key: 'called_at', label: '调用时间', editable: true }
]
},
search_history: {
label: '搜索历史',
columns: [
{ key: 'id', label: 'ID', width: '50px', editable: false },
{ key: 'keyword', label: '关键词', editable: true },
{ key: 'root_path', label: '根路径', editable: true },
{ key: 'result_count', label: '结果数', editable: true, type: 'number' },
{ key: 'searched_at', label: '搜索时间', editable: true }
]
},
recent_paths: {
label: '最近路径',
columns: [
{ key: 'id', label: 'ID', width: '50px', editable: false },
{ key: 'path', label: '路径', editable: true },
{ key: 'last_used', label: '最后使用', editable: true }
]
}
};
function switchDbTab(table, btn) {
dbCurrentTable = table;
dbCurrentPage = 1;
document.getElementById('dbSearchInput').value = '';
document.querySelectorAll('.db-tab').forEach(function(t) { t.classList.remove('active'); });
btn.classList.add('active');
loadDbData();
}
function onDbSearch() {
clearTimeout(dbSearchTimer);
dbCurrentPage = 1;
dbSearchTimer = setTimeout(loadDbData, 300);
}
async function loadDbData() {
var search = document.getElementById('dbSearchInput').value.trim();
var url = '/api/db/' + dbCurrentTable + '?page=' + dbCurrentPage + '&page_size=' + dbPageSize;
if (search) url += '&search=' + encodeURIComponent(search);
try {
var res = await fetch(url);
var data = await res.json();
if (!data.success) return;
renderDbTable(data.rows, data.total);
renderDbPagination(data.total, data.page, data.page_size);
updateDbCounts();
} catch (e) { console.error('加载数据失败:', e); }
}
async function updateDbCounts() {
try {
var res = await fetch('/api/db/tables');
var data = await res.json();
data.tables.forEach(function(t) {
var el = document.getElementById('cnt-' + t.table);
if (el) el.textContent = t.count;
});
} catch (e) {}
}
function renderDbTable(rows, total) {
var cfg = TABLE_CONFIG[dbCurrentTable];
var thead = document.getElementById('db-thead');
var tbody = document.getElementById('db-tbody');
var emptyEl = document.getElementById('db-empty');
var ths = '<tr><th class="col-check"><input type="checkbox" id="dbCheckAll" onchange="toggleAllCheck(this)"></th>';
cfg.columns.forEach(function(c) {
ths += '<th' + (c.width ? ' style="width:' + c.width + '"' : '') + '>' + c.label + '</th>';
});
ths += '<th class="col-actions">操作</th></tr>';
thead.innerHTML = ths;
if (!rows.length) {
tbody.innerHTML = '';
emptyEl.style.display = 'block';
return;
}
emptyEl.style.display = 'none';
tbody.innerHTML = rows.map(function(row) {
var tds = '<tr><td class="col-check"><input type="checkbox" class="db-row-check" value="' + row.id + '"></td>';
cfg.columns.forEach(function(c) {
var v = row[c.key] != null ? row[c.key] : '';
tds += '<td title="' + escapeHtml(String(v)) + '">' + escapeHtml(String(v)) + '</td>';
});
tds += '<td class="col-actions">'
+ '<button class="action-btn" onclick="openEditModal(' + row.id + ')" title="编辑"><i class="fas fa-pen"></i></button> '
+ '<button class="action-btn del" onclick="deleteOne(' + row.id + ')" title="删除"><i class="fas fa-trash"></i></button>'
+ '</td></tr>';
return tds;
}).join('');
}
function escapeHtml(s) {
return (s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function renderDbPagination(total, page, pageSize) {
var totalPages = Math.ceil(total / pageSize) || 1;
document.getElementById('db-page-info').textContent = '共 ' + total + ' 条,第 ' + page + '/' + totalPages + ' 页';
var btns = '';
btns += '<button class="page-btn" onclick="goDbPage(1)" ' + (page <= 1 ? 'disabled' : '') + '>首页</button>';
btns += '<button class="page-btn" onclick="goDbPage(' + (page - 1) + ')" ' + (page <= 1 ? 'disabled' : '') + '>上一页</button>';
var start = Math.max(1, page - 2);
var end = Math.min(totalPages, start + 4);
for (var i = start; i <= end; i++) {
btns += '<button class="page-btn' + (i === page ? ' active' : '') + '" onclick="goDbPage(' + i + ')">' + i + '</button>';
}
btns += '<button class="page-btn" onclick="goDbPage(' + (page + 1) + ')" ' + (page >= totalPages ? 'disabled' : '') + '>下一页</button>';
btns += '<button class="page-btn" onclick="goDbPage(' + totalPages + ')" ' + (page >= totalPages ? 'disabled' : '') + '>末页</button>';
document.getElementById('db-page-btns').innerHTML = btns;
}
function goDbPage(p) {
dbCurrentPage = p;
loadDbData();
}
function toggleAllCheck(el) {
document.querySelectorAll('.db-row-check').forEach(function(cb) { cb.checked = el.checked; });
}
function openAddModal() {
dbEditingId = null;
document.getElementById('modalTitle').textContent = '新增记录';
buildModalForm(null);
document.getElementById('editModal').classList.add('show');
}
async function openEditModal(id) {
dbEditingId = id;
document.getElementById('modalTitle').textContent = '编辑记录 #' + id;
var search = document.getElementById('dbSearchInput').value.trim();
var url = '/api/db/' + dbCurrentTable + '?page=' + dbCurrentPage + '&page_size=' + dbPageSize;
if (search) url += '&search=' + encodeURIComponent(search);
try {
var res = await fetch(url);
var data = await res.json();
var row = data.rows.find(function(r) { return r.id === id; });
buildModalForm(row);
document.getElementById('editModal').classList.add('show');
} catch (e) {}
}
function buildModalForm(row) {
var cfg = TABLE_CONFIG[dbCurrentTable];
var html = '';
cfg.columns.forEach(function(c) {
if (c.key === 'id') return;
var val = row ? (row[c.key] != null ? row[c.key] : '') : '';
html += '<div class="form-row"><label>' + c.label + '</label>';
if (c.type === 'select') {
html += '<select data-col="' + c.key + '">';
c.options.forEach(function(opt) {
html += '<option value="' + opt + '"' + (val === opt ? ' selected' : '') + '>' + opt + '</option>';
});
html += '</select>';
} else {
html += '<input type="' + (c.type || 'text') + '" data-col="' + c.key + '" value="' + escapeHtml(String(val)) + '">';
}
html += '</div>';
});
document.getElementById('modalForm').innerHTML = html;
}
function closeModal() {
document.getElementById('editModal').classList.remove('show');
}
async function saveRecord() {
var cfg = TABLE_CONFIG[dbCurrentTable];
var data = {};
cfg.columns.forEach(function(c) {
if (c.key === 'id') return;
var el = document.querySelector('#modalForm [data-col="' + c.key + '"]');
if (el) data[c.key] = el.value;
});
var url, method;
if (dbEditingId) {
url = '/api/db/' + dbCurrentTable + '/' + dbEditingId;
method = 'PUT';
} else {
url = '/api/db/' + dbCurrentTable;
method = 'POST';
}
try {
var res = await fetch(url, { method: method, headers: {'Content-Type':'application/json'}, body: JSON.stringify(data) });
var result = await res.json();
if (result.success) {
closeModal();
loadDbData();
} else {
alert(result.error || '操作失败');
}
} catch (e) { alert('请求失败: ' + e.message); }
}
async function deleteOne(id) {
if (!confirm('确定删除记录 #' + id + '')) return;
try {
var res = await fetch('/api/db/' + dbCurrentTable + '/' + id, { method: 'DELETE' });
var data = await res.json();
if (data.success) loadDbData();
} catch (e) {}
}
async function batchDelete() {
var ids = [];
document.querySelectorAll('.db-row-check:checked').forEach(function(cb) { ids.push(parseInt(cb.value)); });
if (!ids.length) { alert('请先选择要删除的记录'); return; }
if (!confirm('确定删除选中的 ' + ids.length + ' 条记录?')) return;
try {
var res = await fetch('/api/db/' + dbCurrentTable + '/delete-batch', {
method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ids: ids})
});
var data = await res.json();
if (data.success) loadDbData();
} catch (e) {}
}
async function clearAll() {
var cfg = TABLE_CONFIG[dbCurrentTable];
if (!confirm('确定清空「' + cfg.label + '」的所有数据?此操作不可撤销!')) return;
try {
var res = await fetch('/api/db/' + dbCurrentTable + '/clear', { method: 'POST' });
var data = await res.json();
if (data.success) loadDbData();
} catch (e) {}
}
loadDbData();
</script>
</body>
</html>

View File

@@ -0,0 +1,796 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="{{ url_for('static', filename='font-awesome.css') }}">
<title>视频下载器</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: #f5f5f5;
color: #333;
height: 100vh;
overflow: hidden;
display: flex;
}
/* 左侧面板 */
.left-panel {
flex: 1;
display: flex;
flex-direction: column;
border-right: 1px solid #e0e0e0;
min-width: 420px;
background: #fff;
height: 100vh;
overflow-y: auto;
}
.input-section {
padding: 32px;
height: 100%;
/*border-bottom: 1px solid #e0e0e0;*/
}
.input-section h1 {
font-size: 22px;
font-weight: 700;
margin-bottom: 4px;
color: #333;
letter-spacing: -0.02em;
}
.input-section .subtitle {
color: #666;
font-size: 13px;
margin-bottom: 24px;
}
.input-group {
margin-bottom: 16px;
position: relative;
}
.input-group label {
display: block;
font-size: 12px;
font-weight: 500;
margin-bottom: 6px;
color: #666;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.input-group .input-wrap {
position: relative;
}
.input-group .input-wrap i {
position: absolute;
left: 14px;
top: 50%;
transform: translateY(-50%);
color: #bbb;
font-size: 14px;
pointer-events: none;
}
.input-group input {
width: 100%;
padding: 12px 14px 12px 40px;
border: 1px solid #ddd;
border-radius: 10px;
background: #fff;
color: #333;
font-size: 14px;
outline: none;
transition: all 0.2s ease;
}
.input-group input::placeholder { color: #bbb; }
.input-group input:focus { border-color: #0078d4; box-shadow: 0 0 0 3px rgba(0,120,212,0.1); }
/* 保存路径历史下拉 */
.path-history-wrap { position: relative; }
.path-history {
display: none;
position: absolute;
top: 100%;
left: 0;
right: 0;
background: #fff;
border: 1px solid #ddd;
border-top: none;
border-radius: 0 0 10px 10px;
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
z-index: 10;
max-height: 240px;
overflow-y: auto;
}
.path-history.show { display: block; }
.path-history-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 14px;
cursor: pointer;
font-size: 13px;
color: #333;
transition: background 0.15s;
border-bottom: 1px solid #f5f5f5;
}
.path-history-item:last-child { border-bottom: none; }
.path-history-item:hover { background: #f0f7ff; }
.path-history-item.match { background: #f0f7ff; }
.path-history-item .path-text {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.path-history-item .path-delete {
flex-shrink: 0;
color: #bbb;
font-size: 12px;
margin-left: 8px;
padding: 2px 6px;
border-radius: 4px;
transition: all 0.15s;
cursor: pointer;
background: none;
border: none;
width: auto;
}
.path-history-item .path-delete:hover { color: #c62828; background: #fde8e8; }
.path-history-empty {
padding: 12px 14px;
font-size: 12px;
color: #999;
text-align: center;
}
button {
width: 100%;
padding: 14px;
border: none;
border-radius: 10px;
background: #0078d4;
color: #fff;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
button:hover:not(:disabled) { background: #006cbd; transform: translateY(-1px); }
button:disabled { opacity: 0.4; cursor: not-allowed; }
.status {
margin-top: 16px;
padding: 12px 14px;
border-radius: 10px;
font-size: 13px;
line-height: 1.5;
display: none;
font-weight: 500;
}
.status.show { display: flex; align-items: center; gap: 8px; }
.status.success { background: #e6f7e9; color: #0a7a1a; border: 1px solid #b3e6c0; }
.status.error { background: #fde8e8; color: #c62828; border: 1px solid #f5b3b3; }
.status.info { background: #e3f2fd; color: #1565c0; border: 1px solid #b3d9ff; }
.status i { font-size: 16px; }
.spinner {
display: inline-block;
width: 14px;
height: 14px;
border: 2px solid rgba(21,101,192,0.2);
border-top-color: #1565c0;
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin-right: 8px;
vertical-align: middle;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* 进度条样式 */
.progress-container {
margin-top: 16px;
display: none;
}
.progress-container.show { display: block; }
.progress-bar-wrapper {
width: 100%;
height: 8px;
background: #e0e0e0;
border-radius: 4px;
overflow: hidden;
margin-bottom: 8px;
}
.progress-bar {
height: 100%;
background: linear-gradient(90deg, #0078d4, #00a3ff);
border-radius: 4px;
width: 0%;
transition: width 0.3s ease;
position: relative;
overflow: hidden;
}
.progress-bar::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(
90deg,
transparent 0%,
rgba(255,255,255,0.3) 50%,
transparent 100%
);
animation: shimmer 1.5s infinite;
}
@keyframes shimmer {
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}
.progress-info {
display: flex;
justify-content: space-between;
font-size: 12px;
color: #666;
margin-bottom: 4px;
}
.progress-percent {
font-weight: 600;
color: #0078d4;
}
.progress-details {
font-size: 11px;
color: #999;
}
.progress-merging {
font-size: 13px;
color: #856404;
background: #fff3cd;
border: 1px solid #ffc107;
padding: 10px 14px;
border-radius: 8px;
margin-top: 10px;
display: none;
align-items: center;
gap: 8px;
}
.progress-merging.show { display: flex; }
.footer-meta {
margin-top: 14px;
display: flex;
align-items: center;
gap: 10px;
font-size: 11px;
color: #999;
}
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.02em;
}
.badge.ok { background: #e6f7e9; color: #0a7a1a; border: 1px solid #b3e6c0; }
.badge.warn { background: #fff3cd; color: #856404; border: 1px solid #ffc107; }
/* 日志区域 */
.log-section {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 0;
}
.log-header {
padding: 14px 32px;
font-size: 12px;
font-weight: 600;
color: #666;
text-transform: uppercase;
letter-spacing: 0.05em;
border-bottom: 1px solid #e0e0e0;
display: flex;
align-items: center;
justify-content: space-between;
background: #f5f5f5;
}
.log-panel {
flex: 1;
padding: 20px 32px;
background: #fff;
color: #333;
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace;
font-size: 12px;
line-height: 1.7;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-all;
}
/* 右侧面板 */
.right-panel {
flex: 1.2;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px;
position: relative;
overflow: hidden;
background: #f5f5f5;
}
.right-panel .placeholder {
text-align: center;
color: #bbb;
}
.right-panel .placeholder .placeholder-icon {
width: 80px;
height: 80px;
border-radius: 20px;
background: #fff;
border: 1px solid #e0e0e0;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 20px;
}
.right-panel .placeholder .placeholder-icon i {
font-size: 32px;
color: #ddd;
}
.right-panel .placeholder p {
font-size: 14px;
color: #999;
}
.right-panel .placeholder .placeholder-hint {
font-size: 12px;
color: #ccc;
margin-top: 6px;
}
.video-wrap {
width: 100%;
max-width: 720px;
height: 100%;
display: none;
flex-direction: column;
gap: 16px;
justify-content: center;
align-items: center;
}
.video-wrap.show { display: flex; }
.video-player {
max-width: 100%;
max-height: calc(100% - 70px);
width: auto;
height: auto;
object-fit: contain;
border-radius: 12px;
background: #000;
border: 1px solid #e0e0e0;
outline: none;
}
.video-info {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.video-title {
font-size: 15px;
font-weight: 600;
color: #333;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
flex: 1;
}
.video-actions {
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
.btn-download {
padding: 8px 16px;
border-radius: 8px;
background: #0078d4;
color: #fff;
font-size: 13px;
font-weight: 500;
text-decoration: none;
border: none;
white-space: nowrap;
display: inline-flex;
align-items: center;
gap: 6px;
}
/* 代理开关 */
.proxy-group { margin-bottom: 20px; }
.proxy-group .proxy-toggle-label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
font-size: 13px;
color: #555;
user-select: none;
margin-bottom: 10px;
}
.proxy-toggle-label input[type="checkbox"] { display: none; }
.proxy-check-box {
width: 16px;
height: 16px;
border: 2px solid #ccc;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: all 0.15s;
}
.proxy-check-box i { font-size: 10px; color: #fff; opacity: 0; }
.proxy-toggle-label input:checked ~ .proxy-check-box {
background: #0078d4;
border-color: #0078d4;
}
.proxy-toggle-label input:checked ~ .proxy-check-box i { opacity: 1; }
.proxy-url-wrap {
max-height: 0;
overflow: hidden;
transition: max-height 0.25s ease, opacity 0.25s ease;
opacity: 0;
}
.proxy-url-wrap.show {
max-height: 50px;
opacity: 1;
}
</style>
</head>
<body>
<div class="left-panel">
<div class="input-section">
<h1>视频下载</h1>
<p class="subtitle">支持 X、Bilibili、Instagram 和 YouTube</p>
<div class="input-group">
<label for="url">视频链接</label>
<div class="input-wrap">
<i class="fas fa-link"></i>
<input type="url" id="url" placeholder="粘贴 X、Bilibili、Instagram 或 YouTube 视频链接...">
</div>
</div>
<div class="input-group">
<label for="outputDir">保存路径</label>
<div class="path-history-wrap">
<div class="input-wrap">
<i class="fas fa-folder-open"></i>
<input type="text" id="outputDir" value="" placeholder="选择或输入保存路径..." autocomplete="off">
</div>
<div id="pathHistory" class="path-history"></div>
</div>
</div>
<div class="input-group proxy-group">
<label class="proxy-toggle-label">
<input type="checkbox" id="useProxy" checked>
<span class="proxy-check-box"><i class="fas fa-check"></i></span>
<span>使用代理</span>
</label>
<div id="proxyUrlWrap" class="proxy-url-wrap show">
<div class="input-wrap">
<i class="fas fa-link"></i>
<input type="text" id="proxyUrl" value="socks5://127.0.0.1:10808" placeholder="代理地址,如 socks5://127.0.0.1:10808">
</div>
</div>
</div>
<button id="downloadBtn" onclick="startDownload()">开始下载</button>
<div id="progressContainer" class="progress-container">
<div class="progress-info">
<span id="progressPercent" class="progress-percent">0%</span>
<span id="progressSpeed" class="progress-details"></span>
<span id="progressEta" class="progress-details"></span>
</div>
<div class="progress-bar-wrapper">
<div id="progressBar" class="progress-bar"></div>
</div>
<div id="progressFilename" class="progress-details" style="margin-top: 4px;"></div>
<div id="mergingStatus" class="progress-merging">
<span class="spinner"></span>
<span>正在合并视频和音频...</span>
</div>
</div>
<div id="status" class="status"></div>
<div class="footer-meta">
<span>FFmpeg</span>
{% if ffmpeg_ok %}
<span class="badge ok">已就绪</span>
{% else %}
<span class="badge warn">未检测到</span>
{% endif %}
<span style="margin-left:12px;">Deno</span>
{% if deno_ok %}
<span class="badge ok">已就绪</span>
{% else %}
<span class="badge warn">未检测到</span>
{% endif %}
</div>
</div>
</div>
<div class="right-panel">
<div id="placeholder" class="placeholder">
<div class="placeholder-icon">
<i class="fas fa-video"></i>
</div>
<p>下载完成后在此预览视频</p>
<p class="placeholder-hint">支持 X / Bilibili / Instagram / YouTube 视频</p>
</div>
<div id="videoWrap" class="video-wrap">
<video id="videoPlayer" class="video-player" controls></video>
<div class="video-info">
<div id="videoTitle" class="video-title"></div>
<div class="video-actions">
<a id="downloadLink" class="btn-download" href="#" target="_blank">下载文件</a>
</div>
</div>
</div>
</div>
<script>
const urlInput = document.getElementById('url');
const btn = document.getElementById('downloadBtn');
const statusEl = document.getElementById('status');
const placeholder = document.getElementById('placeholder');
const videoWrap = document.getElementById('videoWrap');
const videoPlayer = document.getElementById('videoPlayer');
const videoTitle = document.getElementById('videoTitle');
const downloadLink = document.getElementById('downloadLink');
const progressContainer = document.getElementById('progressContainer');
const progressBar = document.getElementById('progressBar');
const progressPercent = document.getElementById('progressPercent');
const progressSpeed = document.getElementById('progressSpeed');
const progressEta = document.getElementById('progressEta');
const progressFilename = document.getElementById('progressFilename');
const mergingStatus = document.getElementById('mergingStatus');
let eventSource = null;
let savePaths = [];
// ===== 代理开关 =====
const useProxyCheckbox = document.getElementById('useProxy');
const proxyUrlWrap = document.getElementById('proxyUrlWrap');
useProxyCheckbox.addEventListener('change', function() {
proxyUrlWrap.classList.toggle('show', this.checked);
});
// ===== 保存路径历史 =====
async function loadSavePaths() {
try {
const res = await fetch('/down-video/save-paths');
const data = await res.json();
savePaths = data.paths || [];
} catch (e) { savePaths = []; }
}
function renderPathHistory(filter) {
const el = document.getElementById('pathHistory');
if (!savePaths.length) {
el.innerHTML = '<div class="path-history-empty">暂无历史记录</div>';
return;
}
el.innerHTML = savePaths.map(function(p) {
var isMatch = filter && p.path.toLowerCase() === filter.toLowerCase();
return '<div class="path-history-item' + (isMatch ? ' match' : '') + '" data-path="' + escapePathAttr(p.path) + '">'
+ '<span class="path-text">' + escapePathHtml(p.path) + '</span>'
+ '</div>';
}).join('');
el.querySelectorAll('.path-history-item').forEach(function(item) {
item.addEventListener('click', function() {
document.getElementById('outputDir').value = item.dataset.path;
hidePathHistory();
});
});
}
function showPathHistory() {
const el = document.getElementById('pathHistory');
const val = document.getElementById('outputDir').value.trim();
renderPathHistory(val);
el.classList.add('show');
}
function hidePathHistory() {
document.getElementById('pathHistory').classList.remove('show');
}
async function deleteSavePath(path) {
try {
await fetch('/down-video/save-paths/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: path })
});
savePaths = savePaths.filter(function(p) { return p.path !== path; });
const val = document.getElementById('outputDir').value.trim();
renderPathHistory(val);
} catch (e) {}
}
function escapePathAttr(s) {
return (s || '').replace(/&/g, '&amp;').replace(/'/g, '&#39;').replace(/"/g, '&quot;');
}
function escapePathHtml(s) {
return (s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
// 保存路径到历史
async function saveCurrentPath(path) {
if (!path) return;
try {
await fetch('/down-video/save-paths', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: path })
});
await loadSavePaths();
} catch (e) {}
}
// 输出目录输入框事件
const outputDirInput = document.getElementById('outputDir');
outputDirInput.addEventListener('focus', showPathHistory);
outputDirInput.addEventListener('input', function() {
renderPathHistory(this.value.trim());
});
document.addEventListener('click', function(e) {
if (!e.target.closest('.path-history-wrap')) hidePathHistory();
});
// 页面加载时获取历史
loadSavePaths();
function showStatus(text, type) {
const icons = { success: 'fa-check-circle', error: 'fa-times-circle', info: 'fa-info-circle' };
statusEl.innerHTML = '<i class="fas ' + (icons[type] || '') + '"></i> ' + text;
statusEl.className = 'status show ' + type;
}
function clearStatus() { statusEl.className = 'status'; }
function showVideo(filepath, title) {
placeholder.style.display = 'none';
videoWrap.classList.add('show');
videoPlayer.src = '/down-video/file?path=' + encodeURIComponent(filepath);
videoTitle.textContent = title || '未命名视频';
downloadLink.href = '/down-video/file?path=' + encodeURIComponent(filepath) + '&download=1';
downloadLink.setAttribute('download', '');
}
function updateProgress(percent, speed, eta, filename) {
progressContainer.classList.add('show');
progressBar.style.width = percent + '%';
progressPercent.textContent = Math.round(percent) + '%';
progressSpeed.textContent = speed ? '速度: ' + speed : '';
progressEta.textContent = eta ? '剩余: ' + eta : '';
progressFilename.textContent = filename || '';
}
function resetProgress() {
progressContainer.classList.remove('show');
progressBar.style.width = '0%';
progressPercent.textContent = '0%';
progressSpeed.textContent = '';
progressEta.textContent = '';
progressFilename.textContent = '';
mergingStatus.classList.remove('show');
}
function generateDownloadId() {
return 'dl_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
}
async function startDownload() {
const url = urlInput.value.trim();
const outputDir = document.getElementById('outputDir').value.trim();
if (!url) {
showStatus('请输入视频链接', 'error');
return;
}
// 保存当前路径到历史
if (outputDir) saveCurrentPath(outputDir);
btn.disabled = true;
clearStatus();
showStatus('', 'info');
statusEl.innerHTML = '<span class="spinner"></span> 正在下载,请稍候...';
statusEl.className = 'status show info';
videoWrap.classList.remove('show');
placeholder.style.display = 'block';
videoPlayer.src = '';
resetProgress();
const downloadId = generateDownloadId();
// 启动SSE连接监听进度
eventSource = new EventSource('/down-video/progress/' + downloadId);
eventSource.onmessage = function(event) {
try {
const data = JSON.parse(event.data);
if (data.type === 'progress') {
updateProgress(data.percent, data.speed, data.eta, data.filename);
} else if (data.type === 'merging') {
mergingStatus.classList.add('show');
mergingStatus.querySelector('.spinner').style.display = 'inline-block';
} else if (data.type === 'info') {
// 可选:显示信息日志
} else if (data.type === 'error') {
// 可选:显示错误日志
}
} catch (e) {
console.error('Error parsing SSE data:', e);
}
};
eventSource.onerror = function() {
eventSource.close();
eventSource = null;
};
try {
const res = await fetch('/down-video/download', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
url: url,
output_dir: outputDir,
download_id: downloadId,
use_proxy: useProxyCheckbox.checked,
proxy_url: document.getElementById('proxyUrl').value.trim()
})
});
const data = await res.json();
// 关闭SSE连接
if (eventSource) {
eventSource.close();
eventSource = null;
}
if (data.success) {
updateProgress(100, '', '', '');
showStatus((data.title ? '《' + data.title + '》' : '') + ' 下载完成', 'success');
if (data.filepath) {
showVideo(data.filepath, data.title);
}
} else {
showStatus(data.message, 'error');
}
} catch (err) {
// 关闭SSE连接
if (eventSource) {
eventSource.close();
eventSource = null;
}
showStatus('请求失败: ' + err.message, 'error');
} finally {
btn.disabled = false;
}
}
urlInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') startDownload();
});
</script>
</body>
</html>

View File

@@ -0,0 +1,376 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="{{ url_for('static', filename='font-awesome.css') }}">
<title>Jieba 分词</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: #f5f5f5;
color: #333;
height: 100vh;
overflow: hidden;
display: flex;
}
/* 左侧面板 */
.left-panel {
width: 50%;
flex-shrink: 0;
display: flex;
flex-direction: column;
border-right: 1px solid #e8e8e8;
background: #fff;
height: 100vh;
overflow-y: auto;
}
.left-panel::-webkit-scrollbar { width: 4px; }
.left-panel::-webkit-scrollbar-thumb { background: #ddd; border-radius: 2px; }
.input-section { padding: 20px 24px 28px; }
/* 头部 */
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 2px;
}
.header h1 {
font-size: 18px;
font-weight: 700;
color: #1a1a1a;
letter-spacing: -0.01em;
}
.subtitle { color: #aaa; font-size: 12px; margin-bottom: 16px; }
/* 分组卡片 */
.section-card {
background: #fafafa;
border: 1px solid #f0f0f0;
border-radius: 10px;
padding: 14px 16px;
margin-bottom: 12px;
}
.section-title {
font-size: 11px;
font-weight: 600;
color: #999;
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 6px;
}
.section-title i { font-size: 11px; }
/* 输入组 */
.input-group { margin-bottom: 10px; position: relative; }
.input-group:last-child { margin-bottom: 0; }
.input-group label {
display: block; font-size: 12px; font-weight: 500;
margin-bottom: 4px; color: #555;
}
.input-group .input-wrap { position: relative; }
.input-group .input-wrap i {
position: absolute; left: 10px; top: 50%; transform: translateY(-50%);
color: #c0c0c0; font-size: 12px; pointer-events: none;
}
.input-group input,
.input-group select {
width: 100%; padding: 7px 10px 7px 32px;
border: 1px solid #e0e0e0; border-radius: 6px;
background: #fff; color: #333; font-size: 13px;
outline: none; transition: all 0.15s ease;
}
.input-group input::placeholder { color: #c0c0c0; }
.input-group input:focus,
.input-group select:focus {
border-color: #0078d4;
box-shadow: 0 0 0 2px rgba(0,120,212,0.06);
}
.input-group.no-icon input,
.input-group.no-icon select { padding-left: 10px; }
/* textarea */
.text-input {
width: 100%; padding: 10px 12px;
border: 1px solid #e0e0e0; border-radius: 6px;
font-size: 13px; outline: none; resize: vertical;
min-height: 200px; font-family: inherit; transition: border-color 0.15s;
}
.text-input:focus { border-color: #0078d4; box-shadow: 0 0 0 2px rgba(0,120,212,0.06); }
/* 按钮 */
.btn {
padding: 7px 14px; border: none; border-radius: 6px;
font-size: 12px; font-weight: 600; cursor: pointer;
transition: all 0.12s ease;
display: inline-flex; align-items: center; gap: 5px;
}
.btn-primary { background: #0078d4; color: #fff; }
.btn-primary:hover:not(:disabled) { background: #006cbd; }
.btn-primary:disabled { opacity: 0.4; cursor: not-allowed; }
.btn-outline { background: #fff; color: #666; border: 1px solid #e0e0e0; }
.btn-outline:hover { background: #f5f5f5; color: #333; }
.btn-block { width: 100%; justify-content: center; }
.btn-row { display: flex; gap: 8px; }
/* 合成分词按钮 */
.run-btn {
width: 100%; padding: 10px 18px;
background: linear-gradient(135deg, #0078d4, #005fa3);
color: #fff; border: none; border-radius: 8px;
font-size: 14px; font-weight: 600;
cursor: pointer; transition: all 0.15s ease;
display: flex; align-items: center; justify-content: center; gap: 8px;
margin-top: 14px;
box-shadow: 0 2px 8px rgba(0,120,212,0.2);
}
.run-btn:hover:not(:disabled) {
background: linear-gradient(135deg, #006cbd, #004e8a);
box-shadow: 0 4px 12px rgba(0,120,212,0.3);
transform: translateY(-1px);
}
.run-btn:active:not(:disabled) { transform: translateY(0); }
.run-btn:disabled { opacity: 0.4; cursor: not-allowed; transform: none; }
@keyframes spin { to { transform: rotate(360deg); } }
.spinner {
display: inline-block; width: 16px; height: 16px;
border: 2px solid rgba(255,255,255,0.3); border-top-color: #fff;
border-radius: 50%; animation: spin 0.8s linear infinite;
}
/* ===== 右侧面板 ===== */
.right-panel {
flex: 1;
background: #fff;
display: flex;
flex-direction: column;
height: 100vh;
}
.right-header {
padding: 20px 24px 0;
display: flex;
align-items: center;
justify-content: space-between;
}
.right-header h2 {
font-size: 15px; font-weight: 600; color: #333;
}
.result-badge {
font-size: 12px; font-weight: 600; color: #0078d4;
background: #e8f0fe; padding: 2px 10px; border-radius: 10px;
}
/* 统计栏 */
.stats-bar {
display: flex; flex-wrap: wrap; gap: 16px;
padding: 12px 24px; font-size: 12px; color: #888;
border-bottom: 1px solid #f0f0f0;
}
.stats-bar span { display: flex; align-items: center; gap: 4px; }
.stats-bar .num { font-weight: 700; color: #333; }
/* 结果区域 */
.result-area {
flex: 1; overflow-y: auto; padding: 20px 24px;
}
.result-area::-webkit-scrollbar { width: 4px; }
.result-area::-webkit-scrollbar-thumb { background: #ddd; border-radius: 2px; }
/* 标签 */
.token-list { display: flex; flex-wrap: wrap; gap: 6px; }
.token-tag {
display: inline-flex; align-items: center; gap: 4px;
padding: 4px 10px; border-radius: 5px;
font-size: 13px; cursor: default; transition: transform 0.1s;
}
.token-tag:hover { transform: translateY(-1px); }
.token-tag .type {
font-size: 10px; opacity: 0.65;
background: rgba(0,0,0,0.06); padding: 1px 5px;
border-radius: 3px;
}
/* 占位 */
.placeholder {
flex: 1; display: flex; flex-direction: column;
align-items: center; justify-content: center;
color: #ccc; gap: 12px;
}
.placeholder i { font-size: 48px; }
.placeholder p { font-size: 13px; }
.placeholder .hint { font-size: 11px; color: #ddd; }
/* Toast */
.toast {
position: fixed; top: 20px; left: 50%;
transform: translateX(-50%);
background: #0a7a1a; color: #fff;
padding: 10px 24px; border-radius: 8px;
font-size: 13px; font-weight: 500;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
opacity: 0; transition: opacity 0.3s;
z-index: 9999; pointer-events: none;
}
.toast.show { opacity: 1; }
.toast.error { background: #c62828; }
</style>
</head>
<body>
<div class="toast" id="toast"></div>
<!-- 左侧:输入 -->
<div class="left-panel">
<div class="input-section">
<div class="header">
<h1><i class="fas fa-scissors" style="color:#0078d4;font-size:16px;margin-right:6px;"></i>Jieba 分词</h1>
</div>
<p class="subtitle">中文分词与词性标注工具</p>
<div class="section-card">
<div class="section-title"><i class="fas fa-key"></i> 认证</div>
<div class="input-group">
<label>Bearer Token</label>
<div class="input-wrap">
<i class="fas fa-key"></i>
<input type="text" id="tokenInput" placeholder="请输入 Token">
</div>
</div>
</div>
<div class="section-card" style="flex:1;">
<div class="section-title"><i class="fas fa-file-lines"></i> 输入文本</div>
<div class="input-group no-icon">
<textarea id="inputText" class="text-input" placeholder="在此输入需要分词的文本...&#10;支持中英文混合Ctrl+Enter 快速分词" style="min-height:240px;"></textarea>
</div>
<div class="btn-row" style="margin-top:10px;">
<button class="btn btn-outline" onclick="clearAll()"><i class="fas fa-times-circle"></i> 清空</button>
</div>
<button class="run-btn" id="tokenizeBtn" onclick="doTokenize()">
<i class="fas fa-scissors"></i> 开始分词
</button>
</div>
</div>
</div>
<!-- 右侧:结果 -->
<div class="right-panel">
<div class="right-header">
<h2><i class="fas fa-list" style="color:#0078d4;margin-right:6px;"></i>分词结果</h2>
<span class="result-badge" id="totalCount" style="display:none;"></span>
</div>
<div id="statsBar" class="stats-bar" style="display:none;"></div>
<div id="resultArea" class="result-area">
<div class="placeholder">
<i class="fas fa-scissors"></i>
<p>输入文本并点击"开始分词"查看结果</p>
<span class="hint">Ctrl + Enter 快速分词</span>
</div>
</div>
</div>
<script>
var tokenInput = document.getElementById('tokenInput');
var inputText = document.getElementById('inputText');
var resultArea = document.getElementById('resultArea');
var statsBar = document.getElementById('statsBar');
var totalCount = document.getElementById('totalCount');
var tokenizeBtn = document.getElementById('tokenizeBtn');
tokenInput.value = 'DdzBgb8GEkBpA8gtCJXP24hGJz9bXpEOi6z91fHm25X59q5968XqtLxPi1MfiTHJ';
function showToast(msg, isError) {
var t = document.getElementById('toast');
t.textContent = msg;
t.className = 'toast show' + (isError ? ' error' : '');
setTimeout(function() { t.className = 'toast'; }, 1500);
}
function clearAll() {
inputText.value = '';
resultArea.innerHTML = '<div class="placeholder"><i class="fas fa-scissors"></i><p>输入文本并点击"开始分词"查看结果</p><span class="hint">Ctrl + Enter 快速分词</span></div>';
statsBar.style.display = 'none';
totalCount.style.display = 'none';
}
var typeNames = {
chinese: '中文', english: '英文', compound_eng: '复合词',
number: '数字', punctuation: '标点'
};
var typeColors = {
chinese: { bg: '#fce7f3', color: '#be185d', border: '#fbcfe8' },
english: { bg: '#dbeafe', color: '#1e40af', border: '#bfdbfe' },
compound_eng: { bg: '#ddd6fe', color: '#5b21b6', border: '#c4b5fd' },
number: { bg: '#d1fae5', color: '#065f46', border: '#a7f3d0' },
punctuation: { bg: '#f3f4f6', color: '#4b5563', border: '#e5e7eb' }
};
async function doTokenize() {
var text = inputText.value.trim();
var token = tokenInput.value.trim();
if (!text) { showToast('请输入需要分词的文本', true); return; }
if (!token) { showToast('请输入 Token', true); return; }
tokenizeBtn.disabled = true;
tokenizeBtn.innerHTML = '<span class="spinner"></span> 分词中...';
resultArea.innerHTML = '<div class="placeholder"><span class="spinner" style="border-color:#e0e0e0;border-top-color:#0078d4;width:32px;height:32px;"></span><p style="color:#999;margin-top:8px;">正在分词...</p></div>';
statsBar.style.display = 'none';
totalCount.style.display = 'none';
try {
var res = await fetch('/fen-ci/tokenize', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
body: JSON.stringify({ input_text: text })
});
var data = await res.json();
if (data.success) {
var tokens = data.tokens;
var stats = data.stats;
var html = tokens.map(function(t) {
var s = typeColors[t.type] || typeColors.punctuation;
return '<span class="token-tag" style="background:' + s.bg + ';color:' + s.color + ';border:1px solid ' + s.border + '">' +
t.text + '<span class="type">' + (typeNames[t.type] || t.type) + '</span></span>';
}).join('');
resultArea.innerHTML = '<div class="token-list">' + html + '</div>';
totalCount.textContent = '共 ' + stats.total + ' 个词';
totalCount.style.display = 'inline';
statsBar.innerHTML =
'<span>总计: <span class="num">' + stats.total + '</span></span>' +
'<span>中文: <span class="num">' + stats.chinese + '</span></span>' +
'<span>英文: <span class="num">' + stats.english + '</span></span>' +
'<span>复合词: <span class="num">' + stats.compound_eng + '</span></span>' +
'<span>数字: <span class="num">' + stats.number + '</span></span>' +
'<span>标点: <span class="num">' + stats.punctuation + '</span></span>';
statsBar.style.display = 'flex';
} else {
resultArea.innerHTML = '<div class="placeholder" style="color:#c62828;"><i class="fas fa-circle-exclamation"></i><p>' + (data.error || '未知错误') + '</p></div>';
statsBar.style.display = 'none';
totalCount.style.display = 'none';
}
} catch (err) {
resultArea.innerHTML = '<div class="placeholder" style="color:#c62828;"><i class="fas fa-circle-exclamation"></i><p>请求失败: ' + err.message + '</p></div>';
statsBar.style.display = 'none';
totalCount.style.display = 'none';
} finally {
tokenizeBtn.disabled = false;
tokenizeBtn.innerHTML = '<i class="fas fa-scissors"></i> 开始分词';
}
}
inputText.addEventListener('keydown', function(e) {
if (e.ctrlKey && e.key === 'Enter') doTokenize();
});
</script>
</body>
</html>

View File

@@ -0,0 +1,267 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="{{ url_for('static', filename='font-awesome.css') }}">
<title>HTTP 状态码</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: #f5f5f5; color: #333;
height: 100vh; overflow: hidden; display: flex;
}
.left-panel {
width: 300px; background: #fff; border-right: 1px solid #e0e0e0;
display: flex; flex-direction: column;
}
.panel-header { padding: 28px 24px 18px; border-bottom: 1px solid #f0f0f0; }
.panel-header h1 { font-size: 20px; font-weight: 700; margin-bottom: 2px; }
.panel-header .sub { font-size: 13px; color: #999; }
.search-box {
margin: 20px 24px; position: relative;
}
.search-box i {
position: absolute; left: 12px; top: 50%; transform: translateY(-50%);
color: #bbb; font-size: 13px; pointer-events: none;
}
.search-box input {
width: 100%; padding: 10px 12px 10px 36px;
border: 1px solid #e0e0e0; border-radius: 8px;
font-size: 13px; outline: none; transition: border-color 0.2s;
}
.search-box input::placeholder { color: #ccc; }
.search-box input:focus { border-color: #0078d4; }
.cat-nav { padding: 0 24px; flex: 1; overflow-y: auto; }
.cat-nav-item {
display: flex; align-items: center; justify-content: space-between;
padding: 10px 12px; border-radius: 8px; cursor: pointer;
font-size: 13px; font-weight: 500; color: #555;
transition: all 0.15s; margin-bottom: 2px;
}
.cat-nav-item:hover { background: #f5f5f5; }
.cat-nav-item.active { background: #f0f7ff; color: #0078d4; font-weight: 600; }
.cat-dot {
width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
}
.cat-count {
font-size: 11px; color: #bbb; background: #f5f5f5;
padding: 1px 8px; border-radius: 10px;
}
.cat-nav-item.active .cat-count { background: #ddeeff; color: #0078d4; }
.panel-footer {
padding: 16px 24px; border-top: 1px solid #f0f0f0;
font-size: 11px; color: #bbb; text-align: center;
}
.right-panel { flex: 1; overflow-y: auto; padding: 28px 32px; }
.section-title {
font-size: 13px; font-weight: 700; color: #999;
text-transform: uppercase; letter-spacing: 0.06em;
margin: 28px 0 12px; padding-bottom: 8px;
border-bottom: 1px solid #eee;
}
.section-title:first-child { margin-top: 0; }
.code-grid { display: flex; flex-direction: column; gap: 6px; }
.code-card {
background: #fff; border: 1px solid #e8e8e8; border-radius: 10px;
padding: 14px 18px; display: flex; align-items: baseline; gap: 14px;
transition: all 0.15s; cursor: default;
}
.code-card:hover { border-color: #c0c0c0; box-shadow: 0 2px 8px rgba(0,0,0,0.04); }
.code-num {
font-size: 18px; font-weight: 700; font-family: "SFMono-Regular", Consolas, monospace;
min-width: 44px; flex-shrink: 0;
}
.code-info { flex: 1; min-width: 0; }
.code-name { font-size: 14px; font-weight: 600; color: #333; margin-bottom: 2px; }
.code-desc { font-size: 12px; color: #888; line-height: 1.5; }
.c1xx .code-num { color: #0288d1; }
.c2xx .code-num { color: #2e7d32; }
.c3xx .code-num { color: #f57f17; }
.c4xx .code-num { color: #c62828; }
.c5xx .code-num { color: #7b1fa2; }
.dot-1xx { background: #0288d1; }
.dot-2xx { background: #2e7d32; }
.dot-3xx { background: #f57f17; }
.dot-4xx { background: #c62828; }
.dot-5xx { background: #7b1fa2; }
.empty-state {
text-align: center; padding: 80px 20px; color: #ccc;
}
.empty-state i { font-size: 36px; margin-bottom: 12px; display: block; }
.empty-state p { font-size: 14px; }
.match-count {
font-size: 12px; color: #999; margin-bottom: 16px;
}
</style>
</head>
<body>
<div class="left-panel">
<div class="panel-header">
<h1>HTTP 状态码</h1>
<span class="sub">快速参考手册</span>
</div>
<div class="search-box">
<i class="fas fa-search"></i>
<input type="text" id="searchInput" placeholder="搜索状态码、名称或描述..." oninput="onSearch()">
</div>
<div class="cat-nav" id="catNav"></div>
<div class="panel-footer">共 60+ 状态码</div>
</div>
<div class="right-panel" id="content"></div>
<script>
var data = [
{cat:'1xx 信息响应', cls:'c1xx', dot:'dot-1xx', codes:[
{n:100,e:'Continue',d:'等待客户端继续发送请求体。'},
{n:101,e:'Switching Protocols',d:'服务器同意切换协议。'},
{n:102,e:'Processing',d:'服务器正在处理请求,尚无响应可用。'},
{n:103,e:'Early Hints',d:'服务器在最终响应前返回部分响应头。'}
]},
{cat:'2xx 成功', cls:'c2xx', dot:'dot-2xx', codes:[
{n:200,e:'OK',d:'标准的成功响应。'},
{n:201,e:'Created',d:'请求成功并创建了新资源。'},
{n:202,e:'Accepted',d:'请求已接受但尚未处理完成。'},
{n:203,e:'Non-Authoritative Information',d:'请求成功,但内容已被代理修改。'},
{n:204,e:'No Content',d:'服务器成功处理但不返回内容。'},
{n:205,e:'Reset Content',d:'重置文档视图。'},
{n:206,e:'Partial Content',d:'服务器返回部分内容。'},
{n:207,e:'Multi-Status',d:'XML 消息包含多个独立响应码。(WebDAV)'},
{n:208,e:'Already Reported',d:'DAV 绑定成员已在前面枚举。(WebDAV)'},
{n:226,e:'IM Used',d:'服务器已完成资源请求,响应为操作结果。'}
]},
{cat:'3xx 重定向', cls:'c3xx', dot:'dot-3xx', codes:[
{n:300,e:'Multiple Choices',d:'有多个可选资源。'},
{n:301,e:'Moved Permanently',d:'资源已永久移动到新 URI。'},
{n:302,e:'Found',d:'临时重定向到另一个 URL。'},
{n:303,e:'See Other',d:'可在另一 URI 下用 GET 获取响应。'},
{n:304,e:'Not Modified',d:'资源未修改,使用缓存。'},
{n:305,e:'Use Proxy',d:'必须通过指定代理访问资源。'},
{n:306,e:'Switch Proxy',d:'已不再使用。'},
{n:307,e:'Temporary Redirect',d:'临时重定向,保持请求方法不变。'},
{n:308,e:'Permanent Redirect',d:'永久重定向,保持请求方法不变。'}
]},
{cat:'4xx 客户端错误', cls:'c4xx', dot:'dot-4xx', codes:[
{n:400,e:'Bad Request',d:'请求语法错误,服务器无法处理。'},
{n:401,e:'Unauthorized',d:'需要身份验证。'},
{n:402,e:'Payment Required',d:'保留用于未来数字支付。'},
{n:403,e:'Forbidden',d:'服务器拒绝执行,权限不足。'},
{n:404,e:'Not Found',d:'请求的资源未找到。'},
{n:405,e:'Method Not Allowed',d:'请求方法不被允许。'},
{n:406,e:'Not Acceptable',d:'资源无法生成客户端可接受的内容。'},
{n:407,e:'Proxy Authentication Required',d:'需要代理身份验证。'},
{n:408,e:'Request Timeout',d:'服务器等待请求超时。'},
{n:409,e:'Conflict',d:'请求与服务器当前状态冲突。'},
{n:410,e:'Gone',d:'资源已永久删除且不会再恢复。'},
{n:411,e:'Length Required',d:'缺少 Content-Length 头。'},
{n:412,e:'Precondition Failed',d:'服务器不满足请求中的前提条件。'},
{n:413,e:'Payload Too Large',d:'请求体超过服务器限制。'},
{n:414,e:'URI Too Long',d:'URI 过长无法处理。'},
{n:415,e:'Unsupported Media Type',d:'不支持的媒体类型。'},
{n:416,e:'Range Not Satisfiable',d:'无法满足请求的范围。'},
{n:417,e:'Expectation Failed',d:'无法满足 Expect 请求头。'},
{n:418,e:"I'm a teapot",d:'拒绝用茶壶煮咖啡。'},
{n:421,e:'Misdirected Request',d:'请求被定向到无法产生响应的服务器。'},
{n:422,e:'Unprocessable Entity',d:'请求格式正确但语义错误。(WebDAV)'},
{n:423,e:'Locked',d:'资源已锁定。(WebDAV)'},
{n:424,e:'Failed Dependency',d:'因前序请求失败而失败。(WebDAV)'},
{n:425,e:'Too Early',d:'服务器不愿冒险处理可能被重放的请求。'},
{n:426,e:'Upgrade Required',d:'客户端应切换协议(如 TLS。'},
{n:428,e:'Precondition Required',d:'要求请求为条件请求。'},
{n:429,e:'Too Many Requests',d:'客户端发送请求过于频繁。'},
{n:431,e:'Request Header Fields Too Large',d:'请求头字段过大。'},
{n:451,e:'Unavailable For Legal Reasons',d:'因法律原因不可用。'}
]},
{cat:'5xx 服务端错误', cls:'c5xx', dot:'dot-5xx', codes:[
{n:500,e:'Internal Server Error',d:'服务器内部错误。'},
{n:501,e:'Not Implemented',d:'服务器不支持该请求方法。'},
{n:502,e:'Bad Gateway',d:'网关或代理收到上游无效响应。'},
{n:503,e:'Service Unavailable',d:'服务暂时不可用(过载或维护)。'},
{n:504,e:'Gateway Timeout',d:'网关或代理等待上游响应超时。'},
{n:505,e:'HTTP Version Not Supported',d:'不支持的 HTTP 协议版本。'},
{n:506,e:'Variant Also Negotiates',d:'内容协商出现循环引用。'},
{n:507,e:'Insufficient Storage',d:'服务器存储空间不足。(WebDAV)'},
{n:508,e:'Loop Detected',d:'服务器检测到无限循环。(WebDAV)'},
{n:510,e:'Not Extended',d:'需要进一步扩展请求。'},
{n:511,e:'Network Authentication Required',d:'需要网络身份验证。'}
]}
];
var activeCat = -1;
function esc(s) { return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
function card(cls, n, name, desc) {
return '<div class="code-card ' + cls + '">'
+ '<span class="code-num">' + n + '</span>'
+ '<div class="code-info">'
+ '<div class="code-name">' + esc(name) + '</div>'
+ '<div class="code-desc">' + esc(desc) + '</div>'
+ '</div></div>';
}
function renderNav() {
var html = '<div class="cat-nav-item' + (activeCat === -1 ? ' active' : '') + '" onclick="selectCat(-1)">'
+ '<span>全部</span><span class="cat-count">' + totalCodes() + '</span></div>';
data.forEach(function(c, i) {
html += '<div class="cat-nav-item' + (activeCat === i ? ' active' : '') + '" onclick="selectCat(' + i + ')">'
+ '<span><span class="cat-dot ' + c.dot + '" style="display:inline-block;margin-right:8px;vertical-align:middle;"></span>' + c.cat.split(' ')[0] + '</span>'
+ '<span class="cat-count">' + c.codes.length + '</span></div>';
});
document.getElementById('catNav').innerHTML = html;
}
function totalCodes() { var n = 0; data.forEach(function(c) { n += c.codes.length; }); return n; }
function renderContent(filter) {
var q = (filter || '').toLowerCase();
var html = '';
if (q) {
var hits = [];
data.forEach(function(cat) {
cat.codes.forEach(function(c) {
var str = c.n + ' ' + c.e + ' ' + c.d;
if (str.toLowerCase().indexOf(q) !== -1) {
hits.push({cls: cat.cls, n: c.n, e: c.e, d: c.d});
}
});
});
html = '<div class="match-count">找到 ' + hits.length + ' 个结果</div><div class="code-grid">';
if (!hits.length) {
html = '<div class="empty-state"><i class="fas fa-search"></i><p>未找到匹配结果</p></div>';
} else {
hits.forEach(function(c) { html += card(c.cls, c.n, c.e, c.d); });
}
html += '</div>';
} else {
var cats = activeCat === -1 ? data : [data[activeCat]];
cats.forEach(function(cat) {
html += '<div class="section-title">' + cat.cat + '</div><div class="code-grid">';
cat.codes.forEach(function(c) { html += card(cat.cls, c.n, c.e, c.d); });
html += '</div>';
});
}
document.getElementById('content').innerHTML = html;
}
function selectCat(i) {
activeCat = i;
document.getElementById('searchInput').value = '';
renderNav();
renderContent('');
}
function onSearch() {
renderContent(document.getElementById('searchInput').value);
}
renderNav();
renderContent('');
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,223 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="{{ url_for('static', filename='font-awesome.css') }}">
<title>JSON 美化 / 压缩</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: #f5f5f5;
color: #333;
height: 100vh;
overflow: hidden;
display: flex;
}
.left-panel {
width: 300px;
background: #fff;
border-right: 1px solid #e0e0e0;
display: flex;
flex-direction: column;
}
.panel-header {
padding: 28px 24px 18px;
border-bottom: 1px solid #f0f0f0;
}
.panel-header h1 { font-size: 20px; font-weight: 700; margin-bottom: 2px; }
.panel-header .sub { font-size: 13px; color: #999; }
.panel-body { padding: 20px 24px; flex: 1; overflow-y: auto; }
.group { margin-bottom: 24px; }
.group-title { font-size: 11px; font-weight: 600; color: #aaa; text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 10px; }
.tabs { display: flex; background: #f5f5f5; border-radius: 8px; padding: 3px; gap: 3px; }
.tab-btn {
flex: 1; padding: 9px 0; text-align: center;
font-size: 13px; font-weight: 600; color: #999;
background: none; border: none; border-radius: 6px; cursor: pointer; transition: all 0.2s;
}
.tab-btn.on { background: #fff; color: #0078d4; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
.tab-btn:hover:not(.on) { color: #666; }
select.styled {
width: 100%; padding: 9px 32px 9px 12px;
border: 1px solid #e0e0e0; border-radius: 8px;
font-size: 13px; color: #333; background: #fff;
outline: none; cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 10 10'%3E%3Cpath fill='%23999' d='M5 7L1 3h8z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 10px center;
transition: border-color 0.2s;
}
select.styled:focus { border-color: #0078d4; }
.chk-label { display: flex; align-items: center; gap: 8px; cursor: pointer; font-size: 13px; color: #555; }
.chk-label input { display: none; }
.chk-icon {
width: 16px; height: 16px; border: 2px solid #d0d0d0; border-radius: 4px;
display: flex; align-items: center; justify-content: center; transition: all 0.15s;
}
.chk-icon i { font-size: 10px; color: #fff; opacity: 0; transition: opacity 0.15s; }
.chk-label input:checked ~ .chk-icon { background: #0078d4; border-color: #0078d4; }
.chk-label input:checked ~ .chk-icon i { opacity: 1; }
.panel-actions { padding: 16px 24px; border-top: 1px solid #f0f0f0; }
.panel-actions button {
width: 100%; padding: 10px; border: none; border-radius: 8px;
font-size: 13px; font-weight: 600; cursor: pointer;
display: flex; align-items: center; justify-content: center; gap: 8px;
margin-bottom: 8px; transition: all 0.15s;
}
.panel-actions button:last-child { margin-bottom: 0; }
.btn-main { background: #0078d4; color: #fff; }
.btn-main:hover { background: #006cbd; }
.btn-main.copied { background: #2e7d32; }
.btn-ghost { background: #fff; color: #666; border: 1px solid #e0e0e0; }
.btn-ghost:hover { background: #f5f5f5; color: #333; }
.btn-ghost.danger:hover { color: #c62828; border-color: #f5b3b3; background: #fff5f5; }
.right { flex: 1; display: flex; }
.pane { flex: 1; display: flex; flex-direction: column; background: #fff; }
.pane:first-child { border-right: 1px solid #e0e0e0; }
.pane-head {
padding: 12px 20px; font-size: 11px; font-weight: 600; color: #aaa;
text-transform: uppercase; letter-spacing: 0.06em;
border-bottom: 1px solid #f0f0f0;
display: flex; align-items: center; gap: 6px;
}
.pane-head i { font-size: 11px; color: #ccc; }
.pane-body { flex: 1; display: flex; flex-direction: column; }
.pane-body textarea {
flex: 1; width: 100%; border: none; outline: none; resize: none;
padding: 16px 20px;
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
font-size: 13px; line-height: 1.7; color: #333; background: #fff;
}
.pane-body textarea::placeholder { color: #ccc; }
.pane-body textarea:focus { background: #fefefe; }
.pane-body textarea.has-error { background: #fff8f8; }
.err-bar {
padding: 8px 20px; font-size: 12px; color: #c62828;
background: #fff5f5; border-top: 1px solid #fde8e8;
display: none; align-items: center; gap: 6px;
}
.err-bar.on { display: flex; }
</style>
</head>
<body>
<div class="left-panel">
<div class="panel-header">
<h1>JSON 工具</h1>
<span class="sub">美化 &amp; 压缩</span>
</div>
<div class="panel-body">
<div class="group">
<div class="group-title">模式</div>
<div class="tabs">
<button class="tab-btn on" data-tab="beautify" onclick="switchTab('beautify')">美化</button>
<button class="tab-btn" data-tab="minify" onclick="switchTab('minify')">压缩</button>
</div>
</div>
<div id="beautifyOpts">
<div class="group">
<div class="group-title">缩进空格</div>
<select id="indentSize" class="styled" onchange="process()">
<option value="2">2</option>
<option value="4" selected>4</option>
<option value="6">6</option>
<option value="8">8</option>
</select>
</div>
<div class="group">
<label class="chk-label">
<input type="checkbox" id="sortKeys" onchange="process()">
<span class="chk-icon"><i class="fas fa-check"></i></span>
<span>键名排序</span>
</label>
</div>
</div>
</div>
<div class="panel-actions">
<button class="btn-main" id="copyBtn" onclick="copyOutput()"><i class="fas fa-copy"></i><span>复制结果</span></button>
<button class="btn-ghost" onclick="swapIO()"><i class="fas fa-refresh"></i> 交换输入输出</button>
<button class="btn-ghost danger" onclick="clearAll()"><i class="fas fa-trash"></i> 清空</button>
</div>
</div>
<div class="right">
<div class="pane">
<div class="pane-head"><i class="fas fa-file"></i> 输入</div>
<div class="pane-body">
<textarea id="input" placeholder="在此粘贴 JSON..." oninput="process()"></textarea>
<div class="err-bar" id="errBar"><i class="fas fa-times-circle"></i><span id="errMsg"></span></div>
</div>
</div>
<div class="pane">
<div class="pane-head"><i class="fas fa-check"></i> <span id="outLabel">美化结果</span></div>
<div class="pane-body">
<textarea id="output" readonly placeholder="结果将显示在这里..."></textarea>
</div>
</div>
</div>
<script>
var cur = 'beautify';
function switchTab(t) {
cur = t;
document.querySelectorAll('.tab-btn').forEach(function(b) { b.classList.toggle('on', b.dataset.tab === t); });
document.getElementById('beautifyOpts').style.display = t === 'beautify' ? 'block' : 'none';
document.getElementById('outLabel').textContent = t === 'beautify' ? '美化结果' : '压缩结果';
process();
}
function sortKeys(o) {
if (typeof o !== 'object' || o === null) return o;
if (Array.isArray(o)) return o.map(sortKeys);
return Object.keys(o).sort().reduce(function(a, k) { a[k] = sortKeys(o[k]); return a; }, {});
}
function process() {
var v = document.getElementById('input').value;
var errBar = document.getElementById('errBar');
var errMsg = document.getElementById('errMsg');
var out = document.getElementById('output');
var inp = document.getElementById('input');
if (!v.trim()) { out.value = ''; errBar.classList.remove('on'); inp.classList.remove('has-error'); return; }
try {
var obj = JSON.parse(v);
if (cur === 'beautify') {
var n = parseInt(document.getElementById('indentSize').value);
out.value = JSON.stringify(document.getElementById('sortKeys').checked ? sortKeys(obj) : obj, null, n);
} else {
out.value = JSON.stringify(obj);
}
errBar.classList.remove('on'); inp.classList.remove('has-error');
} catch (e) {
errMsg.textContent = e.message; errBar.classList.add('on'); inp.classList.add('has-error'); out.value = '';
}
}
function copyOutput() {
var t = document.getElementById('output').value;
if (!t) return;
var el = document.createElement('textarea');
el.value = t; el.style.cssText = 'position:fixed;left:-9999px';
document.body.appendChild(el); el.select(); document.execCommand('copy'); document.body.removeChild(el);
var b = document.getElementById('copyBtn');
b.classList.add('copied'); b.querySelector('span').textContent = '已复制';
setTimeout(function() { b.classList.remove('copied'); b.querySelector('span').textContent = '复制结果'; }, 1500);
}
function swapIO() {
var a = document.getElementById('input'), b = document.getElementById('output'), t = a.value;
a.value = b.value; b.value = t; process();
}
function clearAll() {
document.getElementById('input').value = '';
document.getElementById('output').value = '';
document.getElementById('errBar').classList.remove('on');
document.getElementById('input').classList.remove('has-error');
}
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,100 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>漫画阅读 - {{ current_path_name }}</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #1a1a1a;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
padding: 0;
}
.reader-bar {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 40px;
background: rgba(0,0,0,0.85);
display: flex;
align-items: center;
padding: 0 16px;
z-index: 100;
gap: 12px;
backdrop-filter: blur(8px);
}
.reader-bar .title {
color: rgba(255,255,255,0.8);
font-size: 13px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
}
.reader-bar .count {
color: rgba(255,255,255,0.5);
font-size: 12px;
white-space: nowrap;
}
.reader-bar .close-btn {
color: rgba(255,255,255,0.6);
text-decoration: none;
font-size: 20px;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
transition: all 0.15s;
}
.reader-bar .close-btn:hover { background: rgba(255,255,255,0.15); color: #fff; }
.manga-container {
width: 100%;
max-width: 900px;
margin-top: 40px;
}
.manga-item {
width: 100%;
line-height: 0;
}
.manga-item img {
width: 100%;
height: auto;
display: block;
}
.manga-empty {
color: rgba(255,255,255,0.4);
text-align: center;
padding: 120px 20px;
font-size: 15px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
</style>
</head>
<body>
<div class="reader-bar">
<a class="close-btn" onclick="window.close()" title="关闭">&times;</a>
<span class="title">{{ current_path_name }}</span>
<span class="count">{{ images|length }} 张</span>
</div>
<div class="manga-container">
{% if images %}
{% for image in images %}
<div class="manga-item">
<img src="/pin-tu/media/{{ root_b64 }}?p={{ (current_subpath + image) | urlencode }}" alt="{{ image }}" loading="lazy">
</div>
{% endfor %}
{% else %}
<div class="manga-empty">此文件夹没有图片</div>
{% endif %}
</div>
</body>
</html>

View File

@@ -0,0 +1,561 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>搜索 - {{ root_display }}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='font-awesome.css') }}">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #f5f5f5;
color: #333;
min-height: 100vh;
}
.search-header {
background: #fff;
border-bottom: 1px solid #e0e0e0;
padding: 14px 20px;
display: flex;
align-items: center;
gap: 10px;
}
.back-link {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 6px;
font-size: 13px;
color: #0078d4;
text-decoration: none;
transition: background 0.15s;
}
.back-link:hover { background: #e8f0fe; }
.search-bar {
flex: 1;
display: flex;
align-items: center;
background: #f5f5f5;
border: 1px solid #ddd;
border-radius: 8px;
padding: 8px 14px;
gap: 8px;
transition: border-color 0.2s;
max-width: 600px;
}
.search-bar:focus-within { border-color: #0078d4; }
.search-bar i { color: #999; }
.search-bar input {
flex: 1;
border: none;
background: none;
outline: none;
font-size: 14px;
color: #333;
}
.search-bar input::placeholder { color: #bbb; }
.search-bar .clear-btn {
color: #ccc;
cursor: pointer;
font-size: 14px;
transition: color 0.15s;
}
.search-bar .clear-btn:hover { color: #999; }
.search-bar .go-btn {
background: #0078d4;
color: #fff;
border: none;
border-radius: 6px;
padding: 6px 16px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: background 0.15s;
white-space: nowrap;
margin-left: 4px;
}
.search-bar .go-btn:hover { background: #006cbd; }
.search-status {
padding: 10px 20px;
font-size: 12px;
color: #999;
background: #fff;
border-bottom: 1px solid #e0e0e0;
}
.search-content {
max-width: 1000px;
margin: 0 auto;
padding: 16px 20px;
}
.result-item {
display: flex;
align-items: center;
padding: 10px 16px;
background: #fff;
border: 1px solid #e0e0e0;
border-radius: 8px;
margin-bottom: 8px;
cursor: pointer;
transition: all 0.15s;
gap: 12px;
}
.result-item:hover { border-color: #0078d4; background: #f0f8ff; }
.result-icon {
font-size: 20px;
width: 28px;
text-align: center;
flex-shrink: 0;
}
.result-icon.folder { color: #ffb300; }
.result-icon.image { color: #0078d4; }
.result-icon.video { color: #e53935; }
.result-icon.file { color: #999; }
.result-info { flex: 1; min-width: 0; }
.result-name { font-size: 14px; font-weight: 500; }
.result-name mark { background: #fff3cd; color: #333; padding: 0 1px; border-radius: 2px; }
.result-path { font-size: 12px; color: #999; margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.result-actions {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.result-type {
font-size: 11px;
color: #999;
background: #f0f0f0;
padding: 2px 8px;
border-radius: 4px;
flex-shrink: 0;
}
.btn-copy {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px; height: 30px;
border: 1px solid #e0e0e0;
border-radius: 6px;
background: #fff;
color: #999;
font-size: 13px;
cursor: pointer;
transition: all 0.15s;
flex-shrink: 0;
}
.btn-copy:hover { background: #f0f0f0; color: #333; border-color: #ccc; }
.btn-copy.copied { background: #e8f5e9; color: #2e7d32; border-color: #a5d6a7; }
.empty-state {
text-align: center;
padding: 80px 20px;
color: #ccc;
}
.empty-state i { font-size: 48px; margin-bottom: 16px; display: block; }
.empty-state p { font-size: 14px; }
.loading { text-align: center; padding: 40px; color: #999; font-size: 13px; }
.spinner {
display: inline-block;
width: 14px; height: 14px;
border: 2px solid rgba(0,120,212,0.2);
border-top-color: #0078d4;
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin-right: 8px;
vertical-align: middle;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* 搜索历史 */
.history-section {
max-width: 1000px;
margin: 0 auto;
padding: 16px 20px 0;
}
.history-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.history-header h3 {
font-size: 13px;
color: #999;
font-weight: 600;
}
.history-clear-btn {
font-size: 12px;
color: #999;
background: none;
border: none;
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
transition: all 0.15s;
}
.history-clear-btn:hover { color: #c62828; background: #fce4ec; }
.history-list { display: flex; flex-wrap: wrap; gap: 8px; }
.history-item {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: #fff;
border: 1px solid #e0e0e0;
border-radius: 20px;
font-size: 13px;
color: #555;
cursor: pointer;
transition: all 0.15s;
user-select: none;
}
.history-item:hover { border-color: #0078d4; color: #0078d4; background: #f0f8ff; }
.history-item .hi-keyword { max-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.history-item .hi-count {
font-size: 11px;
color: #bbb;
}
.history-item .hi-del {
color: #ccc;
font-size: 11px;
padding: 0 2px;
border-radius: 3px;
transition: all 0.15s;
line-height: 1;
}
.history-item .hi-del:hover { color: #c62828; background: #fce4ec; }
/* 自定义确认弹窗 */
.confirm-overlay {
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.4);
display: flex; justify-content: center; align-items: center;
z-index: 10000; opacity: 0; pointer-events: none; transition: opacity 0.2s ease;
}
.confirm-overlay.active { opacity: 1; pointer-events: auto; }
.confirm-card {
background: #fff; border-radius: 14px; padding: 28px 32px;
width: 360px; max-width: 90vw; text-align: center;
box-shadow: 0 12px 40px rgba(0,0,0,0.2);
transform: scale(0.9); transition: transform 0.2s ease;
}
.confirm-overlay.active .confirm-card { transform: scale(1); }
.confirm-icon {
width: 48px; height: 48px; border-radius: 50%;
background: #fff3e0; color: #e65100;
display: inline-flex; align-items: center; justify-content: center;
font-size: 22px; margin-bottom: 14px;
}
.confirm-title { font-size: 16px; font-weight: 600; color: #333; margin-bottom: 8px; }
.confirm-msg { font-size: 13px; color: #888; margin-bottom: 24px; line-height: 1.5; }
.confirm-btns { display: flex; gap: 10px; justify-content: center; }
.confirm-btns button {
flex: 1; padding: 10px 20px; border: none; border-radius: 8px;
font-size: 14px; font-weight: 600; cursor: pointer; transition: all 0.15s;
}
.confirm-cancel { background: #f0f0f0; color: #666; }
.confirm-cancel:hover { background: #e0e0e0; }
.confirm-ok { background: #c62828; color: #fff; }
.confirm-ok:hover { background: #b71c1c; }
</style>
</head>
<body>
<div class="search-header">
<a href="/pin-tu/browse/{{ root_b64 }}" class="back-link"><i class="fas fa-arrow-left"></i> 返回</a>
<div class="search-bar">
<i class="fas fa-search"></i>
<input type="text" id="searchInput" value="{{ query }}" placeholder="搜索文件或文件夹..." autofocus>
<span class="clear-btn" onclick="clearSearch()"><i class="fas fa-times-circle"></i></span>
<button class="go-btn" onclick="doSearch()">搜索</button>
</div>
</div>
<!-- 搜索历史 -->
<div class="history-section" id="historySection" style="display:none;">
<div class="history-header">
<h3><i class="fas fa-clock"></i> 搜索历史</h3>
<button class="history-clear-btn" onclick="clearAllHistory()">清空历史</button>
</div>
<div class="history-list" id="historyList"></div>
</div>
<div class="search-status" id="searchStatus" style="display:none;"></div>
<div class="search-content" id="searchContent">
{% if query %}
<div class="loading"><span class="spinner"></span>搜索中...</div>
{% else %}
<div class="empty-state">
<i class="fas fa-search"></i>
<p>输入关键词搜索文件和文件夹</p>
</div>
{% endif %}
</div>
<!-- 自定义确认弹窗 -->
<div class="confirm-overlay" id="confirmOverlay">
<div class="confirm-card">
<div class="confirm-icon"><i class="fas fa-times-circle"></i></div>
<div class="confirm-title" id="confirmTitle">确认操作</div>
<div class="confirm-msg" id="confirmMsg">确定要执行此操作吗?</div>
<div class="confirm-btns">
<button class="confirm-cancel" onclick="confirmResolve(false)">取消</button>
<button class="confirm-ok" id="confirmOkBtn" onclick="confirmResolve(true)">确定</button>
</div>
</div>
</div>
<script>
let _confirmResolver = null;
function showConfirm(title, msg, okText) {
document.getElementById('confirmTitle').textContent = title || '确认操作';
document.getElementById('confirmMsg').textContent = msg || '确定要执行此操作吗?';
document.getElementById('confirmOkBtn').textContent = okText || '确定';
document.getElementById('confirmOverlay').classList.add('active');
return new Promise(function(resolve) { _confirmResolver = resolve; });
}
function confirmResolve(val) {
document.getElementById('confirmOverlay').classList.remove('active');
if (_confirmResolver) { _confirmResolver(val); _confirmResolver = null; }
}
const ROOT_B64 = '{{ root_b64 }}';
const input = document.getElementById('searchInput');
const content = document.getElementById('searchContent');
const status = document.getElementById('searchStatus');
const historySection = document.getElementById('historySection');
const historyList = document.getElementById('historyList');
let lastResults = [];
const extImage = {'JPG':1,'JPEG':1,'PNG':1,'GIF':1,'BMP':1,'TIFF':1,'WEBP':1};
const extVideo = {'MP4':1,'AVI':1,'MOV':1,'WMV':1,'FLV':1,'WEBM':1,'M4V':1,'MKV':1};
function getIcon(r) {
if (r.type === 'folder') return { cls: 'folder', icon: 'fa-folder' };
if (extImage[r.type]) return { cls: 'image', icon: 'fa-image' };
if (extVideo[r.type]) return { cls: 'video', icon: 'fa-video' };
return { cls: 'file', icon: 'fa-file' };
}
function escapeHtml(text) {
const d = document.createElement('div');
d.textContent = text;
return d.innerHTML;
}
function highlight(text, q) {
if (!q) return escapeHtml(text);
const escaped = escapeHtml(text);
const re = new RegExp('(' + q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ')', 'gi');
return escaped.replace(re, '<mark>$1</mark>');
}
// ===== 复制 =====
function copyName(idx) {
var r = lastResults[idx];
if (!r) return;
var name = r.name;
var btn = document.querySelector('.btn-copy[data-idx="' + idx + '"]');
// 始终用 textarea + execCommand兼容 HTTP 环境
var ta = document.createElement('textarea');
ta.value = name;
ta.style.cssText = 'position:fixed;left:-9999px;top:-9999px;opacity:0;';
document.body.appendChild(ta);
ta.focus();
ta.select();
try {
document.execCommand('copy');
if (btn) {
btn.classList.add('copied');
btn.innerHTML = '<i class="fas fa-check"></i>';
setTimeout(function() {
btn.classList.remove('copied');
btn.innerHTML = '<i class="fas fa-copy"></i>';
}, 1500);
}
} catch (e) {
alert('复制失败,请手动复制');
}
document.body.removeChild(ta);
}
// ===== 搜索 =====
function clearSearch() {
input.value = '';
content.innerHTML = '<div class="empty-state"><i class="fas fa-search"></i><p>输入关键词搜索文件和文件夹</p></div>';
status.style.display = 'none';
lastResults = [];
input.focus();
loadHistory();
}
function renderResults(data, q) {
lastResults = data.results;
status.style.display = 'block';
if (!data.results.length) {
status.textContent = '未找到匹配"' + q + '"的结果';
content.innerHTML = '<div class="empty-state"><i class="fas fa-search"></i><p>未找到匹配结果</p></div>';
return;
}
status.textContent = '找到 ' + data.results.length + ' 个结果' + (data.results.length >= 50 ? '最多显示50条' : '');
historySection.style.display = 'none';
content.innerHTML = data.results.map(function(r, idx) {
var ic = getIcon(r);
var parent = r.path.includes('/') ? r.path.substring(0, r.path.lastIndexOf('/')) : '';
return '<div class="result-item" data-idx="' + idx + '">' +
'<span class="result-icon ' + ic.cls + '"><i class="fas ' + ic.icon + '"></i></span>' +
'<div class="result-info">' +
'<div class="result-name">' + highlight(r.name, q) + '</div>' +
(parent ? '<div class="result-path">' + escapeHtml(parent) + '</div>' : '') +
'</div>' +
'<div class="result-actions">' +
'<span class="result-type">' + escapeHtml(r.type) + '</span>' +
'<button class="btn-copy" data-idx="' + idx + '" title="复制名称"><i class="fas fa-copy"></i></button>' +
'</div>' +
'</div>';
}).join('');
// 绑定点击事件
content.querySelectorAll('.result-item').forEach(function(el) {
el.addEventListener('click', function(e) {
// 如果点的是复制按钮,不跳转
if (e.target.closest('.btn-copy')) return;
goResult(parseInt(this.dataset.idx));
});
});
content.querySelectorAll('.btn-copy').forEach(function(btn) {
btn.addEventListener('click', function(e) {
e.stopPropagation();
copyName(parseInt(this.dataset.idx));
});
});
}
async function doSearch() {
var q = input.value.trim();
if (!q) {
content.innerHTML = '<div class="empty-state"><i class="fas fa-search"></i><p>输入关键词搜索文件和文件夹</p></div>';
status.style.display = 'none';
lastResults = [];
loadHistory();
return;
}
content.innerHTML = '<div class="loading"><span class="spinner"></span>搜索中...</div>';
status.style.display = 'none';
historySection.style.display = 'none';
try {
var res = await fetch('/pin-tu/search/' + ROOT_B64 + '?q=' + encodeURIComponent(q));
if (!res.ok) throw new Error('HTTP ' + res.status);
var data = await res.json();
renderResults(data, q);
// 记录搜索历史
recordSearch(q, data.results.length);
} catch (e) {
lastResults = [];
content.innerHTML = '<div class="empty-state"><p style="color:#c62828;">搜索请求失败:' + escapeHtml(e.message) + '</p></div>';
}
}
function goResult(idx) {
var r = lastResults[idx];
if (!r) return;
var folder = r.type === 'folder' ? r.path : (r.path.includes('/') ? r.path.substring(0, r.path.lastIndexOf('/')) : '');
var url = '/pin-tu/browse/' + ROOT_B64;
var params = [];
if (folder) params.push('p=' + encodeURIComponent(folder));
if (extImage[r.type]) params.push('open=' + encodeURIComponent(r.name));
else if (extVideo[r.type]) params.push('openVideo=' + encodeURIComponent(r.name));
if (params.length) url += '?' + params.join('&');
window.location.href = url;
}
// ===== 搜索历史 =====
async function recordSearch(keyword, count) {
try {
await fetch('/pin-tu/api/search-history/' + ROOT_B64, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ keyword: keyword, result_count: count })
});
} catch (e) { /* 静默失败 */ }
}
async function loadHistory() {
try {
var res = await fetch('/pin-tu/api/search-history/' + ROOT_B64);
var data = await res.json();
if (!data.history || !data.history.length) {
historySection.style.display = 'none';
return;
}
historySection.style.display = 'block';
historyList.innerHTML = data.history.map(function(h) {
return '<div class="history-item" data-keyword="' + escapeHtml(h.keyword) + '">' +
'<span class="hi-keyword">' + escapeHtml(h.keyword) + '</span>' +
'<span class="hi-count">' + h.result_count + '条</span>' +
'<span class="hi-del" data-id="' + h.id + '" title="删除"><i class="fas fa-times-circle"></i></span>' +
'</div>';
}).join('');
// 绑定事件
historyList.querySelectorAll('.history-item').forEach(function(el) {
el.addEventListener('click', function(e) {
if (e.target.closest('.hi-del')) return;
input.value = this.dataset.keyword;
doSearch();
});
});
historyList.querySelectorAll('.hi-del').forEach(function(btn) {
btn.addEventListener('click', function(e) {
e.stopPropagation();
delHistory(parseInt(this.dataset.id));
});
});
} catch (e) { historySection.style.display = 'none'; }
}
function useHistory(keyword) {
input.value = keyword;
doSearch();
}
async function delHistory(id) {
try {
await fetch('/pin-tu/api/search-history/' + ROOT_B64, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids: [id] })
});
loadHistory();
} catch (e) {}
}
async function clearAllHistory() {
var ok = await showConfirm('清空搜索历史', '确定清空所有搜索历史?此操作不可撤销。', '清空');
if (!ok) return;
try {
await fetch('/pin-tu/api/search-history/' + ROOT_B64, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ clear: true })
});
historySection.style.display = 'none';
} catch (e) {}
}
// Enter 搜索
input.addEventListener('keydown', function(e) {
if (e.key === 'Enter') doSearch();
});
// 页面加载
{% if query %}
doSearch();
{% else %}
loadHistory();
{% endif %}
</script>
</body>
</html>

View File

@@ -0,0 +1,231 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="{{ url_for('static', filename='font-awesome.css') }}">
<title>二维码生成器</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: #f5f5f5; color: #333;
height: 100vh; overflow-y: auto;
}
.container { max-width: 700px; margin: 0 auto; padding: 24px 28px 40px; }
.header { display: flex; align-items: center; margin-bottom: 2px; }
.header h1 { font-size: 18px; font-weight: 700; color: #1a1a1a; }
.subtitle { color: #aaa; font-size: 12px; margin-bottom: 16px; }
.section-card {
background: #fafafa; border: 1px solid #f0f0f0;
border-radius: 10px; padding: 14px 16px; margin-bottom: 12px;
}
.section-title {
font-size: 11px; font-weight: 600; color: #999;
text-transform: uppercase; letter-spacing: 0.06em;
margin-bottom: 12px; display: flex; align-items: center; gap: 6px;
}
.section-title i { font-size: 11px; }
/* 输入 */
.input-group { margin-bottom: 10px; }
.input-group:last-child { margin-bottom: 0; }
.input-group label { display: block; font-size: 12px; font-weight: 500; margin-bottom: 4px; color: #555; }
textarea {
width: 100%; padding: 10px 12px;
border: 1px solid #e0e0e0; border-radius: 6px;
font-size: 13px; outline: none; resize: vertical;
min-height: 60px; font-family: inherit; transition: border-color 0.15s;
line-height: 1.5;
}
textarea:focus { border-color: #0078d4; box-shadow: 0 0 0 2px rgba(0,120,212,0.06); }
/* 选择框 */
.styled-select {
width: 100%; padding: 7px 30px 7px 10px;
border: 1px solid #e0e0e0; border-radius: 6px;
font-size: 13px; color: #333; background: #fff;
outline: none; cursor: pointer; appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 10 10'%3E%3Cpath fill='%23999' d='M5 7L1 3h8z'/%3E%3C/svg%3E");
background-repeat: no-repeat; background-position: right 10px center;
transition: border-color 0.15s;
}
.styled-select:focus { border-color: #0078d4; box-shadow: 0 0 0 2px rgba(0,120,212,0.06); }
/* 颜色选择 */
.color-row { display: flex; gap: 10px; }
.color-item { flex: 1; }
.color-item label { display: block; font-size: 12px; font-weight: 500; color: #555; margin-bottom: 4px; }
.color-input {
display: flex; align-items: center; gap: 8px;
border: 1px solid #e0e0e0; border-radius: 6px; padding: 5px 10px; background: #fff;
}
.color-input input[type="color"] {
width: 26px; height: 26px; border: none; padding: 0; cursor: pointer;
border-radius: 4px; background: none;
}
.color-input span {
font-size: 12px; font-family: "SFMono-Regular", Consolas, monospace; color: #888;
}
/* 按钮 */
.run-btn {
width: 100%; padding: 10px 18px;
background: linear-gradient(135deg, #0078d4, #005fa3);
color: #fff; border: none; border-radius: 8px;
font-size: 14px; font-weight: 600; cursor: pointer;
display: flex; align-items: center; justify-content: center; gap: 8px;
margin-top: 12px; transition: all 0.15s;
box-shadow: 0 2px 8px rgba(0,120,212,0.2);
}
.run-btn:hover { background: linear-gradient(135deg, #006cbd, #004e8a); transform: translateY(-1px); }
/* 结果 */
.result-card {
background: #fff; border: 1px solid #f0f0f0;
border-radius: 10px; padding: 24px;
text-align: center;
}
.qr-box {
display: inline-block; padding: 16px; background: #fafafa;
border: 1px solid #e8e8e8; border-radius: 8px;
}
.qr-box canvas { display: block; }
.qr-hint { margin-top: 10px; font-size: 12px; color: #ccc; }
.btn-row { display: flex; gap: 8px; justify-content: center; margin-top: 14px; }
.btn {
padding: 7px 14px; border: none; border-radius: 6px;
font-size: 12px; font-weight: 600; cursor: pointer;
transition: all 0.12s ease;
display: inline-flex; align-items: center; gap: 5px;
}
.btn-outline { background: #fff; color: #666; border: 1px solid #e0e0e0; }
.btn-outline:hover { background: #f5f5f5; color: #333; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1><i class="fas fa-qrcode" style="color:#0078d4;font-size:16px;margin-right:6px;"></i>二维码生成器</h1>
</div>
<p class="subtitle">文本 / URL 转二维码</p>
<!-- 配置 -->
<div class="section-card">
<div class="section-title"><i class="fas fa-sliders"></i> 配置</div>
<div class="input-group">
<label>内容</label>
<textarea id="qrText" placeholder="输入文字或链接..." oninput="generate()">https://example.com</textarea>
</div>
<div class="color-row" style="margin-top:10px;">
<div class="color-item">
<label>前景色</label>
<div class="color-input">
<input type="color" id="fgColor" value="#000000" onchange="generate()">
<span id="fgHex">#000000</span>
</div>
</div>
<div class="color-item">
<label>背景色</label>
<div class="color-input">
<input type="color" id="bgColor" value="#ffffff" onchange="generate()">
<span id="bgHex">#ffffff</span>
</div>
</div>
</div>
<div class="input-group" style="margin-top:10px;">
<label>纠错级别</label>
<select id="ecLevel" class="styled-select" onchange="generate()">
<option value="L">低 (7%)</option>
<option value="M" selected>中 (15%)</option>
<option value="Q">较高 (25%)</option>
<option value="H">高 (30%)</option>
</select>
</div>
</div>
<!-- 结果 -->
<div class="result-card">
<div class="qr-box"><canvas id="qrCanvas"></canvas></div>
<div class="qr-hint" id="qrHint"></div>
<div class="btn-row">
<button class="btn btn-outline" onclick="downloadQR()"><i class="fas fa-download"></i> 下载二维码</button>
<button class="btn btn-outline" onclick="copyQR()"><i class="fas fa-copy"></i> 复制到剪贴板</button>
</div>
</div>
</div>
<script src="/static/qrcode.min.js"></script>
<script>
function generate(){
var text=document.getElementById('qrText').value.trim();
var canvas=document.getElementById('qrCanvas');
var ctx=canvas.getContext('2d');
var hint=document.getElementById('qrHint');
var fg=document.getElementById('fgColor').value;
var bg=document.getElementById('bgColor').value;
document.getElementById('fgHex').textContent=fg;
document.getElementById('bgHex').textContent=bg;
if(!text){canvas.width=0;canvas.height=0;hint.textContent='';return;}
var ec=document.getElementById('ecLevel').value;
try{
var qr=qrcode(0,ec);
qr.addData(text);qr.make();
var modules=qr.getModuleCount();
var cellSize=6;var margin=cellSize*2;
var size=modules*cellSize+margin*2;
canvas.width=size;canvas.height=size;
ctx.fillStyle=bg;ctx.fillRect(0,0,size,size);
ctx.fillStyle=fg;
for(var r=0;r<modules;r++){
for(var c=0;c<modules;c++){
if(qr.isDark(r,c)){
ctx.fillRect(margin+c*cellSize,margin+r*cellSize,cellSize,cellSize);
}
}
}
hint.textContent=modules+' x '+modules+' 模块';
}catch(e){
canvas.width=0;canvas.height=0;
hint.textContent='生成失败: '+e.message;
}
}
function downloadQR(){
var canvas=document.getElementById('qrCanvas');
if(!canvas.width)return;
var a=document.createElement('a');
a.download='qrcode.png';
a.href=canvas.toDataURL('image/png');
a.click();
}
function copyQR(){
var canvas=document.getElementById('qrCanvas');
if(!canvas.width)return;
canvas.toBlob(function(blob){
if(navigator.clipboard&&navigator.clipboard.write){
navigator.clipboard.write([new ClipboardItem({'image/png':blob})]).then(function(){
showToast('已复制到剪贴板');
});
}else{
showToast('浏览器不支持复制图片');
}
});
}
function showToast(msg){
var t=document.createElement('div');
t.textContent=msg;
t.style.cssText='position:fixed;top:20px;left:50%;transform:translateX(-50%);background:#0a7a1a;color:#fff;padding:10px 24px;border-radius:8px;font-size:13px;font-weight:500;box-shadow:0 4px 12px rgba(0,0,0,0.15);z-index:9999;transition:opacity 0.3s;';
document.body.appendChild(t);
setTimeout(function(){t.style.opacity='0';},1200);
setTimeout(function(){document.body.removeChild(t);},1500);
}
generate();
</script>
</body>
</html>

View File

@@ -0,0 +1,816 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="{{ url_for('static', filename='font-awesome.css') }}">
<title>RVC 变声</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: #f5f5f5; color: #333; height: 100vh; overflow: hidden; display: flex;
}
.left-panel {
width: 50%; flex-shrink: 0; display: flex; flex-direction: column;
border-right: 1px solid #e8e8e8; background: #fff; height: 100vh; overflow-y: auto;
}
.left-panel::-webkit-scrollbar { width: 4px; }
.left-panel::-webkit-scrollbar-thumb { background: #ddd; border-radius: 2px; }
.input-section { padding: 20px 24px 28px; }
.header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 2px; }
.header h1 { font-size: 18px; font-weight: 700; color: #1a1a1a; letter-spacing: -0.01em; }
.settings-icon {
width: 32px; height: 32px; border: none; background: transparent;
color: #999; font-size: 15px; cursor: pointer; border-radius: 8px;
display: flex; align-items: center; justify-content: center; transition: all 0.15s;
}
.settings-icon:hover { background: #f5f5f5; color: #333; }
.subtitle { color: #aaa; font-size: 12px; margin-bottom: 16px; }
.status-dot {
display: inline-block; width: 8px; height: 8px; border-radius: 50%;
margin-right: 6px; vertical-align: middle;
}
.status-dot.ready { background: #4caf50; }
.status-dot.loading { background: #ff9800; animation: pulse 1s infinite; }
.status-dot.error { background: #f44336; }
.status-dot.offline { background: #ccc; }
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } }
.section-card {
background: #fafafa; border: 1px solid #f0f0f0; border-radius: 10px;
padding: 14px 16px; margin-bottom: 12px; flex: 1; min-width: 0;
}
.card-pair { display: flex; gap: 8px; margin-bottom: 8px; }
.card-pair .section-card { margin-bottom: 0; }
.section-title {
font-size: 11px; font-weight: 600; color: #999; text-transform: uppercase;
letter-spacing: 0.06em; margin-bottom: 12px; display: flex; align-items: center; gap: 6px;
}
.section-title i { font-size: 11px; }
.input-group { margin-bottom: 10px; position: relative; }
.input-group:last-child { margin-bottom: 0; }
.input-group label { display: block; font-size: 12px; font-weight: 500; margin-bottom: 4px; color: #555; }
.input-group select {
width: 100%; padding: 7px 10px;
border: 1px solid #e0e0e0; border-radius: 6px;
background: #fff; color: #333; font-size: 13px;
outline: none; transition: all 0.15s ease;
}
.input-group select:focus { border-color: #0078d4; box-shadow: 0 0 0 2px rgba(0,120,212,0.06); }
.input-group.no-icon select { padding-left: 10px; }
.path-row { display: flex; gap: 6px; align-items: flex-end; }
.path-row select { width: 90px; flex-shrink: 0; padding: 7px 6px; font-size: 12px; }
.path-row input { flex: 1; }
.slider-group { margin-bottom: 10px; }
.slider-group:last-child { margin-bottom: 0; }
.slider-label {
display: flex; justify-content: space-between; align-items: center;
font-size: 12px; font-weight: 500; margin-bottom: 4px; color: #555;
}
.slider-value { font-weight: 600; color: #0078d4; font-size: 12px; }
input[type="range"] {
width: 100%; height: 4px; -webkit-appearance: none; appearance: none;
background: #e0e0e0; border-radius: 2px; outline: none;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none; width: 14px; height: 14px;
background: #0078d4; border-radius: 50%; cursor: pointer;
}
.param-desc { font-size: 11px; color: #aaa; margin-top: 2px; line-height: 1.4; }
.btn {
padding: 7px 14px; border: none; border-radius: 6px; font-size: 12px;
font-weight: 600; cursor: pointer; transition: all 0.12s ease;
display: inline-flex; align-items: center; gap: 5px;
}
.btn-primary { background: #0078d4; color: #fff; }
.btn-primary:hover:not(:disabled) { background: #006cbd; }
.btn-primary:disabled { opacity: 0.4; cursor: not-allowed; }
.btn-outline { background: #fff; color: #666; border: 1px solid #e0e0e0; }
.btn-outline:hover { background: #f5f5f5; color: #333; }
.btn-sm { padding: 5px 10px; font-size: 11px; }
.btn-block { width: 100%; justify-content: center; }
.convert-btn {
width: 100%; padding: 10px 18px;
background: linear-gradient(135deg, #0078d4, #005fa3);
color: #fff; border: none; border-radius: 8px;
font-size: 14px; font-weight: 600; cursor: pointer; transition: all 0.15s ease;
display: flex; align-items: center; justify-content: center; gap: 8px;
margin-top: 14px; box-shadow: 0 2px 8px rgba(0,120,212,0.2);
}
.convert-btn:hover:not(:disabled) {
background: linear-gradient(135deg, #006cbd, #004e8a);
box-shadow: 0 4px 12px rgba(0,120,212,0.3); transform: translateY(-1px);
}
.convert-btn:active:not(:disabled) { transform: translateY(0); }
.convert-btn:disabled { opacity: 0.4; cursor: not-allowed; transform: none; }
@keyframes spin { to { transform: rotate(360deg); } }
.loading-spinner { animation: spin 1s linear infinite; }
.upload-area {
border: 2px dashed #e0e0e0; border-radius: 8px; padding: 40px;
text-align: center; cursor: pointer; transition: all 0.15s;
background: #fafafa;
}
.upload-area:hover { border-color: #0078d4; background: #f0f7ff; }
.upload-area.has-file { border-color: #4caf50; background: #f0fff0; }
.upload-area input[type="file"] { display: none; }
.upload-area .upload-label { font-size: 13px; color: #888; }
.upload-area .upload-filename { font-size: 13px; color: #333; font-weight: 600; margin-top: 4px; }
/* Right panel */
.right-panel {
flex: 1; display: flex; flex-direction: column; background: #fafafa;
height: 100vh; overflow: hidden;
}
.right-content {
flex: 1; display: flex; align-items: center; justify-content: center;
padding: 20px; min-height: 0; overflow-y: auto;
}
.placeholder { text-align: center; color: #ccc; }
.placeholder i { font-size: 48px; margin-bottom: 12px; }
.placeholder p { font-size: 13px; }
.audio-wrap { width: 100%; max-width: 480px; text-align: center; }
.audio-wrap audio { width: 100%; margin-bottom: 12px; }
.audio-meta { font-size: 12px; color: #888; margin-bottom: 12px; }
.meta-label { font-weight: 600; }
.meta-value { color: #0078d4; }
.audio-actions { display: flex; gap: 8px; justify-content: center; }
.log-area {
flex: 2; display: flex; flex-direction: column;
border-top: 1px solid #e8e8e8; min-height: 0;
}
.log-header {
display: flex; justify-content: space-between; align-items: center;
padding: 8px 16px; background: #2d2d2d; border-bottom: 1px solid #3d3d3d;
}
.log-header span { font-size: 12px; color: #aaa; font-weight: 600; }
.log-content {
flex: 1; overflow-y: auto; background: #1e1e1e; padding: 12px 16px;
font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
font-size: 12px; line-height: 1.6; color: #d4d4d4;
}
.log-content::-webkit-scrollbar { width: 6px; }
.log-content::-webkit-scrollbar-thumb { background: #555; border-radius: 3px; }
.log-line { white-space: pre-wrap; word-break: break-all; }
.log-line.error { color: #f48771; }
.log-line.warn { color: #cca700; }
/* Settings modal */
.modal-overlay {
display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.4);
z-index: 100; align-items: center; justify-content: center;
}
.modal-overlay.show { display: flex; }
.modal {
background: #fff; border-radius: 12px; width: 480px; max-height: 80vh;
overflow-y: auto; box-shadow: 0 20px 60px rgba(0,0,0,0.2);
}
.modal-header {
display: flex; justify-content: space-between; align-items: center;
padding: 16px 20px; border-bottom: 1px solid #f0f0f0;
}
.modal-header h3 { font-size: 15px; font-weight: 700; }
.modal-close {
width: 28px; height: 28px; border: none; background: transparent;
color: #999; font-size: 16px; cursor: pointer; border-radius: 6px;
display: flex; align-items: center; justify-content: center;
}
.modal-close:hover { background: #f5f5f5; }
.modal-body { padding: 16px 20px; }
.modal-body .input-group { margin-bottom: 12px; }
.modal-body .input-group label { font-size: 13px; }
.modal-body .input-group input {
width: 100%; padding: 7px 10px; border: 1px solid #e0e0e0; border-radius: 6px;
font-size: 13px; outline: none;
}
.modal-body .input-group input:focus { border-color: #0078d4; }
.engine-status-box {
padding: 10px 12px; border-radius: 8px; margin-bottom: 12px;
font-size: 12px; display: flex; align-items: center; gap: 8px;
}
.engine-status-box.ready { background: #e8f5e9; color: #2e7d32; }
.engine-status-box.loading { background: #fff3e0; color: #e65100; }
.engine-status-box.error { background: #fce4ec; color: #c62828; }
.engine-status-box.offline { background: #f5f5f5; color: #999; }
.modal-footer {
padding: 12px 20px; border-top: 1px solid #f0f0f0;
display: flex; justify-content: flex-end; gap: 8px;
}
/* Toast */
.toast-container { position: fixed; top: 20px; right: 20px; z-index: 200; display: flex; flex-direction: column; gap: 8px; }
.toast {
padding: 10px 16px; border-radius: 8px; font-size: 13px; font-weight: 500;
box-shadow: 0 4px 12px rgba(0,0,0,0.15); animation: slideIn 0.3s ease;
max-width: 360px;
}
.toast.success { background: #e8f5e9; color: #2e7d32; border: 1px solid #c8e6c9; }
.toast.error { background: #fce4ec; color: #c62828; border: 1px solid #f8bbd0; }
.toast.info { background: #e3f2fd; color: #1565c0; border: 1px solid #bbdefb; }
@keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
</style>
</head>
<body>
<div class="left-panel">
<div class="input-section">
<!-- Header -->
<div class="header">
<h1><span class="status-dot offline" id="statusDot"></span>RVC 变声</h1>
<button class="settings-icon" onclick="openSettings()"><i class="fas fa-wrench"></i></button>
</div>
<div class="subtitle" id="statusText">引擎未初始化</div>
<!-- Model selection -->
<div class="card-pair">
<div class="section-card">
<div class="section-title"><i class="fas fa-robot"></i> 模型选择</div>
<div class="input-group no-icon">
<label>语音模型 (.pth)</label>
<select id="modelSelect"><option value="">请选择模型</option></select>
</div>
<div class="input-group no-icon" style="margin-top:8px;">
<label>Index 文件</label>
<select id="indexSelect"><option value="">无 (不使用检索)</option></select>
</div>
<div style="margin-top:10px; display:flex; gap:8px;">
<button class="btn btn-primary btn-sm" id="loadModelBtn" style="flex:1;" onclick="switchModel()" disabled><i class="fas fa-refresh"></i> 加载模型</button>
<button class="btn btn-outline btn-sm" style="flex:1;" onclick="saveModelConfig()"><i class="fas fa-check"></i> 保存选择</button>
</div>
</div>
<!-- Upload audio -->
<div class="section-card">
<div class="section-title"><i class="fas fa-file"></i> 输入音频</div>
<div class="upload-area" id="uploadArea" onclick="document.getElementById('audioFile').click()"
ondragover="event.preventDefault(); this.style.borderColor='#0078d4';"
ondragleave="this.style.borderColor='#e0e0e0';"
ondrop="event.preventDefault(); this.style.borderColor='#e0e0e0'; handleDrop(event);">
<input type="file" id="audioFile" accept=".wav,.mp3,.flac,.ogg,.m4a,.aac" onchange="handleFileSelect(this)">
<div class="upload-label" id="uploadLabel"><i class="fas fa-file"></i> 点击选择或拖拽音频文件</div>
<div class="upload-filename" id="uploadFilename" style="display:none;"></div>
</div>
<audio id="uploadPreview" controls style="display:none; width:100%; margin-top:8px; height:32px;"></audio>
</div>
</div>
<!-- Parameters -->
<div class="section-card">
<div class="section-title" style="justify-content:space-between;">
<span><i class="fas fa-wrench"></i> 变声参数</span>
<button class="btn btn-outline btn-sm" onclick="saveInferConfig()" style="text-transform:none; letter-spacing:0; font-size:11px;"><i class="fas fa-check"></i> 保存参数</button>
</div>
<div class="input-group no-icon">
<label>F0 提取方法</label>
<div class="param-desc" style="margin-bottom: 10px">选择音高提取算法,输入歌声可用pm提速,harvest低音好但巨慢无比,crepe效果好但吃GPU,rmvpe效果最好且微吃GPU</div>
<select id="f0Method">
{% for m in f0_methods %}
<option value="{{ m }}">{{ f0_method_names[m] }}</option>
{% endfor %}
</select>
</div>
<div class="input-group no-icon">
<label>后处理重采样至最终采样率</label>
<select id="resampleSr">
<option value="0">不重采样</option>
<option value="16000">16000 Hz</option>
<option value="22050">22050 Hz</option>
<option value="32000">32000 Hz</option>
<option value="40000">40000 Hz</option>
<option value="44100">44100 Hz</option>
<option value="48000">48000 Hz</option>
</select>
</div>
<div class="slider-group">
<div class="slider-label"><span>音调升降</span><span class="slider-value" id="f0UpKeyValue">0</span></div>
<div class="param-desc">整数,半音数量,升八度 +12降八度 -12</div>
<input type="range" id="f0UpKey" min="-24" max="24" value="0" step="1"
oninput="document.getElementById('f0UpKeyValue').textContent=this.value">
</div>
<div class="slider-group">
<div class="slider-label"><span>辅音保护</span><span class="slider-value" id="protectValue">0.33</span></div>
<div class="param-desc">保护清辅音和呼吸声,防止电音撕裂等 artifact。拉满 0.5 不开启,调低加大保护力度但可能降低索引效果</div>
<input type="range" id="protect" min="0" max="0.5" value="0.33" step="0.01"
oninput="document.getElementById('protectValue').textContent=parseFloat(this.value).toFixed(2)">
</div>
<div class="slider-group">
<div class="slider-label"><span>滤波半径</span><span class="slider-value" id="filterRadiusValue">3</span></div>
<div class="param-desc">&ge;3 则对 harvest 音高识别结果使用中值滤波,数值为滤波半径,可以削弱哑音</div>
<input type="range" id="filterRadius" min="0" max="7" value="3" step="1"
oninput="document.getElementById('filterRadiusValue').textContent=this.value">
</div>
<div class="slider-group">
<div class="slider-label"><span>音量包络混合</span><span class="slider-value" id="rmsMixRateValue">0.25</span></div>
<div class="param-desc">输入源音量包络替换输出音量包络的融合比例,越靠近 1 越使用输出包络</div>
<input type="range" id="rmsMixRate" min="0" max="1" value="0.25" step="0.01"
oninput="document.getElementById('rmsMixRateValue').textContent=parseFloat(this.value).toFixed(2)">
</div>
<div class="slider-group">
<div class="slider-label"><span>检索特征比率</span><span class="slider-value" id="indexRateValue">0.75</span></div>
<input type="range" id="indexRate" min="0" max="1" value="0.75" step="0.01"
oninput="document.getElementById('indexRateValue').textContent=parseFloat(this.value).toFixed(2)">
</div>
</div>
<!-- Convert button -->
<button class="convert-btn" id="convertBtn" onclick="convert()" disabled>
<i class="fas fa-play"></i> 开始变声
</button>
</div>
</div>
<!-- Right panel -->
<div class="right-panel">
<div class="right-content">
<div class="placeholder" id="placeholder">
<i class="fas fa-microphone"></i>
<p>上传音频,选择模型,开始变声</p>
</div>
<div id="convertLoading" style="display:none; text-align:center;">
<i class="fas fa-refresh loading-spinner" style="font-size:32px; color:#0078d4;"></i>
<p style="margin-top:10px; color:#888; font-size:13px;">变声处理中...</p>
</div>
<div class="audio-wrap" id="audioWrap" style="display:none;">
<audio id="audioPlayer" controls></audio>
<div class="audio-meta" id="audioMeta"></div>
<div class="audio-actions">
<a id="downloadLink" class="btn btn-outline btn-sm" href="#" download="rvc_output.wav">
<i class="fas fa-file"></i> 下载
</a>
<button class="btn btn-outline btn-sm" onclick="replay()"><i class="fas fa-play"></i> 重播</button>
</div>
</div>
</div>
<div class="log-area">
<div class="log-header">
<span><i class="fas fa-code"></i> 控制台日志</span>
<button class="btn btn-outline btn-sm" onclick="clearLogs()" style="padding:3px 8px; font-size:11px;">清除</button>
</div>
<div class="log-content" id="logContent"></div>
</div>
</div>
<!-- Settings modal -->
<div class="modal-overlay" id="settingsModal">
<div class="modal">
<div class="modal-header">
<h3><i class="fas fa-wrench"></i> RVC 设置</h3>
<button class="modal-close" onclick="closeSettings()">&times;</button>
</div>
<div class="modal-body">
<div class="engine-status-box offline" id="engineStatusBox">
<span class="status-dot offline" id="engineStatusDot"></span>
<span id="engineStatusText">未初始化</span>
</div>
<div class="input-group">
<label>RVC 项目路径</label>
<input type="text" id="rvcProjectPath" placeholder="E:\AI\RVC\RVC1006Nvidia">
</div>
<div class="input-group">
<label>语音模型目录</label>
<div class="path-row">
<select id="modelDirMode" onchange="updatePathPlaceholder('model')">
<option value="relative">相对路径</option>
<option value="absolute">绝对路径</option>
</select>
<input type="text" id="modelDir" placeholder="assets/weights">
</div>
</div>
<div class="input-group">
<label>Index 目录</label>
<div class="path-row">
<select id="indexDirMode" onchange="updatePathPlaceholder('index')">
<option value="relative">相对路径</option>
<option value="absolute">绝对路径</option>
</select>
<input type="text" id="indexDir" placeholder="logs">
</div>
</div>
<div style="display:flex; gap:8px; margin-top:12px;">
<button class="btn btn-primary btn-sm" style="flex:1;" onclick="initEngine()"><i class="fas fa-play"></i> 启动引擎</button>
<button class="btn btn-outline btn-sm" style="flex:1;" onclick="reloadEngine()"><i class="fas fa-refresh"></i> 重启</button>
<button class="btn btn-outline btn-sm" style="flex:1;" onclick="stopEngine()"><i class="fas fa-times-circle"></i> 停止</button>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-outline btn-sm" onclick="closeSettings()">取消</button>
<button class="btn btn-primary btn-sm" onclick="saveSettings()">保存设置</button>
</div>
</div>
</div>
<!-- Toast container -->
<div class="toast-container" id="toastContainer"></div>
<script>
// === State ===
var _uploadedPath = '';
var _uploadedFilename = '';
var _logPollTimer = null;
// === Elements ===
var statusDot = document.getElementById('statusDot');
var statusText = document.getElementById('statusText');
var convertBtn = document.getElementById('convertBtn');
var placeholder = document.getElementById('placeholder');
var convertLoading = document.getElementById('convertLoading');
var audioWrap = document.getElementById('audioWrap');
var audioPlayer = document.getElementById('audioPlayer');
var downloadLink = document.getElementById('downloadLink');
var audioMeta = document.getElementById('audioMeta');
var logContent = document.getElementById('logContent');
// === Toast ===
function showMessage(text, type, duration) {
type = type || 'info'; duration = duration || 3000;
var t = document.createElement('div');
t.className = 'toast ' + type;
t.textContent = text;
document.getElementById('toastContainer').appendChild(t);
setTimeout(function() { t.style.opacity = '0'; t.style.transition = 'opacity 0.3s'; setTimeout(function(){ t.remove(); }, 300); }, duration);
}
// === Settings ===
function openSettings() { document.getElementById('settingsModal').classList.add('show'); checkEngineStatus(); }
function closeSettings() { document.getElementById('settingsModal').classList.remove('show'); }
function saveSettings() {
var cfg = {
rvc_project_path: document.getElementById('rvcProjectPath').value.trim(),
model_dir: document.getElementById('modelDir').value.trim(),
model_dir_mode: document.getElementById('modelDirMode').value,
index_dir: document.getElementById('indexDir').value.trim(),
index_dir_mode: document.getElementById('indexDirMode').value
};
fetch('/rvc/config', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify(cfg)
}).then(function(r){ return r.json(); }).then(function(data){
if (data.success) { showMessage('设置已保存', 'success'); loadMainConfig(); }
else showMessage(data.error || '保存失败', 'error');
});
}
function saveInferConfig() {
var cfg = {
f0_up_key: parseInt(document.getElementById('f0UpKey').value) || 0,
f0_method: document.getElementById('f0Method').value,
index_rate: parseFloat(document.getElementById('indexRate').value),
filter_radius: parseInt(document.getElementById('filterRadius').value),
resample_sr: parseInt(document.getElementById('resampleSr').value) || 0,
rms_mix_rate: parseFloat(document.getElementById('rmsMixRate').value),
protect: parseFloat(document.getElementById('protect').value)
};
fetch('/rvc/config', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify(cfg)
}).then(function(r){ return r.json(); }).then(function(data){
if (data.success) showMessage('参数已保存', 'success');
else showMessage(data.error || '保存失败', 'error');
});
}
function saveModelConfig() {
var cfg = {
last_model: document.getElementById('modelSelect').value || '',
last_index: document.getElementById('indexSelect').value || ''
};
fetch('/rvc/config', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify(cfg)
}).then(function(r){ return r.json(); }).then(function(data){
if (data.success) showMessage('模型选择已保存', 'success');
else showMessage(data.error || '保存失败', 'error');
});
}
// === Engine ===
function initEngine() {
showMessage('正在启动引擎...', 'info');
fetch('/rvc/init-engine', {method:'POST'}).then(function(r){ return r.json(); }).then(function(data){
if (data.success) showMessage('引擎启动成功 (' + (data.device||'') + ')', 'success');
else showMessage(data.error || '启动失败', 'error');
startLogPolling();
});
}
function reloadEngine() {
showMessage('正在重启引擎...', 'info');
fetch('/rvc/reload-engine', {method:'POST'}).then(function(r){ return r.json(); }).then(function(data){
if (data.success) showMessage('引擎重启成功', 'success');
else showMessage(data.error || '重启失败', 'error');
startLogPolling();
});
}
function stopEngine() {
fetch('/rvc/stop-engine', {method:'POST'}).then(function(r){ return r.json(); }).then(function(data){
showMessage('引擎已停止', 'info');
stopLogPolling();
updateEngineUI({initialized:false, loading:false, error:null});
});
}
function checkEngineStatus() {
fetch('/rvc/engine-status').then(function(r){ return r.json(); }).then(function(data){
updateEngineUI(data);
updateLogs(data.logs || []);
});
}
function updateEngineUI(data) {
var dot = statusDot;
var txt = statusText;
var eDot = document.getElementById('engineStatusDot');
var eTxt = document.getElementById('engineStatusText');
var eBox = document.getElementById('engineStatusBox');
dot.className = 'status-dot';
eDot.className = 'status-dot';
eBox.className = 'engine-status-box';
if (data.loading) {
dot.classList.add('loading'); eDot.classList.add('loading');
eBox.classList.add('loading');
txt.textContent = '引擎加载中...';
eTxt.textContent = '加载中...';
document.getElementById('loadModelBtn').disabled = true;
convertBtn.disabled = true;
} else if (data.initialized) {
dot.classList.add('ready'); eDot.classList.add('ready');
eBox.classList.add('ready');
document.getElementById('loadModelBtn').disabled = false;
if (data.model_loaded) {
txt.textContent = '引擎就绪 (' + (data.device || '') + ')';
eTxt.textContent = '就绪 (' + (data.device || '') + ')';
convertBtn.disabled = !_uploadedPath;
} else {
txt.textContent = '引擎就绪,请加载模型';
eTxt.textContent = '就绪,请加载模型';
convertBtn.disabled = true;
}
} else if (data.error) {
dot.classList.add('error'); eDot.classList.add('error');
eBox.classList.add('error');
txt.textContent = '引擎错误';
eTxt.textContent = data.error;
document.getElementById('loadModelBtn').disabled = true;
convertBtn.disabled = true;
} else {
dot.classList.add('offline'); eDot.classList.add('offline');
eBox.classList.add('offline');
txt.textContent = '引擎未初始化';
eTxt.textContent = '未初始化';
document.getElementById('loadModelBtn').disabled = true;
convertBtn.disabled = true;
}
}
function updateLogs(logs) {
if (!logs.length) {
if (!document.querySelector('#statusDot.loading')) logContent.innerHTML = '';
return;
}
var html = '';
logs.forEach(function(line) {
var cls = 'log-line';
if (/error|exception|traceback/i.test(line)) cls += ' error';
else if (/warn/i.test(line)) cls += ' warn';
html += '<div class="' + cls + '">' + escapeHtml(line) + '</div>';
});
logContent.innerHTML = html;
logContent.scrollTop = logContent.scrollHeight;
}
function escapeHtml(s) {
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
function startLogPolling() {
stopLogPolling();
_logPollTimer = setInterval(checkEngineStatus, 600);
}
function stopLogPolling() {
if (_logPollTimer) { clearInterval(_logPollTimer); _logPollTimer = null; }
}
function clearLogs() {
logContent.innerHTML = '';
fetch('/rvc/clear-logs', {method:'POST'});
}
function updatePathPlaceholder(type) {
var mode = document.getElementById(type + 'DirMode').value;
var input = document.getElementById(type + 'Dir');
if (type === 'model') {
input.placeholder = mode === 'absolute' ? 'E:\\models\\weights' : 'assets/weights';
} else {
input.placeholder = mode === 'absolute' ? 'E:\\models\\logs' : 'logs';
}
}
// === Model ===
function switchModel() {
var model = document.getElementById('modelSelect').value;
if (!model) { showMessage('请选择模型', 'error'); return; }
showMessage('正在加载模型...', 'info');
fetch('/rvc/switch-model', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({model_name: model})
}).then(function(r){ return r.json(); }).then(function(data){
if (data.success) showMessage(data.message || '模型已加载', 'success');
else showMessage(data.error || '加载失败', 'error');
});
}
// === File upload ===
function handleFileSelect(input) {
if (!input.files.length) return;
uploadFile(input.files[0]);
}
function handleDrop(event) {
var files = event.dataTransfer.files;
if (files.length) uploadFile(files[0]);
}
function uploadFile(file) {
var formData = new FormData();
formData.append('audio', file);
showMessage('正在上传...', 'info');
fetch('/rvc/upload-audio', {method:'POST', body: formData}).then(function(r){ return r.json(); }).then(function(data){
if (data.success) {
_uploadedPath = data.path;
_uploadedFilename = data.filename;
document.getElementById('uploadLabel').style.display = 'none';
var fn = document.getElementById('uploadFilename');
fn.textContent = data.filename;
fn.style.display = 'block';
document.getElementById('uploadArea').classList.add('has-file');
var preview = document.getElementById('uploadPreview');
preview.src = URL.createObjectURL(file);
preview.style.display = '';
convertBtn.disabled = false;
showMessage('上传成功', 'success');
} else {
showMessage(data.error || '上传失败', 'error');
}
});
}
// === Convert ===
function convert() {
if (!_uploadedPath) { showMessage('请先上传音频', 'error'); return; }
convertBtn.disabled = true;
placeholder.style.display = 'none';
audioWrap.style.display = 'none';
convertLoading.style.display = '';
var payload = {
input_path: _uploadedPath,
file_index: document.getElementById('indexSelect').value || '',
f0_up_key: parseInt(document.getElementById('f0UpKey').value) || 0,
f0_method: document.getElementById('f0Method').value,
index_rate: parseFloat(document.getElementById('indexRate').value),
filter_radius: parseInt(document.getElementById('filterRadius').value),
resample_sr: parseInt(document.getElementById('resampleSr').value) || 0,
rms_mix_rate: parseFloat(document.getElementById('rmsMixRate').value),
protect: parseFloat(document.getElementById('protect').value)
};
var startTime = Date.now();
fetch('/rvc/convert', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify(payload)
}).then(function(r){ return r.json(); }).then(function(data){
if (!data.success) {
showMessage(data.error || '变声失败', 'error');
convertLoading.style.display = 'none';
placeholder.style.display = '';
convertBtn.disabled = false;
return;
}
pollTask(data.task_id, startTime);
}).catch(function(e) {
showMessage('请求失败: ' + e.message, 'error');
convertLoading.style.display = 'none';
placeholder.style.display = '';
convertBtn.disabled = false;
});
}
function pollTask(taskId, startTime) {
var poll = setInterval(function() {
fetch('/rvc/status/' + taskId).then(function(r){ return r.json(); }).then(function(data){
if (!data.success) return;
var p = data.progress;
if (p.status === 'done') {
clearInterval(poll);
var elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
convertLoading.style.display = 'none';
audioWrap.style.display = '';
audioPlayer.src = data.audio_url;
var safeName = _uploadedFilename.replace(/\.[^.]+$/, '').replace(/[\\/:*?"<>|]/g, '_');
var model = document.getElementById('modelSelect').value.replace(/\.[^.]+$/, '');
var now = new Date();
var ts = now.getFullYear() + String(now.getMonth()+1).padStart(2,'0') + String(now.getDate()).padStart(2,'0')
+ String(now.getHours()).padStart(2,'0') + String(now.getMinutes()).padStart(2,'0')
+ String(now.getSeconds()).padStart(2,'0') + String(now.getMilliseconds()).padStart(3,'0');
var dlName = model + '_' + safeName + '_' + ts + '.wav';
fetch(data.audio_url).then(function(r){ return r.blob(); }).then(function(blob){
if (downloadLink._blobUrl) URL.revokeObjectURL(downloadLink._blobUrl);
downloadLink._blobUrl = URL.createObjectURL(blob);
downloadLink.href = downloadLink._blobUrl;
downloadLink.download = dlName;
});
audioMeta.innerHTML = '<span class="meta-label">耗时</span> <span class="meta-value">' + elapsed + 's</span>';
convertBtn.disabled = false;
showMessage('变声完成,耗时 ' + elapsed + ' 秒', 'success');
} else if (p.status === 'error') {
clearInterval(poll);
showMessage(p.error || '变声失败', 'error');
convertLoading.style.display = 'none';
placeholder.style.display = '';
convertBtn.disabled = false;
}
});
}, 500);
}
function replay() { audioPlayer.currentTime = 0; audioPlayer.play(); }
// === Load config ===
function loadMainConfig() {
fetch('/rvc/config').then(function(r){ return r.json(); }).then(function(cfg){
// Populate models
var mSel = document.getElementById('modelSelect');
mSel.innerHTML = '<option value="">请选择模型</option>';
(cfg.models || []).forEach(function(m) {
var opt = document.createElement('option');
opt.value = m; opt.textContent = m;
if (m === cfg.last_model) opt.selected = true;
mSel.appendChild(opt);
});
// Populate indexes
var iSel = document.getElementById('indexSelect');
iSel.innerHTML = '<option value="">无 (不使用检索)</option>';
(cfg.index_files || []).forEach(function(f) {
var opt = document.createElement('option');
opt.value = f; opt.textContent = f.replace(/\\/g, '/').split('/').pop();
if (f === cfg.last_index) opt.selected = true;
iSel.appendChild(opt);
});
// Populate params
document.getElementById('f0UpKey').value = cfg.f0_up_key || 0;
document.getElementById('f0UpKeyValue').textContent = cfg.f0_up_key || 0;
document.getElementById('f0Method').value = cfg.f0_method || 'rmvpe';
document.getElementById('indexRate').value = cfg.index_rate != null ? cfg.index_rate : 0.75;
document.getElementById('indexRateValue').textContent = (cfg.index_rate != null ? cfg.index_rate : 0.75).toFixed(2);
document.getElementById('filterRadius').value = cfg.filter_radius != null ? cfg.filter_radius : 3;
document.getElementById('filterRadiusValue').textContent = cfg.filter_radius != null ? cfg.filter_radius : 3;
document.getElementById('resampleSr').value = cfg.resample_sr || 0;
document.getElementById('rmsMixRate').value = cfg.rms_mix_rate != null ? cfg.rms_mix_rate : 0.25;
document.getElementById('rmsMixRateValue').textContent = (cfg.rms_mix_rate != null ? cfg.rms_mix_rate : 0.25).toFixed(2);
document.getElementById('protect').value = cfg.protect != null ? cfg.protect : 0.33;
document.getElementById('protectValue').textContent = (cfg.protect != null ? cfg.protect : 0.33).toFixed(2);
// Populate settings modal
document.getElementById('rvcProjectPath').value = cfg.rvc_project_path || '';
document.getElementById('modelDir').value = cfg.model_dir || 'assets/weights';
document.getElementById('modelDirMode').value = cfg.model_dir_mode || 'relative';
document.getElementById('indexDir').value = cfg.index_dir || 'logs';
document.getElementById('indexDirMode').value = cfg.index_dir_mode || 'relative';
// Engine status
if (cfg.engine_status) {
updateEngineUI(cfg.engine_status);
if (cfg.engine_status.initialized || cfg.engine_status.loading) startLogPolling();
}
});
}
// === Keyboard ===
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') closeSettings();
});
// === Init ===
loadMainConfig();
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,740 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="{{ url_for('static', filename='font-awesome.css') }}">
<title>STT 语音识别</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: #f5f5f5;
color: #333;
height: 100vh;
overflow: hidden;
display: flex;
}
/* 左侧面板 */
.left-panel {
width: 420px;
flex-shrink: 0;
display: flex;
flex-direction: column;
border-right: 1px solid #e8e8e8;
background: #fff;
height: 100vh;
overflow-y: auto;
}
.left-panel::-webkit-scrollbar { width: 4px; }
.left-panel::-webkit-scrollbar-thumb { background: #ddd; border-radius: 2px; }
.input-section { padding: 20px 24px 28px; }
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 2px;
}
.header h1 {
font-size: 18px;
font-weight: 700;
color: #1a1a1a;
letter-spacing: -0.01em;
}
.subtitle { color: #aaa; font-size: 12px; margin-bottom: 16px; }
.settings-icon {
width: 32px; height: 32px; border: none; background: transparent;
color: #999; font-size: 15px; cursor: pointer; border-radius: 8px;
display: flex; align-items: center; justify-content: center; transition: all 0.15s;
}
.settings-icon:hover { background: #f5f5f5; color: #333; }
.section-card {
background: #fafafa;
border: 1px solid #f0f0f0;
border-radius: 10px;
padding: 14px 16px;
margin-bottom: 12px;
}
.section-title {
font-size: 11px;
font-weight: 600;
color: #999;
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 6px;
}
.section-title i { font-size: 11px; }
.input-group { margin-bottom: 10px; position: relative; }
.input-group:last-child { margin-bottom: 0; }
.input-group label {
display: block; font-size: 12px; font-weight: 500;
margin-bottom: 4px; color: #555;
}
.input-group select {
width: 100%; padding: 7px 10px;
border: 1px solid #e0e0e0; border-radius: 6px;
background: #fff; color: #333; font-size: 13px;
outline: none; transition: all 0.15s ease;
}
.input-group select:focus {
border-color: #0078d4;
box-shadow: 0 0 0 2px rgba(0,120,212,0.06);
}
/* 文件上传 */
.file-drop {
border: 2px dashed #e0e0e0;
border-radius: 8px;
padding: 24px 16px;
text-align: center;
cursor: pointer;
transition: all 0.2s;
background: #fff;
}
.file-drop:hover, .file-drop.dragover {
border-color: #0078d4;
background: #f0f7ff;
}
.file-drop i { font-size: 28px; color: #ccc; margin-bottom: 8px; }
.file-drop p { font-size: 13px; color: #999; }
.file-drop .filename {
font-size: 13px; color: #0078d4; font-weight: 600;
margin-top: 6px; word-break: break-all;
}
.file-drop input { display: none; }
/* 音频播放器 */
.audio-player {
display: none; margin-top: 10px;
}
.audio-player audio {
width: 100%; height: 36px; border-radius: 8px;
}
/* 格式选择 */
.format-group {
display: flex; gap: 6px;
}
.format-btn {
flex: 1; padding: 6px 0;
border: 1px solid #e0e0e0; border-radius: 6px;
background: #fff; color: #666;
font-size: 12px; font-weight: 500;
cursor: pointer; text-align: center;
transition: all 0.15s ease;
}
.format-btn:hover { border-color: #0078d4; color: #0078d4; }
.format-btn.active {
background: #e8f0fe; border-color: #0078d4; color: #0078d4;
}
/* 开始按钮 */
.run-btn {
width: 100%; padding: 10px 18px;
background: linear-gradient(135deg, #0078d4, #005fa3);
color: #fff; border: none; border-radius: 8px;
font-size: 14px; font-weight: 600;
cursor: pointer; transition: all 0.15s ease;
display: flex; align-items: center; justify-content: center; gap: 8px;
margin-top: 14px;
box-shadow: 0 2px 8px rgba(0,120,212,0.2);
}
.run-btn:hover:not(:disabled) {
background: linear-gradient(135deg, #006cbd, #004e8a);
box-shadow: 0 4px 12px rgba(0,120,212,0.3);
transform: translateY(-1px);
}
.run-btn:active:not(:disabled) { transform: translateY(0); }
.run-btn:disabled { opacity: 0.4; cursor: not-allowed; transform: none; }
@keyframes spin { to { transform: rotate(360deg); } }
.spinner {
display: inline-block; width: 16px; height: 16px;
border: 2px solid rgba(255,255,255,0.3); border-top-color: #fff;
border-radius: 50%; animation: spin 0.8s linear infinite;
}
/* ===== 右侧面板 ===== */
.right-panel {
flex: 1;
background: #fff;
display: flex;
flex-direction: column;
height: 100vh;
}
.right-header {
padding: 20px 24px 0;
display: flex;
align-items: center;
justify-content: space-between;
}
.right-header h2 {
font-size: 15px; font-weight: 600; color: #333;
}
.result-badge {
font-size: 12px; font-weight: 600; color: #0078d4;
background: #e8f0fe; padding: 2px 10px; border-radius: 10px;
}
/* 进度 */
.progress-wrap {
padding: 16px 24px;
display: none;
}
.progress-bar-bg {
height: 6px; background: #f0f0f0;
border-radius: 3px; overflow: hidden;
}
.progress-bar-fill {
height: 100%; background: linear-gradient(90deg, #0078d4, #00a4ef);
border-radius: 3px; transition: width 0.3s;
width: 0%;
}
.progress-info {
display: flex; justify-content: space-between;
margin-top: 6px; font-size: 11px; color: #999;
}
/* 结果区域 */
.result-area {
flex: 1; overflow-y: auto; padding: 20px 24px;
}
.result-area::-webkit-scrollbar { width: 4px; }
.result-area::-webkit-scrollbar-thumb { background: #ddd; border-radius: 2px; }
.result-textarea {
width: 100%; height: 100%; min-height: 300px;
border: 1px solid #e0e0e0; border-radius: 8px;
padding: 12px 14px; font-size: 14px; line-height: 1.8;
color: #333; background: #fff; resize: vertical;
font-family: inherit; outline: none;
transition: border-color 0.15s;
}
.result-textarea:focus {
border-color: #0078d4;
box-shadow: 0 0 0 2px rgba(0,120,212,0.06);
}
.result-text {
font-size: 14px; line-height: 1.8;
color: #333; white-space: pre-wrap;
word-break: break-word;
}
.result-srt {
font-size: 13px; line-height: 1.6;
}
.srt-item {
padding: 8px 12px; margin-bottom: 6px;
background: #fafafa; border: 1px solid #f0f0f0;
border-radius: 6px;
}
.srt-time {
font-size: 11px; color: #0078d4;
font-family: monospace; margin-bottom: 2px;
}
.srt-text { font-size: 14px; color: #333; }
/* 占位 */
.placeholder {
flex: 1; display: flex; flex-direction: column;
align-items: center; justify-content: center;
color: #ccc; gap: 12px;
}
.placeholder i { font-size: 48px; }
.placeholder p { font-size: 13px; }
/* 操作栏 */
.action-bar {
padding: 12px 24px;
border-top: 1px solid #f0f0f0;
display: none;
gap: 8px;
}
.btn {
padding: 7px 14px; border: none; border-radius: 6px;
font-size: 12px; font-weight: 600; cursor: pointer;
transition: all 0.12s ease;
display: inline-flex; align-items: center; gap: 5px;
}
.btn-primary { background: #0078d4; color: #fff; }
.btn-primary:hover { background: #006cbd; }
.btn-outline { background: #fff; color: #666; border: 1px solid #e0e0e0; }
.btn-outline:hover { background: #f5f5f5; color: #333; }
/* Toast */
.toast {
position: fixed; top: 20px; left: 50%;
transform: translateX(-50%);
background: #0a7a1a; color: #fff;
padding: 10px 24px; border-radius: 8px;
font-size: 13px; font-weight: 500;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
opacity: 0; transition: opacity 0.3s;
z-index: 9999; pointer-events: none;
}
.toast.show { opacity: 1; }
.toast.error { background: #c62828; }
/* Settings modal */
.modal-overlay {
display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.4);
z-index: 100; align-items: center; justify-content: center;
}
.modal-overlay.show { display: flex; }
.modal {
background: #fff; border-radius: 12px; width: 460px;
box-shadow: 0 20px 60px rgba(0,0,0,0.2);
}
.modal-header {
display: flex; justify-content: space-between; align-items: center;
padding: 16px 20px; border-bottom: 1px solid #f0f0f0;
}
.modal-header h3 { font-size: 15px; font-weight: 700; }
.modal-close {
width: 28px; height: 28px; border: none; background: transparent;
color: #999; font-size: 16px; cursor: pointer; border-radius: 6px;
display: flex; align-items: center; justify-content: center;
}
.modal-close:hover { background: #f5f5f5; }
.modal-body { padding: 16px 20px; }
.modal-body .input-group { margin-bottom: 12px; }
.modal-body .input-group label { font-size: 13px; }
.modal-body .input-group input {
width: 100%; padding: 7px 10px; border: 1px solid #e0e0e0; border-radius: 6px;
font-size: 13px; outline: none;
}
.modal-body .input-group input:focus { border-color: #0078d4; }
.modal-body .input-group .input-desc { font-size: 11px; color: #aaa; margin-top: 4px; }
.modal-footer {
padding: 12px 20px; border-top: 1px solid #f0f0f0;
display: flex; justify-content: flex-end; gap: 8px;
}
</style>
</head>
<body>
<!-- Settings modal -->
<div class="modal-overlay" id="settingsModal">
<div class="modal">
<div class="modal-header">
<h3><i class="fas fa-wrench"></i> STT 设置</h3>
<button class="modal-close" onclick="closeSettings()">&times;</button>
</div>
<div class="modal-body">
<div class="input-group">
<label>识别模型目录</label>
<input type="text" id="sttModelDir" placeholder="E:\AI\models\stt_models">
<div class="input-desc">faster-whisper 模型所在目录,填写后将自动扫描可用模型</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-outline" onclick="closeSettings()">取消</button>
<button class="btn btn-primary" onclick="saveSettings()">保存设置</button>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<!-- 左侧:输入 -->
<div class="left-panel">
<div class="input-section">
<div class="header">
<h1><i class="fas fa-microphone" style="color:#0078d4;font-size:16px;margin-right:6px;"></i>STT 语音识别</h1>
<button class="settings-icon" onclick="openSettings()"><i class="fas fa-wrench"></i></button>
</div>
<p class="subtitle">使用 faster-whisper 将音频转为文字</p>
<div class="section-card">
<div class="section-title"><i class="fas fa-file"></i> 上传音频</div>
<div class="file-drop" id="fileDrop" onclick="document.getElementById('audioFile').click()">
<i class="fas fa-microphone"></i>
<p id="dropText">点击或拖拽音频文件到此处</p>
<div class="filename" id="fileName" style="display:none;"></div>
<input type="file" id="audioFile" accept="audio/*,video/*">
</div>
<div class="audio-player" id="audioPlayer">
<audio id="audioPreview" controls preload="metadata"></audio>
</div>
</div>
<div class="section-card">
<div class="section-title"><i class="fas fa-brain"></i> 模型与语言</div>
<div class="input-group">
<label>识别模型</label>
<select id="modelSelect">
<option value="" disabled selected>请先配置模型路径</option>
</select>
</div>
<div class="input-group">
<label>语言</label>
<select id="langSelect">
{% for code, name in languages.items() %}
<option value="{{ code }}">{{ name }}</option>
{% endfor %}
</select>
</div>
<div class="input-group">
<label>运行设备</label>
<select id="deviceSelect">
<option value="cpu">CPU</option>
<option value="cuda" disabled>CUDA (检测中...)</option>
</select>
<span id="cudaStatus" style="font-size:11px;color:#999;margin-top:3px;display:block;"></span>
</div>
</div>
<div class="section-card">
<div class="section-title"><i class="fas fa-list"></i> 输出格式</div>
<div class="format-group">
<button class="format-btn active" data-fmt="text" onclick="setFormat(this)">纯文本</button>
<button class="format-btn" data-fmt="srt" onclick="setFormat(this)">SRT 字幕</button>
<button class="format-btn" data-fmt="json" onclick="setFormat(this)">JSON</button>
</div>
</div>
<button class="run-btn" id="startBtn" onclick="startTranscribe()" disabled>
<i class="fas fa-play"></i> 开始识别
</button>
</div>
</div>
<!-- 右侧:结果 -->
<div class="right-panel">
<div class="right-header">
<h2><i class="fas fa-list" style="color:#0078d4;margin-right:6px;"></i>识别结果</h2>
<span class="result-badge" id="resultBadge" style="display:none;"></span>
</div>
<div class="progress-wrap" id="progressWrap">
<div class="progress-bar-bg">
<div class="progress-bar-fill" id="progressFill"></div>
</div>
<div class="progress-info">
<span id="progressStatus">等待中...</span>
<span id="progressPercent">0%</span>
</div>
</div>
<div id="resultArea" class="result-area">
<div class="placeholder">
<i class="fas fa-microphone"></i>
<p>上传音频文件并点击"开始识别"</p>
</div>
</div>
<div class="action-bar" id="actionBar">
<button class="btn btn-primary" onclick="copyResult()"><i class="fas fa-copy"></i> 复制结果</button>
<button class="btn btn-outline" onclick="downloadResult()"><i class="fas fa-file"></i> 下载结果</button>
</div>
</div>
<script>
// === Settings ===
function openSettings() {
document.getElementById('settingsModal').classList.add('show');
loadSettings();
}
function closeSettings() {
document.getElementById('settingsModal').classList.remove('show');
}
function loadSettings() {
fetch('/stt/config').then(function(r){ return r.json(); }).then(function(cfg){
document.getElementById('sttModelDir').value = cfg.model_dir || '';
});
}
function saveSettings() {
var cfg = { model_dir: document.getElementById('sttModelDir').value.trim() };
fetch('/stt/config', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify(cfg)
}).then(function(r){ return r.json(); }).then(function(data){
if (data.success) {
showToast('设置已保存');
closeSettings();
loadModels();
} else {
showToast(data.error || '保存失败', true);
}
});
}
function loadModels() {
fetch('/stt/models').then(function(r){ return r.json(); }).then(function(data){
var sel = document.getElementById('modelSelect');
sel.innerHTML = '';
if (data.models && data.models.length) {
data.models.forEach(function(m) {
var opt = document.createElement('option');
opt.value = m; opt.textContent = m;
sel.appendChild(opt);
});
startBtn.disabled = !audioFile.files.length;
} else {
sel.innerHTML = '<option value="" disabled selected>请先配置模型路径</option>';
startBtn.disabled = true;
}
});
}
function loadMainConfig() {
fetch('/stt/config').then(function(r){ return r.json(); }).then(function(cfg){
var sel = document.getElementById('modelSelect');
sel.innerHTML = '';
if (cfg.model_dir && cfg.models && cfg.models.length) {
cfg.models.forEach(function(m) {
var opt = document.createElement('option');
opt.value = m; opt.textContent = m;
sel.appendChild(opt);
});
} else {
sel.innerHTML = '<option value="" disabled selected>请先配置模型路径</option>';
}
});
}
var audioFile = document.getElementById('audioFile');
var fileDrop = document.getElementById('fileDrop');
var fileName = document.getElementById('fileName');
var dropText = document.getElementById('dropText');
var startBtn = document.getElementById('startBtn');
var progressWrap = document.getElementById('progressWrap');
var progressFill = document.getElementById('progressFill');
var progressStatus = document.getElementById('progressStatus');
var progressPercent = document.getElementById('progressPercent');
var resultArea = document.getElementById('resultArea');
var resultBadge = document.getElementById('resultBadge');
var actionBar = document.getElementById('actionBar');
var currentFormat = 'text';
var currentResult = '';
var pollTimer = null;
// 文件选择
audioFile.addEventListener('change', function() {
if (this.files.length > 0) {
showFile(this.files[0]);
}
});
// 拖拽
fileDrop.addEventListener('dragover', function(e) {
e.preventDefault(); this.classList.add('dragover');
});
fileDrop.addEventListener('dragleave', function() {
this.classList.remove('dragover');
});
fileDrop.addEventListener('drop', function(e) {
e.preventDefault(); this.classList.remove('dragover');
if (e.dataTransfer.files.length > 0) {
audioFile.files = e.dataTransfer.files;
showFile(e.dataTransfer.files[0]);
}
});
function showFile(f) {
fileName.textContent = f.name + ' (' + formatSize(f.size) + ')';
fileName.style.display = 'block';
dropText.style.display = 'none';
// 音频预览
var preview = document.getElementById('audioPreview');
var player = document.getElementById('audioPlayer');
preview.src = URL.createObjectURL(f);
player.style.display = 'block';
resetBtn();
}
function formatSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / 1048576).toFixed(1) + ' MB';
}
function setFormat(btn) {
document.querySelectorAll('.format-btn').forEach(function(b) { b.classList.remove('active'); });
btn.classList.add('active');
currentFormat = btn.dataset.fmt;
}
function showToast(msg, isError) {
var t = document.getElementById('toast');
t.textContent = msg;
t.className = 'toast show' + (isError ? ' error' : '');
setTimeout(function() { t.className = 'toast'; }, 1500);
}
// 开始识别
async function startTranscribe() {
if (!audioFile.files.length) { showToast('请先上传音频文件', true); return; }
var formData = new FormData();
formData.append('audio', audioFile.files[0]);
formData.append('model', document.getElementById('modelSelect').value);
formData.append('language', document.getElementById('langSelect').value);
formData.append('device', document.getElementById('deviceSelect').value);
formData.append('format', currentFormat);
startBtn.disabled = true;
startBtn.innerHTML = '<span class="spinner"></span> 上传中...';
actionBar.style.display = 'none';
resultBadge.style.display = 'none';
resultArea.innerHTML = '<div class="placeholder"><span class="spinner" style="border-color:#e0e0e0;border-top-color:#0078d4;width:32px;height:32px;"></span><p style="color:#999;margin-top:8px;">正在上传并启动识别...</p></div>';
try {
var res = await fetch('/stt/transcribe', { method: 'POST', body: formData });
var data = await res.json();
if (data.success) {
progressWrap.style.display = 'block';
progressFill.style.width = '0%';
progressStatus.textContent = '排队中...';
progressPercent.textContent = '0%';
startBtn.innerHTML = '<span class="spinner"></span> 识别中...';
pollStatus(data.task_id);
} else {
showToast(data.error || '启动失败', true);
resetBtn();
}
} catch (err) {
showToast('请求失败: ' + err.message, true);
resetBtn();
}
}
function pollStatus(taskId) {
if (pollTimer) clearInterval(pollTimer);
pollTimer = setInterval(async function() {
try {
var res = await fetch('/stt/status/' + taskId);
var data = await res.json();
if (!data.success) return;
var p = data.progress;
var pct = Math.round((p.percent || 0) * 100);
progressFill.style.width = pct + '%';
progressPercent.textContent = pct + '%';
var statusMap = {
queued: '排队中...', loading: '加载模型...',
transcribing: '识别中...', done: '识别完成', error: '识别失败'
};
var statusText = statusMap[p.status] || p.status;
if (p.warning) {
statusText += ' (' + p.warning + ')';
progressStatus.style.color = '#e65100';
}
progressStatus.textContent = statusText;
if (p.status === 'done') {
clearInterval(pollTimer);
pollTimer = null;
progressFill.style.width = '100%';
showResult(data.result);
resetBtn();
} else if (p.status === 'error') {
clearInterval(pollTimer);
pollTimer = null;
showToast(p.error || '识别失败', true);
resultArea.innerHTML = '<div class="placeholder" style="color:#c62828;"><i class="fas fa-circle-exclamation"></i><p>' + (p.error || '识别失败') + '</p></div>';
resetBtn();
}
} catch (err) { /* 忽略网络错误,继续轮询 */ }
}, 800);
}
function showResult(result) {
currentResult = result;
resultBadge.style.display = 'inline';
if (currentFormat === 'srt') {
resultBadge.textContent = 'SRT 字幕';
var items = result.trim().split(/\n\n+/);
var html = '<div class="result-srt">';
items.forEach(function(block) {
var lines = block.trim().split('\n');
if (lines.length >= 3) {
html += '<div class="srt-item"><div class="srt-time">' + escapeHtml(lines[1]) + '</div><div class="srt-text">' + escapeHtml(lines.slice(2).join(' ')) + '</div></div>';
}
});
html += '</div>';
resultArea.innerHTML = html;
} else if (currentFormat === 'json') {
resultBadge.textContent = 'JSON';
resultArea.innerHTML = '<pre class="result-text" style="font-size:12px;font-family:monospace;background:#fafafa;padding:12px;border-radius:6px;overflow:auto;">' + escapeHtml(JSON.stringify(result, null, 2)) + '</pre>';
} else {
resultBadge.textContent = '纯文本';
resultArea.innerHTML = '<textarea class="result-textarea" id="resultTextarea">' + escapeHtml(result) + '</textarea>';
}
actionBar.style.display = 'flex';
}
function escapeHtml(s) {
if (typeof s !== 'string') return '';
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
function resetBtn() {
var hasModel = document.getElementById('modelSelect').options.length > 0 && document.getElementById('modelSelect').options[0].value !== '';
startBtn.disabled = !audioFile.files.length || !hasModel;
startBtn.innerHTML = '<i class="fas fa-play"></i> 开始识别';
}
// 复制
function copyResult() {
var ta = document.getElementById('resultTextarea');
var text = (currentFormat === 'text' && ta) ? ta.value : currentResult;
if (typeof text === 'object') text = JSON.stringify(text, null, 2);
navigator.clipboard.writeText(text).then(function() {
showToast('已复制到剪贴板');
}).catch(function() {
showToast('复制失败', true);
});
}
// 下载
function downloadResult() {
var ta = document.getElementById('resultTextarea');
var text = (currentFormat === 'text' && ta) ? ta.value : currentResult;
if (typeof text === 'object') text = JSON.stringify(text, null, 2);
var ext = currentFormat === 'srt' ? 'srt' : currentFormat === 'json' ? 'json' : 'txt';
var blob = new Blob([text], { type: 'text/plain;charset=utf-8' });
var a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'stt_result.' + ext;
a.click();
URL.revokeObjectURL(a.href);
}
// 页面加载时检测 CUDA
(function() {
var sel = document.getElementById('deviceSelect');
var status = document.getElementById('cudaStatus');
fetch('/stt/cuda-check').then(function(r) { return r.json(); }).then(function(d) {
var cudaOpt = sel.querySelector('option[value="cuda"]');
if (d.cuda) {
cudaOpt.disabled = false;
cudaOpt.textContent = 'CUDA - ' + d.name;
sel.value = 'cuda';
status.textContent = '已检测到 GPU已自动选择 CUDA';
status.style.color = '#0a7a1a';
} else {
cudaOpt.textContent = 'CUDA (不可用)';
status.textContent = '未检测到 CUDA使用 CPU';
}
}).catch(function() {
sel.querySelector('option[value="cuda"]').textContent = 'CUDA (不可用)';
status.textContent = '检测失败,使用 CPU';
});
})();
// ESC 关闭弹窗
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') closeSettings();
});
// 初始化加载模型列表
loadMainConfig();
</script>
</body>
</html>

View File

@@ -0,0 +1,185 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="{{ url_for('static', filename='font-awesome.css') }}">
<title>Token 生成器</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: #f5f5f5; color: #333;
height: 100vh; overflow-y: auto;
}
.container { max-width: 700px; margin: 0 auto; padding: 24px 28px 40px; }
.header { display: flex; align-items: center; margin-bottom: 2px; }
.header h1 { font-size: 18px; font-weight: 700; color: #1a1a1a; }
.subtitle { color: #aaa; font-size: 12px; margin-bottom: 16px; }
.section-card {
background: #fafafa; border: 1px solid #f0f0f0;
border-radius: 10px; padding: 14px 16px; margin-bottom: 12px;
}
.section-title {
font-size: 11px; font-weight: 600; color: #999;
text-transform: uppercase; letter-spacing: 0.06em;
margin-bottom: 12px; display: flex; align-items: center; gap: 6px;
}
.section-title i { font-size: 11px; }
/* 复选框 */
.chk-label {
display: flex; align-items: center; gap: 8px;
cursor: pointer; font-size: 13px; color: #555;
padding: 6px 10px; border-radius: 6px; transition: background 0.15s;
margin-bottom: 2px;
}
.chk-label:hover { background: #f0f0f0; }
.chk-label input { display: none; }
.chk-icon {
width: 17px; height: 17px; border: 2px solid #d0d0d0; border-radius: 4px;
display: flex; align-items: center; justify-content: center;
transition: all 0.15s; flex-shrink: 0;
}
.chk-icon i { font-size: 10px; color: #fff; opacity: 0; transition: opacity 0.15s; }
.chk-label input:checked ~ .chk-icon { background: #0078d4; border-color: #0078d4; }
.chk-label input:checked ~ .chk-icon i { opacity: 1; }
/* 滑块 */
.slider-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
.slider-head .label { font-size: 12px; font-weight: 500; color: #555; }
.slider-head .val { font-size: 18px; font-weight: 700; color: #0078d4; font-family: "SFMono-Regular", Consolas, monospace; }
input[type="range"] {
width: 100%; height: 6px; -webkit-appearance: none; appearance: none;
background: #e0e0e0; border-radius: 3px; outline: none;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none; width: 18px; height: 18px;
background: #0078d4; border-radius: 50%; cursor: pointer;
box-shadow: 0 1px 4px rgba(0,0,0,0.15);
}
/* 按钮 */
.btn {
padding: 7px 14px; border: none; border-radius: 6px;
font-size: 12px; font-weight: 600; cursor: pointer;
transition: all 0.12s ease;
display: inline-flex; align-items: center; gap: 5px;
}
.btn-outline { background: #fff; color: #666; border: 1px solid #e0e0e0; }
.btn-outline:hover { background: #f5f5f5; color: #333; }
.run-btn {
width: 100%; padding: 10px 18px;
background: linear-gradient(135deg, #0078d4, #005fa3);
color: #fff; border: none; border-radius: 8px;
font-size: 14px; font-weight: 600; cursor: pointer;
display: flex; align-items: center; justify-content: center; gap: 8px;
transition: all 0.15s;
box-shadow: 0 2px 8px rgba(0,120,212,0.2);
}
.run-btn:hover { background: linear-gradient(135deg, #006cbd, #004e8a); transform: translateY(-1px); }
.run-btn.copied { background: linear-gradient(135deg, #2e7d32, #1b5e20); }
/* 结果 */
.result-card {
background: #fff; border: 1px solid #f0f0f0;
border-radius: 10px; padding: 20px 24px; text-align: center;
}
.token-box {
font-family: "SFMono-Regular", Consolas, monospace;
font-size: 14px; line-height: 1.8; color: #333;
word-break: break-all; user-select: all;
padding: 16px; background: #fafafa; border: 1px solid #e8e8e8;
border-radius: 8px; max-height: 200px; overflow-y: auto;
min-height: 60px;
}
.token-len { margin-top: 10px; font-size: 12px; color: #bbb; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1><i class="fas fa-key" style="color:#0078d4;font-size:16px;margin-right:6px;"></i>Token 生成器</h1>
</div>
<p class="subtitle">随机字符串生成</p>
<!-- 配置 -->
<div class="section-card">
<div class="section-title"><i class="fas fa-sliders"></i> 配置</div>
<label class="chk-label"><input type="checkbox" id="optUpper" checked onchange="generate()"><span class="chk-icon"><i class="fas fa-check"></i></span><span>大写字母 A-Z</span></label>
<label class="chk-label"><input type="checkbox" id="optLower" checked onchange="generate()"><span class="chk-icon"><i class="fas fa-check"></i></span><span>小写字母 a-z</span></label>
<label class="chk-label"><input type="checkbox" id="optNum" checked onchange="generate()"><span class="chk-icon"><i class="fas fa-check"></i></span><span>数字 0-9</span></label>
<label class="chk-label"><input type="checkbox" id="optSym" onchange="generate()"><span class="chk-icon"><i class="fas fa-check"></i></span><span>符号 !@#$</span></label>
<div style="margin-top:12px;">
<div class="slider-head">
<span class="label">长度</span>
<span class="val" id="lenVal">64</span>
</div>
<input type="range" id="lenSlider" min="1" max="512" value="64" oninput="updateLen()">
</div>
</div>
<!-- 操作 -->
<div style="display:flex;gap:8px;margin-bottom:12px;">
<button class="run-btn" id="genBtn" onclick="generate()" style="flex:1;">
<i class="fas fa-refresh"></i> 生成
</button>
<button class="run-btn" id="copyBtn" onclick="copyToken()" style="flex:1;">
<i class="fas fa-copy"></i> 复制
</button>
</div>
<!-- 结果 -->
<div class="result-card">
<div class="token-box" id="tokenDisplay"></div>
<div class="token-len" id="tokenLen"></div>
</div>
</div>
<script>
function shuffle(arr){
for(var i=arr.length-1;i>0;i--){
var j=Math.floor(Math.random()*(i+1));
var t=arr[i];arr[i]=arr[j];arr[j]=t;
}
return arr;
}
function generate(){
var chars='';
if(document.getElementById('optUpper').checked) chars+='ABCDEFGHIJKLMNOPQRSTUVWXYZ';
if(document.getElementById('optLower').checked) chars+='abcdefghijklmnopqrstuvwxyz';
if(document.getElementById('optNum').checked) chars+='0123456789';
if(document.getElementById('optSym').checked) chars+='.,;:!?./-"\'#{([-|\\@)]=}*+';
var len=parseInt(document.getElementById('lenSlider').value);
if(!chars){document.getElementById('tokenDisplay').textContent='请至少选择一种字符集';return;}
var repeated='';
while(repeated.length<len) repeated+=chars;
var arr=shuffle(repeated.split(''));
var token=arr.slice(0,len).join('');
document.getElementById('tokenDisplay').textContent=token;
document.getElementById('tokenLen').textContent=len+' 字符';
}
function updateLen(){
document.getElementById('lenVal').textContent=document.getElementById('lenSlider').value;
generate();
}
function copyToken(){
var text=document.getElementById('tokenDisplay').textContent;
if(!text)return;
var el=document.createElement('textarea');
el.value=text;el.style.cssText='position:fixed;left:-9999px';
document.body.appendChild(el);el.select();document.execCommand('copy');document.body.removeChild(el);
var btn=document.getElementById('copyBtn');
btn.innerHTML='<i class="fas fa-check"></i> 已复制';btn.classList.add('copied');
setTimeout(function(){btn.innerHTML='<i class="fas fa-copy"></i> 复制';btn.classList.remove('copied');},1500);
}
generate();
</script>
</body>
</html>

View File

@@ -0,0 +1,249 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="{{ url_for('static', filename='font-awesome.css') }}">
<title>URL 解析器</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: #f5f5f5; color: #333;
height: 100vh; overflow-y: auto;
}
.container { max-width: 900px; margin: 0 auto; padding: 24px 28px 40px; }
/* 头部 */
.header { display: flex; align-items: center; margin-bottom: 2px; }
.header h1 { font-size: 18px; font-weight: 700; color: #1a1a1a; }
.subtitle { color: #aaa; font-size: 12px; margin-bottom: 16px; }
/* 卡片 */
.section-card {
background: #fafafa; border: 1px solid #f0f0f0;
border-radius: 10px; padding: 14px 16px; margin-bottom: 12px;
}
.section-title {
font-size: 11px; font-weight: 600; color: #999;
text-transform: uppercase; letter-spacing: 0.06em;
margin-bottom: 12px; display: flex; align-items: center; gap: 6px;
}
.section-title i { font-size: 11px; }
/* 输入 */
.input-wrap { position: relative; }
.input-wrap i {
position: absolute; left: 10px; top: 50%; transform: translateY(-50%);
color: #c0c0c0; font-size: 12px; pointer-events: none;
}
.input-wrap input {
width: 100%; padding: 9px 12px 9px 32px;
border: 1px solid #e0e0e0; border-radius: 6px;
font-size: 13px; outline: none; transition: all 0.15s;
}
.input-wrap input::placeholder { color: #c0c0c0; }
.input-wrap input:focus { border-color: #0078d4; box-shadow: 0 0 0 2px rgba(0,120,212,0.06); }
.input-wrap input.err { border-color: #c62828; }
.err-msg { font-size: 12px; color: #c62828; margin-top: 4px; min-height: 16px; }
/* 按钮 */
.btn {
padding: 7px 14px; border: none; border-radius: 6px;
font-size: 12px; font-weight: 600; cursor: pointer;
transition: all 0.12s ease;
display: inline-flex; align-items: center; gap: 5px;
}
.btn-outline { background: #fff; color: #666; border: 1px solid #e0e0e0; }
.btn-outline:hover { background: #f5f5f5; color: #333; }
.run-btn {
width: 100%; padding: 10px 18px;
background: linear-gradient(135deg, #0078d4, #005fa3);
color: #fff; border: none; border-radius: 8px;
font-size: 14px; font-weight: 600; cursor: pointer;
display: flex; align-items: center; justify-content: center; gap: 8px;
margin-top: 12px; transition: all 0.15s;
box-shadow: 0 2px 8px rgba(0,120,212,0.2);
}
.run-btn:hover { background: linear-gradient(135deg, #006cbd, #004e8a); transform: translateY(-1px); }
/* 示例列表 */
.example-list { list-style: none; }
.example-list li {
padding: 6px 10px; border-radius: 5px; cursor: pointer;
font-size: 12px; color: #888; font-family: "SFMono-Regular", Consolas, monospace;
transition: all 0.15s; margin-bottom: 2px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.example-list li:hover { background: #f0f7ff; color: #0078d4; }
/* 结果 */
.result-section { margin-bottom: 20px; }
.result-section:last-child { margin-bottom: 0; }
.result-title {
font-size: 11px; font-weight: 700; color: #aaa;
text-transform: uppercase; letter-spacing: 0.06em;
margin-bottom: 10px; padding-bottom: 6px;
border-bottom: 1px solid #eee;
display: flex; align-items: center; gap: 6px;
}
.result-title i { font-size: 11px; color: #ccc; }
.fields { display: flex; flex-direction: column; gap: 4px; }
.field-row {
display: flex; align-items: center; gap: 10px;
background: #fff; border: 1px solid #e8e8e8; border-radius: 6px;
padding: 8px 12px; transition: border-color 0.15s;
}
.field-row:hover { border-color: #c0c0c0; }
.field-label { width: 64px; font-size: 12px; font-weight: 600; color: #999; flex-shrink: 0; }
.field-value {
flex: 1; font-family: "SFMono-Regular", Consolas, monospace;
font-size: 12px; color: #333; word-break: break-all; min-width: 0;
}
.field-value:empty::after { content: '—'; color: #ddd; }
.btn-cp {
padding: 3px 8px; border: 1px solid #e0e0e0; border-radius: 4px;
background: #fff; color: #aaa; font-size: 11px; cursor: pointer;
flex-shrink: 0; transition: all 0.15s;
}
.btn-cp:hover { background: #f5f5f5; color: #666; }
.btn-cp.copied { background: #e8f5e9; color: #2e7d32; border-color: #a5d6a7; }
.params { display: flex; flex-direction: column; gap: 4px; }
.param-row {
display: flex; align-items: center; gap: 10px;
background: #fff; border: 1px solid #e8e8e8; border-radius: 6px;
padding: 6px 12px; transition: border-color 0.15s;
}
.param-row:hover { border-color: #c0c0c0; }
.param-key {
width: 120px; flex-shrink: 0;
padding: 3px 8px; background: #f0f7ff; color: #0078d4;
border-radius: 4px; font-family: "SFMono-Regular", Consolas, monospace;
font-size: 12px; font-weight: 600; text-align: center;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.param-val {
flex: 1; font-family: "SFMono-Regular", Consolas, monospace;
font-size: 12px; color: #555; word-break: break-all; min-width: 0;
}
.no-params { font-size: 13px; color: #ccc; padding: 8px 0; }
.empty-state {
text-align: center; padding: 40px 0; color: #ccc;
}
.empty-state i { font-size: 40px; margin-bottom: 12px; display: block; }
.empty-state p { font-size: 13px; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1><i class="fas fa-link" style="color:#0078d4;font-size:16px;margin-right:6px;"></i>URL 解析器</h1>
</div>
<p class="subtitle">拆解 URL 各组成部分</p>
<!-- 输入 -->
<div class="section-card">
<div class="section-title"><i class="fas fa-pen"></i> 输入 URL</div>
<div class="input-wrap">
<i class="fas fa-link"></i>
<input type="text" id="urlInput" placeholder="粘贴 URL..."
value="https://user:pass@example.com:8080/path/to/page?key1=value1&key2=value2#section"
onkeydown="if(event.key==='Enter')parse()">
</div>
<div class="err-msg" id="errMsg"></div>
<button class="run-btn" onclick="parse()"><i class="fas fa-search"></i> 解析</button>
</div>
<!-- 示例 -->
<div class="section-card">
<div class="section-title"><i class="fas fa-lightbulb"></i> 示例</div>
<ul class="example-list">
<li onclick="fillUrl(this)">https://user:pass@example.com:8080/path?key=value#hash</li>
<li onclick="fillUrl(this)">http://localhost:3000/api/v1/users?page=1&limit=20</li>
<li onclick="fillUrl(this)">https://www.google.com/search?q=hello+world&lang=zh-CN</li>
<li onclick="fillUrl(this)">ftp://files.example.com:21/pub/doc.pdf</li>
</ul>
</div>
<!-- 结果 -->
<div class="section-card" id="resultCard" style="display:none;">
<div class="result-section">
<div class="result-title"><i class="fas fa-file"></i> URL 组成</div>
<div class="fields" id="fields"></div>
</div>
<div class="result-section" style="margin-top:16px;">
<div class="result-title"><i class="fas fa-list"></i> Query 参数</div>
<div id="params"></div>
</div>
</div>
<div class="empty-state" id="emptyState">
<i class="fas fa-link"></i>
<p>输入 URL 并点击解析</p>
</div>
</div>
<script>
var defs = [
{label:'协议',key:'protocol'},{label:'用户名',key:'username'},{label:'密码',key:'password'},
{label:'主机名',key:'hostname'},{label:'端口',key:'port'},{label:'路径',key:'pathname'},
{label:'查询',key:'search'},{label:'哈希',key:'hash'}
];
function esc(s){return(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
function copyText(text,btn){
var el=document.createElement('textarea');
el.value=text;el.style.cssText='position:fixed;left:-9999px';
document.body.appendChild(el);el.select();document.execCommand('copy');document.body.removeChild(el);
btn.classList.add('copied');
setTimeout(function(){btn.classList.remove('copied');},1500);
}
function fillUrl(el){document.getElementById('urlInput').value=el.textContent;parse();}
function parse(){
var raw=document.getElementById('urlInput').value.trim();
var errMsg=document.getElementById('errMsg');
var card=document.getElementById('resultCard');
var empty=document.getElementById('emptyState');
var inp=document.getElementById('urlInput');
if(!raw){errMsg.textContent='';card.style.display='none';empty.style.display='';inp.classList.remove('err');return;}
try{
var u=new URL(raw);
errMsg.textContent='';inp.classList.remove('err');
card.style.display='';empty.style.display='none';
var fh='';
defs.forEach(function(f){
var v=u[f.key]||'';
fh+='<div class="field-row"><span class="field-label">'+f.label+'</span>'
+'<span class="field-value" id="fv-'+f.key+'">'+esc(v)+'</span>'
+'<button class="btn-cp" onclick="copyText(document.getElementById(\'fv-'+f.key+'\').textContent,this)">复制</button></div>';
});
document.getElementById('fields').innerHTML=fh;
var params=[];
u.searchParams.forEach(function(v,k){params.push({k:k,v:v});});
if(params.length){
var ph='<div class="params">';
params.forEach(function(p){
ph+='<div class="param-row"><span class="param-key" title="'+esc(p.k)+'">'+esc(p.k)+'</span>'
+'<span class="param-val">'+esc(p.v)+'</span>'
+'<button class="btn-cp" onclick="copyText(this.previousElementSibling.textContent,this)">复制</button></div>';
});
ph+='</div>';
document.getElementById('params').innerHTML=ph;
}else{
document.getElementById('params').innerHTML='<div class="no-params">无查询参数</div>';
}
}catch(e){
errMsg.textContent='无效的 URL: '+e.message;
inp.classList.add('err');
card.style.display='none';empty.style.display='';
}
}
parse();
</script>
</body>
</html>

View File

@@ -0,0 +1 @@
# utils package

View File

@@ -0,0 +1,316 @@
# -*- coding: utf-8 -*-
"""
down-video 视频下载工具函数
"""
import os
import re
import subprocess
from urllib.parse import urlparse
from config import TWITTER_COOKIE, BILIBILI_COOKIE, INSTAGRAM_COOKIE, YOUTUBE_COOKIE
# 可选依赖
try:
import yt_dlp
YTDLP_AVAILABLE = True
except ImportError:
YTDLP_AVAILABLE = False
def check_ffmpeg():
"""检查 FFmpeg 是否可用"""
try:
result = subprocess.run(['ffmpeg', '-version'], capture_output=True, text=True, timeout=5)
return result.returncode == 0
except Exception:
return False
def check_deno():
"""检查 Deno 是否可用"""
try:
result = subprocess.run(['deno', '--version'], capture_output=True, text=True, timeout=5, shell=True)
if result.returncode == 0:
return True
except Exception:
pass
# fallback: 检查默认安装路径
deno_path = os.path.join(os.path.expanduser('~'), '.deno', 'bin', 'deno.exe')
if os.path.isfile(deno_path):
try:
result = subprocess.run([deno_path, '--version'], capture_output=True, text=True, timeout=5)
return result.returncode == 0
except Exception:
pass
return False
def is_twitter_url(url):
"""检查是否是 Twitter/X 链接"""
domain = urlparse(url).netloc.lower()
return 'x.com' in domain or 'twitter.com' in domain
def is_bilibili_url(url):
"""检查是否是 Bilibili 链接"""
domain = urlparse(url).netloc.lower()
return 'bilibili.com' in domain or 'b23.tv' in domain
def is_instagram_url(url):
"""检查是否是 Instagram 链接"""
domain = urlparse(url).netloc.lower()
return 'instagram.com' in domain
def is_youtube_url(url):
"""检查是否是 YouTube 链接"""
domain = urlparse(url).netloc.lower()
return 'youtube.com' in domain or 'youtu.be' in domain
def _clean_title(title):
"""
清理视频标题,移除特殊字符,使文件名在文件系统中安全可用
"""
if not title:
return "video"
# 移除或替换特殊字符
# Windows 文件名不允许的字符
cleaned = re.sub(r'[<>:"/\\|?*]', '_', title)
# 控制字符
cleaned = re.sub(r'[\x00-\x1f\x7f]', '', cleaned)
# 移除 emoji 和其他 Unicode 特殊字符(保留中文、英文、数字、下划线)
# 匹配非中文、非英文、非数字、非下划线的字符
cleaned = re.sub(r'[^一-鿿㐀-䶿\w]', '_', cleaned)
# 将多个连续下划线合并为一个
cleaned = re.sub(r'_+', '_', cleaned)
# 移除首尾空格和点Windows不允许
cleaned = cleaned.strip('_. ')
# 限制长度(保留扩展名空间)
cleaned = cleaned[:150]
# 如果清理后为空,使用默认名
if not cleaned:
return "video"
return cleaned
def _find_files_by_title(output_dir, safe_title):
"""根据安全标题查找目录中的相关文件"""
return [
f for f in os.listdir(output_dir)
if f.startswith(safe_title) and os.path.isfile(os.path.join(output_dir, f))
]
def _merge_mp4_m4a(output_dir, safe_title):
"""
查找同名的 .mp4 和 .m4a 并用 FFmpeg 合并。
返回 (merged: bool, message: str, filepath: str)
"""
files = _find_files_by_title(output_dir, safe_title)
mp4_files = [f for f in files if f.endswith('.mp4')]
m4a_files = [f for f in files if f.endswith('.m4a')]
if not mp4_files or not m4a_files:
return False, "未检测到需要合并的 .mp4 + .m4a", ""
mp4_files.sort(key=len)
m4a_files.sort(key=len)
mp4_path = os.path.join(output_dir, mp4_files[0])
m4a_path = os.path.join(output_dir, m4a_files[0])
merged_name = safe_title + "_merged.mp4"
merged_path = os.path.join(output_dir, merged_name)
counter = 1
while os.path.exists(merged_path):
merged_name = f"{safe_title}_merged_{counter}.mp4"
merged_path = os.path.join(output_dir, merged_name)
counter += 1
cmd = ['ffmpeg', '-y', '-i', mp4_path, '-i', m4a_path, '-c', 'copy', merged_path]
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
if result.returncode == 0 and os.path.exists(merged_path):
try:
os.remove(mp4_path)
os.remove(m4a_path)
except Exception:
pass
return True, f"已合并为 {merged_name}", merged_path
else:
return False, f"FFmpeg 合并失败: {result.stderr[:200]}", ""
except Exception as e:
return False, f"合并异常: {str(e)}", ""
class DownloadProgressLogger:
"""捕获yt-dlp下载进度的回调类"""
def __init__(self, progress_callback=None):
self.progress_callback = progress_callback
def debug(self, msg):
pass
def warning(self, msg):
pass
def error(self, msg):
if self.progress_callback:
self.progress_callback({'type': 'error', 'message': msg})
def info(self, msg):
if self.progress_callback:
self.progress_callback({'type': 'info', 'message': msg})
def download_progress(self, d):
if d['status'] == 'downloading' and self.progress_callback:
percent = d.get('_percent_str', '0%').replace('%', '').strip()
try:
percent = float(percent)
except ValueError:
percent = 0
speed = d.get('_speed_str', 'N/A')
eta = d.get('_eta_str', 'N/A')
filename = d.get('filename', '').split('\\')[-1].split('/')[-1]
self.progress_callback({
'type': 'progress',
'percent': percent,
'speed': speed,
'eta': eta,
'filename': filename
})
elif d['status'] == 'finished' and self.progress_callback:
self.progress_callback({
'type': 'progress',
'percent': 100,
'speed': '',
'eta': '',
'filename': ''
})
def download_video(video_url, output_dir, platform, progress_callback=None, proxy_url=None):
"""
通用下载函数,返回 (success: bool, message: str, title: str, filepath: str)
支持进度回调
"""
if not YTDLP_AVAILABLE:
return False, "yt-dlp 未安装,视频下载功能不可用。", "", ""
os.makedirs(output_dir, exist_ok=True)
# 先提取视频信息以获取标题
temp_opts = {
'quiet': True,
'no_warnings': True,
'skip_download': True,
}
if platform == 'twitter':
cookie_file = TWITTER_COOKIE
if proxy_url:
temp_opts['proxy'] = proxy_url
elif platform == 'bilibili':
cookie_file = BILIBILI_COOKIE
elif platform == 'instagram':
cookie_file = INSTAGRAM_COOKIE
if proxy_url:
temp_opts['proxy'] = proxy_url
elif platform == 'youtube':
cookie_file = YOUTUBE_COOKIE
if proxy_url:
temp_opts['proxy'] = proxy_url
else:
return False, "不支持的平台", "", ""
if os.path.exists(cookie_file):
temp_opts['cookiefile'] = cookie_file
else:
return False, f"Cookie 文件 '{cookie_file}' 未找到", "", ""
try:
# 创建进度日志器
progress_logger = DownloadProgressLogger(progress_callback)
# 获取视频信息
with yt_dlp.YoutubeDL(temp_opts) as ydl:
info = ydl.extract_info(video_url, download=False)
if not info:
return False, "未能获取视频信息", "", ""
# 清理标题作为安全文件名
title = info.get('title', 'Unknown Title')
safe_title = _clean_title(title)
# 使用清理后的标题构建输出模板
ydl_opts = {
'outtmpl': os.path.join(output_dir, f'{safe_title}.%(ext)s'),
'ignoreerrors': True,
'progress_hooks': [progress_logger.download_progress] if progress_callback else [],
'logger': progress_logger if progress_callback else None,
'format': 'bestvideo+bestaudio/best',
}
if platform == 'twitter':
if proxy_url:
ydl_opts['proxy'] = proxy_url
ydl_opts['merge_output_format'] = 'mp4'
ydl_opts['postprocessors'] = [{
'key': 'FFmpegVideoConvertor',
'preferedformat': 'mp4',
}]
elif platform == 'bilibili':
ydl_opts['merge_output_format'] = 'mp4'
elif platform == 'instagram':
if proxy_url:
ydl_opts['proxy'] = proxy_url
ydl_opts['merge_output_format'] = 'mp4'
elif platform == 'youtube':
if proxy_url:
ydl_opts['proxy'] = proxy_url
ydl_opts['merge_output_format'] = 'mp4'
if os.path.exists(cookie_file):
ydl_opts['cookiefile'] = cookie_file
# 执行下载
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
ydl.download([video_url])
# 查找下载的文件
files = _find_files_by_title(output_dir, safe_title)
if not files:
return False, "下载完成但未找到输出文件", title, ""
filepath = ""
candidate_files = [f for f in files if f.endswith('.mp4')]
if candidate_files:
merged_candidates = [f for f in candidate_files if '_merged' in f]
if merged_candidates:
filepath = os.path.join(output_dir, merged_candidates[0])
else:
candidate_files.sort(key=lambda f: os.path.getsize(os.path.join(output_dir, f)), reverse=True)
filepath = os.path.join(output_dir, candidate_files[0])
else:
files.sort(key=lambda f: os.path.getsize(os.path.join(output_dir, f)), reverse=True)
filepath = os.path.join(output_dir, files[0])
extra_msg = ""
if platform in ('bilibili', 'youtube'):
has_mp4 = any(f.endswith('.mp4') for f in files)
has_m4a = any(f.endswith('.m4a') for f in files)
if has_mp4 and has_m4a:
merged, merge_msg, merged_path = _merge_mp4_m4a(output_dir, safe_title)
extra_msg = f" ({merge_msg})" if merge_msg else ""
if merged and merged_path:
filepath = merged_path
return True, f"下载成功!保存到 {output_dir}{extra_msg}", title, filepath
except Exception as e:
return False, f"下载失败: {str(e)}", "", ""

View File

@@ -0,0 +1,34 @@
import chardet
# 自动选择解码格式
def detect_and_decode(data_bytes):
"""使用 chardet 检测编码并解码"""
# 使用 chardet 检测编码
detected_encoding_info = chardet.detect(data_bytes)
detected_encoding = detected_encoding_info.get('encoding')
confidence = detected_encoding_info.get('confidence', 0)
if detected_encoding:
try:
decoded_string = data_bytes.decode(detected_encoding)
print(f" - 使用检测到的编码 '{detected_encoding}' 成功解码,置信度:{confidence}")
return decoded_string
except UnicodeDecodeError as e:
print(f" - 使用检测到的编码 '{detected_encoding}' 解码失败: {e}")
# 方法二:如果检测失败或置信度低,尝试常见的编码
encodings_to_try = ['utf-8', 'gbk', 'gb2312', 'cp936']
print(f" - 检测失败/置信度低,尝试常见编码")
print(f" - 尝试常见编码列表: {encodings_to_try}")
for enc in encodings_to_try:
try:
decoded_string = data_bytes.decode(enc)
print(f" - 成功使用编码 '{enc}' 解码。")
return decoded_string
except UnicodeDecodeError as e:
print(f" - 尝试编码 '{enc}' 失败: {e}")
continue
# 如果所有方法都失败
print(" - 所有编码尝试均失败。")
return "所有编码尝试均失败。"

View File

@@ -0,0 +1,149 @@
# -*- coding: utf-8 -*-
"""
fen-ci 分词工具函数
"""
import os
import re
import json
from config import JIEBA_DICT_FILE, TOKEN_FILE_PATH
# 可选依赖
try:
import jieba
JIEBA_AVAILABLE = True
except ImportError:
JIEBA_AVAILABLE = False
class Tokenizer:
"""分词器:加载词典并提供分词能力"""
def __init__(self):
self.simple_words = []
self.compound_words = set()
self.compound_pattern = None
self._load_words()
def _load_words(self):
custom_words = set()
if os.path.exists(JIEBA_DICT_FILE):
try:
with open(JIEBA_DICT_FILE, 'r', encoding='utf-8') as f:
for line in f:
word = line.strip()
if word:
custom_words.add(word)
except Exception as e:
print(f"读取自定义词汇文件失败: {e}")
compound = {w for w in custom_words if any(c.isupper() for c in w)}
simple = custom_words - compound
self.simple_words = list(simple)
self.compound_words = compound
if compound:
self.compound_pattern = re.compile('|'.join(re.escape(w) for w in compound), re.IGNORECASE)
else:
self.compound_pattern = None
if JIEBA_AVAILABLE:
for w in self.simple_words:
jieba.add_word(w)
# print(f"加载了 {len(self.simple_words)} 个简单自定义词汇,{len(self.compound_words)} 个复合词。")
def tokenize(self, text):
if not text or not text.strip():
return []
tokens = []
chinese_pattern = re.compile(r'[\u4e00-\u9fff]+')
english_pattern = re.compile(r'[a-zA-Z]+(?:-[a-zA-Z]+)*')
number_pattern = re.compile(r'-?\d+\.?\d*')
pos = 0
text_length = len(text)
while pos < text_length:
char = text[pos]
if char.isspace():
pos += 1
continue
if self.compound_pattern:
compound_match = self.compound_pattern.match(text, pos)
if compound_match:
matched_word = compound_match.group()
tokens.append({'text': matched_word, 'type': 'compound_eng', 'length': len(matched_word)})
pos += len(matched_word)
continue
if chinese_pattern.match(char):
chinese_str = ''
while pos < text_length and chinese_pattern.match(text[pos]):
chinese_str += text[pos]
pos += 1
if JIEBA_AVAILABLE:
chinese_tokens = list(jieba.cut(chinese_str))
else:
chinese_tokens = list(chinese_str)
for token in chinese_tokens:
if token.strip():
tokens.append({'text': token, 'type': 'chinese', 'length': len(token)})
elif english_pattern.match(char):
match = english_pattern.match(text, pos)
if match:
word = match.group()
tokens.append({'text': word, 'type': 'english', 'length': len(word)})
pos += len(word)
elif number_pattern.match(char) or (char == '-' and pos + 1 < text_length and text[pos + 1].isdigit()):
match = number_pattern.match(text, pos)
if match:
number = match.group()
tokens.append({'text': number, 'type': 'number', 'length': len(number)})
pos += len(number)
else:
tokens.append({'text': char, 'type': 'punctuation', 'length': 1})
pos += 1
return tokens
@staticmethod
def get_stats(tokens):
stats = {'total': len(tokens), 'chinese': 0, 'english': 0, 'compound_eng': 0, 'number': 0, 'punctuation': 0}
for token in tokens:
t = token['type']
if t in stats:
stats[t] += 1
stats['total'] = sum(v for k, v in stats.items() if k != 'total')
return stats
class TokenAuth:
"""Token 认证管理"""
def __init__(self):
self.valid_tokens = {}
self._load_tokens()
def _load_tokens(self):
if not os.path.exists(TOKEN_FILE_PATH):
print(f"警告: 找不到Token文件 '{TOKEN_FILE_PATH}'")
return
try:
with open(TOKEN_FILE_PATH, 'r', encoding='utf-8') as f:
tokens_data = json.load(f)
token_map = {}
for item in tokens_data:
if 'user' in item and 'token' in item:
token_map[item['token']] = item['user']
self.valid_tokens = token_map
# print(f"成功加载了 {len(token_map)} 个有效Token。")
except Exception as e:
print(f"读取Token文件失败: {e}")
def get_user(self, token):
return self.valid_tokens.get(token)
# 全局单例
tokenizer = Tokenizer()
token_auth = TokenAuth()

View File

@@ -0,0 +1,72 @@
# -*- coding: utf-8 -*-
"""
pin-tu 图片浏览工具函数
"""
import os
import re
def atoi(text):
"""辅助函数:将文本中的数字部分转换为整数,用于排序"""
return int(text) if text.isdigit() else text
def natural_keys(text):
"""自然排序键生成器"""
return [atoi(c) for c in re.split(r'(\d+)', text)]
def find_media_in_folder(folder_path):
"""在指定文件夹中查找所有图片、视频和其他文件"""
allowed_image_exts = {'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.webp'}
allowed_video_exts = {'.mp4', '.avi', '.mov', '.wmv', '.flv', '.webm', '.m4v', '.mkv', '.mpg', '.mpeg', '.3gp', '.3g2'}
try:
files = os.listdir(folder_path)
except PermissionError:
return [], [], []
image_files = []
video_files = []
other_files = []
for f in files:
if not os.path.isfile(os.path.join(folder_path, f)):
continue
ext = os.path.splitext(f)[1].lower()
if ext in allowed_image_exts:
image_files.append(f)
elif ext in allowed_video_exts:
video_files.append(f)
else:
other_files.append(f)
return sorted(image_files, key=natural_keys), sorted(video_files, key=natural_keys), sorted(other_files, key=natural_keys)
def get_subfolders(folder_path):
"""获取指定文件夹下的所有子文件夹"""
try:
items = os.listdir(folder_path)
except PermissionError:
return []
subfolders = [item for item in items if os.path.isdir(os.path.join(folder_path, item))]
return sorted(subfolders, key=natural_keys)
def build_breadcrumbs(subpath, base_url='/pin-tu/browse'):
"""构建面包屑导航数据
base_url 示例: /pin-tu/browse/RDovRGF0YQ
返回 URL 格式: {base_url}?p={累计子路径}
"""
path_parts = [p for p in subpath.split('/') if p]
breadcrumbs = [{'name': 'Home', 'url': base_url}]
cumulative = ''
for part in path_parts:
cumulative += ('/' if cumulative else '') + part
breadcrumbs.append({'name': part, 'url': f'{base_url}?p={cumulative}'})
return breadcrumbs
def is_safe_path(root, target):
"""安全检查:防止路径穿越"""
return os.path.normpath(target).startswith(os.path.normpath(root))

View File

@@ -0,0 +1,121 @@
# -*- coding: utf-8 -*-
"""
RVC 变声后台工作进程
通过 stdin/stdout 二进制协议与 Flask 通信
sys.stdout 重定向到 stderr防止 RVC 的 print() 污染通信通道
"""
import sys
import os
import json
import struct
import traceback
_comm_stdout = sys.stdout.buffer
sys.stdout = sys.stderr
rvc_dir = sys.argv[1] if len(sys.argv) > 1 else os.path.dirname(os.path.abspath(__file__))
os.chdir(rvc_dir)
if rvc_dir not in sys.path:
sys.path.insert(0, rvc_dir)
# 必须在 Config() 之前,防止 argparse 解析失败
sys.argv = sys.argv[:1]
def send_msg(obj):
data = json.dumps(obj, ensure_ascii=False).encode('utf-8')
_comm_stdout.write(struct.pack('<I', len(data)))
_comm_stdout.write(data)
_comm_stdout.flush()
def recv_msg():
raw_len = sys.stdin.buffer.read(4)
if not raw_len:
return None
msg_len = struct.unpack('<I', raw_len)[0]
data = sys.stdin.buffer.read(msg_len)
return json.loads(data.decode('utf-8'))
def main():
try:
from dotenv import load_dotenv
load_dotenv(os.path.join(rvc_dir, '.env'))
from configs.config import Config
config = Config()
from infer.modules.vc.modules import VC
vc = VC(config)
send_msg({'type': 'ready', 'device': str(config.device), 'is_half': config.is_half})
except Exception:
send_msg({'type': 'error', 'error': traceback.format_exc()})
return
import numpy as np
import wave
while True:
req = recv_msg()
if req is None:
break
msg_type = req.get('type')
if msg_type == 'exit':
break
elif msg_type == 'load_model':
try:
vc.get_vc(req['model_name'])
send_msg({'type': 'done'})
except Exception:
send_msg({'type': 'error', 'error': traceback.format_exc()})
elif msg_type == 'convert':
try:
input_path = req['input_path']
output_path = req['output_path']
f0_up_key = req.get('f0_up_key', 0)
f0_method = req.get('f0_method', 'rmvpe')
file_index = req.get('file_index', '')
index_rate = req.get('index_rate', 0.75)
filter_radius = req.get('filter_radius', 3)
resample_sr = req.get('resample_sr', 0)
rms_mix_rate = req.get('rms_mix_rate', 0.25)
protect = req.get('protect', 0.33)
status, (sr, audio) = vc.vc_single(
sid=0,
input_audio_path=input_path,
f0_up_key=f0_up_key,
f0_file=None,
f0_method=f0_method,
file_index=file_index,
file_index2=None,
index_rate=index_rate,
filter_radius=filter_radius,
resample_sr=resample_sr,
rms_mix_rate=rms_mix_rate,
protect=protect,
)
if audio is None:
send_msg({'type': 'error', 'error': status or '变声失败'})
else:
if audio.dtype != np.int16:
audio = audio.astype(np.int16)
with wave.open(output_path, 'w') as wf:
wf.setnchannels(1)
wf.setsampwidth(2)
wf.setframerate(sr)
wf.writeframes(audio.tobytes())
send_msg({'type': 'done', 'output_path': output_path, 'sample_rate': sr})
except Exception:
send_msg({'type': 'error', 'error': traceback.format_exc()})
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,108 @@
# -*- coding: utf-8 -*-
"""
GPT-SoVITS 后台工作进程
通过 stdin/stdout 二进制协议与 Flask 通信
sys.stdout 重定向到 stderr防止 GPT-SoVITS 的 print() 污染通信通道
"""
import sys
import os
import json
import struct
import traceback
_comm_stdout = sys.stdout.buffer
sys.stdout = sys.stderr
sovits_dir = sys.argv[1] if len(sys.argv) > 1 else os.path.dirname(os.path.abspath(__file__))
os.chdir(sovits_dir)
if sovits_dir not in sys.path:
sys.path.insert(0, sovits_dir)
gpt_sovits_dir = os.path.join(sovits_dir, 'GPT_SoVITS')
if gpt_sovits_dir not in sys.path:
sys.path.insert(0, gpt_sovits_dir)
def send_msg(obj):
data = json.dumps(obj, ensure_ascii=False).encode('utf-8')
_comm_stdout.write(struct.pack('<I', len(data)))
_comm_stdout.write(data)
_comm_stdout.flush()
def recv_msg():
raw_len = sys.stdin.buffer.read(4)
if not raw_len:
return None
msg_len = struct.unpack('<I', raw_len)[0]
data = sys.stdin.buffer.read(msg_len)
return json.loads(data.decode('utf-8'))
def main():
config_yaml = sys.argv[2] if len(sys.argv) > 2 else 'GPT_SoVITS/configs/tts_infer.yaml'
try:
from GPT_SoVITS.TTS_infer_pack.TTS import TTS, TTS_Config
yaml_path = os.path.join(sovits_dir, config_yaml)
tts_config = TTS_Config(yaml_path)
tts_engine = TTS(tts_config)
version = getattr(tts_config, 'version', 'unknown')
send_msg({'type': 'ready', 'version': version})
except Exception as e:
send_msg({'type': 'error', 'error': traceback.format_exc()})
return
import numpy as np
import wave
while True:
req = recv_msg()
if req is None:
break
msg_type = req.get('type')
if msg_type == 'exit':
break
elif msg_type == 'reload_gpt':
try:
tts_engine.init_t2s_weights(req['path'])
send_msg({'type': 'done'})
except Exception as e:
send_msg({'type': 'error', 'error': traceback.format_exc()})
elif msg_type == 'reload_sovits':
try:
tts_engine.init_vits_weights(req['path'])
send_msg({'type': 'done'})
except Exception as e:
send_msg({'type': 'error', 'error': traceback.format_exc()})
elif msg_type == 'synthesize':
try:
inputs = req['inputs']
output_path = req['output_path']
tts_generator = tts_engine.run(inputs)
sr, audio_data = next(tts_generator)
if audio_data.dtype != np.int16:
if audio_data.max() <= 1.0:
audio_data = (audio_data * 32767).astype(np.int16)
else:
audio_data = audio_data.astype(np.int16)
with wave.open(output_path, 'w') as wf:
wf.setnchannels(1)
wf.setsampwidth(2)
wf.setframerate(sr)
wf.writeframes(audio_data.tobytes())
send_msg({'type': 'done', 'output_path': output_path})
except Exception as e:
send_msg({'type': 'error', 'error': traceback.format_exc()})
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,589 @@
# -*- coding: utf-8 -*-
"""
接口调用统计模块
使用 SQLite 存储调用记录,支持按日/月查询
"""
import sqlite3
import os
from datetime import datetime, timedelta
DB_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'stats.db')
try:
from config import BASE_DIR
DB_PATH = os.path.join(BASE_DIR, 'stats.db')
except ImportError:
pass
def _get_conn():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
def init_db():
conn = _get_conn()
conn.execute('''
CREATE TABLE IF NOT EXISTS api_calls (
id INTEGER PRIMARY KEY AUTOINCREMENT,
endpoint TEXT NOT NULL,
method TEXT NOT NULL,
status_code INTEGER,
duration_ms REAL,
called_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
''')
conn.execute('''
CREATE TABLE IF NOT EXISTS search_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
keyword TEXT NOT NULL,
root_path TEXT NOT NULL,
result_count INTEGER DEFAULT 0,
searched_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
''')
conn.execute('''
CREATE TABLE IF NOT EXISTS recent_paths (
id INTEGER PRIMARY KEY AUTOINCREMENT,
path TEXT NOT NULL UNIQUE,
last_used TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
''')
conn.execute('''
CREATE TABLE IF NOT EXISTS content_tag_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
system_msg TEXT,
user_msg TEXT,
max_tags INTEGER DEFAULT 10,
min_length INTEGER DEFAULT 2,
max_length INTEGER DEFAULT 6
)
''')
conn.execute('INSERT OR IGNORE INTO content_tag_settings (id) VALUES (1)')
conn.commit()
conn.close()
def record_call(endpoint, method, status_code, duration_ms):
conn = _get_conn()
conn.execute(
'INSERT INTO api_calls (endpoint, method, status_code, duration_ms) VALUES (?, ?, ?, ?)',
(endpoint, method, status_code, round(duration_ms, 2))
)
conn.commit()
conn.close()
def get_total_count():
conn = _get_conn()
row = conn.execute('SELECT COUNT(*) as cnt FROM api_calls').fetchone()
conn.close()
return row['cnt']
def get_avg_duration():
conn = _get_conn()
row = conn.execute('SELECT AVG(duration_ms) as avg_dur FROM api_calls').fetchone()
conn.close()
return round(row['avg_dur'] or 0, 2)
def get_daily_counts(days=30):
since = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
conn = _get_conn()
rows = conn.execute(
"SELECT DATE(called_at, '+8 hours') as day, COUNT(*) as cnt "
"FROM api_calls WHERE DATE(called_at, '+8 hours') >= ? "
"GROUP BY day ORDER BY day",
(since,)
).fetchall()
conn.close()
return [{'date': r['day'], 'count': r['cnt']} for r in rows]
def get_monthly_counts(year=None):
conn = _get_conn()
if year:
rows = conn.execute(
"SELECT strftime('%Y-%m', called_at, '+8 hours') as month, COUNT(*) as cnt "
"FROM api_calls WHERE strftime('%Y', called_at, '+8 hours') = ? "
"GROUP BY month ORDER BY month",
(str(year),)
).fetchall()
else:
rows = conn.execute(
"SELECT strftime('%Y-%m', called_at, '+8 hours') as month, COUNT(*) as cnt "
"FROM api_calls GROUP BY month ORDER BY month"
).fetchall()
conn.close()
return [{'month': r['month'], 'count': r['cnt']} for r in rows]
def get_available_years():
conn = _get_conn()
rows = conn.execute(
"SELECT DISTINCT strftime('%Y', called_at, '+8 hours') as year "
"FROM api_calls ORDER BY year DESC"
).fetchall()
conn.close()
return [r['year'] for r in rows]
def get_available_months(year):
conn = _get_conn()
rows = conn.execute(
"SELECT DISTINCT strftime('%m', called_at, '+8 hours') as month "
"FROM api_calls WHERE strftime('%Y', called_at, '+8 hours') = ? "
"ORDER BY month",
(str(year),)
).fetchall()
conn.close()
return [r['month'] for r in rows]
def get_daily_counts_by_month(year, month):
ym_prefix = f'{year}-{month:02d}'
conn = _get_conn()
rows = conn.execute(
"SELECT strftime('%d', called_at, '+8 hours') as day, COUNT(*) as cnt "
"FROM api_calls WHERE strftime('%Y-%m', called_at, '+8 hours') = ? "
"GROUP BY day ORDER BY day",
(ym_prefix,)
).fetchall()
conn.close()
day_map = {r['day']: r['cnt'] for r in rows}
# 获取该月天数
import calendar
_, days_in_month = calendar.monthrange(int(year), int(month))
return [{'date': f'{ym_prefix}-{d:02d}', 'day': str(d), 'count': day_map.get(f'{d:02d}', 0)} for d in range(1, days_in_month + 1)]
def get_hourly_counts(date_str):
conn = _get_conn()
rows = conn.execute(
"SELECT strftime('%H', called_at, '+8 hours') as hour, COUNT(*) as cnt "
"FROM api_calls WHERE DATE(called_at, '+8 hours') = ? "
"GROUP BY hour ORDER BY hour",
(date_str,)
).fetchall()
conn.close()
hour_map = {r['hour']: r['cnt'] for r in rows}
return [{'hour': f'{h:02d}', 'count': hour_map.get(f'{h:02d}', 0)} for h in range(24)]
def get_endpoint_stats(date=None):
conn = _get_conn()
if date:
rows = conn.execute(
"SELECT endpoint, COUNT(*) as cnt, AVG(duration_ms) as avg_dur "
"FROM api_calls WHERE DATE(called_at, '+8 hours') = ? "
"GROUP BY endpoint ORDER BY cnt DESC",
(date,)
).fetchall()
else:
rows = conn.execute(
"SELECT endpoint, COUNT(*) as cnt, AVG(duration_ms) as avg_dur "
"FROM api_calls GROUP BY endpoint ORDER BY cnt DESC"
).fetchall()
conn.close()
return [{'endpoint': r['endpoint'], 'count': r['cnt'], 'avg_duration': round(r['avg_dur'] or 0, 2)} for r in rows]
def get_today_count():
conn = _get_conn()
row = conn.execute(
"SELECT COUNT(*) as cnt FROM api_calls WHERE DATE(called_at, '+8 hours') = DATE('now', '+8 hours')"
).fetchone()
conn.close()
return row['cnt']
def get_month_count():
conn = _get_conn()
row = conn.execute(
"SELECT COUNT(*) as cnt FROM api_calls WHERE strftime('%Y-%m', called_at, '+8 hours') = strftime('%Y-%m', 'now', '+8 hours')"
).fetchone()
conn.close()
return row['cnt']
def get_yesterday_count():
conn = _get_conn()
row = conn.execute(
"SELECT COUNT(*) as cnt FROM api_calls WHERE DATE(called_at, '+8 hours') = DATE('now', '+8 hours', '-1 day')"
).fetchone()
conn.close()
return row['cnt']
def get_success_rate():
conn = _get_conn()
total = conn.execute("SELECT COUNT(*) as cnt FROM api_calls").fetchone()['cnt']
if total == 0:
conn.close()
return 100.0
success = conn.execute("SELECT COUNT(*) as cnt FROM api_calls WHERE status_code >= 200 AND status_code < 300").fetchone()['cnt']
conn.close()
return round(success / total * 100, 1)
def get_duration_distribution():
conn = _get_conn()
ranges = [
('<100ms', 0, 100),
('100-500ms', 100, 500),
('0.5-1s', 500, 1000),
('1-3s', 1000, 3000),
('3-10s', 3000, 10000),
('>10s', 10000, 999999999),
]
result = []
for label, lo, hi in ranges:
cnt = conn.execute(
"SELECT COUNT(*) as cnt FROM api_calls WHERE duration_ms >= ? AND duration_ms < ?", (lo, hi)
).fetchone()['cnt']
result.append({'label': label, 'count': cnt})
conn.close()
return result
def get_slowest_endpoints(limit=5):
conn = _get_conn()
rows = conn.execute(
"SELECT endpoint, AVG(duration_ms) as avg_dur, MAX(duration_ms) as max_dur, COUNT(*) as cnt "
"FROM api_calls GROUP BY endpoint ORDER BY avg_dur DESC LIMIT ?", (limit,)
).fetchall()
conn.close()
return [{
'endpoint': r['endpoint'],
'avg_duration': round(r['avg_dur'] or 0, 2),
'max_duration': round(r['max_dur'] or 0, 2),
'count': r['cnt']
} for r in rows]
def get_week_compare():
conn = _get_conn()
this_week = []
last_week = []
for i in range(7):
# 本周: 今天往前 i 天
row = conn.execute(
"SELECT COUNT(*) as cnt FROM api_calls WHERE DATE(called_at, '+8 hours') = DATE('now', '+8 hours', ?)",
(f'-{i} day',)
).fetchone()
this_week.append(row['cnt'])
# 上周: 本周对应天数再往前 7 天
row = conn.execute(
"SELECT COUNT(*) as cnt FROM api_calls WHERE DATE(called_at, '+8 hours') = DATE('now', '+8 hours', ?)",
(f'-{i + 7} day',)
).fetchone()
last_week.append(row['cnt'])
conn.close()
# 返回按周一到周日顺序(今天是 index 0需要反转
this_week.reverse()
last_week.reverse()
# 生成日期标签
from datetime import datetime, timedelta
today = datetime.now()
weekday = today.weekday() # 0=周一
labels = []
for i in range(7):
d = today - timedelta(days=weekday - i)
labels.append(d.strftime('%m-%d'))
return {'labels': labels, 'this_week': this_week, 'last_week': last_week}
def get_recent_calls(limit=20):
conn = _get_conn()
rows = conn.execute(
"SELECT endpoint, method, status_code, duration_ms, "
"strftime('%Y-%m-%d %H:%M:%S', called_at, '+8 hours') as called_at "
"FROM api_calls ORDER BY called_at DESC LIMIT ?", (limit,)
).fetchall()
conn.close()
return [dict(r) for r in rows]
def get_active_days():
conn = _get_conn()
row = conn.execute(
"SELECT COUNT(DISTINCT DATE(called_at, '+8 hours')) as cnt FROM api_calls"
).fetchone()
# 连续活跃天数
rows = conn.execute(
"SELECT DISTINCT DATE(called_at, '+8 hours') as day FROM api_calls ORDER BY day DESC"
).fetchall()
conn.close()
total_days = row['cnt']
streak = 0
if rows:
from datetime import datetime, timedelta
today = datetime.now().strftime('%Y-%m-%d')
expected = today
for r in rows:
if r['day'] == expected:
streak += 1
d = datetime.strptime(expected, '%Y-%m-%d') - timedelta(days=1)
expected = d.strftime('%Y-%m-%d')
else:
break
return {'total_days': total_days, 'streak': streak}
# ===== 搜索历史 =====
def add_search(keyword, root_path, result_count):
conn = _get_conn()
conn.execute(
'INSERT INTO search_history (keyword, root_path, result_count) VALUES (?, ?, ?)',
(keyword, root_path, result_count)
)
conn.commit()
conn.close()
def get_search_history(root_path, limit=20):
conn = _get_conn()
rows = conn.execute(
"SELECT id, keyword, result_count, strftime('%Y-%m-%d %H:%M', searched_at, '+8 hours') as searched_at "
"FROM search_history WHERE root_path = ? ORDER BY searched_at DESC LIMIT ?",
(root_path, limit)
).fetchall()
conn.close()
return [dict(r) for r in rows]
def delete_search_history(ids):
if not ids:
return
conn = _get_conn()
placeholders = ','.join('?' for _ in ids)
conn.execute(f'DELETE FROM search_history WHERE id IN ({placeholders})', ids)
conn.commit()
conn.close()
def clear_search_history(root_path):
conn = _get_conn()
conn.execute('DELETE FROM search_history WHERE root_path = ?', (root_path,))
conn.commit()
conn.close()
# ===== 最近访问路径 =====
def add_recent_path(path):
conn = _get_conn()
conn.execute(
'INSERT INTO recent_paths (path, last_used) VALUES (?, CURRENT_TIMESTAMP) '
'ON CONFLICT(path) DO UPDATE SET last_used = CURRENT_TIMESTAMP',
(path,)
)
conn.commit()
conn.close()
def get_recent_paths(limit=8):
conn = _get_conn()
rows = conn.execute(
"SELECT id, path, strftime('%Y-%m-%d %H:%M', last_used, '+8 hours') as last_used "
"FROM recent_paths ORDER BY last_used DESC LIMIT ?",
(limit,)
).fetchall()
conn.close()
return [dict(r) for r in rows]
def delete_recent_path(path):
conn = _get_conn()
conn.execute('DELETE FROM recent_paths WHERE path = ?', (path,))
conn.commit()
conn.close()
def clear_recent_paths():
conn = _get_conn()
conn.execute('DELETE FROM recent_paths')
conn.commit()
conn.close()
# ===== 通用 CRUD数据管理 =====
ALLOWED_TABLES = {
'api_calls': {
'columns': ['endpoint', 'method', 'status_code', 'duration_ms', 'called_at'],
'searchable': ['endpoint', 'method'],
},
'search_history': {
'columns': ['keyword', 'root_path', 'result_count', 'searched_at'],
'searchable': ['keyword', 'root_path'],
},
'recent_paths': {
'columns': ['path', 'last_used'],
'searchable': ['path'],
},
}
DISPLAY_COLUMNS = {
'api_calls': 'id, endpoint, method, status_code, duration_ms, strftime("%Y-%m-%d %H:%M:%S", called_at, "+8 hours") as called_at',
'search_history': 'id, keyword, root_path, result_count, strftime("%Y-%m-%d %H:%M", searched_at, "+8 hours") as searched_at',
'recent_paths': 'id, path, strftime("%Y-%m-%d %H:%M", last_used, "+8 hours") as last_used',
}
def get_table_info():
conn = _get_conn()
result = []
for table in ALLOWED_TABLES:
row = conn.execute(f'SELECT COUNT(*) as cnt FROM {table}').fetchone()
result.append({'table': table, 'count': row['cnt']})
conn.close()
return result
def get_table_rows(table, page=1, page_size=20, search=''):
if table not in ALLOWED_TABLES:
return []
cols = DISPLAY_COLUMNS[table]
offset = (page - 1) * page_size
conn = _get_conn()
if search:
searchable = ALLOWED_TABLES[table]['searchable']
conditions = ' OR '.join(f'{col} LIKE ?' for col in searchable)
params = [f'%{search}%'] * len(searchable) + [page_size, offset]
rows = conn.execute(
f'SELECT {cols} FROM {table} WHERE {conditions} ORDER BY id DESC LIMIT ? OFFSET ?',
params
).fetchall()
else:
rows = conn.execute(
f'SELECT {cols} FROM {table} ORDER BY id DESC LIMIT ? OFFSET ?',
(page_size, offset)
).fetchall()
conn.close()
return [dict(r) for r in rows]
def get_table_count(table, search=''):
if table not in ALLOWED_TABLES:
return 0
conn = _get_conn()
if search:
searchable = ALLOWED_TABLES[table]['searchable']
conditions = ' OR '.join(f'{col} LIKE ?' for col in searchable)
params = [f'%{search}%'] * len(searchable)
row = conn.execute(f'SELECT COUNT(*) as cnt FROM {table} WHERE {conditions}', params).fetchone()
else:
row = conn.execute(f'SELECT COUNT(*) as cnt FROM {table}').fetchone()
conn.close()
return row['cnt']
def insert_row(table, data):
if table not in ALLOWED_TABLES:
return None
allowed_cols = ALLOWED_TABLES[table]['columns']
cols = []
vals = []
for col in allowed_cols:
if col in data and data[col] != '':
cols.append(col)
vals.append(data[col])
if not cols:
return None
placeholders = ','.join('?' for _ in cols)
col_str = ','.join(cols)
conn = _get_conn()
cur = conn.execute(f'INSERT INTO {table} ({col_str}) VALUES ({placeholders})', vals)
conn.commit()
new_id = cur.lastrowid
conn.close()
return new_id
def update_row(table, row_id, data):
if table not in ALLOWED_TABLES:
return False
allowed_cols = ALLOWED_TABLES[table]['columns']
sets = []
vals = []
for col in allowed_cols:
if col in data:
sets.append(f'{col} = ?')
vals.append(data[col])
if not sets:
return False
vals.append(row_id)
conn = _get_conn()
conn.execute(f'UPDATE {table} SET {",".join(sets)} WHERE id = ?', vals)
conn.commit()
conn.close()
return True
def delete_rows(table, ids):
if table not in ALLOWED_TABLES or not ids:
return 0
conn = _get_conn()
placeholders = ','.join('?' for _ in ids)
cur = conn.execute(f'DELETE FROM {table} WHERE id IN ({placeholders})', ids)
conn.commit()
deleted = cur.rowcount
conn.close()
return deleted
def clear_table(table):
if table not in ALLOWED_TABLES:
return 0
conn = _get_conn()
cur = conn.execute(f'DELETE FROM {table}')
conn.commit()
deleted = cur.rowcount
conn.close()
return deleted
# ===== 文章标签设置 =====
DEFAULT_CONTENT_TAG_SETTINGS = {
'system_msg': "你是一个专门生成文章标签的助手,请你根据我给你的文章的内容总结并生成一系列的标签,格式可以参考[关键词1, 关键词2, 关键词3].你只需要给我生成这种形式的标签即可,其他分析内容无需输出.",
'user_msg': "请严格按照以下要求,从提供的文章内容中提取关键词。\n\n文章内容:\n{content}\n\n要求:\n- 提取最多 {max_tags} 个最能概括文章主旨和核心概念的关键词。\n- 关键词必须来源于文章内容,准确反映文章主题。\n- 每个关键词的长度必须在 {min_length}{max_length} 个字符之间。\n- 输出格式为关键词1, 关键词2, 关键词3, ...\n- 只输出关键词列表,不要有任何其他解释或前缀。",
'max_tags': 10,
'min_length': 2,
'max_length': 6,
}
def get_content_tag_settings():
conn = _get_conn()
row = conn.execute('SELECT * FROM content_tag_settings WHERE id = 1').fetchone()
conn.close()
if not row:
return DEFAULT_CONTENT_TAG_SETTINGS
return {
'system_msg': row['system_msg'] or DEFAULT_CONTENT_TAG_SETTINGS['system_msg'],
'user_msg': row['user_msg'] or DEFAULT_CONTENT_TAG_SETTINGS['user_msg'],
'max_tags': row['max_tags'] if row['max_tags'] is not None else DEFAULT_CONTENT_TAG_SETTINGS['max_tags'],
'min_length': row['min_length'] if row['min_length'] is not None else DEFAULT_CONTENT_TAG_SETTINGS['min_length'],
'max_length': row['max_length'] if row['max_length'] is not None else DEFAULT_CONTENT_TAG_SETTINGS['max_length'],
}
def update_content_tag_settings(data):
allowed = ['system_msg', 'user_msg', 'max_tags', 'min_length', 'max_length']
sets = []
vals = []
for col in allowed:
if col in data:
sets.append(f'{col} = ?')
vals.append(data[col])
if not sets:
return False
conn = _get_conn()
conn.execute(f'UPDATE content_tag_settings SET {", ".join(sets)} WHERE id = 1', vals)
conn.commit()
conn.close()
return True