generated from dellevin/template
feat: complete local version to overwrite remote
This commit is contained in:
47
flask-dev-api/.claude/settings.local.json
Normal file
47
flask-dev-api/.claude/settings.local.json
Normal 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: $?\")"
|
||||
]
|
||||
}
|
||||
}
|
||||
BIN
flask-dev-api/__pycache__/app.cpython-312.pyc
Normal file
BIN
flask-dev-api/__pycache__/app.cpython-312.pyc
Normal file
Binary file not shown.
BIN
flask-dev-api/__pycache__/config.cpython-312.pyc
Normal file
BIN
flask-dev-api/__pycache__/config.cpython-312.pyc
Normal file
Binary file not shown.
BIN
flask-dev-api/__pycache__/run_desktop.cpython-312.pyc
Normal file
BIN
flask-dev-api/__pycache__/run_desktop.cpython-312.pyc
Normal file
Binary file not shown.
22
flask-dev-api/blueprints/__init__.py
Normal file
22
flask-dev-api/blueprints/__init__.py
Normal 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',
|
||||
]
|
||||
BIN
flask-dev-api/blueprints/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
flask-dev-api/blueprints/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
flask-dev-api/blueprints/__pycache__/ai_dubbing.cpython-312.pyc
Normal file
BIN
flask-dev-api/blueprints/__pycache__/ai_dubbing.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
flask-dev-api/blueprints/__pycache__/chmod_calc.cpython-312.pyc
Normal file
BIN
flask-dev-api/blueprints/__pycache__/chmod_calc.cpython-312.pyc
Normal file
Binary file not shown.
BIN
flask-dev-api/blueprints/__pycache__/content_tag.cpython-312.pyc
Normal file
BIN
flask-dev-api/blueprints/__pycache__/content_tag.cpython-312.pyc
Normal file
Binary file not shown.
BIN
flask-dev-api/blueprints/__pycache__/down_image.cpython-312.pyc
Normal file
BIN
flask-dev-api/blueprints/__pycache__/down_image.cpython-312.pyc
Normal file
Binary file not shown.
BIN
flask-dev-api/blueprints/__pycache__/down_video.cpython-312.pyc
Normal file
BIN
flask-dev-api/blueprints/__pycache__/down_video.cpython-312.pyc
Normal file
Binary file not shown.
BIN
flask-dev-api/blueprints/__pycache__/fen_ci.cpython-312.pyc
Normal file
BIN
flask-dev-api/blueprints/__pycache__/fen_ci.cpython-312.pyc
Normal file
Binary file not shown.
BIN
flask-dev-api/blueprints/__pycache__/http_status.cpython-312.pyc
Normal file
BIN
flask-dev-api/blueprints/__pycache__/http_status.cpython-312.pyc
Normal file
Binary file not shown.
BIN
flask-dev-api/blueprints/__pycache__/json_format.cpython-312.pyc
Normal file
BIN
flask-dev-api/blueprints/__pycache__/json_format.cpython-312.pyc
Normal file
Binary file not shown.
BIN
flask-dev-api/blueprints/__pycache__/pin_tu.cpython-312.pyc
Normal file
BIN
flask-dev-api/blueprints/__pycache__/pin_tu.cpython-312.pyc
Normal file
Binary file not shown.
BIN
flask-dev-api/blueprints/__pycache__/qr_code.cpython-312.pyc
Normal file
BIN
flask-dev-api/blueprints/__pycache__/qr_code.cpython-312.pyc
Normal file
Binary file not shown.
BIN
flask-dev-api/blueprints/__pycache__/rvc.cpython-312.pyc
Normal file
BIN
flask-dev-api/blueprints/__pycache__/rvc.cpython-312.pyc
Normal file
Binary file not shown.
BIN
flask-dev-api/blueprints/__pycache__/sovits_tts.cpython-312.pyc
Normal file
BIN
flask-dev-api/blueprints/__pycache__/sovits_tts.cpython-312.pyc
Normal file
Binary file not shown.
BIN
flask-dev-api/blueprints/__pycache__/stt.cpython-312.pyc
Normal file
BIN
flask-dev-api/blueprints/__pycache__/stt.cpython-312.pyc
Normal file
Binary file not shown.
BIN
flask-dev-api/blueprints/__pycache__/token_gen.cpython-312.pyc
Normal file
BIN
flask-dev-api/blueprints/__pycache__/token_gen.cpython-312.pyc
Normal file
Binary file not shown.
BIN
flask-dev-api/blueprints/__pycache__/url_parser.cpython-312.pyc
Normal file
BIN
flask-dev-api/blueprints/__pycache__/url_parser.cpython-312.pyc
Normal file
Binary file not shown.
440
flask-dev-api/blueprints/ai_dubbing.py
Normal file
440
flask-dev-api/blueprints/ai_dubbing.py
Normal 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')
|
||||
60
flask-dev-api/blueprints/base64_codec.py
Normal file
60
flask-dev-api/blueprints/base64_codec.py
Normal 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})
|
||||
9
flask-dev-api/blueprints/chmod_calc.py
Normal file
9
flask-dev-api/blueprints/chmod_calc.py
Normal 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')
|
||||
196
flask-dev-api/blueprints/content_tag.py
Normal file
196
flask-dev-api/blueprints/content_tag.py
Normal 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)}"})
|
||||
184
flask-dev-api/blueprints/down_video.py
Normal file
184
flask-dev-api/blueprints/down_video.py
Normal 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})
|
||||
49
flask-dev-api/blueprints/fen_ci.py
Normal file
49
flask-dev-api/blueprints/fen_ci.py
Normal 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
|
||||
9
flask-dev-api/blueprints/http_status.py
Normal file
9
flask-dev-api/blueprints/http_status.py
Normal 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')
|
||||
9
flask-dev-api/blueprints/json_format.py
Normal file
9
flask-dev-api/blueprints/json_format.py
Normal 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')
|
||||
447
flask-dev-api/blueprints/pin_tu.py
Normal file
447
flask-dev-api/blueprints/pin_tu.py
Normal 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]
|
||||
})
|
||||
9
flask-dev-api/blueprints/qr_code.py
Normal file
9
flask-dev-api/blueprints/qr_code.py
Normal 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')
|
||||
415
flask-dev-api/blueprints/rvc.py
Normal file
415
flask-dev-api/blueprints/rvc.py
Normal 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')
|
||||
342
flask-dev-api/blueprints/sovits_tts.py
Normal file
342
flask-dev-api/blueprints/sovits_tts.py
Normal 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)})
|
||||
338
flask-dev-api/blueprints/stt.py
Normal file
338
flask-dev-api/blueprints/stt.py
Normal 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: ctranslate2(faster-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)
|
||||
9
flask-dev-api/blueprints/token_gen.py
Normal file
9
flask-dev-api/blueprints/token_gen.py
Normal 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')
|
||||
9
flask-dev-api/blueprints/url_parser.py
Normal file
9
flask-dev-api/blueprints/url_parser.py
Normal 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
23
flask-dev-api/config.py
Normal 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
|
||||
20
flask-dev-api/config/ai_dubbing_config.json
Normal file
20
flask-dev-api/config/ai_dubbing_config.json
Normal 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"
|
||||
}
|
||||
49
flask-dev-api/config/dv_cookies/bilibili.com_cookies.txt
Normal file
49
flask-dev-api/config/dv_cookies/bilibili.com_cookies.txt
Normal 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"}}
|
||||
14
flask-dev-api/config/dv_cookies/instagram.com_cookies.txt
Normal file
14
flask-dev-api/config/dv_cookies/instagram.com_cookies.txt
Normal 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"
|
||||
61
flask-dev-api/config/dv_cookies/x.com_cookies.txt
Normal file
61
flask-dev-api/config/dv_cookies/x.com_cookies.txt
Normal 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
|
||||
16
flask-dev-api/config/dv_cookies/youtube.com_cookies.txt
Normal file
16
flask-dev-api/config/dv_cookies/youtube.com_cookies.txt
Normal 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
|
||||
37
flask-dev-api/config/fen_ci/jieba.txt
Normal file
37
flask-dev-api/config/fen_ci/jieba.txt
Normal file
@@ -0,0 +1,37 @@
|
||||
MemoFlow
|
||||
MookNote
|
||||
VoceChat
|
||||
WireGuard
|
||||
SyncClipboard
|
||||
floccus
|
||||
GitNex
|
||||
Bitwarden
|
||||
Immich
|
||||
牛逼
|
||||
安益强
|
||||
张昊洋
|
||||
王美丽
|
||||
李冰素
|
||||
清华大学
|
||||
人工智能学院
|
||||
自然语言处理
|
||||
机器学习算法
|
||||
康嘉超
|
||||
王震瀛
|
||||
张钰
|
||||
贝利亚
|
||||
朱益广
|
||||
好的
|
||||
小程序
|
||||
问一下
|
||||
曹婷婷
|
||||
好滴
|
||||
鲁桂娟
|
||||
安兰兰
|
||||
李春雨
|
||||
王娜娜
|
||||
telegram
|
||||
Telegram
|
||||
v2rayNG
|
||||
tool
|
||||
root
|
||||
6
flask-dev-api/config/fen_ci/usertoken.json
Normal file
6
flask-dev-api/config/fen_ci/usertoken.json
Normal file
@@ -0,0 +1,6 @@
|
||||
[
|
||||
{
|
||||
"user": "admin",
|
||||
"token": "DdzBgb8GEkBpA8gtCJXP24hGJz9bXpEOi6z91fHm25X59q5968XqtLxPi1MfiTHJ"
|
||||
}
|
||||
]
|
||||
16
flask-dev-api/config/rvc_config.json
Normal file
16
flask-dev-api/config/rvc_config.json
Normal 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
|
||||
}
|
||||
16
flask-dev-api/config/sovits_config.json
Normal file
16
flask-dev-api/config/sovits_config.json
Normal 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\\声音素材\\参考音频"
|
||||
}
|
||||
3
flask-dev-api/config/stt_config.json
Normal file
3
flask-dev-api/config/stt_config.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"model_dir": "E:\\AI\\stt\\models"
|
||||
}
|
||||
BIN
flask-dev-api/proj_utils/__pycache__/utils.cpython-312.pyc
Normal file
BIN
flask-dev-api/proj_utils/__pycache__/utils.cpython-312.pyc
Normal file
Binary file not shown.
10
flask-dev-api/requirements.txt
Normal file
10
flask-dev-api/requirements.txt
Normal 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
|
||||
1
flask-dev-api/run_tools.cmd
Normal file
1
flask-dev-api/run_tools.cmd
Normal 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
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
68
flask-dev-api/static/font-awesome.css
vendored
Normal 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
8
flask-dev-api/static/qrcode.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
104
flask-dev-api/static/style.css
Normal file
104
flask-dev-api/static/style.css
Normal 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;
|
||||
}
|
||||
BIN
flask-dev-api/static/webfonts/fa-solid-900.ttf
Normal file
BIN
flask-dev-api/static/webfonts/fa-solid-900.ttf
Normal file
Binary file not shown.
BIN
flask-dev-api/static/webfonts/fa-solid-900.woff2
Normal file
BIN
flask-dev-api/static/webfonts/fa-solid-900.woff2
Normal file
Binary file not shown.
BIN
flask-dev-api/stats.db
Normal file
BIN
flask-dev-api/stats.db
Normal file
Binary file not shown.
1243
flask-dev-api/templates/ai_dubbing.html
Normal file
1243
flask-dev-api/templates/ai_dubbing.html
Normal file
File diff suppressed because it is too large
Load Diff
232
flask-dev-api/templates/base64.html
Normal file
232
flask-dev-api/templates/base64.html
Normal 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>
|
||||
174
flask-dev-api/templates/chmod_calc.html
Normal file
174
flask-dev-api/templates/chmod_calc.html
Normal 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>
|
||||
753
flask-dev-api/templates/content_tag.html
Normal file
753
flask-dev-api/templates/content_tag.html
Normal 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()">×</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> 文章内容
|
||||
<code>{max_tags}</code> 最大标签数
|
||||
<code>{min_length}</code> 最短字数
|
||||
<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>
|
||||
529
flask-dev-api/templates/data_manage.html
Normal file
529
flask-dev-api/templates/data_manage.html
Normal 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
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>
|
||||
796
flask-dev-api/templates/down_video.html
Normal file
796
flask-dev-api/templates/down_video.html
Normal 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, '&').replace(/'/g, ''').replace(/"/g, '"');
|
||||
}
|
||||
function escapePathHtml(s) {
|
||||
return (s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
// 保存路径到历史
|
||||
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>
|
||||
376
flask-dev-api/templates/fen_ci.html
Normal file
376
flask-dev-api/templates/fen_ci.html
Normal 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="在此输入需要分词的文本... 支持中英文混合,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>
|
||||
267
flask-dev-api/templates/http_status.html
Normal file
267
flask-dev-api/templates/http_status.html
Normal 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,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||
|
||||
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>
|
||||
1186
flask-dev-api/templates/index.html
Normal file
1186
flask-dev-api/templates/index.html
Normal file
File diff suppressed because it is too large
Load Diff
223
flask-dev-api/templates/json_format.html
Normal file
223
flask-dev-api/templates/json_format.html
Normal 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">美化 & 压缩</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>
|
||||
1622
flask-dev-api/templates/pin_tu.html
Normal file
1622
flask-dev-api/templates/pin_tu.html
Normal file
File diff suppressed because it is too large
Load Diff
100
flask-dev-api/templates/pin_tu_manga.html
Normal file
100
flask-dev-api/templates/pin_tu_manga.html
Normal 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="关闭">×</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>
|
||||
561
flask-dev-api/templates/pin_tu_search.html
Normal file
561
flask-dev-api/templates/pin_tu_search.html
Normal 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>
|
||||
231
flask-dev-api/templates/qr_code.html
Normal file
231
flask-dev-api/templates/qr_code.html
Normal 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>
|
||||
816
flask-dev-api/templates/rvc.html
Normal file
816
flask-dev-api/templates/rvc.html
Normal 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">≥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()">×</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,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
|
||||
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>
|
||||
1258
flask-dev-api/templates/sovits_tts.html
Normal file
1258
flask-dev-api/templates/sovits_tts.html
Normal file
File diff suppressed because it is too large
Load Diff
740
flask-dev-api/templates/stt.html
Normal file
740
flask-dev-api/templates/stt.html
Normal 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()">×</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,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
|
||||
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>
|
||||
185
flask-dev-api/templates/token_gen.html
Normal file
185
flask-dev-api/templates/token_gen.html
Normal 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>
|
||||
249
flask-dev-api/templates/url_parser.html
Normal file
249
flask-dev-api/templates/url_parser.html
Normal 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,'&').replace(/</g,'<').replace(/>/g,'>');}
|
||||
|
||||
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>
|
||||
1
flask-dev-api/utils/__init__.py
Normal file
1
flask-dev-api/utils/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# utils package
|
||||
BIN
flask-dev-api/utils/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
flask-dev-api/utils/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
flask-dev-api/utils/__pycache__/down_video_utils.cpython-312.pyc
Normal file
BIN
flask-dev-api/utils/__pycache__/down_video_utils.cpython-312.pyc
Normal file
Binary file not shown.
BIN
flask-dev-api/utils/__pycache__/encoding_utils.cpython-312.pyc
Normal file
BIN
flask-dev-api/utils/__pycache__/encoding_utils.cpython-312.pyc
Normal file
Binary file not shown.
BIN
flask-dev-api/utils/__pycache__/fen_ci_utils.cpython-312.pyc
Normal file
BIN
flask-dev-api/utils/__pycache__/fen_ci_utils.cpython-312.pyc
Normal file
Binary file not shown.
BIN
flask-dev-api/utils/__pycache__/pin_tu_utils.cpython-312.pyc
Normal file
BIN
flask-dev-api/utils/__pycache__/pin_tu_utils.cpython-312.pyc
Normal file
Binary file not shown.
BIN
flask-dev-api/utils/__pycache__/stats_db.cpython-312.pyc
Normal file
BIN
flask-dev-api/utils/__pycache__/stats_db.cpython-312.pyc
Normal file
Binary file not shown.
316
flask-dev-api/utils/down_video_utils.py
Normal file
316
flask-dev-api/utils/down_video_utils.py
Normal 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)}", "", ""
|
||||
34
flask-dev-api/utils/encoding_utils.py
Normal file
34
flask-dev-api/utils/encoding_utils.py
Normal 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 "所有编码尝试均失败。"
|
||||
149
flask-dev-api/utils/fen_ci_utils.py
Normal file
149
flask-dev-api/utils/fen_ci_utils.py
Normal 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()
|
||||
72
flask-dev-api/utils/pin_tu_utils.py
Normal file
72
flask-dev-api/utils/pin_tu_utils.py
Normal 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))
|
||||
121
flask-dev-api/utils/rvc_worker.py
Normal file
121
flask-dev-api/utils/rvc_worker.py
Normal 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()
|
||||
108
flask-dev-api/utils/sovits_worker.py
Normal file
108
flask-dev-api/utils/sovits_worker.py
Normal 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()
|
||||
589
flask-dev-api/utils/stats_db.py
Normal file
589
flask-dev-api/utils/stats_db.py
Normal 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
|
||||
Reference in New Issue
Block a user