generated from dellevin/template
feat: complete local version to overwrite remote
This commit is contained in:
8
.idea/.gitignore
generated
vendored
Normal file
8
.idea/.gitignore
generated
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
12
.idea/dataSources.xml
generated
Normal file
12
.idea/dataSources.xml
generated
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
|
||||
<data-source source="LOCAL" name="stats" uuid="32513af8-99ba-4f4c-a53e-02c51cca2573">
|
||||
<driver-ref>sqlite.xerial</driver-ref>
|
||||
<synchronize>true</synchronize>
|
||||
<jdbc-driver>org.sqlite.JDBC</jdbc-driver>
|
||||
<jdbc-url>jdbc:sqlite:D:\UserData\Desktop\my_proj\py_demo\python_script\flask-dev-api\stats.db</jdbc-url>
|
||||
<working-dir>$ProjectFileDir$</working-dir>
|
||||
</data-source>
|
||||
</component>
|
||||
</project>
|
||||
23
.idea/inspectionProfiles/Project_Default.xml
generated
Normal file
23
.idea/inspectionProfiles/Project_Default.xml
generated
Normal file
@@ -0,0 +1,23 @@
|
||||
<component name="InspectionProjectProfileManager">
|
||||
<profile version="1.0">
|
||||
<option name="myName" value="Project Default" />
|
||||
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="PyPackageRequirementsInspection" enabled="true" level="WARNING" enabled_by_default="true">
|
||||
<option name="ignoredPackages">
|
||||
<value>
|
||||
<list size="9">
|
||||
<item index="0" class="java.lang.String" itemvalue="sentence_transformers" />
|
||||
<item index="1" class="java.lang.String" itemvalue="fast_langdetect" />
|
||||
<item index="2" class="java.lang.String" itemvalue="jieba_fast" />
|
||||
<item index="3" class="java.lang.String" itemvalue="rotary_embedding_torch" />
|
||||
<item index="4" class="java.lang.String" itemvalue="ko_pron" />
|
||||
<item index="5" class="java.lang.String" itemvalue="pyopenjtalk" />
|
||||
<item index="6" class="java.lang.String" itemvalue="g2p_en" />
|
||||
<item index="7" class="java.lang.String" itemvalue="huggingface_hub" />
|
||||
<item index="8" class="java.lang.String" itemvalue="x_transformers" />
|
||||
</list>
|
||||
</value>
|
||||
</option>
|
||||
</inspection_tool>
|
||||
</profile>
|
||||
</component>
|
||||
6
.idea/inspectionProfiles/profiles_settings.xml
generated
Normal file
6
.idea/inspectionProfiles/profiles_settings.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<component name="InspectionProjectProfileManager">
|
||||
<settings>
|
||||
<option name="USE_PROJECT_PROFILE" value="false" />
|
||||
<version value="1.0" />
|
||||
</settings>
|
||||
</component>
|
||||
7
.idea/misc.xml
generated
Normal file
7
.idea/misc.xml
generated
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="Black">
|
||||
<option name="sdkName" value="Python 3.12 (python_script)" />
|
||||
</component>
|
||||
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.12" project-jdk-type="Python SDK" />
|
||||
</project>
|
||||
8
.idea/modules.xml
generated
Normal file
8
.idea/modules.xml
generated
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/python_script.iml" filepath="$PROJECT_DIR$/.idea/python_script.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
10
.idea/python_script.iml
generated
Normal file
10
.idea/python_script.iml
generated
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="PYTHON_MODULE" version="4">
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$">
|
||||
<excludeFolder url="file://$MODULE_DIR$/.venv" />
|
||||
</content>
|
||||
<orderEntry type="jdk" jdkName="Python 3.12" jdkType="Python SDK" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
6
.idea/vcs.xml
generated
Normal file
6
.idea/vcs.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
70
99demo/base64-de-in-code/api.py
Normal file
70
99demo/base64-de-in-code/api.py
Normal file
@@ -0,0 +1,70 @@
|
||||
from flask import Flask, render_template, request, jsonify
|
||||
import base64
|
||||
import proj_utils.utils as utils # 导入你的工具模块
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
|
||||
@app.route('/')
|
||||
def index():
|
||||
"""渲染主页模板"""
|
||||
return render_template('index.html')
|
||||
|
||||
@app.route('/en-de-code')
|
||||
def index():
|
||||
"""渲染主页模板"""
|
||||
return render_template('en-de-code-index.html')
|
||||
|
||||
@app.route('/decode', methods=['POST'])
|
||||
def decode_endpoint():
|
||||
"""处理解码请求的 API 端点"""
|
||||
data = request.get_json()
|
||||
base64_input = data.get('input_text', '').strip()
|
||||
|
||||
if not base64_input:
|
||||
return jsonify({'success': False, 'error': '输入不能为空。'})
|
||||
|
||||
try:
|
||||
# 1. Base64 解码
|
||||
decoded_bytes = base64.b64decode(base64_input)
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': f'Base64 解码失败: {str(e)}'})
|
||||
|
||||
# 2. 使用 utils 模块中的 detect_and_decode 函数进行编码检测和解码
|
||||
decoded_result = utils.detect_and_decode(decoded_bytes)
|
||||
# 返回成功的 JSON 响应
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'result': decoded_result,
|
||||
})
|
||||
|
||||
@app.route('/encode', methods=['POST'])
|
||||
def encode_endpoint():
|
||||
"""处理编码请求的 API 端点"""
|
||||
data = request.get_json()
|
||||
text_to_encode = data.get('text_to_encode', '')
|
||||
encoding = data.get('encoding', 'utf-8') # 默认使用 UTF-8
|
||||
|
||||
if text_to_encode is None:
|
||||
return jsonify({'success': False, 'error': '输入不能为空。'})
|
||||
|
||||
try:
|
||||
# 1. 根据指定编码格式将文本转换为字节数组
|
||||
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)}'})
|
||||
|
||||
# 2. 将字节数组进行 Base64 编码
|
||||
encoded_string = base64.b64encode(encoded_bytes).decode('ascii')
|
||||
|
||||
# 返回成功的 JSON 响应
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'result': encoded_string
|
||||
})
|
||||
# pip install pipreqs
|
||||
# pipreqs D:\UserData\Desktop\demo\python_script\base64-de-in-code
|
||||
if __name__ == '__main__':
|
||||
app.run(debug=True, host='0.0.0.0', port=5000)
|
||||
Binary file not shown.
34
99demo/base64-de-in-code/proj_utils/utils.py
Normal file
34
99demo/base64-de-in-code/proj_utils/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 "所有编码尝试均失败。"
|
||||
2
99demo/base64-de-in-code/requirements.txt
Normal file
2
99demo/base64-de-in-code/requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
chardet==5.2.0
|
||||
Flask==3.1.2
|
||||
120
99demo/base64-de-in-code/static/style.css
Normal file
120
99demo/base64-de-in-code/static/style.css
Normal file
@@ -0,0 +1,120 @@
|
||||
/* 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: #f6f8fa;
|
||||
color: #24292f;
|
||||
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: #24292f;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.page-header p {
|
||||
margin: 4px 0;
|
||||
color: #57606a;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
width: 90%;
|
||||
max-width: 1600px; /* 增加最大宽度 */
|
||||
gap: 20px; /* 面板间距 */
|
||||
flex: 1; /* 容器占据剩余空间 */
|
||||
height: 60vh; /* 调整容器高度,给标题留出空间 */
|
||||
}
|
||||
|
||||
.panel {
|
||||
flex: 1; /* 左右各占50% */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: #ffffff;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.24);
|
||||
overflow: hidden; /* 防止内容溢出圆角 */
|
||||
}
|
||||
|
||||
.panel h2 {
|
||||
margin: 0;
|
||||
padding: 12px 16px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
background-color: #f6f8fa;
|
||||
border-bottom: 1px solid #eaecef;
|
||||
color: #57606a;
|
||||
}
|
||||
|
||||
textarea {
|
||||
flex: 1; /* 占据剩余空间 */
|
||||
padding: 16px;
|
||||
border: none;
|
||||
resize: none; /* 禁用拖拽调整大小,由JS控制 */
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
background-color: #ffffff;
|
||||
color: #24292f;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
min-height: 200px; /* 设置最小高度 */
|
||||
}
|
||||
|
||||
textarea:focus {
|
||||
/* Typecho 风格无明显焦点样式,保持简洁 */
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
justify-content: flex-end; /* 按钮靠右 */
|
||||
gap: 8px; /* 按钮间距 */
|
||||
padding: 12px 16px;
|
||||
background-color: #f6f8fa;
|
||||
border-top: 1px solid #eaecef;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #24292f;
|
||||
background-color: #f6f8fa;
|
||||
border: 1px solid #d0d7de;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background-color: #f3f4f6;
|
||||
}
|
||||
|
||||
button:active {
|
||||
background-color: #eaecef;
|
||||
}
|
||||
|
||||
button[type="submit"] {
|
||||
background-color: #238636; /* 绿色按钮 */
|
||||
color: white;
|
||||
border-color: #238636;
|
||||
}
|
||||
|
||||
button[type="submit"]:hover {
|
||||
background-color: #2ea043;
|
||||
}
|
||||
|
||||
button[type="submit"]:active {
|
||||
background-color: #3fb950;
|
||||
}
|
||||
149
99demo/base64-de-in-code/templates/en-de-code-index.html
Normal file
149
99demo/base64-de-in-code/templates/en-de-code-index.html
Normal file
@@ -0,0 +1,149 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Base64/编码检测与解码器</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- 页面标题和说明 -->
|
||||
<div class="page-header">
|
||||
<h2>Base64 编码/解码器</h2>
|
||||
<p>因为其他网站base64解码都一坨屎,解码格式一直有问题,无法进行正常解码,要不然就是解码之后中文乱码了,所以写了这个demo</p>
|
||||
<p>接口地址:http(s)://网站域名/decode | http(s)://网站域名/encode</p>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<!-- 左侧面板 (输入) -->
|
||||
<div class="panel">
|
||||
<h2>输入(待编码/解码文本)</h2>
|
||||
<textarea
|
||||
id="inputText"
|
||||
placeholder="在此粘贴待处理的文本..."
|
||||
oninput="autoResize(this)"
|
||||
></textarea>
|
||||
<div class="controls">
|
||||
<button type="button" onclick="clearTextarea('inputText')">清空</button>
|
||||
<button type="button" onclick="process('encode')">编码</button>
|
||||
<button type="button" onclick="process('decode')">解码</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧面板 (输出) -->
|
||||
<div class="panel">
|
||||
<h2>输出</h2>
|
||||
<textarea
|
||||
id="outputResult"
|
||||
readonly
|
||||
placeholder="处理结果将显示在这里..."
|
||||
oninput="autoResize(this)"
|
||||
></textarea>
|
||||
<div class="controls">
|
||||
<button type="button" onclick="clearTextarea('outputResult')">清空</button>
|
||||
<button type="button" onclick="copyResult()">复制</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 自动调整 textarea 高度
|
||||
function autoResize(textarea) {
|
||||
textarea.style.height = 'auto';
|
||||
textarea.style.height = Math.min(textarea.scrollHeight, 400) + 'px'; // 最大高度400px
|
||||
}
|
||||
|
||||
// 清空指定的 textarea
|
||||
function clearTextarea(id) {
|
||||
const element = document.getElementById(id);
|
||||
element.value = '';
|
||||
autoResize(element); // 清空后重置高度
|
||||
}
|
||||
|
||||
// 主处理函数 (根据传入的 mode 决定是编码还是解码)
|
||||
async function process(mode) {
|
||||
const inputElement = document.getElementById('inputText');
|
||||
const outputResult = document.getElementById('outputResult');
|
||||
const inputValue = inputElement.value.trim();
|
||||
|
||||
if (!inputValue) {
|
||||
alert('输入不能为空!');
|
||||
return;
|
||||
}
|
||||
|
||||
let endpoint = '';
|
||||
let payload = {};
|
||||
|
||||
if (mode === 'decode') {
|
||||
endpoint = '/decode';
|
||||
payload.input_text = inputValue;
|
||||
} else if (mode === 'encode') {
|
||||
endpoint = '/encode';
|
||||
payload.text_to_encode = inputValue;
|
||||
// 使用默认编码 UTF-8
|
||||
payload.encoding = 'utf-8';
|
||||
} else {
|
||||
alert('未知的处理模式');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
outputResult.value = data.result;
|
||||
autoResize(outputResult); // 处理后调整高度
|
||||
} else {
|
||||
outputResult.value = `错误: ${data.error}`;
|
||||
autoResize(outputResult);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`请求失败:`, error);
|
||||
outputResult.value = `请求失败: ${error.message}`;
|
||||
autoResize(outputResult);
|
||||
}
|
||||
}
|
||||
|
||||
// 复制结果到剪贴板
|
||||
function copyResult() {
|
||||
const outputResult = document.getElementById('outputResult');
|
||||
if (outputResult.value === '') {
|
||||
alert('没有内容可复制!');
|
||||
return;
|
||||
}
|
||||
|
||||
outputResult.select();
|
||||
outputResult.setSelectionRange(0, 99999); // 为了移动端兼容
|
||||
|
||||
try {
|
||||
const successful = document.execCommand('copy');
|
||||
if (successful) {
|
||||
// 临时提示
|
||||
const originalText = document.querySelector('.panel:nth-child(2) .controls button:last-child').innerText;
|
||||
document.querySelector('.panel:nth-child(2) .controls button:last-child').innerText = '已复制!';
|
||||
setTimeout(() => {
|
||||
document.querySelector('.panel:nth-child(2) .controls button:last-child').innerText = originalText;
|
||||
}, 2000);
|
||||
} else {
|
||||
alert('复制失败,请手动选择复制。');
|
||||
}
|
||||
} catch (err) {
|
||||
alert('浏览器不支持自动复制,请手动选择复制。');
|
||||
}
|
||||
}
|
||||
|
||||
// 页面加载后聚焦到输入框
|
||||
window.onload = function() {
|
||||
document.getElementById('inputText').focus();
|
||||
};
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
37
99demo/base64-de-in-code/templates/index.html
Normal file
37
99demo/base64-de-in-code/templates/index.html
Normal file
@@ -0,0 +1,37 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>白荼 - BAITU</title>
|
||||
<!-- 引入 Google Fonts -->
|
||||
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-family: 'Noto Serif SC', serif;
|
||||
background-color: #f5f5f5;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 2rem;
|
||||
font-weight: 400;
|
||||
text-align: center;
|
||||
line-height: 1.5;
|
||||
letter-spacing: 1px;
|
||||
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<p>恭喜你,来到了一片荒芜之地</p>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
139
99demo/content-to-tag/extract_doc_tag.py
Normal file
139
99demo/content-to-tag/extract_doc_tag.py
Normal file
@@ -0,0 +1,139 @@
|
||||
import requests
|
||||
import json
|
||||
import re
|
||||
import time # 导入 time 模块
|
||||
|
||||
# OLLAMA_BASE_URL = "http://127.0.0.1:11434/api/generate"
|
||||
# OLLAMA_BASE_URL = "http://152.136.153.72:27009/api/generate"
|
||||
def extract_tags_with_ollama_from_content(ollama_base_url,model, article_content, max_tags=10, min_length=2, max_length=6):
|
||||
"""
|
||||
使用本地 Ollama 大语言模型分析文章内容并提取关键词。
|
||||
|
||||
Args:
|
||||
article_content (str): 输入的文章内容字符串。
|
||||
max_tags (int): 希望返回的最大标签数量。
|
||||
min_length (int): 关键词最小长度。
|
||||
max_length (int): 关键词最大长度。
|
||||
|
||||
Returns:
|
||||
list: 提取到的关键词/标签列表。
|
||||
"""
|
||||
if not article_content or not article_content.strip():
|
||||
return {
|
||||
'code': 0,
|
||||
'message': 'success',
|
||||
'data': {
|
||||
'model': model,
|
||||
'think': '警告:输入的文章内容为空或仅包含空白字符。',
|
||||
'tags': [],
|
||||
'consume': 0.0
|
||||
}
|
||||
}
|
||||
|
||||
# 2. 构建 Prompt
|
||||
# 使用中文提示,更符合 qwen 模型的特点
|
||||
prompt = f"""
|
||||
请严格按照以下要求,从提供的文章内容中提取关键词。
|
||||
|
||||
文章内容:
|
||||
{article_content}
|
||||
|
||||
要求:
|
||||
- 提取最多 {max_tags} 个最能概括文章主旨和核心概念的关键词。
|
||||
- 关键词必须来源于文章内容,准确反映文章主题。
|
||||
- 每个关键词的长度必须在 {min_length} 到 {max_length} 个字符之间。
|
||||
- 输出格式为:关键词1, 关键词2, 关键词3, ...
|
||||
- 只输出关键词列表,不要有任何其他解释或前缀。
|
||||
|
||||
"""
|
||||
# 记录开始时间
|
||||
start_time = time.time()
|
||||
# 3. 准备发送给 Ollama API 的 payload
|
||||
payload = {
|
||||
"model": model,
|
||||
"prompt": prompt,
|
||||
"system": "你是一个专门生成文章标签的助手,请你根据我给你的文章的内容总结并生成一系列的标签,格式可以参考[关键词1, 关键词2, 关键词3].你只需要给我生成这种形式的标签即可,其他分析内容无需输出.",
|
||||
"stream": False,
|
||||
"options": {
|
||||
"top_p": 0.9,
|
||||
"temperature": 0.1, # 较低的温度使输出更确定、更聚焦
|
||||
"num_predict": 64000, # 控制预测的最大 token 数
|
||||
}
|
||||
}
|
||||
|
||||
try:
|
||||
# 4. 发送 POST 请求到 Ollama API
|
||||
response = requests.post(ollama_base_url, json=payload)
|
||||
# 5. 检查响应状态
|
||||
if response.status_code != 200:
|
||||
# print(f"Error: Ollama API returned status code {response.status_code}")
|
||||
print(response.text)
|
||||
return {
|
||||
'code': 404,
|
||||
'message': f"{response['error']}",
|
||||
}
|
||||
# 6. 解析 JSON 响应
|
||||
result = response.json()
|
||||
if "response" not in result:
|
||||
print(result)
|
||||
return {
|
||||
'code': 500,
|
||||
'message': "Error: Unexpected response format from Ollama",
|
||||
}
|
||||
|
||||
llm_output = result["response"].strip()
|
||||
# print(llm_output)
|
||||
think_match = re.search(r'<think>(.*?)</think>', llm_output, re.DOTALL)
|
||||
ai_think = think_match.group(1).strip() if think_match else ""
|
||||
# 移除 <think>...</think> 标签及其内容,得到纯净的关键词列表部分
|
||||
clean_output = re.sub(r'<think>.*?</think>', '', llm_output, count=1, flags=re.DOTALL).strip()
|
||||
# 7. 简单清洗和验证关键词
|
||||
# 假设 LLM 输出格式为 "关键词1, 关键词2, ..."
|
||||
raw_tags = [tag.strip() for tag in clean_output.split(',') if tag.strip()]
|
||||
# print(raw_tags)
|
||||
# 过滤掉不符合长度要求的词
|
||||
# filtered_tags = [
|
||||
# tag for tag in raw_tags
|
||||
# if min_length <= len(tag) <= max_length and tag # 忽略空字符串
|
||||
# ]
|
||||
# 去重并保持顺序
|
||||
seen = set()
|
||||
unique_filtered_tags = []
|
||||
for tag in raw_tags:
|
||||
if tag not in seen:
|
||||
seen.add(tag)
|
||||
unique_filtered_tags.append(tag)
|
||||
|
||||
# 记录结束时间
|
||||
end_time = time.time()
|
||||
# 计算耗时
|
||||
elapsed_time = end_time - start_time
|
||||
return {
|
||||
'code':0,
|
||||
'message': 'success',
|
||||
'data': {
|
||||
'model': model,
|
||||
'think': ai_think,
|
||||
'tags': unique_filtered_tags,
|
||||
'consume': elapsed_time
|
||||
}
|
||||
}
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
# print(f"Error calling Ollama API: {e}")
|
||||
return {
|
||||
'code': 500,
|
||||
'message': f"Error calling Ollama API: {e}",
|
||||
}
|
||||
except json.JSONDecodeError as e:
|
||||
# print(f"Error decoding JSON response from Ollama: {e}")
|
||||
return {
|
||||
'code': 500,
|
||||
'message': f"Error decoding JSON response from Ollama: {e}"
|
||||
}
|
||||
except Exception as e:
|
||||
# print(f"An unexpected error occurred: {e}")
|
||||
return {
|
||||
'code': 500,
|
||||
'message': f"An unexpected error occurred: {e}"
|
||||
}
|
||||
40
99demo/content-to-tag/main.py
Normal file
40
99demo/content-to-tag/main.py
Normal file
@@ -0,0 +1,40 @@
|
||||
import extract_doc_tag as ex_doc_tag
|
||||
|
||||
# --- 方式一:指定文件路径 ---
|
||||
file_path = 'test.txt'
|
||||
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as file:
|
||||
my_article_content = file.read()
|
||||
except FileNotFoundError:
|
||||
print(f"错误:找不到文件 '{file_path}'")
|
||||
exit(1)
|
||||
except Exception as e:
|
||||
print(f"读取文件时发生错误: {e}")
|
||||
exit(1)
|
||||
|
||||
model = "qwen3:1.7b"
|
||||
# model = "qwen3:0.6b"
|
||||
# OLLAMA_BASE_URL = "http://127.0.0.1:11434/api/generate"
|
||||
OLLAMA_BASE_URL = "http://152.136.153.72:27009/api/generate"
|
||||
extracted_tags = ex_doc_tag.extract_tags_with_ollama_from_content(
|
||||
OLLAMA_BASE_URL,
|
||||
model,
|
||||
my_article_content,
|
||||
max_tags=5,
|
||||
min_length=2,
|
||||
max_length=10
|
||||
)
|
||||
|
||||
if extracted_tags['code'] == 0:
|
||||
print('思考过程:')
|
||||
print(extracted_tags['data']['think'])
|
||||
print('=' * 60)
|
||||
print('文章内容长度', len(my_article_content))
|
||||
# print(f"Ollama 模型:{model}")
|
||||
print('=' * 60)
|
||||
print('文章标签:', extracted_tags['data']['tags'])
|
||||
print('=' * 60)
|
||||
print(f"总耗时: {extracted_tags['data']['consume']:.2f} 秒")
|
||||
else:
|
||||
print(extracted_tags)
|
||||
13
99demo/content-to-tag/test.txt
Normal file
13
99demo/content-to-tag/test.txt
Normal file
@@ -0,0 +1,13 @@
|
||||
今天周六早上起床,心血来潮开始刷视频,不知不觉时间就像被偷走一样,和游戏类似,两三个小时转瞬即逝。我每次都想着,诶呀一个一分多钟的视频算什么,下一个!于是在百个视频过去后一上午的时间也消失了。
|
||||
|
||||
从喜羊羊,到情感生活,到家庭育儿,到程序编码,到机器制作,再到科幻制作,再到大黄蜂。你可能会奇怪,喜羊羊和情感生活有什么关系,家庭育儿和程序编码有什么关系,机器制作和科幻制作又有什么关系。其实这些没有奇怪的地方,喜羊羊里面的喜羊羊美羊羊沸羊羊的关系引出情感,儿童的幼年编程引出了程序编码,机器制作的炫酷效果引出了科幻制作。
|
||||
|
||||
一个视频就像细胞上面的无数的受体和另一个细胞上面的配体一样,他们那种概念的联系将一个个视频连锁成宇宙中的群星看似毫无联系,细看却是万般变化的星座。
|
||||
|
||||
我在想是什么变成这样的呢?标签?模糊的标签?也许是这样,但这这是一种浅显的理解吧!因为这种标签,把他们分成了不同的种类,划分成不同的视频,然后用一个概念展示给我们看。罗翔老师讲过一个他的一件故事,他在上大学的时候,初入大学很多一个地区的人会互相认识,他去了他们省的聚会,然后他突然发现下次的聚合没有叫他,才知道是一个市区的聚会。具体内容我早已忘记,但是大概意思是这样的。我们每个人都像网络视频那样被打上了标签,我们用这个来区分自己的同类,异类。我们会和同类笑着说“看啊!那个人说话好怪啊!看啊!那个人吃饭的样子好丑”但是也许你在你同类眼里也是个异类,一个嘲笑他人的异类。于是你不断给自己打标签,做完这个做那个,加入这个加那个,最终像一只蝙蝠一样,鸟兽不分。
|
||||
|
||||
扯远了。其实这种标签带来的分化自古就有,可以具象的堪称阶级划分,就像盖茨比赚了再多的钱,举办了再多的宴会也融入不进去权贵阶层。可以抽象的理解为人们的闲话,谁家怎么怎么了,那家又如何如何。这种东西就像是无形的线,勒住我们的思想和手指。大数据的判别让我们困于信息茧房,让我们只看到自己想看的(比如追星的人会刷到自己喜欢明星的一切,不喜欢那个明星的则会刷到各种搞怪的视频)。我们的思想逐渐变得狭隘,三分钟的视频让我们的喜欢和伤心都会变得特别廉价。我经常看到的一句话就是“原本很开心的,看到你这里绷不住了”,“挺难受的,你这评论给我笑嘻了”无数的视频过去,让我们的喜欢随便的说出口,让我们的痛苦说来就来。
|
||||
|
||||
很难不否认短视频在释放压力方面是对当代打工人的良药。快节奏+短视频带给无数人不一样的生活见地,可是这种方式终归是饮鸩止渴,浅陋而自大的。你再也不会记得白鹿原里的白嘉轩,只会明白他叫大壮,我们再也不会记得南海十三郎里面的江誉镠,也许他的名字会叫作小亮。真不知百年之后人们的思想会是如何。
|
||||
|
||||
我相信还是会有下一个鲁迅的,那种《中国人难道失掉自信力了吗》那种俯首甘为孺子牛的鲁迅。用笔尖刺痛每一人的脊梁。也许我们不是愚蠢,只是不想醒罢了
|
||||
BIN
99demo/down-video/__pycache__/main.cpython-312.pyc
Normal file
BIN
99demo/down-video/__pycache__/main.cpython-312.pyc
Normal file
Binary file not shown.
2951
99demo/down-video/bilibili_cookies.txt
Normal file
2951
99demo/down-video/bilibili_cookies.txt
Normal file
File diff suppressed because it is too large
Load Diff
241
99demo/down-video/main.py
Normal file
241
99demo/down-video/main.py
Normal file
@@ -0,0 +1,241 @@
|
||||
import yt_dlp
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import json
|
||||
import time
|
||||
import queue
|
||||
import threading
|
||||
import subprocess
|
||||
from urllib.parse import urlparse
|
||||
from flask import Flask, render_template, request, jsonify, Response, stream_with_context, send_file
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
DEFAULT_OUTPUT_DIR = r"D:\UserData\Desktop\xiazai"
|
||||
TWITTER_COOKIE = "x_cookies.txt"
|
||||
BILIBILI_COOKIE = "bilibili_cookies.txt"
|
||||
PROXY_URL = "socks5://127.0.0.1:10808"
|
||||
|
||||
|
||||
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 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 _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"
|
||||
|
||||
# 取最可能匹配的一对:文件名最短(不含 quality suffix 的通常最短)
|
||||
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)}", ""
|
||||
|
||||
|
||||
def download_video(video_url, output_dir, platform):
|
||||
"""
|
||||
通用下载函数,返回 (success: bool, message: str, title: str, filepath: str)
|
||||
"""
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
ydl_opts = {
|
||||
'outtmpl': os.path.join(output_dir, '%(title)s.%(ext)s'),
|
||||
'ignoreerrors': True,
|
||||
}
|
||||
|
||||
if platform == 'twitter':
|
||||
cookie_file = TWITTER_COOKIE
|
||||
if PROXY_URL:
|
||||
ydl_opts['proxy'] = PROXY_URL
|
||||
elif platform == 'bilibili':
|
||||
cookie_file = BILIBILI_COOKIE
|
||||
# Bilibili 不使用代理,且需要合并配置
|
||||
ydl_opts['merge_output_format'] = 'mp4'
|
||||
ydl_opts['postprocessors'] = [{
|
||||
'key': 'FFmpegVideoConvertor',
|
||||
'preferedformat': 'mp4',
|
||||
}]
|
||||
else:
|
||||
return False, "不支持的平台", "", ""
|
||||
|
||||
if os.path.exists(cookie_file):
|
||||
ydl_opts['cookiefile'] = cookie_file
|
||||
else:
|
||||
return False, f"Cookie 文件 '{cookie_file}' 未找到", "", ""
|
||||
|
||||
try:
|
||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||
info = ydl.extract_info(video_url, download=True)
|
||||
if not info:
|
||||
return False, "未能获取视频信息,下载可能失败", "", ""
|
||||
|
||||
title = info.get('title', 'Unknown Title')
|
||||
safe_title = re.sub(r'[<>:\"/\\|?*]', '_', title)
|
||||
files = _find_files_by_title(output_dir, safe_title)
|
||||
|
||||
if not files:
|
||||
return False, "下载完成但未找到输出文件", title, ""
|
||||
|
||||
# 确定最终视频文件路径
|
||||
# 优先取合并后的文件,否则取最大的 mp4 文件
|
||||
filepath = ""
|
||||
candidate_files = [f for f in files if f.endswith('.mp4')]
|
||||
if candidate_files:
|
||||
# 优先 merged 文件
|
||||
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:
|
||||
# 没有 mp4,取任意文件
|
||||
files.sort(key=lambda f: os.path.getsize(os.path.join(output_dir, f)), reverse=True)
|
||||
filepath = os.path.join(output_dir, files[0])
|
||||
|
||||
# Bilibili 后处理:如果存在分离的 mp4 + m4a,手动合并
|
||||
extra_msg = ""
|
||||
if platform == 'bilibili':
|
||||
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)}", "", ""
|
||||
|
||||
|
||||
@app.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
|
||||
|
||||
return send_file(filepath, as_attachment=as_download)
|
||||
|
||||
|
||||
@app.route("/")
|
||||
def index():
|
||||
ffmpeg_ok = check_ffmpeg()
|
||||
return render_template("index.html", ffmpeg_ok=ffmpeg_ok)
|
||||
|
||||
|
||||
@app.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
|
||||
|
||||
if not url:
|
||||
return jsonify({"success": False, "message": "请输入视频链接"}), 400
|
||||
|
||||
if is_twitter_url(url):
|
||||
platform = 'twitter'
|
||||
elif is_bilibili_url(url):
|
||||
platform = 'bilibili'
|
||||
else:
|
||||
return jsonify({"success": False, "message": "仅支持 Twitter/X 和 Bilibili 视频链接"}), 400
|
||||
|
||||
if not output_dir:
|
||||
output_dir = DEFAULT_OUTPUT_DIR
|
||||
|
||||
success, message, title, filepath = download_video(url, output_dir, platform)
|
||||
return jsonify({"success": success, "message": message, "title": title, "filepath": filepath})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("=" * 50)
|
||||
print("Twitter / Bilibili 视频下载器")
|
||||
print("=" * 50)
|
||||
if check_ffmpeg():
|
||||
print("FFmpeg 已检测到")
|
||||
else:
|
||||
print("警告: 未检测到 FFmpeg")
|
||||
print(f"默认保存目录: {DEFAULT_OUTPUT_DIR}")
|
||||
print("访问 http://127.0.0.1:5000 打开下载页面")
|
||||
print("=" * 50)
|
||||
app.run(host="0.0.0.0", port=5000, debug=False, threaded=True)
|
||||
380
99demo/down-video/templates/index.html
Normal file
380
99demo/down-video/templates/index.html
Normal file
@@ -0,0 +1,380 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<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: #0a0a0a;
|
||||
color: #f5f5f5;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
}
|
||||
/* 左侧面板 */
|
||||
.left-panel {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid #222;
|
||||
min-width: 420px;
|
||||
}
|
||||
.input-section {
|
||||
padding: 32px;
|
||||
border-bottom: 1px solid #222;
|
||||
}
|
||||
.input-section h1 {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 4px;
|
||||
color: #fff;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.input-section .subtitle {
|
||||
color: #666;
|
||||
font-size: 13px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.input-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.input-group label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 6px;
|
||||
color: #888;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.input-group input {
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid #333;
|
||||
border-radius: 10px;
|
||||
background: #0a0a0a;
|
||||
color: #f5f5f5;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.input-group input::placeholder { color: #444; }
|
||||
.input-group input:focus { border-color: #fff; background: #000; }
|
||||
button {
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
color: #000;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
button:hover:not(:disabled) { background: #e0e0e0; 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: block; }
|
||||
.status.success { background: #0a1f0a; color: #4ade80; border: 1px solid #1a3a1a; }
|
||||
.status.error { background: #1f0a0a; color: #f87171; border: 1px solid #3a1a1a; }
|
||||
.status.info { background: #0a0a1f; color: #60a5fa; border: 1px solid #1a1a3a; }
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid rgba(255,255,255,0.15);
|
||||
border-top-color: #60a5fa;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
margin-right: 8px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.footer-meta {
|
||||
margin-top: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 11px;
|
||||
color: #444;
|
||||
}
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.badge.ok { background: #0a1f0a; color: #4ade80; border: 1px solid #1a3a1a; }
|
||||
.badge.warn { background: #1f1a0a; color: #fbbf24; border: 1px solid #3a301a; }
|
||||
|
||||
/* 日志区域 */
|
||||
.log-section {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.log-header {
|
||||
padding: 14px 32px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #666;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.log-panel {
|
||||
flex: 1;
|
||||
padding: 20px 32px;
|
||||
background: #000;
|
||||
color: #4ade80;
|
||||
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;
|
||||
}
|
||||
.right-panel .placeholder {
|
||||
text-align: center;
|
||||
color: #333;
|
||||
}
|
||||
.right-panel .placeholder svg {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.3;
|
||||
}
|
||||
.right-panel .placeholder p {
|
||||
font-size: 14px;
|
||||
}
|
||||
.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 #222;
|
||||
outline: none;
|
||||
}
|
||||
.video-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
.video-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
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;
|
||||
}
|
||||
.countdown {
|
||||
font-size: 12px;
|
||||
color: #f87171;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.btn-download {
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
background: #1a1a1a;
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
border: 1px solid #333;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn-download:hover { background: #2a2a2a; border-color: #444; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="left-panel">
|
||||
<div class="input-section">
|
||||
<h1>视频下载</h1>
|
||||
<p class="subtitle">支持 X 和 Bilibili</p>
|
||||
|
||||
<div class="input-group">
|
||||
<label for="url">视频链接</label>
|
||||
<input type="url" id="url" placeholder="https://x.com/... 或 https://www.bilibili.com/video/...">
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<label for="outputDir">保存路径</label>
|
||||
<input type="text" id="outputDir" value="D:\UserData\Desktop\xiazai">
|
||||
</div>
|
||||
|
||||
<button id="downloadBtn" onclick="startDownload()">开始下载</button>
|
||||
|
||||
<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 %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="log-section" style="display:none;">
|
||||
<div class="log-header">
|
||||
<span>控制台输出</span>
|
||||
<span id="logStatus" style="font-size:11px;color:#444;">等待开始</span>
|
||||
</div>
|
||||
<pre id="logPanel" class="log-panel"></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="right-panel">
|
||||
<div id="placeholder" class="placeholder">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<rect x="2" y="2" width="20" height="20" rx="4"/>
|
||||
<polygon points="9,8 17,12 9,16"/>
|
||||
</svg>
|
||||
<p>下载完成后在此预览视频</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 logPanel = document.getElementById('logPanel');
|
||||
const logStatus = document.getElementById('logStatus');
|
||||
const placeholder = document.getElementById('placeholder');
|
||||
const videoWrap = document.getElementById('videoWrap');
|
||||
const videoPlayer = document.getElementById('videoPlayer');
|
||||
const videoTitle = document.getElementById('videoTitle');
|
||||
const countdownEl = document.getElementById('countdown');
|
||||
const downloadLink = document.getElementById('downloadLink');
|
||||
|
||||
function showStatus(text, type) {
|
||||
statusEl.textContent = 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 = '/file?path=' + encodeURIComponent(filepath);
|
||||
videoTitle.textContent = title || '未命名视频';
|
||||
downloadLink.href = '/file?path=' + encodeURIComponent(filepath) + '&download=1';
|
||||
downloadLink.setAttribute('download', '');
|
||||
}
|
||||
|
||||
async function startDownload() {
|
||||
const url = urlInput.value.trim();
|
||||
const outputDir = document.getElementById('outputDir').value.trim();
|
||||
if (!url) {
|
||||
showStatus('请输入视频链接', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
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 = '';
|
||||
|
||||
try {
|
||||
const res = await fetch('/download', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url: url, output_dir: outputDir })
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
showStatus((data.title ? '《' + data.title + '》' : '') + ' 下载完成', 'success');
|
||||
if (data.filepath) {
|
||||
showVideo(data.filepath, data.title);
|
||||
}
|
||||
} else {
|
||||
showStatus(data.message, 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
showStatus('请求失败: ' + err.message, 'error');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
urlInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') startDownload();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
35
99demo/down-video/x_cookies.txt
Normal file
35
99demo/down-video/x_cookies.txt
Normal file
@@ -0,0 +1,35 @@
|
||||
# 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 1808747867 __cuid b00c4a6e73cf471e95c093aec1d1c0b0
|
||||
.x.com TRUE / TRUE 1807414323 personalization_id "v1_X+OVgjnn20lE7ppYGHrXzQ=="
|
||||
.x.com TRUE / TRUE 1807414324 guest_id v1%3A177285432573681062
|
||||
.x.com TRUE / TRUE 1807414357 kdt aYTMkoEta2sR2Cbrf9TCU2vL6NQrNTk6xP26HzJA
|
||||
.x.com TRUE / TRUE 1807414357 auth_token e0a16f1e58541f609e0b26bfd2759c5508ad67c6
|
||||
.x.com TRUE / TRUE 1807414357 ct0 0699cddc947b9fa7e4b226e70d7fc69fdd46a9d4623aea18b98cf8ed9d99fd308a01fec0e531ae1bb50b602a0ba9e87691d694888c39440c7bc8bdc3e35afd1d0757903d5bc702b71fa81c648a0108e7
|
||||
.x.com TRUE / TRUE 1808748895 guest_id_ads v1%3A177285432573681062
|
||||
.x.com TRUE / TRUE 1808748895 guest_id_marketing v1%3A177285432573681062
|
||||
.x.com TRUE / TRUE 1805724895 twid u%3D1051785185509822466
|
||||
.x.com TRUE / TRUE 1777215667 __cf_bm k7mwLxnd0mtyuyQLThqb20Oyb3q2.yEJfZw570X8CEI-1777213867.2375512-1.0.1.1-GAwpDeSYeFaRo9HzHK2Xf5tYtXiD4SzQj_L0kFw1S1P7FZWwSnOuJZ2JfxMxlG7vnUjDS0lBbszszPUWdOFb0upzl1YbBdE0jf6q0t7nJB0qvN9MElSi3lTTFOdf7HEs
|
||||
www.dropbox.com FALSE / TRUE 1790090452 gvc MTk3NzU0NTUwMjg0MDYzMDYzMzA4NTQ0MjEyNzAzODM1MzUxNTYz
|
||||
www.dropbox.com FALSE / TRUE 1787066452 __Host-js_csrf ee7vG1QZzX2hhBoptjGNMkhg
|
||||
.dropbox.com TRUE / TRUE 1787066452 t ee7vG1QZzX2hhBoptjGNMkhg
|
||||
.netflix.com TRUE / FALSE 1789916349 nfvdid BQFmAAEBEKpkhEFXmQMTd61zb4rT6FdApPuZS-qvyP3HIzl9d5Tk2rjxE6wW5b--LFBw-Ebbcmp7rJ640s9P91ltSiy8DTzu-0gEy3Ob8B_JHniDavIQXQ%3D%3D
|
||||
.netflix.com TRUE / TRUE 1789916349 SecureNetflixId v%3D3%26mac%3DAQEAEQABABSSBFH4KvE6qPl_-6UI_re1LAdlfAy3rZk.%26dt%3D1758380348892
|
||||
.netflix.com TRUE / TRUE 1789916349 NetflixId v%3D3%26ct%3DBgjHlOvcAxLAAb-lG8nEmlB-x4EMpdZG6gG0_ZGe7HgQvUbBUgbe6kPr6Or-1cC94UaN0RbSzxx_OYBaaHLg2MvdKRvw0dKlWwzRJyychkiKImYRxA1PHETG8KjG86tx5smLKn9QcETtqecvyrKl04PUFZ6KuO8Y3LrCO8posa-VMj5U3gonTjruh63uy_zFQYQygkvrJIShQ1yQjNxCropJEzK536vs46y7MYjt_KPCIK9E8tpT80whmRDQ7DgBniSOyGTtiKKyFhgGIg4KDEsJyQjnvm9ZXXKI7g..
|
||||
.yandex.com TRUE / TRUE 1795878312 yuidss 3388071441761318311
|
||||
.yandex.com TRUE / TRUE 1795878312 is_gdpr 0
|
||||
.yandex.com TRUE / TRUE 1795878312 yandexuid 3388071441761318311
|
||||
.yandex.com TRUE / TRUE 1806578318 my YwA=
|
||||
.yandex.com TRUE / TRUE 1795878652 is_gdpr_b CMWxMBD63gIoAg==
|
||||
.yandex.com TRUE / TRUE 1806578314 i Tl5C1iTbvBz58B4Pigqm2d8QgUC+4wYSpsK4t9kymsq2s0hX0eVihjDkvNmLxPLd8yXet7JjFuW1h5SeT3bJ+65Aepo=
|
||||
.yandex.com TRUE / TRUE 1806846551 yp 2087646552.pcs.0#1792854544.swntab.0#1787786326.szm.1:1920x1080:1304x732:15#1774610321.ygu.1#1772882321.dlp.2
|
||||
.yandex.com TRUE / TRUE 1806848331 _yasc RfaPS2g6xVw7Omhl+7/4pXIp30Wti0n8MwSiKMV6meOJl3Phq4TlP15XKqkV+MVJhr3OKmKyvTnS0m3SuMVVFhSLDPGFpWauk4yqACQuAydoupgmirYCoa16gRa9L3hZv/kc4QnkyqvC3E2KLQ==
|
||||
.yandex.com TRUE / TRUE 1806848512 bh EkEiQ2hyb21pdW0iO3Y9IjE0MCIsICJOb3Q9QT9CcmFuZCI7dj0iMjQiLCAiR29vZ2xlIENocm9tZSI7dj0iMTQwIhoFIng4NiIiECIxNDAuMC43MzM5LjEyOCIqAj8wMgIiIjoJIldpbmRvd3MiQggiMTkuMC4wIkoEIjY0IlJdIkNocm9taXVtIjt2PSIxNDAuMC43MzM5LjEyOCIsICJOb3Q9QT9CcmFuZCI7dj0iMjQuMC4wLjAiLCAiR29vZ2xlIENocm9tZSI7dj0iMTQwLjAuNzMzOS4xMjgiWgI/MGDD84vNBmoe3Mrh/wiS2KGxA5/P4eoD+/rw5w3r//32D/68z4cI
|
||||
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 0 lang zh-cn
|
||||
www.wmmflix.com FALSE / FALSE 1805607768 lastvisit 121%091774071767%09%2Fapp-index-run%3Fapp%3Dsearch%26keywords%3Dtt0817177
|
||||
687
99demo/pin-img/pin-tu.py
Normal file
687
99demo/pin-img/pin-tu.py
Normal file
@@ -0,0 +1,687 @@
|
||||
# app.py
|
||||
from flask import Flask, render_template_string, send_from_directory, request
|
||||
import os
|
||||
import re
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
# --- 配置 ---
|
||||
# 请在这里修改为你存放图片的文件夹路径
|
||||
ROOT_IMAGE_FOLDER = r'\\192.168.31.92\ubuntuShare2T\学习资料' # 使用原始字符串以避免转义问题
|
||||
PORT = 80 # 你可以修改这个端口号,如果5000被占用了
|
||||
# --- 配置结束 ---
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
|
||||
def atoi(text):
|
||||
"""辅助函数:将文本中的数字部分转换为整数,用于排序"""
|
||||
return int(text) if text.isdigit() else text
|
||||
|
||||
|
||||
def natural_keys(text):
|
||||
"""
|
||||
辅助函数:生成用于自然排序的键。
|
||||
例如: '1002' -> ['1002'] (其中 '1002' 被转换为整数)
|
||||
'img_10' -> ['img_', 10]
|
||||
这样可以确保 '1001.jpg' 排在 '1002.png' 之前。
|
||||
"""
|
||||
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:
|
||||
print(f"权限不足,无法访问: {folder_path}")
|
||||
return [], []
|
||||
|
||||
image_files = [f for f in files if os.path.isfile(os.path.join(folder_path, f)) and os.path.splitext(f)[
|
||||
1].lower() in allowed_image_exts]
|
||||
video_files = [f for f in files if os.path.isfile(os.path.join(folder_path, f)) and os.path.splitext(f)[
|
||||
1].lower() in allowed_video_exts]
|
||||
|
||||
return sorted(image_files, key=natural_keys), sorted(video_files, key=natural_keys)
|
||||
|
||||
|
||||
def get_subfolders(folder_path):
|
||||
"""获取指定文件夹下的所有子文件夹"""
|
||||
try:
|
||||
items = os.listdir(folder_path)
|
||||
except PermissionError:
|
||||
print(f"权限不足,无法访问: {folder_path}")
|
||||
return []
|
||||
subfolders = [item for item in items if os.path.isdir(os.path.join(folder_path, item))]
|
||||
return sorted(subfolders, key=natural_keys)
|
||||
|
||||
|
||||
@app.route('/')
|
||||
def index():
|
||||
# 默认访问根目录,subpath为空字符串
|
||||
return list_folder_content('')
|
||||
|
||||
|
||||
@app.route('/browse/')
|
||||
@app.route('/browse/<path:subpath>')
|
||||
def list_folder_content(subpath=''):
|
||||
"""
|
||||
递归浏览文件夹内容的主函数
|
||||
subpath: URL中传递的子路径
|
||||
"""
|
||||
# 从查询参数获取显示模式,默认为 'list'
|
||||
mode = request.args.get('mode', 'list')
|
||||
if mode not in ['list', 'grid', 'manga']:
|
||||
mode = 'list'
|
||||
|
||||
# 构建当前要浏览的物理路径
|
||||
# 注意:这里使用全局变量 ROOT_IMAGE_FOLDER
|
||||
current_path = os.path.normpath(os.path.join(ROOT_IMAGE_FOLDER, subpath))
|
||||
|
||||
# 检测是否为漫画路径
|
||||
is_manga_path = '漫画' in subpath
|
||||
|
||||
# 安全检查,防止路径穿越
|
||||
if not current_path.startswith(os.path.normpath(ROOT_IMAGE_FOLDER)):
|
||||
return "Access Denied", 403
|
||||
|
||||
# 查找当前文件夹下的媒体文件和子文件夹
|
||||
image_files, video_files = find_media_in_folder(current_path)
|
||||
subfolders = get_subfolders(current_path)
|
||||
|
||||
# 构建面包屑导航
|
||||
path_parts = [p for p in subpath.split('/') if p]
|
||||
breadcrumbs = [{'name': 'Home', 'url': '/'}]
|
||||
cumulative_path = ''
|
||||
for part in path_parts:
|
||||
cumulative_path += part + '/'
|
||||
breadcrumbs.append({'name': part, 'url': f'/browse/{cumulative_path.rstrip("/")}'})
|
||||
|
||||
html_template = '''
|
||||
<!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 {
|
||||
font-family: "Segoe UI", "Microsoft YaHei", Arial, sans-serif;
|
||||
background-color: #f5f5f5;
|
||||
color: #333;
|
||||
}
|
||||
/* 顶部地址栏 - Windows风格 */
|
||||
.address-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #ddd;
|
||||
padding: 8px 16px;
|
||||
gap: 2px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.address-bar a, .address-bar span.current {
|
||||
padding: 4px 10px;
|
||||
border-radius: 3px;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
text-decoration: none;
|
||||
}
|
||||
.address-bar a:hover {
|
||||
background: #e5e5e5;
|
||||
}
|
||||
.address-bar span.current {
|
||||
color: #000;
|
||||
font-weight: 600;
|
||||
}
|
||||
.address-bar .sep {
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
padding: 0 2px;
|
||||
user-select: none;
|
||||
}
|
||||
.address-bar .home-btn {
|
||||
font-size: 16px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
/* 工具栏 */
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px 16px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #ddd;
|
||||
gap: 12px;
|
||||
}
|
||||
.mode-btn {
|
||||
padding: 5px 14px;
|
||||
border: 1px solid #ccc;
|
||||
background: #fff;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
text-decoration: none;
|
||||
color: #333;
|
||||
}
|
||||
.mode-btn:hover {
|
||||
background: #f0f0f0;
|
||||
}
|
||||
.mode-btn.active {
|
||||
background: #0078d4;
|
||||
color: #fff;
|
||||
border-color: #0078d4;
|
||||
}
|
||||
/* 主内容区 */
|
||||
.main-content {
|
||||
padding: 16px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.section {
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
.section-title {
|
||||
font-size: 14px;
|
||||
color: #555;
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 6px;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
/* 网格模式 */
|
||||
.grid-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.grid-item {
|
||||
background: #fff;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 6px;
|
||||
padding: 12px 8px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
text-decoration: none;
|
||||
color: #333;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
.grid-item:hover {
|
||||
border-color: #0078d4;
|
||||
background: #f0f8ff;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
|
||||
}
|
||||
.grid-item .icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 8px;
|
||||
line-height: 1;
|
||||
}
|
||||
.grid-item img.thumb {
|
||||
width: 100%;
|
||||
height: 100px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.grid-item .name {
|
||||
font-size: 12px;
|
||||
word-break: break-all;
|
||||
line-height: 1.4;
|
||||
max-height: 2.8em;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
/* 列表模式 */
|
||||
.list-container {
|
||||
background: #fff;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
text-decoration: none;
|
||||
color: #333;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.list-item:last-child { border-bottom: none; }
|
||||
.list-item:hover {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
.list-item .icon {
|
||||
font-size: 24px;
|
||||
margin-right: 12px;
|
||||
width: 28px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.list-item .thumb {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
margin-right: 12px;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid #eee;
|
||||
}
|
||||
.list-item .info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.list-item .name {
|
||||
font-size: 14px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.list-item .type {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-top: 2px;
|
||||
}
|
||||
/* Modal / Lightbox */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(0,0,0,0.85);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 9999;
|
||||
padding: 20px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.25s ease;
|
||||
}
|
||||
.modal-overlay.active {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.modal-content {
|
||||
position: relative;
|
||||
max-width: 90vw;
|
||||
max-height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
.modal-content img {
|
||||
max-width: 90vw;
|
||||
max-height: 85vh;
|
||||
object-fit: contain;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.5);
|
||||
}
|
||||
.modal-content video {
|
||||
max-width: 90vw;
|
||||
max-height: 85vh;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.5);
|
||||
background: #000;
|
||||
}
|
||||
.modal-close {
|
||||
position: absolute;
|
||||
top: -40px;
|
||||
right: 0;
|
||||
color: #fff;
|
||||
font-size: 32px;
|
||||
cursor: pointer;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
transition: background 0.2s;
|
||||
user-select: none;
|
||||
}
|
||||
.modal-close:hover {
|
||||
background: rgba(255,255,255,0.2);
|
||||
}
|
||||
.modal-title {
|
||||
color: #fff;
|
||||
margin-top: 10px;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
max-width: 80vw;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.img-trigger, .video-trigger {
|
||||
cursor: pointer;
|
||||
}
|
||||
/* 漫画模式 */
|
||||
.manga-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
background: #1a1a1a;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
.manga-item {
|
||||
width: 100%;
|
||||
max-width: 900px;
|
||||
line-height: 0;
|
||||
}
|
||||
.manga-item img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
border: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- 地址栏 -->
|
||||
<div class="address-bar">
|
||||
<a href="/" class="home-btn" title="主页">🏠</a>
|
||||
{% for crumb in breadcrumbs %}
|
||||
{% if not loop.first %}
|
||||
<span class="sep">›</span>
|
||||
{% if loop.last %}
|
||||
<span class="current">{{ crumb.name }}</span>
|
||||
{% else %}
|
||||
<a href="{{ crumb.url }}">{{ crumb.name }}</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- 工具栏 -->
|
||||
<div class="toolbar">
|
||||
<a href="?mode=list" class="mode-btn {% if mode == 'list' %}active{% endif %}">📋 列表</a>
|
||||
<a href="?mode=grid" class="mode-btn {% if mode == 'grid' %}active{% endif %}">⊞ 网格</a>
|
||||
{% if is_manga_path %}
|
||||
<a href="?mode=manga" class="mode-btn {% if mode == 'manga' %}active{% endif %}">📖 漫画</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="main-content">
|
||||
<!-- 文件夹 -->
|
||||
{% if subfolders %}
|
||||
<div class="section">
|
||||
<div class="section-title"><span>📁</span> 文件夹 <span style="color:#999;font-weight:400;">({{ subfolders|length }})</span></div>
|
||||
{% if mode == 'grid' %}
|
||||
<div class="grid-container">
|
||||
{% for folder in subfolders %}
|
||||
<a href="{{ url_for('list_folder_content', subpath=(current_subpath + folder) if current_subpath else folder) }}?mode={{ mode }}" class="grid-item">
|
||||
<div class="icon">📁</div>
|
||||
<div class="name">{{ folder }}</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="list-container">
|
||||
{% for folder in subfolders %}
|
||||
<a href="{{ url_for('list_folder_content', subpath=(current_subpath + folder) if current_subpath else folder) }}?mode={{ mode }}" class="list-item">
|
||||
<div class="icon">📁</div>
|
||||
<div class="info">
|
||||
<div class="name">{{ folder }}</div>
|
||||
<div class="type">文件夹</div>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- 图片 -->
|
||||
{% if images %}
|
||||
<div class="section">
|
||||
<div class="section-title"><span>📷</span> 图片 <span style="color:#999;font-weight:400;">({{ images|length }})</span></div>
|
||||
{% if mode == 'manga' %}
|
||||
<div class="manga-container">
|
||||
{% for image in images %}
|
||||
<div class="manga-item img-trigger" data-src="{{ url_for('serve_media', full_path=(current_subpath + image) if current_subpath else image) }}" data-name="{{ image }}">
|
||||
<img src="{{ url_for('serve_media', full_path=(current_subpath + image) if current_subpath else image) }}" alt="{{ image }}" loading="lazy">
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% elif mode == 'grid' %}
|
||||
<div class="grid-container">
|
||||
{% for image in images %}
|
||||
<div class="grid-item img-trigger" data-src="{{ url_for('serve_media', full_path=(current_subpath + image) if current_subpath else image) }}" data-name="{{ image }}">
|
||||
<img class="thumb" src="{{ url_for('serve_media', full_path=(current_subpath + image) if current_subpath else image) }}" alt="{{ image }}" loading="lazy">
|
||||
<div class="name">{{ image }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="list-container">
|
||||
{% for image in images %}
|
||||
<div class="list-item img-trigger" data-src="{{ url_for('serve_media', full_path=(current_subpath + image) if current_subpath else image) }}" data-name="{{ image }}">
|
||||
<img class="thumb" src="{{ url_for('serve_media', full_path=(current_subpath + image) if current_subpath else image) }}" alt="" loading="lazy">
|
||||
<div class="info">
|
||||
<div class="name">{{ image }}</div>
|
||||
<div class="type">图片文件</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- 视频 -->
|
||||
{% if videos %}
|
||||
<div class="section">
|
||||
<div class="section-title"><span>🎬</span> 视频 <span style="color:#999;font-weight:400;">({{ videos|length }})</span></div>
|
||||
{% if mode == 'grid' %}
|
||||
<div class="grid-container">
|
||||
{% for video in videos %}
|
||||
<div class="grid-item video-trigger" data-src="{{ url_for('serve_media', full_path=(current_subpath + video) if current_subpath else video) }}" data-name="{{ video }}">
|
||||
<div class="icon">🎬</div>
|
||||
<div class="name">{{ video }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="list-container">
|
||||
{% for video in videos %}
|
||||
<div class="list-item video-trigger" data-src="{{ url_for('serve_media', full_path=(current_subpath + video) if current_subpath else video) }}" data-name="{{ video }}">
|
||||
<div class="icon">🎬</div>
|
||||
<div class="info">
|
||||
<div class="name">{{ video }}</div>
|
||||
<div class="type">视频文件</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- 图片/视频预览弹窗 -->
|
||||
<div class="modal-overlay" id="modalOverlay" onclick="closeModal(event)">
|
||||
<div class="modal-content" onclick="event.stopPropagation()">
|
||||
<div class="modal-close" onclick="closeModal()">×</div>
|
||||
<img id="modalImg" style="display:none;" alt="">
|
||||
<video id="modalVideo" style="display:none;" controls playsinline></video>
|
||||
<div class="modal-title" id="modalTitle"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const overlay = document.getElementById('modalOverlay');
|
||||
const modalImg = document.getElementById('modalImg');
|
||||
const modalVideo = document.getElementById('modalVideo');
|
||||
const modalTitle = document.getElementById('modalTitle');
|
||||
|
||||
function openImage(src, name) {
|
||||
modalVideo.style.display = 'none';
|
||||
modalVideo.pause();
|
||||
modalVideo.src = '';
|
||||
modalImg.style.display = 'block';
|
||||
modalImg.src = src;
|
||||
modalTitle.textContent = name || '';
|
||||
overlay.classList.add('active');
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
function openVideo(src, name) {
|
||||
modalImg.style.display = 'none';
|
||||
modalImg.src = '';
|
||||
modalVideo.style.display = 'block';
|
||||
modalVideo.src = src;
|
||||
modalVideo.play();
|
||||
modalTitle.textContent = name || '';
|
||||
overlay.classList.add('active');
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
function closeModal(e) {
|
||||
if (e && e.target !== overlay && !e.target.classList.contains('modal-close')) return;
|
||||
overlay.classList.remove('active');
|
||||
modalVideo.pause();
|
||||
modalVideo.src = '';
|
||||
modalImg.src = '';
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
document.querySelectorAll('.img-trigger').forEach(el => {
|
||||
el.addEventListener('click', () => openImage(el.dataset.src, el.dataset.name));
|
||||
});
|
||||
document.querySelectorAll('.video-trigger').forEach(el => {
|
||||
el.addEventListener('click', () => openVideo(el.dataset.src, el.dataset.name));
|
||||
});
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.key === 'Escape') closeModal();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
'''
|
||||
|
||||
# 获取当前路径的名称用于显示
|
||||
current_path_name = os.path.basename(current_path) if current_path != ROOT_IMAGE_FOLDER else 'Root Directory'
|
||||
|
||||
return render_template_string(
|
||||
html_template,
|
||||
images=image_files,
|
||||
videos=video_files,
|
||||
subfolders=subfolders,
|
||||
current_path_name=current_path_name,
|
||||
current_subpath=subpath + '/' if subpath else '', # 传递给模板,用于构建文件路径
|
||||
breadcrumbs=breadcrumbs,
|
||||
mode=mode, # 将模式传递给模板
|
||||
is_manga_path=is_manga_path
|
||||
)
|
||||
|
||||
|
||||
# 新增:用于在新页面播放视频的路由
|
||||
@app.route('/play_video/<path:full_path>')
|
||||
def play_video(full_path):
|
||||
"""
|
||||
在新页面播放单个视频
|
||||
full_path: 视频文件的完整路径(包含子文件夹)
|
||||
"""
|
||||
# 安全检查
|
||||
full_file_path = os.path.normpath(os.path.join(ROOT_IMAGE_FOLDER, full_path))
|
||||
if not full_file_path.startswith(os.path.normpath(ROOT_IMAGE_FOLDER)):
|
||||
return "Access Denied", 403
|
||||
|
||||
# 获取视频文件名
|
||||
video_filename = os.path.basename(full_path)
|
||||
|
||||
# 将 video_url 的生成移到模板内部
|
||||
video_player_template = '''
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>正在播放: {{ video_filename }}</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: #000; /* 黑色背景 */
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh; /* 全屏高度 */
|
||||
}
|
||||
.video-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-width: 90%; /* 最大宽度 */
|
||||
max-height: 90vh; /* 最大高度 */
|
||||
}
|
||||
.video-player {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain; /* 保持视频比例 */
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="video-container">
|
||||
<video class="video-player" controls autoplay playsinline preload="none">
|
||||
<!-- 在模板内部使用 url_for -->
|
||||
<source src="{{ url_for('serve_media', full_path=full_path) }}" type="video/{{ video_filename.split('.')[-1].lower() }}">
|
||||
您的浏览器不支持视频播放。
|
||||
</video>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
'''
|
||||
# 注意:传递给 render_template_string 的变量,可以在模板内部直接使用
|
||||
return render_template_string(
|
||||
video_player_template,
|
||||
video_filename=video_filename,
|
||||
full_path=full_path # 将 full_path 也传递给模板,以便在模板内使用 url_for
|
||||
)
|
||||
|
||||
|
||||
# 统一的媒体文件服务路由,处理图片和视频
|
||||
@app.route('/media_file/<path:full_path>')
|
||||
def serve_media(full_path):
|
||||
"""
|
||||
提供媒体文件(图片/视频)服务
|
||||
full_path: 包含子文件夹路径和文件名
|
||||
"""
|
||||
# 从完整路径中分离出文件夹和文件名
|
||||
folder_path = os.path.dirname(full_path)
|
||||
filename = os.path.basename(full_path)
|
||||
|
||||
# 构建完整的物理路径
|
||||
base_dir = os.path.join(ROOT_IMAGE_FOLDER, folder_path)
|
||||
|
||||
# 安全检查
|
||||
full_file_path = os.path.normpath(os.path.join(base_dir, filename))
|
||||
if not full_file_path.startswith(os.path.normpath(ROOT_IMAGE_FOLDER)):
|
||||
return "Access Denied", 403
|
||||
|
||||
return send_from_directory(base_dir, filename)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
# 确保根图片文件夹存在
|
||||
if not os.path.exists(ROOT_IMAGE_FOLDER):
|
||||
print(f"错误:找不到根图片文件夹 '{ROOT_IMAGE_FOLDER}'")
|
||||
exit(1)
|
||||
|
||||
print(f"Web服务即将启动,请访问 http://127.0.0.1:{PORT}")
|
||||
print(f"根目录路径: {os.path.abspath(ROOT_IMAGE_FOLDER)}")
|
||||
app.run(debug=True, host='0.0.0.0', port=PORT)
|
||||
301
99demo/tokenizer-demo/api.py
Normal file
301
99demo/tokenizer-demo/api.py
Normal file
@@ -0,0 +1,301 @@
|
||||
from flask import Flask, request, jsonify
|
||||
import re
|
||||
import jieba
|
||||
import json
|
||||
import os
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
# --- 日志配置 ---
|
||||
# 创建一个logger
|
||||
logger = logging.getLogger('user_activity')
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
# 创建一个handler,用于写入日志文件
|
||||
file_handler = logging.FileHandler('useruse.log', mode='a', encoding='utf-8')
|
||||
file_handler.setLevel(logging.INFO)
|
||||
|
||||
# 创建一个handler,用于输出到控制台(可选)
|
||||
console_handler = logging.StreamHandler()
|
||||
console_handler.setLevel(logging.INFO)
|
||||
|
||||
# 定义日志格式
|
||||
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
||||
file_handler.setFormatter(formatter)
|
||||
console_handler.setFormatter(formatter)
|
||||
|
||||
# 给logger添加handler
|
||||
logger.addHandler(file_handler)
|
||||
logger.addHandler(console_handler)
|
||||
|
||||
# --- 从文件加载自定义词汇 ---
|
||||
JIEBA_DICT_FILE = 'jieba.txt'
|
||||
|
||||
|
||||
def load_custom_words_from_file(file_path):
|
||||
"""从指定文件加载自定义词汇"""
|
||||
custom_words = set() # 使用set避免重复
|
||||
if not os.path.exists(file_path):
|
||||
print(f"警告: 找不到自定义词汇文件 '{file_path}',将使用空列表。")
|
||||
return list(custom_words), set()
|
||||
|
||||
try:
|
||||
with open(file_path, '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"错误: 读取自定义词汇文件 '{file_path}' 时发生异常: {e}")
|
||||
|
||||
# 从加载的词汇中筛选出需要特殊处理的复合词(包含大写字母的驼峰命名)
|
||||
compound_words = {word for word in custom_words if any(c.isupper() for c in word)}
|
||||
simple_custom_words = custom_words - compound_words
|
||||
|
||||
return list(simple_custom_words), compound_words
|
||||
|
||||
|
||||
# 从文件加载自定义词汇
|
||||
SIMPLE_CUSTOM_WORDS, COMPOUND_WORDS_SET = load_custom_words_from_file(JIEBA_DICT_FILE)
|
||||
|
||||
# 为简单词汇添加到jieba词典
|
||||
for word in SIMPLE_CUSTOM_WORDS:
|
||||
jieba.add_word(word)
|
||||
# print(f"成功从 '{JIEBA_DICT_FILE}' 加载了 {len(SIMPLE_CUSTOM_WORDS)} 个简单自定义词汇。")
|
||||
# print(f"识别出 {len(COMPOUND_WORDS_SET)} 个复合词汇待处理: {list(COMPOUND_WORDS_SET)[:10]}...") # 打印前10个作为示例
|
||||
|
||||
# 预编译复合词的正则表达式模式,以提高性能
|
||||
# 例如: (MemoFlow|MookNote|VoceChat)
|
||||
COMPOUND_PATTERN = re.compile('|'.join(re.escape(w) for w in COMPOUND_WORDS_SET), re.IGNORECASE)
|
||||
|
||||
|
||||
def tokenize_text(text):
|
||||
"""
|
||||
对中英文及数字进行分词
|
||||
- 复合词:优先匹配自定义的驼峰命名复合词
|
||||
- 中文:使用 jieba 分词
|
||||
- 英文:按空格和标点分词
|
||||
- 数字:单独提取
|
||||
"""
|
||||
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
|
||||
|
||||
# --- 新增逻辑:优先匹配复合词 ---
|
||||
compound_match = 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
|
||||
|
||||
# 使用 jieba 对中文字符串进行分词
|
||||
# 注意:这里的 jieba.cut() 会使用我们之前 add_word 添加的自定义词汇
|
||||
chinese_tokens = list(jieba.cut(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
|
||||
|
||||
|
||||
def get_token_stats(tokens):
|
||||
"""获取分词统计信息"""
|
||||
stats = {
|
||||
'total': len(tokens),
|
||||
'chinese': 0,
|
||||
'english': 0,
|
||||
'compound_eng': 0, # 为复合词类型添加计数
|
||||
'number': 0,
|
||||
'punctuation': 0
|
||||
}
|
||||
|
||||
for token in tokens:
|
||||
token_type = token['type']
|
||||
if token_type in stats:
|
||||
stats[token_type] += 1
|
||||
|
||||
# 重新计算总数,确保准确性
|
||||
stats['total'] = sum(v for k, v in stats.items() if k != 'total')
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
# 定义用户Token文件路径
|
||||
TOKEN_FILE_PATH = 'usertoken.json'
|
||||
|
||||
|
||||
# 启动时加载Token文件
|
||||
def load_valid_tokens():
|
||||
"""从文件加载有效的Token列表"""
|
||||
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为键的字典,便于快速查找
|
||||
token_map = {}
|
||||
for item in tokens_data:
|
||||
# 确保数据格式正确
|
||||
if 'user' in item and 'token' in item:
|
||||
token_map[item['token']] = item['user']
|
||||
else:
|
||||
print(f"警告: Token文件中发现格式错误的条目: {item}")
|
||||
|
||||
# print(f"成功从 '{TOKEN_FILE_PATH}' 加载了 {len(token_map)} 个有效Token。")
|
||||
return token_map
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"错误: Token文件 '{TOKEN_FILE_PATH}' 不是有效的JSON格式。{e}")
|
||||
return {}
|
||||
except Exception as e:
|
||||
print(f"错误: 读取Token文件时发生异常: {e}")
|
||||
return {}
|
||||
|
||||
|
||||
# 程序启动时加载一次
|
||||
VALID_TOKENS_MAP = load_valid_tokens()
|
||||
|
||||
|
||||
def get_user_by_token(token):
|
||||
"""根据token获取用户名,如果无效则返回None"""
|
||||
return VALID_TOKENS_MAP.get(token)
|
||||
|
||||
|
||||
def authenticate_request():
|
||||
"""检查请求头中的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 = get_user_by_token(token)
|
||||
|
||||
if not user:
|
||||
return None, "无效的Token"
|
||||
|
||||
return user, None
|
||||
|
||||
|
||||
@app.route('/tokenize', methods=['POST'])
|
||||
def tokenize_endpoint():
|
||||
"""处理分词请求的 API 端点"""
|
||||
user, error_msg = authenticate_request()
|
||||
if not user:
|
||||
return jsonify({'success': False, 'error': error_msg}), 401
|
||||
|
||||
data = request.get_json()
|
||||
input_text = data.get('input_text', '')
|
||||
|
||||
# 记录日志:谁在什么时候输入了什么内容
|
||||
log_message = f"User '{user}' submitted text: '{input_text}'"
|
||||
logger.info(log_message)
|
||||
print(f"Log Recorded: {log_message}") # 控制台也打印一下,方便即时查看
|
||||
|
||||
try:
|
||||
# 执行分词
|
||||
tokens = tokenize_text(input_text)
|
||||
|
||||
# 获取统计信息
|
||||
stats = get_token_stats(tokens)
|
||||
|
||||
# 返回成功的 JSON 响应,包含请求的用户信息
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'tokens': tokens,
|
||||
'stats': stats,
|
||||
'original_text': input_text,
|
||||
'requested_by': user # 添加请求者信息
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
# 如果处理出错,也记录一条错误日志
|
||||
error_log_message = f"Error processing request for user '{user}': {str(e)}"
|
||||
logger.error(error_log_message)
|
||||
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': f'分词处理失败: {str(e)}'
|
||||
}), 500
|
||||
|
||||
|
||||
# --- 以下为原有的分词和统计逻辑,保持不变 ---
|
||||
# (tokenize_text 和 get_token_stats 已经在上面定义过了)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(debug=False, host='0.0.0.0', port=27056) # 生产环境建议关闭debug
|
||||
62
99demo/tokenizer-demo/example_client.py
Normal file
62
99demo/tokenizer-demo/example_client.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""
|
||||
分词接口调用示例
|
||||
"""
|
||||
import requests
|
||||
import json
|
||||
|
||||
BASE_URL = "http://127.0.0.1:27056"
|
||||
# BASE_URL = "http://fenci.iletter.top/"
|
||||
|
||||
|
||||
def tokenize_single(text):
|
||||
"""单文本分词"""
|
||||
url = f"{BASE_URL}/tokenize"
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer DdzBgb8GEkBpA8gtCJXP24hGJz9bXpEOi6z91fHm25X59q5968XqtLxPi1MfiTHJ"
|
||||
}
|
||||
payload = {"input_text": text}
|
||||
|
||||
response = requests.post(url, headers=headers, json=payload)
|
||||
print(response.json())
|
||||
return response.json()
|
||||
|
||||
|
||||
def tokenize_batch(texts):
|
||||
"""批量分词"""
|
||||
url = f"{BASE_URL}/batch-tokenize"
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer DdzBgb8GEkBpA8gtCJXP24hGJz9bXpEOi6z91fHm25X59q5968XqtLxPi1MfiTHJ"
|
||||
}
|
||||
payload = {"texts": texts}
|
||||
|
||||
response = requests.post(url, headers=headers, json=payload)
|
||||
return response.json()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 示例 1: 单文本分词
|
||||
print("=" * 50)
|
||||
print("示例 1: 单文本分词")
|
||||
print("=" * 50)
|
||||
|
||||
text = "MemoFlowMookNoteVoceChat"
|
||||
result = tokenize_single(text)
|
||||
|
||||
if result["success"]:
|
||||
print(f"原文: {result['original_text']}")
|
||||
print(f"\n分词结果:")
|
||||
for token in result["tokens"]:
|
||||
print(f" [{token['type']:12}] {token['text']}")
|
||||
|
||||
print(f"\n统计信息:")
|
||||
stats = result["stats"]
|
||||
print(f" 总计: {stats['total']} 个单词")
|
||||
print(f" 中文: {stats['chinese']} 个")
|
||||
print(f" 英文: {stats['english']} 个")
|
||||
print(f" 数字: {stats['number']} 个")
|
||||
print(f" 标点: {stats['punctuation']} 个")
|
||||
else:
|
||||
print(f"错误: {result['error']}")
|
||||
|
||||
37
99demo/tokenizer-demo/jieba.txt
Normal file
37
99demo/tokenizer-demo/jieba.txt
Normal file
@@ -0,0 +1,37 @@
|
||||
MemoFlow
|
||||
MookNote
|
||||
VoceChat
|
||||
WireGuard
|
||||
SyncClipboard
|
||||
floccus
|
||||
GitNex
|
||||
Bitwarden
|
||||
Immich
|
||||
牛逼
|
||||
安益强
|
||||
张昊洋
|
||||
王美丽
|
||||
李冰素
|
||||
清华大学
|
||||
人工智能学院
|
||||
自然语言处理
|
||||
机器学习算法
|
||||
康嘉超
|
||||
王震瀛
|
||||
张钰
|
||||
贝利亚
|
||||
朱益广
|
||||
好的
|
||||
小程序
|
||||
问一下
|
||||
曹婷婷
|
||||
好滴
|
||||
鲁桂娟
|
||||
安兰兰
|
||||
李春雨
|
||||
王娜娜
|
||||
telegram
|
||||
Telegram
|
||||
v2rayNG
|
||||
tool
|
||||
root
|
||||
2
99demo/tokenizer-demo/requirements.txt
Normal file
2
99demo/tokenizer-demo/requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
Flask==3.1.2
|
||||
jieba==0.42.1
|
||||
6
99demo/tokenizer-demo/usertoken.json
Normal file
6
99demo/tokenizer-demo/usertoken.json
Normal file
@@ -0,0 +1,6 @@
|
||||
[
|
||||
{
|
||||
"user": "admin",
|
||||
"token": "DdzBgb8GEkBpA8gtCJXP24hGJz9bXpEOi6z91fHm25X59q5968XqtLxPi1MfiTHJ"
|
||||
}
|
||||
]
|
||||
6
99demo/tokenizer-demo/useruse.log
Normal file
6
99demo/tokenizer-demo/useruse.log
Normal file
@@ -0,0 +1,6 @@
|
||||
2026-03-26 19:23:28,016 - user_activity - INFO - User 'admin' submitted text: '安益强,张昊洋,张文灵,李冰素,王一'
|
||||
2026-03-26 19:31:03,289 - user_activity - INFO - User 'admin' submitted text: '山东省信用金桥中小企业综合服务平台'
|
||||
2026-03-26 19:34:07,902 - user_activity - INFO - User 'admin' submitted text: '瘦了10斤了,我真牛逼。暂时不考虑收徒~'
|
||||
2026-03-26 19:35:47,899 - user_activity - INFO - User 'admin' submitted text: '瘦了10斤了,我真牛逼。暂时不考虑收徒~'
|
||||
2026-03-26 20:04:52,096 - user_activity - INFO - User 'admin' submitted text: 'MemoFlowMookNoteVoceChat'
|
||||
2026-03-26 20:08:06,572 - user_activity - INFO - User 'admin' submitted text: 'MemoFlowMookNoteVoceChat'
|
||||
14
README.md
Normal file
14
README.md
Normal file
@@ -0,0 +1,14 @@
|
||||
# 个人python脚本
|
||||
|
||||
日常生活中的突发奇想,为了方便写的脚本
|
||||
|
||||
1. `content-to-tag`调用ollama/llama.cpp大语言模型管理软件,使用对应模型,根据文章内容生成标签
|
||||
2. `base64-de-in-code` base64编码解码
|
||||
1. 编码接口(post): http(s)://网站域名/encode
|
||||
2. 解码接口(post): http(s)://网站域名/decode
|
||||
3. `down-video`获取x/bilibili/ins/youtube视频
|
||||
4. `pin-img`拼接图片上下形式
|
||||
5. `tokenizer-demo`分词接口
|
||||
6. `gpt-sovits配音` 配音管理器 引擎版/接口版
|
||||
7. `stt` 语音转文字
|
||||
8. `rvc` 语音音色转换
|
||||
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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user