generated from dellevin/template
feat: complete local version to overwrite remote
This commit is contained in:
1243
flask-dev-api/templates/ai_dubbing.html
Normal file
1243
flask-dev-api/templates/ai_dubbing.html
Normal file
File diff suppressed because it is too large
Load Diff
232
flask-dev-api/templates/base64.html
Normal file
232
flask-dev-api/templates/base64.html
Normal file
@@ -0,0 +1,232 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='font-awesome.css') }}">
|
||||
<title>Base64 编码/解码</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
background: #f5f5f5;
|
||||
color: #333;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
}
|
||||
/* 左侧面板 */
|
||||
.left-panel {
|
||||
width: 50%; flex-shrink: 0;
|
||||
display: flex; flex-direction: column;
|
||||
border-right: 1px solid #e8e8e8;
|
||||
background: #fff; height: 100vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.left-panel::-webkit-scrollbar { width: 4px; }
|
||||
.left-panel::-webkit-scrollbar-thumb { background: #ddd; border-radius: 2px; }
|
||||
.input-section { padding: 20px 24px 28px; flex: 1; display: flex; flex-direction: column; }
|
||||
|
||||
/* 头部 */
|
||||
.header { display: flex; align-items: center; margin-bottom: 2px; }
|
||||
.header h1 { font-size: 18px; font-weight: 700; color: #1a1a1a; letter-spacing: -0.01em; }
|
||||
.subtitle { color: #aaa; font-size: 12px; margin-bottom: 16px; }
|
||||
|
||||
/* 分组卡片 */
|
||||
.section-card {
|
||||
background: #fafafa; border: 1px solid #f0f0f0;
|
||||
border-radius: 10px; padding: 14px 16px; margin-bottom: 12px;
|
||||
}
|
||||
.section-title {
|
||||
font-size: 11px; font-weight: 600; color: #999;
|
||||
text-transform: uppercase; letter-spacing: 0.06em;
|
||||
margin-bottom: 12px; display: flex; align-items: center; gap: 6px;
|
||||
}
|
||||
.section-title i { font-size: 11px; }
|
||||
|
||||
/* textarea */
|
||||
.text-input {
|
||||
width: 100%; padding: 10px 12px;
|
||||
border: 1px solid #e0e0e0; border-radius: 6px;
|
||||
font-size: 13px; outline: none; resize: vertical;
|
||||
font-family: inherit; transition: border-color 0.15s;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.text-input:focus { border-color: #0078d4; box-shadow: 0 0 0 2px rgba(0,120,212,0.06); }
|
||||
.text-input[readonly] { background: #f9f9f9; color: #555; }
|
||||
|
||||
/* 按钮 */
|
||||
.btn {
|
||||
padding: 7px 14px; border: none; border-radius: 6px;
|
||||
font-size: 12px; font-weight: 600; cursor: pointer;
|
||||
transition: all 0.12s ease;
|
||||
display: inline-flex; align-items: center; gap: 5px;
|
||||
}
|
||||
.btn-primary { background: #0078d4; color: #fff; }
|
||||
.btn-primary:hover { background: #006cbd; }
|
||||
.btn-success { background: #0a7a1a; color: #fff; }
|
||||
.btn-success:hover { background: #096b16; }
|
||||
.btn-outline { background: #fff; color: #666; border: 1px solid #e0e0e0; }
|
||||
.btn-outline:hover { background: #f5f5f5; color: #333; }
|
||||
.btn-row { display: flex; gap: 8px; }
|
||||
|
||||
/* 操作按钮 */
|
||||
.action-btn {
|
||||
width: 100%; padding: 10px 18px;
|
||||
border: none; border-radius: 8px;
|
||||
font-size: 14px; font-weight: 600; cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
display: flex; align-items: center; justify-content: center; gap: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0,120,212,0.2);
|
||||
}
|
||||
.action-btn:hover { transform: translateY(-1px); }
|
||||
.action-btn:active { transform: translateY(0); }
|
||||
.action-btn.encode {
|
||||
background: linear-gradient(135deg, #0078d4, #005fa3); color: #fff;
|
||||
}
|
||||
.action-btn.encode:hover { box-shadow: 0 4px 12px rgba(0,120,212,0.3); }
|
||||
.action-btn.decode {
|
||||
background: linear-gradient(135deg, #0a7a1a, #065f12); color: #fff;
|
||||
}
|
||||
.action-btn.decode:hover { box-shadow: 0 4px 12px rgba(10,122,26,0.3); }
|
||||
|
||||
/* 右侧面板 */
|
||||
.right-panel {
|
||||
flex: 1; background: #fff;
|
||||
display: flex; flex-direction: column;
|
||||
height: 100vh;
|
||||
}
|
||||
.right-header {
|
||||
padding: 20px 24px 16px;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
}
|
||||
.right-header h2 { font-size: 15px; font-weight: 600; color: #333; }
|
||||
.result-body {
|
||||
flex: 1; display: flex; flex-direction: column;
|
||||
padding: 0 24px 24px; gap: 12px;
|
||||
}
|
||||
.result-body textarea { flex: 1; min-height: 0; }
|
||||
|
||||
/* Toast */
|
||||
.toast {
|
||||
position: fixed; top: 20px; left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: #0a7a1a; color: #fff;
|
||||
padding: 10px 24px; border-radius: 8px;
|
||||
font-size: 13px; font-weight: 500;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||
opacity: 0; transition: opacity 0.3s;
|
||||
z-index: 9999; pointer-events: none;
|
||||
}
|
||||
.toast.show { opacity: 1; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<!-- 左侧:输入 -->
|
||||
<div class="left-panel">
|
||||
<div class="input-section">
|
||||
<div class="header">
|
||||
<h1><i class="fas fa-lock" style="color:#0078d4;font-size:16px;margin-right:6px;"></i>Base64 编码/解码</h1>
|
||||
</div>
|
||||
<p class="subtitle">文本与 Base64 互转</p>
|
||||
|
||||
<div class="section-card" style="flex:1;display:flex;flex-direction:column;">
|
||||
<div class="section-title"><i class="fas fa-pen"></i> 输入文本</div>
|
||||
<textarea id="inputText" class="text-input" placeholder="在此粘贴待处理的文本..." style="flex:1;min-height:200px;"></textarea>
|
||||
<div class="btn-row" style="margin-top:10px;">
|
||||
<button class="btn btn-outline" onclick="clearEl('inputText')"><i class="fas fa-times-circle"></i> 清空</button>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;margin-top:12px;">
|
||||
<button class="action-btn encode" onclick="doProcess('encode')" style="flex:1;">
|
||||
<i class="fas fa-lock"></i> 编码
|
||||
</button>
|
||||
<button class="action-btn decode" onclick="doProcess('decode')" style="flex:1;">
|
||||
<i class="fas fa-lock-open"></i> 解码
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:输出 -->
|
||||
<div class="right-panel">
|
||||
<div class="right-header">
|
||||
<h2><i class="fas fa-check-circle" style="color:#0078d4;margin-right:6px;"></i>输出结果</h2>
|
||||
<button class="btn btn-outline" onclick="copyResult()"><i class="fas fa-copy"></i> 复制</button>
|
||||
</div>
|
||||
<div class="result-body">
|
||||
<textarea id="outputResult" class="text-input" readonly placeholder="处理结果将显示在这里..."></textarea>
|
||||
<div class="btn-row">
|
||||
<button class="btn btn-outline" onclick="clearEl('outputResult')"><i class="fas fa-times-circle"></i> 清空</button>
|
||||
<button class="btn btn-outline" onclick="swapIO()"><i class="fas fa-arrow-right-arrow-left"></i> 结果互换到输入</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function showToast(msg) {
|
||||
var t = document.getElementById('toast');
|
||||
t.textContent = msg;
|
||||
t.className = 'toast show';
|
||||
setTimeout(function() { t.className = 'toast'; }, 1500);
|
||||
}
|
||||
|
||||
function clearEl(id) { document.getElementById(id).value = ''; }
|
||||
|
||||
function swapIO() {
|
||||
var out = document.getElementById('outputResult').value;
|
||||
if (!out) { showToast('没有内容可互换'); return; }
|
||||
document.getElementById('inputText').value = out;
|
||||
document.getElementById('outputResult').value = '';
|
||||
showToast('已移到输入框');
|
||||
}
|
||||
|
||||
async function doProcess(mode) {
|
||||
var text = document.getElementById('inputText').value.trim();
|
||||
if (!text) { showToast('请输入文本'); return; }
|
||||
|
||||
var endpoint, payload;
|
||||
if (mode === 'encode') {
|
||||
endpoint = '/base64-de-in-code/encode';
|
||||
payload = { text_to_encode: text, encoding: 'utf-8' };
|
||||
} else {
|
||||
endpoint = '/base64-de-in-code/decode';
|
||||
payload = { input_text: text };
|
||||
}
|
||||
|
||||
try {
|
||||
var res = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
var data = await res.json();
|
||||
document.getElementById('outputResult').value = data.success ? data.result : '错误: ' + data.error;
|
||||
} catch (e) {
|
||||
document.getElementById('outputResult').value = '请求失败: ' + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
function copyResult() {
|
||||
var text = document.getElementById('outputResult').value;
|
||||
if (!text) { showToast('没有内容可复制'); return; }
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(text).then(function() { showToast('已复制'); });
|
||||
} else {
|
||||
var ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.style.cssText = 'position:fixed;left:-9999px';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
showToast('已复制');
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('inputText').focus();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
174
flask-dev-api/templates/chmod_calc.html
Normal file
174
flask-dev-api/templates/chmod_calc.html
Normal file
@@ -0,0 +1,174 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='font-awesome.css') }}">
|
||||
<title>Chmod 计算器</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
background: #f5f5f5; color: #333;
|
||||
height: 100vh; overflow-y: auto;
|
||||
}
|
||||
.container { max-width: 700px; margin: 0 auto; padding: 24px 28px 40px; }
|
||||
|
||||
.header { display: flex; align-items: center; margin-bottom: 2px; }
|
||||
.header h1 { font-size: 18px; font-weight: 700; color: #1a1a1a; }
|
||||
.subtitle { color: #aaa; font-size: 12px; margin-bottom: 16px; }
|
||||
|
||||
.section-card {
|
||||
background: #fafafa; border: 1px solid #f0f0f0;
|
||||
border-radius: 10px; padding: 14px 16px; margin-bottom: 12px;
|
||||
}
|
||||
.section-title {
|
||||
font-size: 11px; font-weight: 600; color: #999;
|
||||
text-transform: uppercase; letter-spacing: 0.06em;
|
||||
margin-bottom: 12px; display: flex; align-items: center; gap: 6px;
|
||||
}
|
||||
.section-title i { font-size: 11px; }
|
||||
|
||||
/* 权限网格 */
|
||||
.perm-grid {
|
||||
display: grid; grid-template-columns: 72px 1fr 1fr 1fr;
|
||||
gap: 0; border: 1px solid #e8e8e8; border-radius: 8px; overflow: hidden;
|
||||
background: #fff;
|
||||
}
|
||||
.perm-grid .gh {
|
||||
padding: 8px; font-size: 11px; font-weight: 600; color: #999;
|
||||
text-transform: uppercase; letter-spacing: 0.04em; text-align: center;
|
||||
background: #fafafa; border-bottom: 1px solid #e8e8e8;
|
||||
}
|
||||
.perm-grid .gh:first-child { text-align: right; }
|
||||
.perm-grid .rh {
|
||||
padding: 8px 10px; font-size: 12px; font-weight: 600; color: #666;
|
||||
text-align: right; background: #fafafa;
|
||||
border-right: 1px solid #e8e8e8; border-bottom: 1px solid #f0f0f0;
|
||||
display: flex; align-items: center; justify-content: flex-end; gap: 4px;
|
||||
}
|
||||
.perm-grid .rh:last-of-type { border-bottom: none; }
|
||||
.perm-grid .rh .val { font-size: 10px; color: #bbb; font-weight: 400; }
|
||||
.perm-grid .cell {
|
||||
padding: 8px; text-align: center;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.perm-grid .row-last .cell { border-bottom: none; }
|
||||
.perm-grid input[type="checkbox"] { display: none; }
|
||||
.perm-grid .cb {
|
||||
width: 30px; height: 30px; border: 2px solid #ddd; border-radius: 5px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
cursor: pointer; transition: all 0.15s;
|
||||
}
|
||||
.perm-grid .cb i { font-size: 13px; color: #fff; opacity: 0; transition: opacity 0.15s; }
|
||||
.perm-grid input:checked + .cb { background: #0078d4; border-color: #0078d4; }
|
||||
.perm-grid input:checked + .cb i { opacity: 1; }
|
||||
|
||||
/* 结果区 */
|
||||
.result-card {
|
||||
background: #fff; border: 1px solid #f0f0f0;
|
||||
border-radius: 10px; padding: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
.octal-display {
|
||||
font-size: 64px; font-weight: 700; font-family: "SFMono-Regular", Consolas, monospace;
|
||||
color: #0078d4; letter-spacing: 0.08em; margin-bottom: 4px;
|
||||
}
|
||||
.symbolic-display {
|
||||
font-size: 22px; font-family: "SFMono-Regular", Consolas, monospace;
|
||||
color: #555; letter-spacing: 0.15em; margin-bottom: 20px;
|
||||
}
|
||||
.cmd-wrap {
|
||||
display: inline-flex; align-items: center; gap: 10px;
|
||||
background: #fafafa; border: 1px solid #e0e0e0; border-radius: 8px;
|
||||
padding: 10px 16px;
|
||||
}
|
||||
.cmd-wrap code {
|
||||
font-family: "SFMono-Regular", Consolas, monospace;
|
||||
font-size: 14px; color: #333;
|
||||
}
|
||||
.btn-cp {
|
||||
padding: 5px 12px; background: #0078d4; color: #fff; border: none;
|
||||
border-radius: 5px; font-size: 12px; font-weight: 600; cursor: pointer;
|
||||
transition: all 0.15s; white-space: nowrap;
|
||||
}
|
||||
.btn-cp:hover { background: #006cbd; }
|
||||
.btn-cp.copied { background: #2e7d32; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1><i class="fas fa-shield-halved" style="color:#0078d4;font-size:16px;margin-right:6px;"></i>Chmod 计算器</h1>
|
||||
</div>
|
||||
<p class="subtitle">Linux 文件权限速算</p>
|
||||
|
||||
<div class="section-card">
|
||||
<div class="section-title"><i class="fas fa-check-square"></i> 勾选权限</div>
|
||||
<div class="perm-grid">
|
||||
<div class="gh"></div>
|
||||
<div class="gh">Owner</div>
|
||||
<div class="gh">Group</div>
|
||||
<div class="gh">Public</div>
|
||||
|
||||
<div class="rh">Read <span class="val">(4)</span></div>
|
||||
<div class="cell"><label><input type="checkbox" data-g="owner" data-s="read" onchange="calc()"><span class="cb"><i class="fas fa-check"></i></span></label></div>
|
||||
<div class="cell"><label><input type="checkbox" data-g="group" data-s="read" onchange="calc()"><span class="cb"><i class="fas fa-check"></i></span></label></div>
|
||||
<div class="cell"><label><input type="checkbox" data-g="public" data-s="read" onchange="calc()"><span class="cb"><i class="fas fa-check"></i></span></label></div>
|
||||
|
||||
<div class="rh">Write <span class="val">(2)</span></div>
|
||||
<div class="cell"><label><input type="checkbox" data-g="owner" data-s="write" onchange="calc()"><span class="cb"><i class="fas fa-check"></i></span></label></div>
|
||||
<div class="cell"><label><input type="checkbox" data-g="group" data-s="write" onchange="calc()"><span class="cb"><i class="fas fa-check"></i></span></label></div>
|
||||
<div class="cell"><label><input type="checkbox" data-g="public" data-s="write" onchange="calc()"><span class="cb"><i class="fas fa-check"></i></span></label></div>
|
||||
|
||||
<div class="rh row-last">Exec <span class="val">(1)</span></div>
|
||||
<div class="cell"><label><input type="checkbox" data-g="owner" data-s="execute" onchange="calc()"><span class="cb"><i class="fas fa-check"></i></span></label></div>
|
||||
<div class="cell"><label><input type="checkbox" data-g="group" data-s="execute" onchange="calc()"><span class="cb"><i class="fas fa-check"></i></span></label></div>
|
||||
<div class="cell"><label><input type="checkbox" data-g="public" data-s="execute" onchange="calc()"><span class="cb"><i class="fas fa-check"></i></span></label></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="result-card">
|
||||
<div class="octal-display" id="octal">000</div>
|
||||
<div class="symbolic-display" id="symbolic">---------</div>
|
||||
<div class="cmd-wrap">
|
||||
<code id="cmd">chmod 000 path</code>
|
||||
<button class="btn-cp" id="copyBtn" onclick="copyCmd()">复制</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var vals={read:4,write:2,execute:1};
|
||||
var chars={read:'r',write:'w',execute:'x'};
|
||||
|
||||
function calc(){
|
||||
var groups=['owner','group','public'];
|
||||
var octal='',symbolic='';
|
||||
groups.forEach(function(g){
|
||||
var sum=0,sym='';
|
||||
['read','write','execute'].forEach(function(s){
|
||||
var checked=document.querySelector('input[data-g="'+g+'"][data-s="'+s+'"]').checked;
|
||||
if(checked){sum+=vals[s];sym+=chars[s];}else{sym+='-';}
|
||||
});
|
||||
octal+=sum;symbolic+=sym;
|
||||
});
|
||||
document.getElementById('octal').textContent=octal;
|
||||
document.getElementById('symbolic').textContent=symbolic;
|
||||
document.getElementById('cmd').textContent='chmod '+octal+' path';
|
||||
}
|
||||
|
||||
function copyCmd(){
|
||||
var text=document.getElementById('cmd').textContent;
|
||||
var el=document.createElement('textarea');
|
||||
el.value=text;el.style.cssText='position:fixed;left:-9999px';
|
||||
document.body.appendChild(el);el.select();document.execCommand('copy');document.body.removeChild(el);
|
||||
var btn=document.getElementById('copyBtn');
|
||||
btn.textContent='已复制';btn.classList.add('copied');
|
||||
setTimeout(function(){btn.textContent='复制';btn.classList.remove('copied');},1500);
|
||||
}
|
||||
calc();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
753
flask-dev-api/templates/content_tag.html
Normal file
753
flask-dev-api/templates/content_tag.html
Normal file
@@ -0,0 +1,753 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='font-awesome.css') }}">
|
||||
<title>文章标签生成</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
background: #f5f5f5;
|
||||
color: #333;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
}
|
||||
.left-panel {
|
||||
width: 45%;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid #e8e8e8;
|
||||
background: #fff;
|
||||
height: 100vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.left-panel::-webkit-scrollbar { width: 4px; }
|
||||
.left-panel::-webkit-scrollbar-thumb { background: #ddd; border-radius: 2px; }
|
||||
.input-section { padding: 20px 24px 28px; flex-shrink: 0; }
|
||||
.content-section { padding: 0 24px 24px; flex: 1; display: flex; flex-direction: column; min-height: 0; }
|
||||
|
||||
/* 头部 */
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.header h1 {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #1a1a1a;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.settings-icon {
|
||||
width: 32px; height: 32px;
|
||||
border: none; background: transparent;
|
||||
color: #999; font-size: 15px;
|
||||
cursor: pointer; border-radius: 8px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.settings-icon:hover { background: #f5f5f5; color: #333; }
|
||||
.subtitle { color: #aaa; font-size: 12px; margin-bottom: 16px; }
|
||||
|
||||
/* section-card */
|
||||
.section-card {
|
||||
background: #fafafa;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 10px;
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.section-title {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #999;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.section-title i { font-size: 11px; }
|
||||
|
||||
/* 输入 */
|
||||
.input-group { margin-bottom: 10px; }
|
||||
.input-group:last-child { margin-bottom: 0; }
|
||||
.input-group label {
|
||||
display: block; font-size: 12px; font-weight: 500;
|
||||
margin-bottom: 4px; color: #555;
|
||||
}
|
||||
.input-group .input-wrap { position: relative; }
|
||||
.input-group .input-wrap i {
|
||||
position: absolute; left: 10px; top: 50%; transform: translateY(-50%);
|
||||
color: #c0c0c0; font-size: 12px; pointer-events: none;
|
||||
}
|
||||
.input-group input,
|
||||
.input-group select {
|
||||
width: 100%; padding: 7px 10px 7px 32px;
|
||||
border: 1px solid #e0e0e0; border-radius: 6px;
|
||||
background: #fff; color: #333; font-size: 13px;
|
||||
outline: none; transition: all 0.15s ease;
|
||||
}
|
||||
.input-group input::placeholder { color: #c0c0c0; }
|
||||
.input-group input:focus,
|
||||
.input-group select:focus {
|
||||
border-color: #0078d4;
|
||||
box-shadow: 0 0 0 2px rgba(0,120,212,0.06);
|
||||
}
|
||||
.input-group.no-icon input,
|
||||
.input-group.no-icon select { padding-left: 10px; }
|
||||
.input-group select {
|
||||
-webkit-appearance: none; -moz-appearance: none; appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 10 10'%3E%3Cpath fill='%23999' d='M5 7L1 3h8z'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat; background-position: right 10px center;
|
||||
padding-right: 28px; cursor: pointer;
|
||||
}
|
||||
.input-row { display: flex; gap: 8px; }
|
||||
.input-row .input-group { flex: 1; }
|
||||
|
||||
/* API 行 */
|
||||
.api-row { display: flex; gap: 8px; align-items: flex-end; }
|
||||
.api-row .input-group { flex: 1; margin-bottom: 0; }
|
||||
|
||||
/* 按钮 */
|
||||
.btn {
|
||||
padding: 7px 14px; border: none; border-radius: 6px;
|
||||
font-size: 12px; font-weight: 600; cursor: pointer;
|
||||
transition: all 0.12s ease;
|
||||
display: inline-flex; align-items: center; gap: 5px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn-primary { background: #0078d4; color: #fff; }
|
||||
.btn-primary:hover:not(:disabled) { background: #006cbd; }
|
||||
.btn-primary:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.btn-outline { background: #fff; color: #666; border: 1px solid #e0e0e0; }
|
||||
.btn-outline:hover { background: #f5f5f5; color: #333; }
|
||||
.btn-sm { padding: 5px 10px; font-size: 11px; }
|
||||
.btn-block { width: 100%; justify-content: center; }
|
||||
.btn-row { display: flex; gap: 8px; margin-top: 12px; }
|
||||
|
||||
/* textarea */
|
||||
.text-input {
|
||||
width: 100%; padding: 10px 12px;
|
||||
border: 1px solid #e0e0e0; border-radius: 6px;
|
||||
font-size: 13px; outline: none; resize: none;
|
||||
font-family: inherit; transition: border-color 0.15s;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.text-input:focus { border-color: #0078d4; box-shadow: 0 0 0 2px rgba(0,120,212,0.06); }
|
||||
.text-input::placeholder { color: #c0c0c0; }
|
||||
|
||||
/* 生成按钮 */
|
||||
.generate-btn {
|
||||
width: 100%; padding: 10px 18px;
|
||||
background: linear-gradient(135deg, #0078d4, #005fa3);
|
||||
color: #fff; border: none; border-radius: 8px;
|
||||
font-size: 14px; font-weight: 600;
|
||||
cursor: pointer; transition: all 0.15s ease;
|
||||
display: flex; align-items: center; justify-content: center; gap: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0,120,212,0.2);
|
||||
}
|
||||
.generate-btn:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #006cbd, #004e8a);
|
||||
box-shadow: 0 4px 12px rgba(0,120,212,0.3);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.generate-btn:active:not(:disabled) { transform: translateY(0); }
|
||||
.generate-btn:disabled { opacity: 0.4; cursor: not-allowed; transform: none; }
|
||||
|
||||
/* 右侧面板 */
|
||||
.right-panel {
|
||||
flex: 1; display: flex; flex-direction: column;
|
||||
padding: 28px 32px; overflow-y: auto;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
.right-panel::-webkit-scrollbar { width: 4px; }
|
||||
.right-panel::-webkit-scrollbar-thumb { background: #ddd; border-radius: 2px; }
|
||||
|
||||
/* placeholder */
|
||||
.result-placeholder {
|
||||
flex: 1; display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center;
|
||||
text-align: center; color: #bbb;
|
||||
}
|
||||
.result-placeholder .ph-icon {
|
||||
width: 72px; height: 72px; border-radius: 18px;
|
||||
background: #fff; border: 1px solid #e0e0e0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.result-placeholder .ph-icon i { font-size: 28px; color: #ddd; }
|
||||
.result-placeholder p { font-size: 14px; color: #999; }
|
||||
.result-placeholder .ph-hint { font-size: 12px; color: #ccc; margin-top: 4px; }
|
||||
|
||||
/* 右侧结果 */
|
||||
.result-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.result-header h2 { font-size: 16px; font-weight: 700; color: #1a1a1a; }
|
||||
.meta {
|
||||
display: flex; gap: 16px; font-size: 12px; color: #999;
|
||||
margin-bottom: 20px; flex-wrap: wrap;
|
||||
}
|
||||
.meta span { display: flex; align-items: center; gap: 4px; }
|
||||
.meta .value { font-weight: 600; color: #555; }
|
||||
.tag-list {
|
||||
display: flex; flex-wrap: wrap; gap: 8px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.tag {
|
||||
display: inline-flex; align-items: center; gap: 5px;
|
||||
padding: 6px 14px;
|
||||
background: #e8f0fe; color: #0078d4;
|
||||
border: 1px solid #b3d9ff; border-radius: 20px;
|
||||
font-size: 13px; font-weight: 500;
|
||||
cursor: pointer; transition: all 0.15s;
|
||||
user-select: all;
|
||||
}
|
||||
.tag:hover { background: #d0e4fd; }
|
||||
.tag .copy-hint { font-size: 10px; opacity: 0; transition: opacity 0.15s; }
|
||||
.tag:hover .copy-hint { opacity: 0.6; }
|
||||
.tag-text-box {
|
||||
padding: 12px 14px;
|
||||
background: #fff; border: 1px solid #e0e0e0; border-radius: 8px;
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
}
|
||||
.tag-text-box .tag-text {
|
||||
flex: 1; font-size: 13px; color: #333;
|
||||
word-break: break-all; line-height: 1.6; user-select: all;
|
||||
}
|
||||
.think-card {
|
||||
margin-top: 16px; background: #fff; border: 1px solid #e0e0e0;
|
||||
border-radius: 8px; overflow: hidden;
|
||||
}
|
||||
.think-header {
|
||||
padding: 10px 14px; font-size: 12px; font-weight: 600;
|
||||
color: #999; background: #fafafa;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
text-transform: uppercase; letter-spacing: 0.05em;
|
||||
}
|
||||
.think-box {
|
||||
padding: 14px; font-size: 13px; line-height: 1.6;
|
||||
color: #666; white-space: pre-wrap; word-break: break-all;
|
||||
max-height: 200px; overflow-y: auto;
|
||||
}
|
||||
/* loading */
|
||||
.loading-wrap {
|
||||
flex: 1; display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center;
|
||||
color: #999;
|
||||
}
|
||||
.loading-wrap .spinner {
|
||||
width: 32px; height: 32px; margin-bottom: 16px;
|
||||
border: 3px solid rgba(0,120,212,0.15);
|
||||
border-top-color: #0078d4; border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
.loading-wrap p { font-size: 13px; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.spinner-sm {
|
||||
display: inline-block; width: 14px; height: 14px;
|
||||
border: 2px solid rgba(255,255,255,0.3);
|
||||
border-top-color: #fff; border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
/* error bar */
|
||||
.error-bar {
|
||||
padding: 12px 16px; background: #fde8e8; color: #c62828;
|
||||
border: 1px solid #f5b3b3; border-radius: 8px;
|
||||
font-size: 13px; display: flex; align-items: center; gap: 8px;
|
||||
}
|
||||
|
||||
/* Toast */
|
||||
.toast {
|
||||
position: fixed; top: 20px; left: 50%; transform: translateX(-50%);
|
||||
background: #0a7a1a; color: #fff;
|
||||
padding: 10px 24px; border-radius: 8px;
|
||||
font-size: 14px; font-weight: 500;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||
opacity: 0; transition: opacity 0.3s;
|
||||
z-index: 9999; pointer-events: none;
|
||||
}
|
||||
.toast.show { opacity: 1; }
|
||||
.toast.error { background: #c62828; }
|
||||
|
||||
/* 设置弹窗 */
|
||||
.modal-overlay {
|
||||
display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(0,0,0,0.45); z-index: 1000;
|
||||
align-items: center; justify-content: center;
|
||||
}
|
||||
.modal-overlay.show { display: flex; }
|
||||
.modal {
|
||||
background: #fff; border-radius: 12px; width: 520px;
|
||||
max-height: 80vh; overflow: hidden;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,0.25);
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
.modal-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 18px 22px; border-bottom: 1px solid #eee;
|
||||
}
|
||||
.modal-header h2 { font-size: 15px; font-weight: 600; color: #333; display: flex; align-items: center; gap: 8px; }
|
||||
.modal-close {
|
||||
width: 26px; height: 26px; border: none; background: transparent;
|
||||
color: #999; font-size: 18px; cursor: pointer; border-radius: 6px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.modal-close:hover { background: #f5f5f5; color: #333; }
|
||||
.modal-body { padding: 18px 22px; overflow-y: auto; flex: 1; }
|
||||
.modal-footer {
|
||||
display: flex; justify-content: flex-end; gap: 10px;
|
||||
padding: 14px 22px; border-top: 1px solid #eee; background: #fafafa;
|
||||
}
|
||||
.hint-box {
|
||||
padding: 10px 14px; background: #f0f7ff;
|
||||
border: 1px solid #b3d9ff; border-radius: 6px;
|
||||
font-size: 12px; color: #555; line-height: 1.8;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.hint-box strong { color: #0078d4; }
|
||||
.hint-box code {
|
||||
background: #e3f2fd; padding: 1px 6px; border-radius: 3px;
|
||||
font-family: Consolas, "Courier New", monospace; font-size: 11px; color: #1565c0;
|
||||
}
|
||||
.prompt-tabs {
|
||||
display: flex; gap: 0; margin-bottom: 12px;
|
||||
border-bottom: 2px solid #eee;
|
||||
}
|
||||
.prompt-tab {
|
||||
padding: 8px 16px; font-size: 12px; cursor: pointer;
|
||||
border: none; background: none; color: #999; font-weight: 600;
|
||||
border-bottom: 2px solid transparent; margin-bottom: -2px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.prompt-tab.active { color: #0078d4; border-bottom-color: #0078d4; }
|
||||
.prompt-content { display: none; }
|
||||
.prompt-content.active { display: block; }
|
||||
.prompt-textarea {
|
||||
width: 100%; height: 200px;
|
||||
padding: 10px 12px; border: 1px solid #e0e0e0; border-radius: 6px;
|
||||
font-size: 12px; line-height: 1.6; resize: vertical; outline: none;
|
||||
font-family: Consolas, "Courier New", monospace;
|
||||
background: #fff; color: #333; transition: border-color 0.15s;
|
||||
}
|
||||
.prompt-textarea:focus { border-color: #0078d4; box-shadow: 0 0 0 2px rgba(0,120,212,0.06); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<!-- 设置弹窗 -->
|
||||
<div class="modal-overlay" id="promptModal">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h2><i class="fas fa-wrench"></i> 高级设置</h2>
|
||||
<button class="modal-close" onclick="closePromptModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="input-row" style="margin-bottom:16px;">
|
||||
<div class="input-group no-icon">
|
||||
<label for="maxTags">最大标签数</label>
|
||||
<input type="number" id="maxTags" value="10" min="1" max="50">
|
||||
</div>
|
||||
<div class="input-group no-icon">
|
||||
<label for="minLength">最短字数</label>
|
||||
<input type="number" id="minLength" value="2" min="1" max="20">
|
||||
</div>
|
||||
<div class="input-group no-icon">
|
||||
<label for="maxLength">最长字数</label>
|
||||
<input type="number" id="maxLength" value="6" min="1" max="50">
|
||||
</div>
|
||||
</div>
|
||||
<div class="hint-box">
|
||||
<strong>可用变量:</strong><br>
|
||||
<code>{content}</code> 文章内容
|
||||
<code>{max_tags}</code> 最大标签数
|
||||
<code>{min_length}</code> 最短字数
|
||||
<code>{max_length}</code> 最长字数
|
||||
</div>
|
||||
<div class="prompt-tabs">
|
||||
<button class="prompt-tab active" onclick="switchPromptTab('system')">系统提示词</button>
|
||||
<button class="prompt-tab" onclick="switchPromptTab('user')">用户提示词</button>
|
||||
</div>
|
||||
<div class="prompt-content active" id="systemPromptPane">
|
||||
<textarea class="prompt-textarea" id="systemMsg"></textarea>
|
||||
</div>
|
||||
<div class="prompt-content" id="userPromptPane">
|
||||
<textarea class="prompt-textarea" id="userMsg"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-outline" onclick="resetPrompts()">恢复默认</button>
|
||||
<button class="btn btn-primary" onclick="savePrompts()"><i class="fas fa-check"></i> 保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 左侧面板 -->
|
||||
<div class="left-panel">
|
||||
<div class="input-section">
|
||||
<div class="header">
|
||||
<h1>文章标签 AI 生成</h1>
|
||||
<button class="settings-icon" onclick="openPromptModal()" title="高级设置">
|
||||
<i class="fas fa-wrench"></i>
|
||||
</button>
|
||||
</div>
|
||||
<p class="subtitle">接入 LLM 模型,自动提取文章关键词标签</p>
|
||||
|
||||
<!-- 连接设置 -->
|
||||
<div class="section-card">
|
||||
<div class="section-title"><i class="fas fa-link"></i> 连接设置</div>
|
||||
<div class="input-row">
|
||||
<div class="input-group no-icon" style="max-width:120px;">
|
||||
<label for="software">软件类型</label>
|
||||
<select id="software" onchange="onSoftwareChange()">
|
||||
<option value="ollama">Ollama</option>
|
||||
<option value="llamacpp" selected>llama.cpp</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="input-group" id="modelGroup" style="display:none;">
|
||||
<label for="model">模型</label>
|
||||
<div class="input-wrap">
|
||||
<i class="fas fa-robot"></i>
|
||||
<select id="model">
|
||||
<option value="">请先获取模型列表</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="api-row" style="margin-top:8px;">
|
||||
<div class="input-group">
|
||||
<label for="apiUrl">API 地址</label>
|
||||
<div class="input-wrap">
|
||||
<i class="fas fa-link"></i>
|
||||
<input type="text" id="apiUrl" placeholder="http://127.0.0.1:8080/v1/chat/completions" value="http://127.0.0.1:8080/v1/chat/completions">
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-outline" id="testBtn" onclick="testApi()"><i class="fas fa-refresh"></i> 测试</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 文章输入 -->
|
||||
<div class="content-section">
|
||||
<div class="section-card" style="flex:1; display:flex; flex-direction:column; margin-bottom:0;">
|
||||
<div class="section-title"><i class="fas fa-pen"></i> 文章内容</div>
|
||||
<textarea class="text-input" id="content" placeholder="在此粘贴文章内容…" style="flex:1; min-height:0;"></textarea>
|
||||
<div class="btn-row">
|
||||
<button class="btn btn-outline" onclick="clearAll()"><i class="fas fa-trash"></i> 清空</button>
|
||||
<button class="generate-btn" id="generateBtn" onclick="generate()" style="flex:1;" disabled>
|
||||
<i class="fas fa-tags"></i> 请先测试连接
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧面板 -->
|
||||
<div class="right-panel" id="rightPanel">
|
||||
<div class="result-placeholder" id="resultPlaceholder">
|
||||
<div class="ph-icon"><i class="fas fa-tags"></i></div>
|
||||
<p>粘贴文章内容,点击「生成标签」</p>
|
||||
<span class="ph-hint">Ctrl + Enter 快速生成</span>
|
||||
</div>
|
||||
<div id="resultContent" style="display:none; width:100%;"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var softwareSelect = document.getElementById('software');
|
||||
var apiUrl = document.getElementById('apiUrl');
|
||||
var modelSelect = document.getElementById('model');
|
||||
var modelGroup = document.getElementById('modelGroup');
|
||||
var maxTagsInput = document.getElementById('maxTags');
|
||||
var minLengthInput = document.getElementById('minLength');
|
||||
var maxLengthInput = document.getElementById('maxLength');
|
||||
var contentInput = document.getElementById('content');
|
||||
var generateBtn = document.getElementById('generateBtn');
|
||||
var rightPanel = document.getElementById('rightPanel');
|
||||
var resultPlaceholder = document.getElementById('resultPlaceholder');
|
||||
var resultContent = document.getElementById('resultContent');
|
||||
|
||||
var currentTags = [];
|
||||
|
||||
var DEFAULT_SYSTEM_MSG = "你是一个专门生成文章标签的助手,请你根据我给你的文章的内容总结并生成一系列的标签,格式可以参考[关键词1, 关键词2, 关键词3].你只需要给我生成这种形式的标签即可,其他分析内容无需输出.";
|
||||
var DEFAULT_USER_MSG = "请严格按照以下要求,从提供的文章内容中提取关键词。\n\n文章内容:\n{content}\n\n要求:\n- 提取最多 {max_tags} 个最能概括文章主旨和核心概念的关键词。\n- 关键词必须来源于文章内容,准确反映文章主题。\n- 每个关键词的长度必须在 {min_length} 到 {max_length} 个字符之间。\n- 输出格式为:关键词1, 关键词2, 关键词3, ...\n- 只输出关键词列表,不要有任何其他解释或前缀。";
|
||||
|
||||
var defaultUrls = {
|
||||
ollama: 'http://127.0.0.1:11434/api/generate',
|
||||
llamacpp: 'http://127.0.0.1:8080/v1/chat/completions'
|
||||
};
|
||||
|
||||
/* 设置弹窗 */
|
||||
function openPromptModal() { document.getElementById('promptModal').classList.add('show'); }
|
||||
function closePromptModal() { document.getElementById('promptModal').classList.remove('show'); }
|
||||
function switchPromptTab(tab) {
|
||||
document.querySelectorAll('.prompt-tab').forEach(function(t, i) {
|
||||
t.classList.toggle('active', tab === 'system' ? i === 0 : i === 1);
|
||||
});
|
||||
document.getElementById('systemPromptPane').classList.toggle('active', tab === 'system');
|
||||
document.getElementById('userPromptPane').classList.toggle('active', tab === 'user');
|
||||
}
|
||||
function resetPrompts() {
|
||||
document.getElementById('systemMsg').value = DEFAULT_SYSTEM_MSG;
|
||||
document.getElementById('userMsg').value = DEFAULT_USER_MSG;
|
||||
maxTagsInput.value = 10;
|
||||
minLengthInput.value = 2;
|
||||
maxLengthInput.value = 6;
|
||||
}
|
||||
function savePrompts() {
|
||||
fetch('/content-tag/settings', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
system_msg: document.getElementById('systemMsg').value,
|
||||
user_msg: document.getElementById('userMsg').value,
|
||||
max_tags: parseInt(maxTagsInput.value) || 10,
|
||||
min_length: parseInt(minLengthInput.value) || 2,
|
||||
max_length: parseInt(maxLengthInput.value) || 6
|
||||
})
|
||||
}).then(function(r) { return r.json(); }).then(function(d) {
|
||||
if (d.success) { closePromptModal(); showToast('设置已保存'); }
|
||||
else showToast(d.error || '保存失败', 'error');
|
||||
}).catch(function(e) { showToast('保存失败', 'error'); });
|
||||
}
|
||||
function loadSettings() {
|
||||
fetch('/content-tag/settings').then(function(r) { return r.json(); }).then(function(d) {
|
||||
if (d.success && d.settings) {
|
||||
var s = d.settings;
|
||||
document.getElementById('systemMsg').value = s.system_msg || DEFAULT_SYSTEM_MSG;
|
||||
document.getElementById('userMsg').value = s.user_msg || DEFAULT_USER_MSG;
|
||||
maxTagsInput.value = s.max_tags || 10;
|
||||
minLengthInput.value = s.min_length || 2;
|
||||
maxLengthInput.value = s.max_length || 6;
|
||||
}
|
||||
}).catch(function() {});
|
||||
}
|
||||
loadSettings();
|
||||
|
||||
[maxTagsInput, minLengthInput, maxLengthInput].forEach(function(el) {
|
||||
el.addEventListener('change', function() {
|
||||
fetch('/content-tag/settings', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
max_tags: parseInt(maxTagsInput.value) || 10,
|
||||
min_length: parseInt(minLengthInput.value) || 2,
|
||||
max_length: parseInt(maxLengthInput.value) || 6
|
||||
})
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/* 软件切换 */
|
||||
function onSoftwareChange() {
|
||||
var sw = softwareSelect.value;
|
||||
apiUrl.value = defaultUrls[sw] || '';
|
||||
modelGroup.style.display = sw === 'ollama' ? '' : 'none';
|
||||
document.getElementById('testBtn').innerHTML = '<i class="fas fa-refresh"></i> 测试';
|
||||
}
|
||||
|
||||
/* 测试连接 */
|
||||
async function testApi() {
|
||||
var testBtn = document.getElementById('testBtn');
|
||||
testBtn.disabled = true;
|
||||
testBtn.innerHTML = '<span class="spinner-sm" style="border-color:rgba(0,0,0,0.15);border-top-color:#333;"></span>';
|
||||
try {
|
||||
var sw = softwareSelect.value;
|
||||
var res = await fetch('/content-tag/test-api', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ api_url: apiUrl.value.trim(), model: modelSelect.value, software: sw })
|
||||
});
|
||||
var data = await res.json();
|
||||
if (data.success) {
|
||||
testBtn.innerHTML = '<i class="fas fa-check" style="color:#0a7a1a;"></i>';
|
||||
generateBtn.disabled = false;
|
||||
generateBtn.innerHTML = '<i class="fas fa-tags"></i> 生成标签';
|
||||
if (sw === 'ollama' && data.models) {
|
||||
modelSelect.innerHTML = data.models.map(function(m) { return '<option value="' + m + '">' + m + '</option>'; }).join('');
|
||||
modelGroup.style.display = '';
|
||||
}
|
||||
showToast('连接成功');
|
||||
} else {
|
||||
testBtn.innerHTML = '<i class="fas fa-times-circle" style="color:#c62828;"></i>';
|
||||
if (data.error) showToast(data.error, 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
testBtn.innerHTML = '<i class="fas fa-times-circle" style="color:#c62828;"></i>';
|
||||
showToast('请求失败: ' + err.message, 'error');
|
||||
} finally {
|
||||
testBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
/* 清空 */
|
||||
function clearAll() {
|
||||
contentInput.value = '';
|
||||
resultContent.style.display = 'none';
|
||||
resultContent.innerHTML = '';
|
||||
resultPlaceholder.style.display = 'flex';
|
||||
currentTags = [];
|
||||
}
|
||||
|
||||
/* 生成 */
|
||||
async function generate() {
|
||||
var url = apiUrl.value.trim();
|
||||
var model = modelSelect.value;
|
||||
var content = contentInput.value.trim();
|
||||
var software = softwareSelect.value;
|
||||
|
||||
if (!url) { showToast('请填写 API 地址', 'error'); return; }
|
||||
if (software === 'ollama' && !model) { showToast('请选择模型', 'error'); return; }
|
||||
if (!content) { showToast('请输入文章内容', 'error'); return; }
|
||||
|
||||
var TIMEOUT_SEC = 120;
|
||||
var elapsed = 0;
|
||||
generateBtn.disabled = true;
|
||||
generateBtn.innerHTML = '<span class="spinner-sm"></span> 分析中 0s';
|
||||
resultPlaceholder.style.display = 'none';
|
||||
resultContent.style.display = 'block';
|
||||
resultContent.innerHTML =
|
||||
'<div class="loading-wrap"><div class="spinner"></div><p>正在调用 AI 模型分析文章…</p></div>';
|
||||
|
||||
var timer = setInterval(function() {
|
||||
elapsed++;
|
||||
generateBtn.innerHTML = '<span class="spinner-sm"></span> 分析中 ' + elapsed + 's / ' + TIMEOUT_SEC + 's';
|
||||
}, 1000);
|
||||
|
||||
var controller = new AbortController();
|
||||
var timeoutId = setTimeout(function() { controller.abort(); }, TIMEOUT_SEC * 1000);
|
||||
|
||||
try {
|
||||
var res = await fetch('/content-tag/generate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
api_url: url, model: model, software: software,
|
||||
content: content,
|
||||
max_tags: parseInt(maxTagsInput.value) || 10,
|
||||
min_length: parseInt(minLengthInput.value) || 2,
|
||||
max_length: parseInt(maxLengthInput.value) || 6,
|
||||
system_msg: document.getElementById('systemMsg').value,
|
||||
user_msg: document.getElementById('userMsg').value
|
||||
}),
|
||||
signal: controller.signal
|
||||
});
|
||||
var data = await res.json();
|
||||
if (data.success) {
|
||||
currentTags = data.tags;
|
||||
renderResult(data);
|
||||
} else {
|
||||
resultContent.innerHTML = '<div class="error-bar"><i class="fas fa-times-circle"></i> ' + data.error + '</div>';
|
||||
}
|
||||
} catch (err) {
|
||||
var msg = err.name === 'AbortError' ? '请求超时(' + TIMEOUT_SEC + '秒)' : '请求失败: ' + err.message;
|
||||
resultContent.innerHTML = '<div class="error-bar"><i class="fas fa-times-circle"></i> ' + msg + '</div>';
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
clearInterval(timer);
|
||||
generateBtn.disabled = false;
|
||||
generateBtn.innerHTML = '<i class="fas fa-tags"></i> 生成标签';
|
||||
}
|
||||
}
|
||||
|
||||
/* 渲染结果 */
|
||||
function renderResult(data) {
|
||||
var tagsHtml = data.tags.map(function(t) {
|
||||
return '<span class="tag" onclick="copyTag(this, \'' + t.replace(/'/g, "\\'") + '\')">' +
|
||||
t + '<span class="copy-hint">复制</span></span>';
|
||||
}).join('');
|
||||
var tagsText = data.tags.join(', ');
|
||||
|
||||
var html = '' +
|
||||
'<div class="result-header">' +
|
||||
'<h2>生成结果</h2>' +
|
||||
'<button class="btn btn-outline btn-sm" id="copyAllBtn" onclick="copyAllTags()"><i class="fas fa-copy"></i> 复制全部</button>' +
|
||||
'</div>' +
|
||||
'<div class="meta">' +
|
||||
'<span><i class="fas fa-robot"></i> <span class="value">' + data.model + '</span></span>' +
|
||||
'<span><i class="fas fa-clock"></i> <span class="value">' + data.consume + 's</span></span>' +
|
||||
'<span><i class="fas fa-tags"></i> <span class="value">' + data.tags.length + ' 个标签</span></span>' +
|
||||
'</div>' +
|
||||
'<div class="tag-list">' + tagsHtml + '</div>' +
|
||||
'<div class="tag-text-box">' +
|
||||
'<span class="tag-text">' + tagsText + '</span>' +
|
||||
'<button class="btn btn-outline btn-sm" onclick="copyTagText(this)"><i class="fas fa-copy"></i> 复制</button>' +
|
||||
'</div>';
|
||||
|
||||
if (data.think) {
|
||||
html += '<div class="think-card"><div class="think-header"><i class="fas fa-brain"></i> AI 思考过程</div>' +
|
||||
'<div class="think-box"></div></div>';
|
||||
}
|
||||
|
||||
resultContent.innerHTML = html;
|
||||
|
||||
if (data.think) {
|
||||
resultContent.querySelector('.think-box').textContent = data.think;
|
||||
}
|
||||
}
|
||||
|
||||
/* Toast */
|
||||
function showToast(msg, type) {
|
||||
var toast = document.getElementById('toast');
|
||||
toast.textContent = msg;
|
||||
toast.className = 'toast show' + (type === 'error' ? ' error' : '');
|
||||
setTimeout(function() { toast.className = 'toast'; }, 2000);
|
||||
}
|
||||
|
||||
/* 复制 */
|
||||
function copyToClipboard(text) {
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
return navigator.clipboard.writeText(text);
|
||||
}
|
||||
return new Promise(function(resolve, reject) {
|
||||
var ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.style.cssText = 'position:fixed;left:-9999px;top:-9999px';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
try { document.execCommand('copy') ? resolve() : reject(); }
|
||||
catch (e) { reject(e); }
|
||||
finally { document.body.removeChild(ta); }
|
||||
});
|
||||
}
|
||||
function copyTag(el, text) {
|
||||
copyToClipboard(text).then(function() {
|
||||
showToast('已复制');
|
||||
el.style.background = '#d1fae5'; el.style.borderColor = '#a7f3d0'; el.style.color = '#065f46';
|
||||
setTimeout(function() { el.style.background = ''; el.style.borderColor = ''; el.style.color = ''; }, 600);
|
||||
});
|
||||
}
|
||||
function copyAllTags() {
|
||||
if (!currentTags.length) return;
|
||||
var btn = document.getElementById('copyAllBtn');
|
||||
copyToClipboard(currentTags.join(', ')).then(function() {
|
||||
showToast('已复制');
|
||||
btn.innerHTML = '<i class="fas fa-check"></i> 已复制';
|
||||
setTimeout(function() { btn.innerHTML = '<i class="fas fa-copy"></i> 复制全部'; }, 1500);
|
||||
});
|
||||
}
|
||||
function copyTagText(btn) {
|
||||
var text = btn.parentElement.querySelector('.tag-text').textContent;
|
||||
copyToClipboard(text).then(function() {
|
||||
showToast('已复制');
|
||||
btn.innerHTML = '<i class="fas fa-check"></i> 已复制';
|
||||
setTimeout(function() { btn.innerHTML = '<i class="fas fa-copy"></i> 复制'; }, 1500);
|
||||
});
|
||||
}
|
||||
|
||||
/* 快捷键 */
|
||||
contentInput.addEventListener('keydown', function(e) {
|
||||
if (e.ctrlKey && e.key === 'Enter') generate();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
529
flask-dev-api/templates/data_manage.html
Normal file
529
flask-dev-api/templates/data_manage.html
Normal file
@@ -0,0 +1,529 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>数据管理</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='font-awesome.css') }}">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "Segoe UI", Roboto, sans-serif;
|
||||
background: #f5f5f5;
|
||||
color: #333;
|
||||
padding: 16px;
|
||||
}
|
||||
.db-section {
|
||||
background: #fff;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.db-tabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border-bottom: 2px solid #e0e0e0;
|
||||
padding: 0 16px;
|
||||
background: #fafafa;
|
||||
border-radius: 10px 10px 0 0;
|
||||
}
|
||||
.db-tab {
|
||||
padding: 10px 18px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: none;
|
||||
color: #666;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -2px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.db-tab.active { color: #0078d4; border-bottom-color: #0078d4; }
|
||||
.db-tab:hover { color: #333; }
|
||||
.db-tab .tab-count {
|
||||
display: inline-block;
|
||||
background: #e0e0e0;
|
||||
color: #666;
|
||||
font-size: 10px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 8px;
|
||||
margin-left: 4px;
|
||||
}
|
||||
.db-tab.active .tab-count { background: #e8f0fe; color: #0078d4; }
|
||||
.db-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.db-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: #f5f5f5;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 6px;
|
||||
padding: 5px 10px;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
min-width: 180px;
|
||||
max-width: 300px;
|
||||
}
|
||||
.db-search:focus-within { border-color: #0078d4; background: #fff; }
|
||||
.db-search i { color: #bbb; font-size: 12px; }
|
||||
.db-search input {
|
||||
border: none;
|
||||
background: none;
|
||||
outline: none;
|
||||
font-size: 13px;
|
||||
width: 100%;
|
||||
color: #333;
|
||||
}
|
||||
.db-btn {
|
||||
padding: 5px 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
background: #fff;
|
||||
color: #666;
|
||||
transition: all 0.15s;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.db-btn:hover { background: #f5f5f5; color: #333; }
|
||||
.db-btn.danger { color: #c62828; border-color: #f5b3b3; }
|
||||
.db-btn.danger:hover { background: #fde8e8; }
|
||||
.db-btn.primary { background: #0078d4; color: #fff; border-color: #0078d4; }
|
||||
.db-btn.primary:hover { background: #006cbd; }
|
||||
.db-table-wrap { overflow-x: auto; }
|
||||
.db-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
}
|
||||
.db-table th {
|
||||
text-align: left;
|
||||
padding: 8px 12px;
|
||||
color: #999;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
font-size: 11px;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
background: #fafafa;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.db-table td {
|
||||
padding: 7px 12px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
color: #555;
|
||||
max-width: 280px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.db-table tr:hover td { background: #f8f9fa; }
|
||||
.db-table .col-check { width: 36px; text-align: center; }
|
||||
.db-table .col-actions { width: 80px; text-align: center; white-space: nowrap; }
|
||||
.db-table .action-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: #999;
|
||||
font-size: 13px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.db-table .action-btn:hover { color: #0078d4; background: #e8f0fe; }
|
||||
.db-table .action-btn.del:hover { color: #c62828; background: #fde8e8; }
|
||||
.db-pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 16px;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
.db-pagination .page-btns { display: flex; gap: 4px; }
|
||||
.db-pagination .page-btn {
|
||||
padding: 4px 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
.db-pagination .page-btn:hover { background: #f0f0f0; }
|
||||
.db-pagination .page-btn.active { background: #0078d4; color: #fff; border-color: #0078d4; }
|
||||
.db-pagination .page-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.db-empty {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #ccc;
|
||||
font-size: 14px;
|
||||
}
|
||||
/* 弹窗 */
|
||||
.modal-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(0,0,0,0.3);
|
||||
z-index: 1000;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.modal-overlay.show { display: flex; }
|
||||
.modal-box {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
width: 480px;
|
||||
max-width: 90vw;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 8px 30px rgba(0,0,0,0.12);
|
||||
}
|
||||
.modal-box h3 {
|
||||
font-size: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.modal-form .form-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.modal-form .form-row label {
|
||||
width: 100px;
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
text-align: right;
|
||||
padding-right: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.modal-form .form-row input,
|
||||
.modal-form .form-row select {
|
||||
flex: 1;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
}
|
||||
.modal-form .form-row input:focus,
|
||||
.modal-form .form-row select:focus { border-color: #0078d4; }
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="db-section">
|
||||
<div class="db-tabs">
|
||||
<button class="db-tab active" data-table="api_calls" onclick="switchDbTab('api_calls', this)">
|
||||
<i class="fas fa-chart-bar"></i> 接口调用记录 <span class="tab-count" id="cnt-api_calls">0</span>
|
||||
</button>
|
||||
<button class="db-tab" data-table="search_history" onclick="switchDbTab('search_history', this)">
|
||||
<i class="fas fa-search"></i> 搜索历史 <span class="tab-count" id="cnt-search_history">0</span>
|
||||
</button>
|
||||
<button class="db-tab" data-table="recent_paths" onclick="switchDbTab('recent_paths', this)">
|
||||
<i class="fas fa-folder"></i> 最近路径 <span class="tab-count" id="cnt-recent_paths">0</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="db-toolbar">
|
||||
<div class="db-search">
|
||||
<i class="fas fa-search"></i>
|
||||
<input type="text" id="dbSearchInput" placeholder="搜索..." oninput="onDbSearch()">
|
||||
</div>
|
||||
<button class="db-btn primary" onclick="openAddModal()"><i class="fas fa-folder-plus"></i> 新增</button>
|
||||
<button class="db-btn danger" onclick="batchDelete()"><i class="fas fa-trash"></i> 删除选中</button>
|
||||
<button class="db-btn danger" onclick="clearAll()"><i class="fas fa-trash"></i> 清空</button>
|
||||
</div>
|
||||
<div class="db-table-wrap">
|
||||
<table class="db-table" id="db-table">
|
||||
<thead id="db-thead"></thead>
|
||||
<tbody id="db-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="db-empty" id="db-empty" style="display:none;">暂无数据</div>
|
||||
<div class="db-pagination">
|
||||
<span id="db-page-info">共 0 条</span>
|
||||
<div class="page-btns" id="db-page-btns"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 编辑弹窗 -->
|
||||
<div class="modal-overlay" id="editModal">
|
||||
<div class="modal-box">
|
||||
<h3 id="modalTitle">新增记录</h3>
|
||||
<div class="modal-form" id="modalForm"></div>
|
||||
<div class="modal-actions">
|
||||
<button class="db-btn" onclick="closeModal()">取消</button>
|
||||
<button class="db-btn primary" onclick="saveRecord()">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var dbCurrentTable = 'api_calls';
|
||||
var dbCurrentPage = 1;
|
||||
var dbPageSize = 20;
|
||||
var dbSearchTimer = null;
|
||||
var dbEditingId = null;
|
||||
|
||||
var TABLE_CONFIG = {
|
||||
api_calls: {
|
||||
label: '接口调用记录',
|
||||
columns: [
|
||||
{ key: 'id', label: 'ID', width: '50px', editable: false },
|
||||
{ key: 'endpoint', label: '接口', editable: true },
|
||||
{ key: 'method', label: '方法', editable: true, type: 'select', options: ['GET','POST','PUT','DELETE'] },
|
||||
{ key: 'status_code', label: '状态码', editable: true, type: 'number' },
|
||||
{ key: 'duration_ms', label: '耗时(ms)', editable: true, type: 'number' },
|
||||
{ key: 'called_at', label: '调用时间', editable: true }
|
||||
]
|
||||
},
|
||||
search_history: {
|
||||
label: '搜索历史',
|
||||
columns: [
|
||||
{ key: 'id', label: 'ID', width: '50px', editable: false },
|
||||
{ key: 'keyword', label: '关键词', editable: true },
|
||||
{ key: 'root_path', label: '根路径', editable: true },
|
||||
{ key: 'result_count', label: '结果数', editable: true, type: 'number' },
|
||||
{ key: 'searched_at', label: '搜索时间', editable: true }
|
||||
]
|
||||
},
|
||||
recent_paths: {
|
||||
label: '最近路径',
|
||||
columns: [
|
||||
{ key: 'id', label: 'ID', width: '50px', editable: false },
|
||||
{ key: 'path', label: '路径', editable: true },
|
||||
{ key: 'last_used', label: '最后使用', editable: true }
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
function switchDbTab(table, btn) {
|
||||
dbCurrentTable = table;
|
||||
dbCurrentPage = 1;
|
||||
document.getElementById('dbSearchInput').value = '';
|
||||
document.querySelectorAll('.db-tab').forEach(function(t) { t.classList.remove('active'); });
|
||||
btn.classList.add('active');
|
||||
loadDbData();
|
||||
}
|
||||
|
||||
function onDbSearch() {
|
||||
clearTimeout(dbSearchTimer);
|
||||
dbCurrentPage = 1;
|
||||
dbSearchTimer = setTimeout(loadDbData, 300);
|
||||
}
|
||||
|
||||
async function loadDbData() {
|
||||
var search = document.getElementById('dbSearchInput').value.trim();
|
||||
var url = '/api/db/' + dbCurrentTable + '?page=' + dbCurrentPage + '&page_size=' + dbPageSize;
|
||||
if (search) url += '&search=' + encodeURIComponent(search);
|
||||
try {
|
||||
var res = await fetch(url);
|
||||
var data = await res.json();
|
||||
if (!data.success) return;
|
||||
renderDbTable(data.rows, data.total);
|
||||
renderDbPagination(data.total, data.page, data.page_size);
|
||||
updateDbCounts();
|
||||
} catch (e) { console.error('加载数据失败:', e); }
|
||||
}
|
||||
|
||||
async function updateDbCounts() {
|
||||
try {
|
||||
var res = await fetch('/api/db/tables');
|
||||
var data = await res.json();
|
||||
data.tables.forEach(function(t) {
|
||||
var el = document.getElementById('cnt-' + t.table);
|
||||
if (el) el.textContent = t.count;
|
||||
});
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function renderDbTable(rows, total) {
|
||||
var cfg = TABLE_CONFIG[dbCurrentTable];
|
||||
var thead = document.getElementById('db-thead');
|
||||
var tbody = document.getElementById('db-tbody');
|
||||
var emptyEl = document.getElementById('db-empty');
|
||||
|
||||
var ths = '<tr><th class="col-check"><input type="checkbox" id="dbCheckAll" onchange="toggleAllCheck(this)"></th>';
|
||||
cfg.columns.forEach(function(c) {
|
||||
ths += '<th' + (c.width ? ' style="width:' + c.width + '"' : '') + '>' + c.label + '</th>';
|
||||
});
|
||||
ths += '<th class="col-actions">操作</th></tr>';
|
||||
thead.innerHTML = ths;
|
||||
|
||||
if (!rows.length) {
|
||||
tbody.innerHTML = '';
|
||||
emptyEl.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
emptyEl.style.display = 'none';
|
||||
|
||||
tbody.innerHTML = rows.map(function(row) {
|
||||
var tds = '<tr><td class="col-check"><input type="checkbox" class="db-row-check" value="' + row.id + '"></td>';
|
||||
cfg.columns.forEach(function(c) {
|
||||
var v = row[c.key] != null ? row[c.key] : '';
|
||||
tds += '<td title="' + escapeHtml(String(v)) + '">' + escapeHtml(String(v)) + '</td>';
|
||||
});
|
||||
tds += '<td class="col-actions">'
|
||||
+ '<button class="action-btn" onclick="openEditModal(' + row.id + ')" title="编辑"><i class="fas fa-pen"></i></button> '
|
||||
+ '<button class="action-btn del" onclick="deleteOne(' + row.id + ')" title="删除"><i class="fas fa-trash"></i></button>'
|
||||
+ '</td></tr>';
|
||||
return tds;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return (s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
function renderDbPagination(total, page, pageSize) {
|
||||
var totalPages = Math.ceil(total / pageSize) || 1;
|
||||
document.getElementById('db-page-info').textContent = '共 ' + total + ' 条,第 ' + page + '/' + totalPages + ' 页';
|
||||
var btns = '';
|
||||
btns += '<button class="page-btn" onclick="goDbPage(1)" ' + (page <= 1 ? 'disabled' : '') + '>首页</button>';
|
||||
btns += '<button class="page-btn" onclick="goDbPage(' + (page - 1) + ')" ' + (page <= 1 ? 'disabled' : '') + '>上一页</button>';
|
||||
var start = Math.max(1, page - 2);
|
||||
var end = Math.min(totalPages, start + 4);
|
||||
for (var i = start; i <= end; i++) {
|
||||
btns += '<button class="page-btn' + (i === page ? ' active' : '') + '" onclick="goDbPage(' + i + ')">' + i + '</button>';
|
||||
}
|
||||
btns += '<button class="page-btn" onclick="goDbPage(' + (page + 1) + ')" ' + (page >= totalPages ? 'disabled' : '') + '>下一页</button>';
|
||||
btns += '<button class="page-btn" onclick="goDbPage(' + totalPages + ')" ' + (page >= totalPages ? 'disabled' : '') + '>末页</button>';
|
||||
document.getElementById('db-page-btns').innerHTML = btns;
|
||||
}
|
||||
|
||||
function goDbPage(p) {
|
||||
dbCurrentPage = p;
|
||||
loadDbData();
|
||||
}
|
||||
|
||||
function toggleAllCheck(el) {
|
||||
document.querySelectorAll('.db-row-check').forEach(function(cb) { cb.checked = el.checked; });
|
||||
}
|
||||
|
||||
function openAddModal() {
|
||||
dbEditingId = null;
|
||||
document.getElementById('modalTitle').textContent = '新增记录';
|
||||
buildModalForm(null);
|
||||
document.getElementById('editModal').classList.add('show');
|
||||
}
|
||||
|
||||
async function openEditModal(id) {
|
||||
dbEditingId = id;
|
||||
document.getElementById('modalTitle').textContent = '编辑记录 #' + id;
|
||||
var search = document.getElementById('dbSearchInput').value.trim();
|
||||
var url = '/api/db/' + dbCurrentTable + '?page=' + dbCurrentPage + '&page_size=' + dbPageSize;
|
||||
if (search) url += '&search=' + encodeURIComponent(search);
|
||||
try {
|
||||
var res = await fetch(url);
|
||||
var data = await res.json();
|
||||
var row = data.rows.find(function(r) { return r.id === id; });
|
||||
buildModalForm(row);
|
||||
document.getElementById('editModal').classList.add('show');
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function buildModalForm(row) {
|
||||
var cfg = TABLE_CONFIG[dbCurrentTable];
|
||||
var html = '';
|
||||
cfg.columns.forEach(function(c) {
|
||||
if (c.key === 'id') return;
|
||||
var val = row ? (row[c.key] != null ? row[c.key] : '') : '';
|
||||
html += '<div class="form-row"><label>' + c.label + '</label>';
|
||||
if (c.type === 'select') {
|
||||
html += '<select data-col="' + c.key + '">';
|
||||
c.options.forEach(function(opt) {
|
||||
html += '<option value="' + opt + '"' + (val === opt ? ' selected' : '') + '>' + opt + '</option>';
|
||||
});
|
||||
html += '</select>';
|
||||
} else {
|
||||
html += '<input type="' + (c.type || 'text') + '" data-col="' + c.key + '" value="' + escapeHtml(String(val)) + '">';
|
||||
}
|
||||
html += '</div>';
|
||||
});
|
||||
document.getElementById('modalForm').innerHTML = html;
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
document.getElementById('editModal').classList.remove('show');
|
||||
}
|
||||
|
||||
async function saveRecord() {
|
||||
var cfg = TABLE_CONFIG[dbCurrentTable];
|
||||
var data = {};
|
||||
cfg.columns.forEach(function(c) {
|
||||
if (c.key === 'id') return;
|
||||
var el = document.querySelector('#modalForm [data-col="' + c.key + '"]');
|
||||
if (el) data[c.key] = el.value;
|
||||
});
|
||||
var url, method;
|
||||
if (dbEditingId) {
|
||||
url = '/api/db/' + dbCurrentTable + '/' + dbEditingId;
|
||||
method = 'PUT';
|
||||
} else {
|
||||
url = '/api/db/' + dbCurrentTable;
|
||||
method = 'POST';
|
||||
}
|
||||
try {
|
||||
var res = await fetch(url, { method: method, headers: {'Content-Type':'application/json'}, body: JSON.stringify(data) });
|
||||
var result = await res.json();
|
||||
if (result.success) {
|
||||
closeModal();
|
||||
loadDbData();
|
||||
} else {
|
||||
alert(result.error || '操作失败');
|
||||
}
|
||||
} catch (e) { alert('请求失败: ' + e.message); }
|
||||
}
|
||||
|
||||
async function deleteOne(id) {
|
||||
if (!confirm('确定删除记录 #' + id + '?')) return;
|
||||
try {
|
||||
var res = await fetch('/api/db/' + dbCurrentTable + '/' + id, { method: 'DELETE' });
|
||||
var data = await res.json();
|
||||
if (data.success) loadDbData();
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
async function batchDelete() {
|
||||
var ids = [];
|
||||
document.querySelectorAll('.db-row-check:checked').forEach(function(cb) { ids.push(parseInt(cb.value)); });
|
||||
if (!ids.length) { alert('请先选择要删除的记录'); return; }
|
||||
if (!confirm('确定删除选中的 ' + ids.length + ' 条记录?')) return;
|
||||
try {
|
||||
var res = await fetch('/api/db/' + dbCurrentTable + '/delete-batch', {
|
||||
method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ids: ids})
|
||||
});
|
||||
var data = await res.json();
|
||||
if (data.success) loadDbData();
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
async function clearAll() {
|
||||
var cfg = TABLE_CONFIG[dbCurrentTable];
|
||||
if (!confirm('确定清空「' + cfg.label + '」的所有数据?此操作不可撤销!')) return;
|
||||
try {
|
||||
var res = await fetch('/api/db/' + dbCurrentTable + '/clear', { method: 'POST' });
|
||||
var data = await res.json();
|
||||
if (data.success) loadDbData();
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
loadDbData();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
796
flask-dev-api/templates/down_video.html
Normal file
796
flask-dev-api/templates/down_video.html
Normal file
@@ -0,0 +1,796 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='font-awesome.css') }}">
|
||||
<title>视频下载器</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
background: #f5f5f5;
|
||||
color: #333;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
}
|
||||
/* 左侧面板 */
|
||||
.left-panel {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid #e0e0e0;
|
||||
min-width: 420px;
|
||||
background: #fff;
|
||||
height: 100vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.input-section {
|
||||
padding: 32px;
|
||||
height: 100%;
|
||||
/*border-bottom: 1px solid #e0e0e0;*/
|
||||
}
|
||||
.input-section h1 {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 4px;
|
||||
color: #333;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.input-section .subtitle {
|
||||
color: #666;
|
||||
font-size: 13px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.input-group {
|
||||
margin-bottom: 16px;
|
||||
position: relative;
|
||||
}
|
||||
.input-group label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 6px;
|
||||
color: #666;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.input-group .input-wrap {
|
||||
position: relative;
|
||||
}
|
||||
.input-group .input-wrap i {
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: #bbb;
|
||||
font-size: 14px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.input-group input {
|
||||
width: 100%;
|
||||
padding: 12px 14px 12px 40px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.input-group input::placeholder { color: #bbb; }
|
||||
.input-group input:focus { border-color: #0078d4; box-shadow: 0 0 0 3px rgba(0,120,212,0.1); }
|
||||
|
||||
/* 保存路径历史下拉 */
|
||||
.path-history-wrap { position: relative; }
|
||||
.path-history {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: #fff;
|
||||
border: 1px solid #ddd;
|
||||
border-top: none;
|
||||
border-radius: 0 0 10px 10px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
|
||||
z-index: 10;
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.path-history.show { display: block; }
|
||||
.path-history-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 14px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
transition: background 0.15s;
|
||||
border-bottom: 1px solid #f5f5f5;
|
||||
}
|
||||
.path-history-item:last-child { border-bottom: none; }
|
||||
.path-history-item:hover { background: #f0f7ff; }
|
||||
.path-history-item.match { background: #f0f7ff; }
|
||||
.path-history-item .path-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.path-history-item .path-delete {
|
||||
flex-shrink: 0;
|
||||
color: #bbb;
|
||||
font-size: 12px;
|
||||
margin-left: 8px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
transition: all 0.15s;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
width: auto;
|
||||
}
|
||||
.path-history-item .path-delete:hover { color: #c62828; background: #fde8e8; }
|
||||
.path-history-empty {
|
||||
padding: 12px 14px;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
text-align: center;
|
||||
}
|
||||
button {
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background: #0078d4;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
button:hover:not(:disabled) { background: #006cbd; transform: translateY(-1px); }
|
||||
button:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.status {
|
||||
margin-top: 16px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
display: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
.status.show { display: flex; align-items: center; gap: 8px; }
|
||||
.status.success { background: #e6f7e9; color: #0a7a1a; border: 1px solid #b3e6c0; }
|
||||
.status.error { background: #fde8e8; color: #c62828; border: 1px solid #f5b3b3; }
|
||||
.status.info { background: #e3f2fd; color: #1565c0; border: 1px solid #b3d9ff; }
|
||||
.status i { font-size: 16px; }
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid rgba(21,101,192,0.2);
|
||||
border-top-color: #1565c0;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
margin-right: 8px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* 进度条样式 */
|
||||
.progress-container {
|
||||
margin-top: 16px;
|
||||
display: none;
|
||||
}
|
||||
.progress-container.show { display: block; }
|
||||
.progress-bar-wrapper {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
background: #e0e0e0;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.progress-bar {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #0078d4, #00a3ff);
|
||||
border-radius: 4px;
|
||||
width: 0%;
|
||||
transition: width 0.3s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.progress-bar::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
rgba(255,255,255,0.3) 50%,
|
||||
transparent 100%
|
||||
);
|
||||
animation: shimmer 1.5s infinite;
|
||||
}
|
||||
@keyframes shimmer {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(100%); }
|
||||
}
|
||||
.progress-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.progress-percent {
|
||||
font-weight: 600;
|
||||
color: #0078d4;
|
||||
}
|
||||
.progress-details {
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
}
|
||||
.progress-merging {
|
||||
font-size: 13px;
|
||||
color: #856404;
|
||||
background: #fff3cd;
|
||||
border: 1px solid #ffc107;
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
margin-top: 10px;
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.progress-merging.show { display: flex; }
|
||||
.footer-meta {
|
||||
margin-top: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
}
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.badge.ok { background: #e6f7e9; color: #0a7a1a; border: 1px solid #b3e6c0; }
|
||||
.badge.warn { background: #fff3cd; color: #856404; border: 1px solid #ffc107; }
|
||||
|
||||
/* 日志区域 */
|
||||
.log-section {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
.log-header {
|
||||
padding: 14px 32px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #666;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
.log-panel {
|
||||
flex: 1;
|
||||
padding: 20px 32px;
|
||||
background: #fff;
|
||||
color: #333;
|
||||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* 右侧面板 */
|
||||
.right-panel {
|
||||
flex: 1.2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
.right-panel .placeholder {
|
||||
text-align: center;
|
||||
color: #bbb;
|
||||
}
|
||||
.right-panel .placeholder .placeholder-icon {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 20px;
|
||||
background: #fff;
|
||||
border: 1px solid #e0e0e0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto 20px;
|
||||
}
|
||||
.right-panel .placeholder .placeholder-icon i {
|
||||
font-size: 32px;
|
||||
color: #ddd;
|
||||
}
|
||||
.right-panel .placeholder p {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
}
|
||||
.right-panel .placeholder .placeholder-hint {
|
||||
font-size: 12px;
|
||||
color: #ccc;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.video-wrap {
|
||||
width: 100%;
|
||||
max-width: 720px;
|
||||
height: 100%;
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
.video-wrap.show { display: flex; }
|
||||
.video-player {
|
||||
max-width: 100%;
|
||||
max-height: calc(100% - 70px);
|
||||
width: auto;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
border-radius: 12px;
|
||||
background: #000;
|
||||
border: 1px solid #e0e0e0;
|
||||
outline: none;
|
||||
}
|
||||
.video-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
.video-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
line-height: 1.4;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.video-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.btn-download {
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
background: #0078d4;
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
border: none;
|
||||
white-space: nowrap;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* 代理开关 */
|
||||
.proxy-group { margin-bottom: 20px; }
|
||||
.proxy-group .proxy-toggle-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: #555;
|
||||
user-select: none;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.proxy-toggle-label input[type="checkbox"] { display: none; }
|
||||
.proxy-check-box {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid #ccc;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.proxy-check-box i { font-size: 10px; color: #fff; opacity: 0; }
|
||||
.proxy-toggle-label input:checked ~ .proxy-check-box {
|
||||
background: #0078d4;
|
||||
border-color: #0078d4;
|
||||
}
|
||||
.proxy-toggle-label input:checked ~ .proxy-check-box i { opacity: 1; }
|
||||
.proxy-url-wrap {
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height 0.25s ease, opacity 0.25s ease;
|
||||
opacity: 0;
|
||||
}
|
||||
.proxy-url-wrap.show {
|
||||
max-height: 50px;
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="left-panel">
|
||||
<div class="input-section">
|
||||
<h1>视频下载</h1>
|
||||
<p class="subtitle">支持 X、Bilibili、Instagram 和 YouTube</p>
|
||||
|
||||
<div class="input-group">
|
||||
<label for="url">视频链接</label>
|
||||
<div class="input-wrap">
|
||||
<i class="fas fa-link"></i>
|
||||
<input type="url" id="url" placeholder="粘贴 X、Bilibili、Instagram 或 YouTube 视频链接...">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<label for="outputDir">保存路径</label>
|
||||
<div class="path-history-wrap">
|
||||
<div class="input-wrap">
|
||||
<i class="fas fa-folder-open"></i>
|
||||
<input type="text" id="outputDir" value="" placeholder="选择或输入保存路径..." autocomplete="off">
|
||||
</div>
|
||||
<div id="pathHistory" class="path-history"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="input-group proxy-group">
|
||||
<label class="proxy-toggle-label">
|
||||
<input type="checkbox" id="useProxy" checked>
|
||||
<span class="proxy-check-box"><i class="fas fa-check"></i></span>
|
||||
<span>使用代理</span>
|
||||
</label>
|
||||
<div id="proxyUrlWrap" class="proxy-url-wrap show">
|
||||
<div class="input-wrap">
|
||||
<i class="fas fa-link"></i>
|
||||
<input type="text" id="proxyUrl" value="socks5://127.0.0.1:10808" placeholder="代理地址,如 socks5://127.0.0.1:10808">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button id="downloadBtn" onclick="startDownload()">开始下载</button>
|
||||
|
||||
<div id="progressContainer" class="progress-container">
|
||||
<div class="progress-info">
|
||||
<span id="progressPercent" class="progress-percent">0%</span>
|
||||
<span id="progressSpeed" class="progress-details"></span>
|
||||
<span id="progressEta" class="progress-details"></span>
|
||||
</div>
|
||||
<div class="progress-bar-wrapper">
|
||||
<div id="progressBar" class="progress-bar"></div>
|
||||
</div>
|
||||
<div id="progressFilename" class="progress-details" style="margin-top: 4px;"></div>
|
||||
<div id="mergingStatus" class="progress-merging">
|
||||
<span class="spinner"></span>
|
||||
<span>正在合并视频和音频...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="status" class="status"></div>
|
||||
|
||||
<div class="footer-meta">
|
||||
<span>FFmpeg</span>
|
||||
{% if ffmpeg_ok %}
|
||||
<span class="badge ok">已就绪</span>
|
||||
{% else %}
|
||||
<span class="badge warn">未检测到</span>
|
||||
{% endif %}
|
||||
<span style="margin-left:12px;">Deno</span>
|
||||
{% if deno_ok %}
|
||||
<span class="badge ok">已就绪</span>
|
||||
{% else %}
|
||||
<span class="badge warn">未检测到</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="right-panel">
|
||||
<div id="placeholder" class="placeholder">
|
||||
<div class="placeholder-icon">
|
||||
<i class="fas fa-video"></i>
|
||||
</div>
|
||||
<p>下载完成后在此预览视频</p>
|
||||
<p class="placeholder-hint">支持 X / Bilibili / Instagram / YouTube 视频</p>
|
||||
</div>
|
||||
|
||||
<div id="videoWrap" class="video-wrap">
|
||||
<video id="videoPlayer" class="video-player" controls></video>
|
||||
<div class="video-info">
|
||||
<div id="videoTitle" class="video-title"></div>
|
||||
<div class="video-actions">
|
||||
<a id="downloadLink" class="btn-download" href="#" target="_blank">下载文件</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const urlInput = document.getElementById('url');
|
||||
const btn = document.getElementById('downloadBtn');
|
||||
const statusEl = document.getElementById('status');
|
||||
const placeholder = document.getElementById('placeholder');
|
||||
const videoWrap = document.getElementById('videoWrap');
|
||||
const videoPlayer = document.getElementById('videoPlayer');
|
||||
const videoTitle = document.getElementById('videoTitle');
|
||||
const downloadLink = document.getElementById('downloadLink');
|
||||
const progressContainer = document.getElementById('progressContainer');
|
||||
const progressBar = document.getElementById('progressBar');
|
||||
const progressPercent = document.getElementById('progressPercent');
|
||||
const progressSpeed = document.getElementById('progressSpeed');
|
||||
const progressEta = document.getElementById('progressEta');
|
||||
const progressFilename = document.getElementById('progressFilename');
|
||||
const mergingStatus = document.getElementById('mergingStatus');
|
||||
|
||||
let eventSource = null;
|
||||
let savePaths = [];
|
||||
|
||||
// ===== 代理开关 =====
|
||||
const useProxyCheckbox = document.getElementById('useProxy');
|
||||
const proxyUrlWrap = document.getElementById('proxyUrlWrap');
|
||||
useProxyCheckbox.addEventListener('change', function() {
|
||||
proxyUrlWrap.classList.toggle('show', this.checked);
|
||||
});
|
||||
|
||||
// ===== 保存路径历史 =====
|
||||
async function loadSavePaths() {
|
||||
try {
|
||||
const res = await fetch('/down-video/save-paths');
|
||||
const data = await res.json();
|
||||
savePaths = data.paths || [];
|
||||
} catch (e) { savePaths = []; }
|
||||
}
|
||||
|
||||
function renderPathHistory(filter) {
|
||||
const el = document.getElementById('pathHistory');
|
||||
if (!savePaths.length) {
|
||||
el.innerHTML = '<div class="path-history-empty">暂无历史记录</div>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = savePaths.map(function(p) {
|
||||
var isMatch = filter && p.path.toLowerCase() === filter.toLowerCase();
|
||||
return '<div class="path-history-item' + (isMatch ? ' match' : '') + '" data-path="' + escapePathAttr(p.path) + '">'
|
||||
+ '<span class="path-text">' + escapePathHtml(p.path) + '</span>'
|
||||
+ '</div>';
|
||||
}).join('');
|
||||
el.querySelectorAll('.path-history-item').forEach(function(item) {
|
||||
item.addEventListener('click', function() {
|
||||
document.getElementById('outputDir').value = item.dataset.path;
|
||||
hidePathHistory();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function showPathHistory() {
|
||||
const el = document.getElementById('pathHistory');
|
||||
const val = document.getElementById('outputDir').value.trim();
|
||||
renderPathHistory(val);
|
||||
el.classList.add('show');
|
||||
}
|
||||
|
||||
function hidePathHistory() {
|
||||
document.getElementById('pathHistory').classList.remove('show');
|
||||
}
|
||||
|
||||
async function deleteSavePath(path) {
|
||||
try {
|
||||
await fetch('/down-video/save-paths/delete', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: path })
|
||||
});
|
||||
savePaths = savePaths.filter(function(p) { return p.path !== path; });
|
||||
const val = document.getElementById('outputDir').value.trim();
|
||||
renderPathHistory(val);
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function escapePathAttr(s) {
|
||||
return (s || '').replace(/&/g, '&').replace(/'/g, ''').replace(/"/g, '"');
|
||||
}
|
||||
function escapePathHtml(s) {
|
||||
return (s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
// 保存路径到历史
|
||||
async function saveCurrentPath(path) {
|
||||
if (!path) return;
|
||||
try {
|
||||
await fetch('/down-video/save-paths', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: path })
|
||||
});
|
||||
await loadSavePaths();
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// 输出目录输入框事件
|
||||
const outputDirInput = document.getElementById('outputDir');
|
||||
outputDirInput.addEventListener('focus', showPathHistory);
|
||||
outputDirInput.addEventListener('input', function() {
|
||||
renderPathHistory(this.value.trim());
|
||||
});
|
||||
document.addEventListener('click', function(e) {
|
||||
if (!e.target.closest('.path-history-wrap')) hidePathHistory();
|
||||
});
|
||||
|
||||
// 页面加载时获取历史
|
||||
loadSavePaths();
|
||||
|
||||
function showStatus(text, type) {
|
||||
const icons = { success: 'fa-check-circle', error: 'fa-times-circle', info: 'fa-info-circle' };
|
||||
statusEl.innerHTML = '<i class="fas ' + (icons[type] || '') + '"></i> ' + text;
|
||||
statusEl.className = 'status show ' + type;
|
||||
}
|
||||
function clearStatus() { statusEl.className = 'status'; }
|
||||
|
||||
function showVideo(filepath, title) {
|
||||
placeholder.style.display = 'none';
|
||||
videoWrap.classList.add('show');
|
||||
videoPlayer.src = '/down-video/file?path=' + encodeURIComponent(filepath);
|
||||
videoTitle.textContent = title || '未命名视频';
|
||||
downloadLink.href = '/down-video/file?path=' + encodeURIComponent(filepath) + '&download=1';
|
||||
downloadLink.setAttribute('download', '');
|
||||
}
|
||||
|
||||
function updateProgress(percent, speed, eta, filename) {
|
||||
progressContainer.classList.add('show');
|
||||
progressBar.style.width = percent + '%';
|
||||
progressPercent.textContent = Math.round(percent) + '%';
|
||||
progressSpeed.textContent = speed ? '速度: ' + speed : '';
|
||||
progressEta.textContent = eta ? '剩余: ' + eta : '';
|
||||
progressFilename.textContent = filename || '';
|
||||
}
|
||||
|
||||
function resetProgress() {
|
||||
progressContainer.classList.remove('show');
|
||||
progressBar.style.width = '0%';
|
||||
progressPercent.textContent = '0%';
|
||||
progressSpeed.textContent = '';
|
||||
progressEta.textContent = '';
|
||||
progressFilename.textContent = '';
|
||||
mergingStatus.classList.remove('show');
|
||||
}
|
||||
|
||||
function generateDownloadId() {
|
||||
return 'dl_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
|
||||
}
|
||||
|
||||
async function startDownload() {
|
||||
const url = urlInput.value.trim();
|
||||
const outputDir = document.getElementById('outputDir').value.trim();
|
||||
if (!url) {
|
||||
showStatus('请输入视频链接', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// 保存当前路径到历史
|
||||
if (outputDir) saveCurrentPath(outputDir);
|
||||
|
||||
btn.disabled = true;
|
||||
clearStatus();
|
||||
showStatus('', 'info');
|
||||
statusEl.innerHTML = '<span class="spinner"></span> 正在下载,请稍候...';
|
||||
statusEl.className = 'status show info';
|
||||
|
||||
videoWrap.classList.remove('show');
|
||||
placeholder.style.display = 'block';
|
||||
videoPlayer.src = '';
|
||||
|
||||
resetProgress();
|
||||
|
||||
const downloadId = generateDownloadId();
|
||||
|
||||
// 启动SSE连接监听进度
|
||||
eventSource = new EventSource('/down-video/progress/' + downloadId);
|
||||
eventSource.onmessage = function(event) {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.type === 'progress') {
|
||||
updateProgress(data.percent, data.speed, data.eta, data.filename);
|
||||
} else if (data.type === 'merging') {
|
||||
mergingStatus.classList.add('show');
|
||||
mergingStatus.querySelector('.spinner').style.display = 'inline-block';
|
||||
} else if (data.type === 'info') {
|
||||
// 可选:显示信息日志
|
||||
} else if (data.type === 'error') {
|
||||
// 可选:显示错误日志
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error parsing SSE data:', e);
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = function() {
|
||||
eventSource.close();
|
||||
eventSource = null;
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await fetch('/down-video/download', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
url: url,
|
||||
output_dir: outputDir,
|
||||
download_id: downloadId,
|
||||
use_proxy: useProxyCheckbox.checked,
|
||||
proxy_url: document.getElementById('proxyUrl').value.trim()
|
||||
})
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
// 关闭SSE连接
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
eventSource = null;
|
||||
}
|
||||
|
||||
if (data.success) {
|
||||
updateProgress(100, '', '', '');
|
||||
showStatus((data.title ? '《' + data.title + '》' : '') + ' 下载完成', 'success');
|
||||
if (data.filepath) {
|
||||
showVideo(data.filepath, data.title);
|
||||
}
|
||||
} else {
|
||||
showStatus(data.message, 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
// 关闭SSE连接
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
eventSource = null;
|
||||
}
|
||||
showStatus('请求失败: ' + err.message, 'error');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
urlInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') startDownload();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
376
flask-dev-api/templates/fen_ci.html
Normal file
376
flask-dev-api/templates/fen_ci.html
Normal file
@@ -0,0 +1,376 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='font-awesome.css') }}">
|
||||
<title>Jieba 分词</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
background: #f5f5f5;
|
||||
color: #333;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
}
|
||||
/* 左侧面板 */
|
||||
.left-panel {
|
||||
width: 50%;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid #e8e8e8;
|
||||
background: #fff;
|
||||
height: 100vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.left-panel::-webkit-scrollbar { width: 4px; }
|
||||
.left-panel::-webkit-scrollbar-thumb { background: #ddd; border-radius: 2px; }
|
||||
.input-section { padding: 20px 24px 28px; }
|
||||
|
||||
/* 头部 */
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.header h1 {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #1a1a1a;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.subtitle { color: #aaa; font-size: 12px; margin-bottom: 16px; }
|
||||
|
||||
/* 分组卡片 */
|
||||
.section-card {
|
||||
background: #fafafa;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 10px;
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.section-title {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #999;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.section-title i { font-size: 11px; }
|
||||
|
||||
/* 输入组 */
|
||||
.input-group { margin-bottom: 10px; position: relative; }
|
||||
.input-group:last-child { margin-bottom: 0; }
|
||||
.input-group label {
|
||||
display: block; font-size: 12px; font-weight: 500;
|
||||
margin-bottom: 4px; color: #555;
|
||||
}
|
||||
.input-group .input-wrap { position: relative; }
|
||||
.input-group .input-wrap i {
|
||||
position: absolute; left: 10px; top: 50%; transform: translateY(-50%);
|
||||
color: #c0c0c0; font-size: 12px; pointer-events: none;
|
||||
}
|
||||
.input-group input,
|
||||
.input-group select {
|
||||
width: 100%; padding: 7px 10px 7px 32px;
|
||||
border: 1px solid #e0e0e0; border-radius: 6px;
|
||||
background: #fff; color: #333; font-size: 13px;
|
||||
outline: none; transition: all 0.15s ease;
|
||||
}
|
||||
.input-group input::placeholder { color: #c0c0c0; }
|
||||
.input-group input:focus,
|
||||
.input-group select:focus {
|
||||
border-color: #0078d4;
|
||||
box-shadow: 0 0 0 2px rgba(0,120,212,0.06);
|
||||
}
|
||||
.input-group.no-icon input,
|
||||
.input-group.no-icon select { padding-left: 10px; }
|
||||
|
||||
/* textarea */
|
||||
.text-input {
|
||||
width: 100%; padding: 10px 12px;
|
||||
border: 1px solid #e0e0e0; border-radius: 6px;
|
||||
font-size: 13px; outline: none; resize: vertical;
|
||||
min-height: 200px; font-family: inherit; transition: border-color 0.15s;
|
||||
}
|
||||
.text-input:focus { border-color: #0078d4; box-shadow: 0 0 0 2px rgba(0,120,212,0.06); }
|
||||
|
||||
/* 按钮 */
|
||||
.btn {
|
||||
padding: 7px 14px; border: none; border-radius: 6px;
|
||||
font-size: 12px; font-weight: 600; cursor: pointer;
|
||||
transition: all 0.12s ease;
|
||||
display: inline-flex; align-items: center; gap: 5px;
|
||||
}
|
||||
.btn-primary { background: #0078d4; color: #fff; }
|
||||
.btn-primary:hover:not(:disabled) { background: #006cbd; }
|
||||
.btn-primary:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.btn-outline { background: #fff; color: #666; border: 1px solid #e0e0e0; }
|
||||
.btn-outline:hover { background: #f5f5f5; color: #333; }
|
||||
.btn-block { width: 100%; justify-content: center; }
|
||||
.btn-row { display: flex; gap: 8px; }
|
||||
|
||||
/* 合成分词按钮 */
|
||||
.run-btn {
|
||||
width: 100%; padding: 10px 18px;
|
||||
background: linear-gradient(135deg, #0078d4, #005fa3);
|
||||
color: #fff; border: none; border-radius: 8px;
|
||||
font-size: 14px; font-weight: 600;
|
||||
cursor: pointer; transition: all 0.15s ease;
|
||||
display: flex; align-items: center; justify-content: center; gap: 8px;
|
||||
margin-top: 14px;
|
||||
box-shadow: 0 2px 8px rgba(0,120,212,0.2);
|
||||
}
|
||||
.run-btn:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #006cbd, #004e8a);
|
||||
box-shadow: 0 4px 12px rgba(0,120,212,0.3);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.run-btn:active:not(:disabled) { transform: translateY(0); }
|
||||
.run-btn:disabled { opacity: 0.4; cursor: not-allowed; transform: none; }
|
||||
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.spinner {
|
||||
display: inline-block; width: 16px; height: 16px;
|
||||
border: 2px solid rgba(255,255,255,0.3); border-top-color: #fff;
|
||||
border-radius: 50%; animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
/* ===== 右侧面板 ===== */
|
||||
.right-panel {
|
||||
flex: 1;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
}
|
||||
.right-header {
|
||||
padding: 20px 24px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.right-header h2 {
|
||||
font-size: 15px; font-weight: 600; color: #333;
|
||||
}
|
||||
.result-badge {
|
||||
font-size: 12px; font-weight: 600; color: #0078d4;
|
||||
background: #e8f0fe; padding: 2px 10px; border-radius: 10px;
|
||||
}
|
||||
|
||||
/* 统计栏 */
|
||||
.stats-bar {
|
||||
display: flex; flex-wrap: wrap; gap: 16px;
|
||||
padding: 12px 24px; font-size: 12px; color: #888;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
.stats-bar span { display: flex; align-items: center; gap: 4px; }
|
||||
.stats-bar .num { font-weight: 700; color: #333; }
|
||||
|
||||
/* 结果区域 */
|
||||
.result-area {
|
||||
flex: 1; overflow-y: auto; padding: 20px 24px;
|
||||
}
|
||||
.result-area::-webkit-scrollbar { width: 4px; }
|
||||
.result-area::-webkit-scrollbar-thumb { background: #ddd; border-radius: 2px; }
|
||||
|
||||
/* 标签 */
|
||||
.token-list { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.token-tag {
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
padding: 4px 10px; border-radius: 5px;
|
||||
font-size: 13px; cursor: default; transition: transform 0.1s;
|
||||
}
|
||||
.token-tag:hover { transform: translateY(-1px); }
|
||||
.token-tag .type {
|
||||
font-size: 10px; opacity: 0.65;
|
||||
background: rgba(0,0,0,0.06); padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* 占位 */
|
||||
.placeholder {
|
||||
flex: 1; display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center;
|
||||
color: #ccc; gap: 12px;
|
||||
}
|
||||
.placeholder i { font-size: 48px; }
|
||||
.placeholder p { font-size: 13px; }
|
||||
.placeholder .hint { font-size: 11px; color: #ddd; }
|
||||
|
||||
/* Toast */
|
||||
.toast {
|
||||
position: fixed; top: 20px; left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: #0a7a1a; color: #fff;
|
||||
padding: 10px 24px; border-radius: 8px;
|
||||
font-size: 13px; font-weight: 500;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||
opacity: 0; transition: opacity 0.3s;
|
||||
z-index: 9999; pointer-events: none;
|
||||
}
|
||||
.toast.show { opacity: 1; }
|
||||
.toast.error { background: #c62828; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<!-- 左侧:输入 -->
|
||||
<div class="left-panel">
|
||||
<div class="input-section">
|
||||
<div class="header">
|
||||
<h1><i class="fas fa-scissors" style="color:#0078d4;font-size:16px;margin-right:6px;"></i>Jieba 分词</h1>
|
||||
</div>
|
||||
<p class="subtitle">中文分词与词性标注工具</p>
|
||||
|
||||
<div class="section-card">
|
||||
<div class="section-title"><i class="fas fa-key"></i> 认证</div>
|
||||
<div class="input-group">
|
||||
<label>Bearer Token</label>
|
||||
<div class="input-wrap">
|
||||
<i class="fas fa-key"></i>
|
||||
<input type="text" id="tokenInput" placeholder="请输入 Token">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-card" style="flex:1;">
|
||||
<div class="section-title"><i class="fas fa-file-lines"></i> 输入文本</div>
|
||||
<div class="input-group no-icon">
|
||||
<textarea id="inputText" class="text-input" placeholder="在此输入需要分词的文本... 支持中英文混合,Ctrl+Enter 快速分词" style="min-height:240px;"></textarea>
|
||||
</div>
|
||||
<div class="btn-row" style="margin-top:10px;">
|
||||
<button class="btn btn-outline" onclick="clearAll()"><i class="fas fa-times-circle"></i> 清空</button>
|
||||
</div>
|
||||
<button class="run-btn" id="tokenizeBtn" onclick="doTokenize()">
|
||||
<i class="fas fa-scissors"></i> 开始分词
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:结果 -->
|
||||
<div class="right-panel">
|
||||
<div class="right-header">
|
||||
<h2><i class="fas fa-list" style="color:#0078d4;margin-right:6px;"></i>分词结果</h2>
|
||||
<span class="result-badge" id="totalCount" style="display:none;"></span>
|
||||
</div>
|
||||
<div id="statsBar" class="stats-bar" style="display:none;"></div>
|
||||
<div id="resultArea" class="result-area">
|
||||
<div class="placeholder">
|
||||
<i class="fas fa-scissors"></i>
|
||||
<p>输入文本并点击"开始分词"查看结果</p>
|
||||
<span class="hint">Ctrl + Enter 快速分词</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var tokenInput = document.getElementById('tokenInput');
|
||||
var inputText = document.getElementById('inputText');
|
||||
var resultArea = document.getElementById('resultArea');
|
||||
var statsBar = document.getElementById('statsBar');
|
||||
var totalCount = document.getElementById('totalCount');
|
||||
var tokenizeBtn = document.getElementById('tokenizeBtn');
|
||||
|
||||
tokenInput.value = 'DdzBgb8GEkBpA8gtCJXP24hGJz9bXpEOi6z91fHm25X59q5968XqtLxPi1MfiTHJ';
|
||||
|
||||
function showToast(msg, isError) {
|
||||
var t = document.getElementById('toast');
|
||||
t.textContent = msg;
|
||||
t.className = 'toast show' + (isError ? ' error' : '');
|
||||
setTimeout(function() { t.className = 'toast'; }, 1500);
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
inputText.value = '';
|
||||
resultArea.innerHTML = '<div class="placeholder"><i class="fas fa-scissors"></i><p>输入文本并点击"开始分词"查看结果</p><span class="hint">Ctrl + Enter 快速分词</span></div>';
|
||||
statsBar.style.display = 'none';
|
||||
totalCount.style.display = 'none';
|
||||
}
|
||||
|
||||
var typeNames = {
|
||||
chinese: '中文', english: '英文', compound_eng: '复合词',
|
||||
number: '数字', punctuation: '标点'
|
||||
};
|
||||
|
||||
var typeColors = {
|
||||
chinese: { bg: '#fce7f3', color: '#be185d', border: '#fbcfe8' },
|
||||
english: { bg: '#dbeafe', color: '#1e40af', border: '#bfdbfe' },
|
||||
compound_eng: { bg: '#ddd6fe', color: '#5b21b6', border: '#c4b5fd' },
|
||||
number: { bg: '#d1fae5', color: '#065f46', border: '#a7f3d0' },
|
||||
punctuation: { bg: '#f3f4f6', color: '#4b5563', border: '#e5e7eb' }
|
||||
};
|
||||
|
||||
async function doTokenize() {
|
||||
var text = inputText.value.trim();
|
||||
var token = tokenInput.value.trim();
|
||||
if (!text) { showToast('请输入需要分词的文本', true); return; }
|
||||
if (!token) { showToast('请输入 Token', true); return; }
|
||||
|
||||
tokenizeBtn.disabled = true;
|
||||
tokenizeBtn.innerHTML = '<span class="spinner"></span> 分词中...';
|
||||
resultArea.innerHTML = '<div class="placeholder"><span class="spinner" style="border-color:#e0e0e0;border-top-color:#0078d4;width:32px;height:32px;"></span><p style="color:#999;margin-top:8px;">正在分词...</p></div>';
|
||||
statsBar.style.display = 'none';
|
||||
totalCount.style.display = 'none';
|
||||
|
||||
try {
|
||||
var res = await fetch('/fen-ci/tokenize', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
|
||||
body: JSON.stringify({ input_text: text })
|
||||
});
|
||||
var data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
var tokens = data.tokens;
|
||||
var stats = data.stats;
|
||||
|
||||
var html = tokens.map(function(t) {
|
||||
var s = typeColors[t.type] || typeColors.punctuation;
|
||||
return '<span class="token-tag" style="background:' + s.bg + ';color:' + s.color + ';border:1px solid ' + s.border + '">' +
|
||||
t.text + '<span class="type">' + (typeNames[t.type] || t.type) + '</span></span>';
|
||||
}).join('');
|
||||
|
||||
resultArea.innerHTML = '<div class="token-list">' + html + '</div>';
|
||||
totalCount.textContent = '共 ' + stats.total + ' 个词';
|
||||
totalCount.style.display = 'inline';
|
||||
|
||||
statsBar.innerHTML =
|
||||
'<span>总计: <span class="num">' + stats.total + '</span></span>' +
|
||||
'<span>中文: <span class="num">' + stats.chinese + '</span></span>' +
|
||||
'<span>英文: <span class="num">' + stats.english + '</span></span>' +
|
||||
'<span>复合词: <span class="num">' + stats.compound_eng + '</span></span>' +
|
||||
'<span>数字: <span class="num">' + stats.number + '</span></span>' +
|
||||
'<span>标点: <span class="num">' + stats.punctuation + '</span></span>';
|
||||
statsBar.style.display = 'flex';
|
||||
} else {
|
||||
resultArea.innerHTML = '<div class="placeholder" style="color:#c62828;"><i class="fas fa-circle-exclamation"></i><p>' + (data.error || '未知错误') + '</p></div>';
|
||||
statsBar.style.display = 'none';
|
||||
totalCount.style.display = 'none';
|
||||
}
|
||||
} catch (err) {
|
||||
resultArea.innerHTML = '<div class="placeholder" style="color:#c62828;"><i class="fas fa-circle-exclamation"></i><p>请求失败: ' + err.message + '</p></div>';
|
||||
statsBar.style.display = 'none';
|
||||
totalCount.style.display = 'none';
|
||||
} finally {
|
||||
tokenizeBtn.disabled = false;
|
||||
tokenizeBtn.innerHTML = '<i class="fas fa-scissors"></i> 开始分词';
|
||||
}
|
||||
}
|
||||
|
||||
inputText.addEventListener('keydown', function(e) {
|
||||
if (e.ctrlKey && e.key === 'Enter') doTokenize();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
267
flask-dev-api/templates/http_status.html
Normal file
267
flask-dev-api/templates/http_status.html
Normal file
@@ -0,0 +1,267 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='font-awesome.css') }}">
|
||||
<title>HTTP 状态码</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
background: #f5f5f5; color: #333;
|
||||
height: 100vh; overflow: hidden; display: flex;
|
||||
}
|
||||
.left-panel {
|
||||
width: 300px; background: #fff; border-right: 1px solid #e0e0e0;
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
.panel-header { padding: 28px 24px 18px; border-bottom: 1px solid #f0f0f0; }
|
||||
.panel-header h1 { font-size: 20px; font-weight: 700; margin-bottom: 2px; }
|
||||
.panel-header .sub { font-size: 13px; color: #999; }
|
||||
.search-box {
|
||||
margin: 20px 24px; position: relative;
|
||||
}
|
||||
.search-box i {
|
||||
position: absolute; left: 12px; top: 50%; transform: translateY(-50%);
|
||||
color: #bbb; font-size: 13px; pointer-events: none;
|
||||
}
|
||||
.search-box input {
|
||||
width: 100%; padding: 10px 12px 10px 36px;
|
||||
border: 1px solid #e0e0e0; border-radius: 8px;
|
||||
font-size: 13px; outline: none; transition: border-color 0.2s;
|
||||
}
|
||||
.search-box input::placeholder { color: #ccc; }
|
||||
.search-box input:focus { border-color: #0078d4; }
|
||||
.cat-nav { padding: 0 24px; flex: 1; overflow-y: auto; }
|
||||
.cat-nav-item {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 10px 12px; border-radius: 8px; cursor: pointer;
|
||||
font-size: 13px; font-weight: 500; color: #555;
|
||||
transition: all 0.15s; margin-bottom: 2px;
|
||||
}
|
||||
.cat-nav-item:hover { background: #f5f5f5; }
|
||||
.cat-nav-item.active { background: #f0f7ff; color: #0078d4; font-weight: 600; }
|
||||
.cat-dot {
|
||||
width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
|
||||
}
|
||||
.cat-count {
|
||||
font-size: 11px; color: #bbb; background: #f5f5f5;
|
||||
padding: 1px 8px; border-radius: 10px;
|
||||
}
|
||||
.cat-nav-item.active .cat-count { background: #ddeeff; color: #0078d4; }
|
||||
.panel-footer {
|
||||
padding: 16px 24px; border-top: 1px solid #f0f0f0;
|
||||
font-size: 11px; color: #bbb; text-align: center;
|
||||
}
|
||||
.right-panel { flex: 1; overflow-y: auto; padding: 28px 32px; }
|
||||
.section-title {
|
||||
font-size: 13px; font-weight: 700; color: #999;
|
||||
text-transform: uppercase; letter-spacing: 0.06em;
|
||||
margin: 28px 0 12px; padding-bottom: 8px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
.section-title:first-child { margin-top: 0; }
|
||||
.code-grid { display: flex; flex-direction: column; gap: 6px; }
|
||||
.code-card {
|
||||
background: #fff; border: 1px solid #e8e8e8; border-radius: 10px;
|
||||
padding: 14px 18px; display: flex; align-items: baseline; gap: 14px;
|
||||
transition: all 0.15s; cursor: default;
|
||||
}
|
||||
.code-card:hover { border-color: #c0c0c0; box-shadow: 0 2px 8px rgba(0,0,0,0.04); }
|
||||
.code-num {
|
||||
font-size: 18px; font-weight: 700; font-family: "SFMono-Regular", Consolas, monospace;
|
||||
min-width: 44px; flex-shrink: 0;
|
||||
}
|
||||
.code-info { flex: 1; min-width: 0; }
|
||||
.code-name { font-size: 14px; font-weight: 600; color: #333; margin-bottom: 2px; }
|
||||
.code-desc { font-size: 12px; color: #888; line-height: 1.5; }
|
||||
.c1xx .code-num { color: #0288d1; }
|
||||
.c2xx .code-num { color: #2e7d32; }
|
||||
.c3xx .code-num { color: #f57f17; }
|
||||
.c4xx .code-num { color: #c62828; }
|
||||
.c5xx .code-num { color: #7b1fa2; }
|
||||
.dot-1xx { background: #0288d1; }
|
||||
.dot-2xx { background: #2e7d32; }
|
||||
.dot-3xx { background: #f57f17; }
|
||||
.dot-4xx { background: #c62828; }
|
||||
.dot-5xx { background: #7b1fa2; }
|
||||
.empty-state {
|
||||
text-align: center; padding: 80px 20px; color: #ccc;
|
||||
}
|
||||
.empty-state i { font-size: 36px; margin-bottom: 12px; display: block; }
|
||||
.empty-state p { font-size: 14px; }
|
||||
.match-count {
|
||||
font-size: 12px; color: #999; margin-bottom: 16px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="left-panel">
|
||||
<div class="panel-header">
|
||||
<h1>HTTP 状态码</h1>
|
||||
<span class="sub">快速参考手册</span>
|
||||
</div>
|
||||
<div class="search-box">
|
||||
<i class="fas fa-search"></i>
|
||||
<input type="text" id="searchInput" placeholder="搜索状态码、名称或描述..." oninput="onSearch()">
|
||||
</div>
|
||||
<div class="cat-nav" id="catNav"></div>
|
||||
<div class="panel-footer">共 60+ 状态码</div>
|
||||
</div>
|
||||
|
||||
<div class="right-panel" id="content"></div>
|
||||
|
||||
<script>
|
||||
var data = [
|
||||
{cat:'1xx 信息响应', cls:'c1xx', dot:'dot-1xx', codes:[
|
||||
{n:100,e:'Continue',d:'等待客户端继续发送请求体。'},
|
||||
{n:101,e:'Switching Protocols',d:'服务器同意切换协议。'},
|
||||
{n:102,e:'Processing',d:'服务器正在处理请求,尚无响应可用。'},
|
||||
{n:103,e:'Early Hints',d:'服务器在最终响应前返回部分响应头。'}
|
||||
]},
|
||||
{cat:'2xx 成功', cls:'c2xx', dot:'dot-2xx', codes:[
|
||||
{n:200,e:'OK',d:'标准的成功响应。'},
|
||||
{n:201,e:'Created',d:'请求成功并创建了新资源。'},
|
||||
{n:202,e:'Accepted',d:'请求已接受但尚未处理完成。'},
|
||||
{n:203,e:'Non-Authoritative Information',d:'请求成功,但内容已被代理修改。'},
|
||||
{n:204,e:'No Content',d:'服务器成功处理但不返回内容。'},
|
||||
{n:205,e:'Reset Content',d:'重置文档视图。'},
|
||||
{n:206,e:'Partial Content',d:'服务器返回部分内容。'},
|
||||
{n:207,e:'Multi-Status',d:'XML 消息包含多个独立响应码。(WebDAV)'},
|
||||
{n:208,e:'Already Reported',d:'DAV 绑定成员已在前面枚举。(WebDAV)'},
|
||||
{n:226,e:'IM Used',d:'服务器已完成资源请求,响应为操作结果。'}
|
||||
]},
|
||||
{cat:'3xx 重定向', cls:'c3xx', dot:'dot-3xx', codes:[
|
||||
{n:300,e:'Multiple Choices',d:'有多个可选资源。'},
|
||||
{n:301,e:'Moved Permanently',d:'资源已永久移动到新 URI。'},
|
||||
{n:302,e:'Found',d:'临时重定向到另一个 URL。'},
|
||||
{n:303,e:'See Other',d:'可在另一 URI 下用 GET 获取响应。'},
|
||||
{n:304,e:'Not Modified',d:'资源未修改,使用缓存。'},
|
||||
{n:305,e:'Use Proxy',d:'必须通过指定代理访问资源。'},
|
||||
{n:306,e:'Switch Proxy',d:'已不再使用。'},
|
||||
{n:307,e:'Temporary Redirect',d:'临时重定向,保持请求方法不变。'},
|
||||
{n:308,e:'Permanent Redirect',d:'永久重定向,保持请求方法不变。'}
|
||||
]},
|
||||
{cat:'4xx 客户端错误', cls:'c4xx', dot:'dot-4xx', codes:[
|
||||
{n:400,e:'Bad Request',d:'请求语法错误,服务器无法处理。'},
|
||||
{n:401,e:'Unauthorized',d:'需要身份验证。'},
|
||||
{n:402,e:'Payment Required',d:'保留用于未来数字支付。'},
|
||||
{n:403,e:'Forbidden',d:'服务器拒绝执行,权限不足。'},
|
||||
{n:404,e:'Not Found',d:'请求的资源未找到。'},
|
||||
{n:405,e:'Method Not Allowed',d:'请求方法不被允许。'},
|
||||
{n:406,e:'Not Acceptable',d:'资源无法生成客户端可接受的内容。'},
|
||||
{n:407,e:'Proxy Authentication Required',d:'需要代理身份验证。'},
|
||||
{n:408,e:'Request Timeout',d:'服务器等待请求超时。'},
|
||||
{n:409,e:'Conflict',d:'请求与服务器当前状态冲突。'},
|
||||
{n:410,e:'Gone',d:'资源已永久删除且不会再恢复。'},
|
||||
{n:411,e:'Length Required',d:'缺少 Content-Length 头。'},
|
||||
{n:412,e:'Precondition Failed',d:'服务器不满足请求中的前提条件。'},
|
||||
{n:413,e:'Payload Too Large',d:'请求体超过服务器限制。'},
|
||||
{n:414,e:'URI Too Long',d:'URI 过长无法处理。'},
|
||||
{n:415,e:'Unsupported Media Type',d:'不支持的媒体类型。'},
|
||||
{n:416,e:'Range Not Satisfiable',d:'无法满足请求的范围。'},
|
||||
{n:417,e:'Expectation Failed',d:'无法满足 Expect 请求头。'},
|
||||
{n:418,e:"I'm a teapot",d:'拒绝用茶壶煮咖啡。'},
|
||||
{n:421,e:'Misdirected Request',d:'请求被定向到无法产生响应的服务器。'},
|
||||
{n:422,e:'Unprocessable Entity',d:'请求格式正确但语义错误。(WebDAV)'},
|
||||
{n:423,e:'Locked',d:'资源已锁定。(WebDAV)'},
|
||||
{n:424,e:'Failed Dependency',d:'因前序请求失败而失败。(WebDAV)'},
|
||||
{n:425,e:'Too Early',d:'服务器不愿冒险处理可能被重放的请求。'},
|
||||
{n:426,e:'Upgrade Required',d:'客户端应切换协议(如 TLS)。'},
|
||||
{n:428,e:'Precondition Required',d:'要求请求为条件请求。'},
|
||||
{n:429,e:'Too Many Requests',d:'客户端发送请求过于频繁。'},
|
||||
{n:431,e:'Request Header Fields Too Large',d:'请求头字段过大。'},
|
||||
{n:451,e:'Unavailable For Legal Reasons',d:'因法律原因不可用。'}
|
||||
]},
|
||||
{cat:'5xx 服务端错误', cls:'c5xx', dot:'dot-5xx', codes:[
|
||||
{n:500,e:'Internal Server Error',d:'服务器内部错误。'},
|
||||
{n:501,e:'Not Implemented',d:'服务器不支持该请求方法。'},
|
||||
{n:502,e:'Bad Gateway',d:'网关或代理收到上游无效响应。'},
|
||||
{n:503,e:'Service Unavailable',d:'服务暂时不可用(过载或维护)。'},
|
||||
{n:504,e:'Gateway Timeout',d:'网关或代理等待上游响应超时。'},
|
||||
{n:505,e:'HTTP Version Not Supported',d:'不支持的 HTTP 协议版本。'},
|
||||
{n:506,e:'Variant Also Negotiates',d:'内容协商出现循环引用。'},
|
||||
{n:507,e:'Insufficient Storage',d:'服务器存储空间不足。(WebDAV)'},
|
||||
{n:508,e:'Loop Detected',d:'服务器检测到无限循环。(WebDAV)'},
|
||||
{n:510,e:'Not Extended',d:'需要进一步扩展请求。'},
|
||||
{n:511,e:'Network Authentication Required',d:'需要网络身份验证。'}
|
||||
]}
|
||||
];
|
||||
|
||||
var activeCat = -1;
|
||||
|
||||
function esc(s) { return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||
|
||||
function card(cls, n, name, desc) {
|
||||
return '<div class="code-card ' + cls + '">'
|
||||
+ '<span class="code-num">' + n + '</span>'
|
||||
+ '<div class="code-info">'
|
||||
+ '<div class="code-name">' + esc(name) + '</div>'
|
||||
+ '<div class="code-desc">' + esc(desc) + '</div>'
|
||||
+ '</div></div>';
|
||||
}
|
||||
|
||||
function renderNav() {
|
||||
var html = '<div class="cat-nav-item' + (activeCat === -1 ? ' active' : '') + '" onclick="selectCat(-1)">'
|
||||
+ '<span>全部</span><span class="cat-count">' + totalCodes() + '</span></div>';
|
||||
data.forEach(function(c, i) {
|
||||
html += '<div class="cat-nav-item' + (activeCat === i ? ' active' : '') + '" onclick="selectCat(' + i + ')">'
|
||||
+ '<span><span class="cat-dot ' + c.dot + '" style="display:inline-block;margin-right:8px;vertical-align:middle;"></span>' + c.cat.split(' ')[0] + '</span>'
|
||||
+ '<span class="cat-count">' + c.codes.length + '</span></div>';
|
||||
});
|
||||
document.getElementById('catNav').innerHTML = html;
|
||||
}
|
||||
|
||||
function totalCodes() { var n = 0; data.forEach(function(c) { n += c.codes.length; }); return n; }
|
||||
|
||||
function renderContent(filter) {
|
||||
var q = (filter || '').toLowerCase();
|
||||
var html = '';
|
||||
|
||||
if (q) {
|
||||
var hits = [];
|
||||
data.forEach(function(cat) {
|
||||
cat.codes.forEach(function(c) {
|
||||
var str = c.n + ' ' + c.e + ' ' + c.d;
|
||||
if (str.toLowerCase().indexOf(q) !== -1) {
|
||||
hits.push({cls: cat.cls, n: c.n, e: c.e, d: c.d});
|
||||
}
|
||||
});
|
||||
});
|
||||
html = '<div class="match-count">找到 ' + hits.length + ' 个结果</div><div class="code-grid">';
|
||||
if (!hits.length) {
|
||||
html = '<div class="empty-state"><i class="fas fa-search"></i><p>未找到匹配结果</p></div>';
|
||||
} else {
|
||||
hits.forEach(function(c) { html += card(c.cls, c.n, c.e, c.d); });
|
||||
}
|
||||
html += '</div>';
|
||||
} else {
|
||||
var cats = activeCat === -1 ? data : [data[activeCat]];
|
||||
cats.forEach(function(cat) {
|
||||
html += '<div class="section-title">' + cat.cat + '</div><div class="code-grid">';
|
||||
cat.codes.forEach(function(c) { html += card(cat.cls, c.n, c.e, c.d); });
|
||||
html += '</div>';
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('content').innerHTML = html;
|
||||
}
|
||||
|
||||
function selectCat(i) {
|
||||
activeCat = i;
|
||||
document.getElementById('searchInput').value = '';
|
||||
renderNav();
|
||||
renderContent('');
|
||||
}
|
||||
|
||||
function onSearch() {
|
||||
renderContent(document.getElementById('searchInput').value);
|
||||
}
|
||||
|
||||
renderNav();
|
||||
renderContent('');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
1186
flask-dev-api/templates/index.html
Normal file
1186
flask-dev-api/templates/index.html
Normal file
File diff suppressed because it is too large
Load Diff
223
flask-dev-api/templates/json_format.html
Normal file
223
flask-dev-api/templates/json_format.html
Normal file
@@ -0,0 +1,223 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='font-awesome.css') }}">
|
||||
<title>JSON 美化 / 压缩</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
background: #f5f5f5;
|
||||
color: #333;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
}
|
||||
.left-panel {
|
||||
width: 300px;
|
||||
background: #fff;
|
||||
border-right: 1px solid #e0e0e0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.panel-header {
|
||||
padding: 28px 24px 18px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
.panel-header h1 { font-size: 20px; font-weight: 700; margin-bottom: 2px; }
|
||||
.panel-header .sub { font-size: 13px; color: #999; }
|
||||
.panel-body { padding: 20px 24px; flex: 1; overflow-y: auto; }
|
||||
.group { margin-bottom: 24px; }
|
||||
.group-title { font-size: 11px; font-weight: 600; color: #aaa; text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 10px; }
|
||||
.tabs { display: flex; background: #f5f5f5; border-radius: 8px; padding: 3px; gap: 3px; }
|
||||
.tab-btn {
|
||||
flex: 1; padding: 9px 0; text-align: center;
|
||||
font-size: 13px; font-weight: 600; color: #999;
|
||||
background: none; border: none; border-radius: 6px; cursor: pointer; transition: all 0.2s;
|
||||
}
|
||||
.tab-btn.on { background: #fff; color: #0078d4; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
|
||||
.tab-btn:hover:not(.on) { color: #666; }
|
||||
select.styled {
|
||||
width: 100%; padding: 9px 32px 9px 12px;
|
||||
border: 1px solid #e0e0e0; border-radius: 8px;
|
||||
font-size: 13px; color: #333; background: #fff;
|
||||
outline: none; cursor: pointer;
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 10 10'%3E%3Cpath fill='%23999' d='M5 7L1 3h8z'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 10px center;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
select.styled:focus { border-color: #0078d4; }
|
||||
.chk-label { display: flex; align-items: center; gap: 8px; cursor: pointer; font-size: 13px; color: #555; }
|
||||
.chk-label input { display: none; }
|
||||
.chk-icon {
|
||||
width: 16px; height: 16px; border: 2px solid #d0d0d0; border-radius: 4px;
|
||||
display: flex; align-items: center; justify-content: center; transition: all 0.15s;
|
||||
}
|
||||
.chk-icon i { font-size: 10px; color: #fff; opacity: 0; transition: opacity 0.15s; }
|
||||
.chk-label input:checked ~ .chk-icon { background: #0078d4; border-color: #0078d4; }
|
||||
.chk-label input:checked ~ .chk-icon i { opacity: 1; }
|
||||
.panel-actions { padding: 16px 24px; border-top: 1px solid #f0f0f0; }
|
||||
.panel-actions button {
|
||||
width: 100%; padding: 10px; border: none; border-radius: 8px;
|
||||
font-size: 13px; font-weight: 600; cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center; gap: 8px;
|
||||
margin-bottom: 8px; transition: all 0.15s;
|
||||
}
|
||||
.panel-actions button:last-child { margin-bottom: 0; }
|
||||
.btn-main { background: #0078d4; color: #fff; }
|
||||
.btn-main:hover { background: #006cbd; }
|
||||
.btn-main.copied { background: #2e7d32; }
|
||||
.btn-ghost { background: #fff; color: #666; border: 1px solid #e0e0e0; }
|
||||
.btn-ghost:hover { background: #f5f5f5; color: #333; }
|
||||
.btn-ghost.danger:hover { color: #c62828; border-color: #f5b3b3; background: #fff5f5; }
|
||||
.right { flex: 1; display: flex; }
|
||||
.pane { flex: 1; display: flex; flex-direction: column; background: #fff; }
|
||||
.pane:first-child { border-right: 1px solid #e0e0e0; }
|
||||
.pane-head {
|
||||
padding: 12px 20px; font-size: 11px; font-weight: 600; color: #aaa;
|
||||
text-transform: uppercase; letter-spacing: 0.06em;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
}
|
||||
.pane-head i { font-size: 11px; color: #ccc; }
|
||||
.pane-body { flex: 1; display: flex; flex-direction: column; }
|
||||
.pane-body textarea {
|
||||
flex: 1; width: 100%; border: none; outline: none; resize: none;
|
||||
padding: 16px 20px;
|
||||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
|
||||
font-size: 13px; line-height: 1.7; color: #333; background: #fff;
|
||||
}
|
||||
.pane-body textarea::placeholder { color: #ccc; }
|
||||
.pane-body textarea:focus { background: #fefefe; }
|
||||
.pane-body textarea.has-error { background: #fff8f8; }
|
||||
.err-bar {
|
||||
padding: 8px 20px; font-size: 12px; color: #c62828;
|
||||
background: #fff5f5; border-top: 1px solid #fde8e8;
|
||||
display: none; align-items: center; gap: 6px;
|
||||
}
|
||||
.err-bar.on { display: flex; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="left-panel">
|
||||
<div class="panel-header">
|
||||
<h1>JSON 工具</h1>
|
||||
<span class="sub">美化 & 压缩</span>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="group">
|
||||
<div class="group-title">模式</div>
|
||||
<div class="tabs">
|
||||
<button class="tab-btn on" data-tab="beautify" onclick="switchTab('beautify')">美化</button>
|
||||
<button class="tab-btn" data-tab="minify" onclick="switchTab('minify')">压缩</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="beautifyOpts">
|
||||
<div class="group">
|
||||
<div class="group-title">缩进空格</div>
|
||||
<select id="indentSize" class="styled" onchange="process()">
|
||||
<option value="2">2</option>
|
||||
<option value="4" selected>4</option>
|
||||
<option value="6">6</option>
|
||||
<option value="8">8</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="group">
|
||||
<label class="chk-label">
|
||||
<input type="checkbox" id="sortKeys" onchange="process()">
|
||||
<span class="chk-icon"><i class="fas fa-check"></i></span>
|
||||
<span>键名排序</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-actions">
|
||||
<button class="btn-main" id="copyBtn" onclick="copyOutput()"><i class="fas fa-copy"></i><span>复制结果</span></button>
|
||||
<button class="btn-ghost" onclick="swapIO()"><i class="fas fa-refresh"></i> 交换输入输出</button>
|
||||
<button class="btn-ghost danger" onclick="clearAll()"><i class="fas fa-trash"></i> 清空</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="right">
|
||||
<div class="pane">
|
||||
<div class="pane-head"><i class="fas fa-file"></i> 输入</div>
|
||||
<div class="pane-body">
|
||||
<textarea id="input" placeholder="在此粘贴 JSON..." oninput="process()"></textarea>
|
||||
<div class="err-bar" id="errBar"><i class="fas fa-times-circle"></i><span id="errMsg"></span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pane">
|
||||
<div class="pane-head"><i class="fas fa-check"></i> <span id="outLabel">美化结果</span></div>
|
||||
<div class="pane-body">
|
||||
<textarea id="output" readonly placeholder="结果将显示在这里..."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var cur = 'beautify';
|
||||
|
||||
function switchTab(t) {
|
||||
cur = t;
|
||||
document.querySelectorAll('.tab-btn').forEach(function(b) { b.classList.toggle('on', b.dataset.tab === t); });
|
||||
document.getElementById('beautifyOpts').style.display = t === 'beautify' ? 'block' : 'none';
|
||||
document.getElementById('outLabel').textContent = t === 'beautify' ? '美化结果' : '压缩结果';
|
||||
process();
|
||||
}
|
||||
|
||||
function sortKeys(o) {
|
||||
if (typeof o !== 'object' || o === null) return o;
|
||||
if (Array.isArray(o)) return o.map(sortKeys);
|
||||
return Object.keys(o).sort().reduce(function(a, k) { a[k] = sortKeys(o[k]); return a; }, {});
|
||||
}
|
||||
|
||||
function process() {
|
||||
var v = document.getElementById('input').value;
|
||||
var errBar = document.getElementById('errBar');
|
||||
var errMsg = document.getElementById('errMsg');
|
||||
var out = document.getElementById('output');
|
||||
var inp = document.getElementById('input');
|
||||
if (!v.trim()) { out.value = ''; errBar.classList.remove('on'); inp.classList.remove('has-error'); return; }
|
||||
try {
|
||||
var obj = JSON.parse(v);
|
||||
if (cur === 'beautify') {
|
||||
var n = parseInt(document.getElementById('indentSize').value);
|
||||
out.value = JSON.stringify(document.getElementById('sortKeys').checked ? sortKeys(obj) : obj, null, n);
|
||||
} else {
|
||||
out.value = JSON.stringify(obj);
|
||||
}
|
||||
errBar.classList.remove('on'); inp.classList.remove('has-error');
|
||||
} catch (e) {
|
||||
errMsg.textContent = e.message; errBar.classList.add('on'); inp.classList.add('has-error'); out.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
function copyOutput() {
|
||||
var t = document.getElementById('output').value;
|
||||
if (!t) return;
|
||||
var el = document.createElement('textarea');
|
||||
el.value = t; el.style.cssText = 'position:fixed;left:-9999px';
|
||||
document.body.appendChild(el); el.select(); document.execCommand('copy'); document.body.removeChild(el);
|
||||
var b = document.getElementById('copyBtn');
|
||||
b.classList.add('copied'); b.querySelector('span').textContent = '已复制';
|
||||
setTimeout(function() { b.classList.remove('copied'); b.querySelector('span').textContent = '复制结果'; }, 1500);
|
||||
}
|
||||
|
||||
function swapIO() {
|
||||
var a = document.getElementById('input'), b = document.getElementById('output'), t = a.value;
|
||||
a.value = b.value; b.value = t; process();
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
document.getElementById('input').value = '';
|
||||
document.getElementById('output').value = '';
|
||||
document.getElementById('errBar').classList.remove('on');
|
||||
document.getElementById('input').classList.remove('has-error');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
1622
flask-dev-api/templates/pin_tu.html
Normal file
1622
flask-dev-api/templates/pin_tu.html
Normal file
File diff suppressed because it is too large
Load Diff
100
flask-dev-api/templates/pin_tu_manga.html
Normal file
100
flask-dev-api/templates/pin_tu_manga.html
Normal file
@@ -0,0 +1,100 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>漫画阅读 - {{ current_path_name }}</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
background: #1a1a1a;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 0;
|
||||
}
|
||||
.reader-bar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 40px;
|
||||
background: rgba(0,0,0,0.85);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 16px;
|
||||
z-index: 100;
|
||||
gap: 12px;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
.reader-bar .title {
|
||||
color: rgba(255,255,255,0.8);
|
||||
font-size: 13px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex: 1;
|
||||
}
|
||||
.reader-bar .count {
|
||||
color: rgba(255,255,255,0.5);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.reader-bar .close-btn {
|
||||
color: rgba(255,255,255,0.6);
|
||||
text-decoration: none;
|
||||
font-size: 20px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 6px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.reader-bar .close-btn:hover { background: rgba(255,255,255,0.15); color: #fff; }
|
||||
|
||||
.manga-container {
|
||||
width: 100%;
|
||||
max-width: 900px;
|
||||
margin-top: 40px;
|
||||
}
|
||||
.manga-item {
|
||||
width: 100%;
|
||||
line-height: 0;
|
||||
}
|
||||
.manga-item img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
.manga-empty {
|
||||
color: rgba(255,255,255,0.4);
|
||||
text-align: center;
|
||||
padding: 120px 20px;
|
||||
font-size: 15px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="reader-bar">
|
||||
<a class="close-btn" onclick="window.close()" title="关闭">×</a>
|
||||
<span class="title">{{ current_path_name }}</span>
|
||||
<span class="count">{{ images|length }} 张</span>
|
||||
</div>
|
||||
<div class="manga-container">
|
||||
{% if images %}
|
||||
{% for image in images %}
|
||||
<div class="manga-item">
|
||||
<img src="/pin-tu/media/{{ root_b64 }}?p={{ (current_subpath + image) | urlencode }}" alt="{{ image }}" loading="lazy">
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="manga-empty">此文件夹没有图片</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
561
flask-dev-api/templates/pin_tu_search.html
Normal file
561
flask-dev-api/templates/pin_tu_search.html
Normal file
@@ -0,0 +1,561 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>搜索 - {{ root_display }}</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='font-awesome.css') }}">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
background: #f5f5f5;
|
||||
color: #333;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.search-header {
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
padding: 14px 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.back-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
color: #0078d4;
|
||||
text-decoration: none;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.back-link:hover { background: #e8f0fe; }
|
||||
.search-bar {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: #f5f5f5;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
padding: 8px 14px;
|
||||
gap: 8px;
|
||||
transition: border-color 0.2s;
|
||||
max-width: 600px;
|
||||
}
|
||||
.search-bar:focus-within { border-color: #0078d4; }
|
||||
.search-bar i { color: #999; }
|
||||
.search-bar input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: none;
|
||||
outline: none;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
}
|
||||
.search-bar input::placeholder { color: #bbb; }
|
||||
.search-bar .clear-btn {
|
||||
color: #ccc;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
.search-bar .clear-btn:hover { color: #999; }
|
||||
.search-bar .go-btn {
|
||||
background: #0078d4;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 6px 16px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
white-space: nowrap;
|
||||
margin-left: 4px;
|
||||
}
|
||||
.search-bar .go-btn:hover { background: #006cbd; }
|
||||
.search-status {
|
||||
padding: 10px 20px;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
.search-content {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
padding: 16px 20px;
|
||||
}
|
||||
.result-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px 16px;
|
||||
background: #fff;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
gap: 12px;
|
||||
}
|
||||
.result-item:hover { border-color: #0078d4; background: #f0f8ff; }
|
||||
.result-icon {
|
||||
font-size: 20px;
|
||||
width: 28px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.result-icon.folder { color: #ffb300; }
|
||||
.result-icon.image { color: #0078d4; }
|
||||
.result-icon.video { color: #e53935; }
|
||||
.result-icon.file { color: #999; }
|
||||
.result-info { flex: 1; min-width: 0; }
|
||||
.result-name { font-size: 14px; font-weight: 500; }
|
||||
.result-name mark { background: #fff3cd; color: #333; padding: 0 1px; border-radius: 2px; }
|
||||
.result-path { font-size: 12px; color: #999; margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.result-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.result-type {
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
background: #f0f0f0;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.btn-copy {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px; height: 30px;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
color: #999;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.btn-copy:hover { background: #f0f0f0; color: #333; border-color: #ccc; }
|
||||
.btn-copy.copied { background: #e8f5e9; color: #2e7d32; border-color: #a5d6a7; }
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 80px 20px;
|
||||
color: #ccc;
|
||||
}
|
||||
.empty-state i { font-size: 48px; margin-bottom: 16px; display: block; }
|
||||
.empty-state p { font-size: 14px; }
|
||||
.loading { text-align: center; padding: 40px; color: #999; font-size: 13px; }
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
width: 14px; height: 14px;
|
||||
border: 2px solid rgba(0,120,212,0.2);
|
||||
border-top-color: #0078d4;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
margin-right: 8px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* 搜索历史 */
|
||||
.history-section {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
padding: 16px 20px 0;
|
||||
}
|
||||
.history-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.history-header h3 {
|
||||
font-size: 13px;
|
||||
color: #999;
|
||||
font-weight: 600;
|
||||
}
|
||||
.history-clear-btn {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.history-clear-btn:hover { color: #c62828; background: #fce4ec; }
|
||||
.history-list { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.history-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
background: #fff;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 20px;
|
||||
font-size: 13px;
|
||||
color: #555;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
user-select: none;
|
||||
}
|
||||
.history-item:hover { border-color: #0078d4; color: #0078d4; background: #f0f8ff; }
|
||||
.history-item .hi-keyword { max-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.history-item .hi-count {
|
||||
font-size: 11px;
|
||||
color: #bbb;
|
||||
}
|
||||
.history-item .hi-del {
|
||||
color: #ccc;
|
||||
font-size: 11px;
|
||||
padding: 0 2px;
|
||||
border-radius: 3px;
|
||||
transition: all 0.15s;
|
||||
line-height: 1;
|
||||
}
|
||||
.history-item .hi-del:hover { color: #c62828; background: #fce4ec; }
|
||||
|
||||
/* 自定义确认弹窗 */
|
||||
.confirm-overlay {
|
||||
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(0,0,0,0.4);
|
||||
display: flex; justify-content: center; align-items: center;
|
||||
z-index: 10000; opacity: 0; pointer-events: none; transition: opacity 0.2s ease;
|
||||
}
|
||||
.confirm-overlay.active { opacity: 1; pointer-events: auto; }
|
||||
.confirm-card {
|
||||
background: #fff; border-radius: 14px; padding: 28px 32px;
|
||||
width: 360px; max-width: 90vw; text-align: center;
|
||||
box-shadow: 0 12px 40px rgba(0,0,0,0.2);
|
||||
transform: scale(0.9); transition: transform 0.2s ease;
|
||||
}
|
||||
.confirm-overlay.active .confirm-card { transform: scale(1); }
|
||||
.confirm-icon {
|
||||
width: 48px; height: 48px; border-radius: 50%;
|
||||
background: #fff3e0; color: #e65100;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
font-size: 22px; margin-bottom: 14px;
|
||||
}
|
||||
.confirm-title { font-size: 16px; font-weight: 600; color: #333; margin-bottom: 8px; }
|
||||
.confirm-msg { font-size: 13px; color: #888; margin-bottom: 24px; line-height: 1.5; }
|
||||
.confirm-btns { display: flex; gap: 10px; justify-content: center; }
|
||||
.confirm-btns button {
|
||||
flex: 1; padding: 10px 20px; border: none; border-radius: 8px;
|
||||
font-size: 14px; font-weight: 600; cursor: pointer; transition: all 0.15s;
|
||||
}
|
||||
.confirm-cancel { background: #f0f0f0; color: #666; }
|
||||
.confirm-cancel:hover { background: #e0e0e0; }
|
||||
.confirm-ok { background: #c62828; color: #fff; }
|
||||
.confirm-ok:hover { background: #b71c1c; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="search-header">
|
||||
<a href="/pin-tu/browse/{{ root_b64 }}" class="back-link"><i class="fas fa-arrow-left"></i> 返回</a>
|
||||
<div class="search-bar">
|
||||
<i class="fas fa-search"></i>
|
||||
<input type="text" id="searchInput" value="{{ query }}" placeholder="搜索文件或文件夹..." autofocus>
|
||||
<span class="clear-btn" onclick="clearSearch()"><i class="fas fa-times-circle"></i></span>
|
||||
<button class="go-btn" onclick="doSearch()">搜索</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 搜索历史 -->
|
||||
<div class="history-section" id="historySection" style="display:none;">
|
||||
<div class="history-header">
|
||||
<h3><i class="fas fa-clock"></i> 搜索历史</h3>
|
||||
<button class="history-clear-btn" onclick="clearAllHistory()">清空历史</button>
|
||||
</div>
|
||||
<div class="history-list" id="historyList"></div>
|
||||
</div>
|
||||
|
||||
<div class="search-status" id="searchStatus" style="display:none;"></div>
|
||||
<div class="search-content" id="searchContent">
|
||||
{% if query %}
|
||||
<div class="loading"><span class="spinner"></span>搜索中...</div>
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<i class="fas fa-search"></i>
|
||||
<p>输入关键词搜索文件和文件夹</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- 自定义确认弹窗 -->
|
||||
<div class="confirm-overlay" id="confirmOverlay">
|
||||
<div class="confirm-card">
|
||||
<div class="confirm-icon"><i class="fas fa-times-circle"></i></div>
|
||||
<div class="confirm-title" id="confirmTitle">确认操作</div>
|
||||
<div class="confirm-msg" id="confirmMsg">确定要执行此操作吗?</div>
|
||||
<div class="confirm-btns">
|
||||
<button class="confirm-cancel" onclick="confirmResolve(false)">取消</button>
|
||||
<button class="confirm-ok" id="confirmOkBtn" onclick="confirmResolve(true)">确定</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let _confirmResolver = null;
|
||||
|
||||
function showConfirm(title, msg, okText) {
|
||||
document.getElementById('confirmTitle').textContent = title || '确认操作';
|
||||
document.getElementById('confirmMsg').textContent = msg || '确定要执行此操作吗?';
|
||||
document.getElementById('confirmOkBtn').textContent = okText || '确定';
|
||||
document.getElementById('confirmOverlay').classList.add('active');
|
||||
return new Promise(function(resolve) { _confirmResolver = resolve; });
|
||||
}
|
||||
|
||||
function confirmResolve(val) {
|
||||
document.getElementById('confirmOverlay').classList.remove('active');
|
||||
if (_confirmResolver) { _confirmResolver(val); _confirmResolver = null; }
|
||||
}
|
||||
|
||||
const ROOT_B64 = '{{ root_b64 }}';
|
||||
const input = document.getElementById('searchInput');
|
||||
const content = document.getElementById('searchContent');
|
||||
const status = document.getElementById('searchStatus');
|
||||
const historySection = document.getElementById('historySection');
|
||||
const historyList = document.getElementById('historyList');
|
||||
let lastResults = [];
|
||||
|
||||
const extImage = {'JPG':1,'JPEG':1,'PNG':1,'GIF':1,'BMP':1,'TIFF':1,'WEBP':1};
|
||||
const extVideo = {'MP4':1,'AVI':1,'MOV':1,'WMV':1,'FLV':1,'WEBM':1,'M4V':1,'MKV':1};
|
||||
|
||||
function getIcon(r) {
|
||||
if (r.type === 'folder') return { cls: 'folder', icon: 'fa-folder' };
|
||||
if (extImage[r.type]) return { cls: 'image', icon: 'fa-image' };
|
||||
if (extVideo[r.type]) return { cls: 'video', icon: 'fa-video' };
|
||||
return { cls: 'file', icon: 'fa-file' };
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = text;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function highlight(text, q) {
|
||||
if (!q) return escapeHtml(text);
|
||||
const escaped = escapeHtml(text);
|
||||
const re = new RegExp('(' + q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ')', 'gi');
|
||||
return escaped.replace(re, '<mark>$1</mark>');
|
||||
}
|
||||
|
||||
// ===== 复制 =====
|
||||
function copyName(idx) {
|
||||
var r = lastResults[idx];
|
||||
if (!r) return;
|
||||
var name = r.name;
|
||||
var btn = document.querySelector('.btn-copy[data-idx="' + idx + '"]');
|
||||
// 始终用 textarea + execCommand,兼容 HTTP 环境
|
||||
var ta = document.createElement('textarea');
|
||||
ta.value = name;
|
||||
ta.style.cssText = 'position:fixed;left:-9999px;top:-9999px;opacity:0;';
|
||||
document.body.appendChild(ta);
|
||||
ta.focus();
|
||||
ta.select();
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
if (btn) {
|
||||
btn.classList.add('copied');
|
||||
btn.innerHTML = '<i class="fas fa-check"></i>';
|
||||
setTimeout(function() {
|
||||
btn.classList.remove('copied');
|
||||
btn.innerHTML = '<i class="fas fa-copy"></i>';
|
||||
}, 1500);
|
||||
}
|
||||
} catch (e) {
|
||||
alert('复制失败,请手动复制');
|
||||
}
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
|
||||
// ===== 搜索 =====
|
||||
function clearSearch() {
|
||||
input.value = '';
|
||||
content.innerHTML = '<div class="empty-state"><i class="fas fa-search"></i><p>输入关键词搜索文件和文件夹</p></div>';
|
||||
status.style.display = 'none';
|
||||
lastResults = [];
|
||||
input.focus();
|
||||
loadHistory();
|
||||
}
|
||||
|
||||
function renderResults(data, q) {
|
||||
lastResults = data.results;
|
||||
status.style.display = 'block';
|
||||
if (!data.results.length) {
|
||||
status.textContent = '未找到匹配"' + q + '"的结果';
|
||||
content.innerHTML = '<div class="empty-state"><i class="fas fa-search"></i><p>未找到匹配结果</p></div>';
|
||||
return;
|
||||
}
|
||||
status.textContent = '找到 ' + data.results.length + ' 个结果' + (data.results.length >= 50 ? '(最多显示50条)' : '');
|
||||
historySection.style.display = 'none';
|
||||
|
||||
content.innerHTML = data.results.map(function(r, idx) {
|
||||
var ic = getIcon(r);
|
||||
var parent = r.path.includes('/') ? r.path.substring(0, r.path.lastIndexOf('/')) : '';
|
||||
return '<div class="result-item" data-idx="' + idx + '">' +
|
||||
'<span class="result-icon ' + ic.cls + '"><i class="fas ' + ic.icon + '"></i></span>' +
|
||||
'<div class="result-info">' +
|
||||
'<div class="result-name">' + highlight(r.name, q) + '</div>' +
|
||||
(parent ? '<div class="result-path">' + escapeHtml(parent) + '</div>' : '') +
|
||||
'</div>' +
|
||||
'<div class="result-actions">' +
|
||||
'<span class="result-type">' + escapeHtml(r.type) + '</span>' +
|
||||
'<button class="btn-copy" data-idx="' + idx + '" title="复制名称"><i class="fas fa-copy"></i></button>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
|
||||
// 绑定点击事件
|
||||
content.querySelectorAll('.result-item').forEach(function(el) {
|
||||
el.addEventListener('click', function(e) {
|
||||
// 如果点的是复制按钮,不跳转
|
||||
if (e.target.closest('.btn-copy')) return;
|
||||
goResult(parseInt(this.dataset.idx));
|
||||
});
|
||||
});
|
||||
content.querySelectorAll('.btn-copy').forEach(function(btn) {
|
||||
btn.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
copyName(parseInt(this.dataset.idx));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function doSearch() {
|
||||
var q = input.value.trim();
|
||||
if (!q) {
|
||||
content.innerHTML = '<div class="empty-state"><i class="fas fa-search"></i><p>输入关键词搜索文件和文件夹</p></div>';
|
||||
status.style.display = 'none';
|
||||
lastResults = [];
|
||||
loadHistory();
|
||||
return;
|
||||
}
|
||||
content.innerHTML = '<div class="loading"><span class="spinner"></span>搜索中...</div>';
|
||||
status.style.display = 'none';
|
||||
historySection.style.display = 'none';
|
||||
|
||||
try {
|
||||
var res = await fetch('/pin-tu/search/' + ROOT_B64 + '?q=' + encodeURIComponent(q));
|
||||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||||
var data = await res.json();
|
||||
renderResults(data, q);
|
||||
// 记录搜索历史
|
||||
recordSearch(q, data.results.length);
|
||||
} catch (e) {
|
||||
lastResults = [];
|
||||
content.innerHTML = '<div class="empty-state"><p style="color:#c62828;">搜索请求失败:' + escapeHtml(e.message) + '</p></div>';
|
||||
}
|
||||
}
|
||||
|
||||
function goResult(idx) {
|
||||
var r = lastResults[idx];
|
||||
if (!r) return;
|
||||
var folder = r.type === 'folder' ? r.path : (r.path.includes('/') ? r.path.substring(0, r.path.lastIndexOf('/')) : '');
|
||||
var url = '/pin-tu/browse/' + ROOT_B64;
|
||||
var params = [];
|
||||
if (folder) params.push('p=' + encodeURIComponent(folder));
|
||||
if (extImage[r.type]) params.push('open=' + encodeURIComponent(r.name));
|
||||
else if (extVideo[r.type]) params.push('openVideo=' + encodeURIComponent(r.name));
|
||||
if (params.length) url += '?' + params.join('&');
|
||||
window.location.href = url;
|
||||
}
|
||||
|
||||
// ===== 搜索历史 =====
|
||||
async function recordSearch(keyword, count) {
|
||||
try {
|
||||
await fetch('/pin-tu/api/search-history/' + ROOT_B64, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ keyword: keyword, result_count: count })
|
||||
});
|
||||
} catch (e) { /* 静默失败 */ }
|
||||
}
|
||||
|
||||
async function loadHistory() {
|
||||
try {
|
||||
var res = await fetch('/pin-tu/api/search-history/' + ROOT_B64);
|
||||
var data = await res.json();
|
||||
if (!data.history || !data.history.length) {
|
||||
historySection.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
historySection.style.display = 'block';
|
||||
historyList.innerHTML = data.history.map(function(h) {
|
||||
return '<div class="history-item" data-keyword="' + escapeHtml(h.keyword) + '">' +
|
||||
'<span class="hi-keyword">' + escapeHtml(h.keyword) + '</span>' +
|
||||
'<span class="hi-count">' + h.result_count + '条</span>' +
|
||||
'<span class="hi-del" data-id="' + h.id + '" title="删除"><i class="fas fa-times-circle"></i></span>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
// 绑定事件
|
||||
historyList.querySelectorAll('.history-item').forEach(function(el) {
|
||||
el.addEventListener('click', function(e) {
|
||||
if (e.target.closest('.hi-del')) return;
|
||||
input.value = this.dataset.keyword;
|
||||
doSearch();
|
||||
});
|
||||
});
|
||||
historyList.querySelectorAll('.hi-del').forEach(function(btn) {
|
||||
btn.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
delHistory(parseInt(this.dataset.id));
|
||||
});
|
||||
});
|
||||
} catch (e) { historySection.style.display = 'none'; }
|
||||
}
|
||||
|
||||
function useHistory(keyword) {
|
||||
input.value = keyword;
|
||||
doSearch();
|
||||
}
|
||||
|
||||
async function delHistory(id) {
|
||||
try {
|
||||
await fetch('/pin-tu/api/search-history/' + ROOT_B64, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ids: [id] })
|
||||
});
|
||||
loadHistory();
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
async function clearAllHistory() {
|
||||
var ok = await showConfirm('清空搜索历史', '确定清空所有搜索历史?此操作不可撤销。', '清空');
|
||||
if (!ok) return;
|
||||
try {
|
||||
await fetch('/pin-tu/api/search-history/' + ROOT_B64, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ clear: true })
|
||||
});
|
||||
historySection.style.display = 'none';
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// Enter 搜索
|
||||
input.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') doSearch();
|
||||
});
|
||||
|
||||
// 页面加载
|
||||
{% if query %}
|
||||
doSearch();
|
||||
{% else %}
|
||||
loadHistory();
|
||||
{% endif %}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
231
flask-dev-api/templates/qr_code.html
Normal file
231
flask-dev-api/templates/qr_code.html
Normal file
@@ -0,0 +1,231 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='font-awesome.css') }}">
|
||||
<title>二维码生成器</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
background: #f5f5f5; color: #333;
|
||||
height: 100vh; overflow-y: auto;
|
||||
}
|
||||
.container { max-width: 700px; margin: 0 auto; padding: 24px 28px 40px; }
|
||||
|
||||
.header { display: flex; align-items: center; margin-bottom: 2px; }
|
||||
.header h1 { font-size: 18px; font-weight: 700; color: #1a1a1a; }
|
||||
.subtitle { color: #aaa; font-size: 12px; margin-bottom: 16px; }
|
||||
|
||||
.section-card {
|
||||
background: #fafafa; border: 1px solid #f0f0f0;
|
||||
border-radius: 10px; padding: 14px 16px; margin-bottom: 12px;
|
||||
}
|
||||
.section-title {
|
||||
font-size: 11px; font-weight: 600; color: #999;
|
||||
text-transform: uppercase; letter-spacing: 0.06em;
|
||||
margin-bottom: 12px; display: flex; align-items: center; gap: 6px;
|
||||
}
|
||||
.section-title i { font-size: 11px; }
|
||||
|
||||
/* 输入 */
|
||||
.input-group { margin-bottom: 10px; }
|
||||
.input-group:last-child { margin-bottom: 0; }
|
||||
.input-group label { display: block; font-size: 12px; font-weight: 500; margin-bottom: 4px; color: #555; }
|
||||
textarea {
|
||||
width: 100%; padding: 10px 12px;
|
||||
border: 1px solid #e0e0e0; border-radius: 6px;
|
||||
font-size: 13px; outline: none; resize: vertical;
|
||||
min-height: 60px; font-family: inherit; transition: border-color 0.15s;
|
||||
line-height: 1.5;
|
||||
}
|
||||
textarea:focus { border-color: #0078d4; box-shadow: 0 0 0 2px rgba(0,120,212,0.06); }
|
||||
|
||||
/* 选择框 */
|
||||
.styled-select {
|
||||
width: 100%; padding: 7px 30px 7px 10px;
|
||||
border: 1px solid #e0e0e0; border-radius: 6px;
|
||||
font-size: 13px; color: #333; background: #fff;
|
||||
outline: none; cursor: pointer; appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 10 10'%3E%3Cpath fill='%23999' d='M5 7L1 3h8z'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat; background-position: right 10px center;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.styled-select:focus { border-color: #0078d4; box-shadow: 0 0 0 2px rgba(0,120,212,0.06); }
|
||||
|
||||
/* 颜色选择 */
|
||||
.color-row { display: flex; gap: 10px; }
|
||||
.color-item { flex: 1; }
|
||||
.color-item label { display: block; font-size: 12px; font-weight: 500; color: #555; margin-bottom: 4px; }
|
||||
.color-input {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
border: 1px solid #e0e0e0; border-radius: 6px; padding: 5px 10px; background: #fff;
|
||||
}
|
||||
.color-input input[type="color"] {
|
||||
width: 26px; height: 26px; border: none; padding: 0; cursor: pointer;
|
||||
border-radius: 4px; background: none;
|
||||
}
|
||||
.color-input span {
|
||||
font-size: 12px; font-family: "SFMono-Regular", Consolas, monospace; color: #888;
|
||||
}
|
||||
|
||||
/* 按钮 */
|
||||
.run-btn {
|
||||
width: 100%; padding: 10px 18px;
|
||||
background: linear-gradient(135deg, #0078d4, #005fa3);
|
||||
color: #fff; border: none; border-radius: 8px;
|
||||
font-size: 14px; font-weight: 600; cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center; gap: 8px;
|
||||
margin-top: 12px; transition: all 0.15s;
|
||||
box-shadow: 0 2px 8px rgba(0,120,212,0.2);
|
||||
}
|
||||
.run-btn:hover { background: linear-gradient(135deg, #006cbd, #004e8a); transform: translateY(-1px); }
|
||||
|
||||
/* 结果 */
|
||||
.result-card {
|
||||
background: #fff; border: 1px solid #f0f0f0;
|
||||
border-radius: 10px; padding: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
.qr-box {
|
||||
display: inline-block; padding: 16px; background: #fafafa;
|
||||
border: 1px solid #e8e8e8; border-radius: 8px;
|
||||
}
|
||||
.qr-box canvas { display: block; }
|
||||
.qr-hint { margin-top: 10px; font-size: 12px; color: #ccc; }
|
||||
.btn-row { display: flex; gap: 8px; justify-content: center; margin-top: 14px; }
|
||||
.btn {
|
||||
padding: 7px 14px; border: none; border-radius: 6px;
|
||||
font-size: 12px; font-weight: 600; cursor: pointer;
|
||||
transition: all 0.12s ease;
|
||||
display: inline-flex; align-items: center; gap: 5px;
|
||||
}
|
||||
.btn-outline { background: #fff; color: #666; border: 1px solid #e0e0e0; }
|
||||
.btn-outline:hover { background: #f5f5f5; color: #333; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1><i class="fas fa-qrcode" style="color:#0078d4;font-size:16px;margin-right:6px;"></i>二维码生成器</h1>
|
||||
</div>
|
||||
<p class="subtitle">文本 / URL 转二维码</p>
|
||||
|
||||
<!-- 配置 -->
|
||||
<div class="section-card">
|
||||
<div class="section-title"><i class="fas fa-sliders"></i> 配置</div>
|
||||
<div class="input-group">
|
||||
<label>内容</label>
|
||||
<textarea id="qrText" placeholder="输入文字或链接..." oninput="generate()">https://example.com</textarea>
|
||||
</div>
|
||||
<div class="color-row" style="margin-top:10px;">
|
||||
<div class="color-item">
|
||||
<label>前景色</label>
|
||||
<div class="color-input">
|
||||
<input type="color" id="fgColor" value="#000000" onchange="generate()">
|
||||
<span id="fgHex">#000000</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="color-item">
|
||||
<label>背景色</label>
|
||||
<div class="color-input">
|
||||
<input type="color" id="bgColor" value="#ffffff" onchange="generate()">
|
||||
<span id="bgHex">#ffffff</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-group" style="margin-top:10px;">
|
||||
<label>纠错级别</label>
|
||||
<select id="ecLevel" class="styled-select" onchange="generate()">
|
||||
<option value="L">低 (7%)</option>
|
||||
<option value="M" selected>中 (15%)</option>
|
||||
<option value="Q">较高 (25%)</option>
|
||||
<option value="H">高 (30%)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 结果 -->
|
||||
<div class="result-card">
|
||||
<div class="qr-box"><canvas id="qrCanvas"></canvas></div>
|
||||
<div class="qr-hint" id="qrHint"></div>
|
||||
<div class="btn-row">
|
||||
<button class="btn btn-outline" onclick="downloadQR()"><i class="fas fa-download"></i> 下载二维码</button>
|
||||
<button class="btn btn-outline" onclick="copyQR()"><i class="fas fa-copy"></i> 复制到剪贴板</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/qrcode.min.js"></script>
|
||||
<script>
|
||||
function generate(){
|
||||
var text=document.getElementById('qrText').value.trim();
|
||||
var canvas=document.getElementById('qrCanvas');
|
||||
var ctx=canvas.getContext('2d');
|
||||
var hint=document.getElementById('qrHint');
|
||||
var fg=document.getElementById('fgColor').value;
|
||||
var bg=document.getElementById('bgColor').value;
|
||||
document.getElementById('fgHex').textContent=fg;
|
||||
document.getElementById('bgHex').textContent=bg;
|
||||
if(!text){canvas.width=0;canvas.height=0;hint.textContent='';return;}
|
||||
var ec=document.getElementById('ecLevel').value;
|
||||
try{
|
||||
var qr=qrcode(0,ec);
|
||||
qr.addData(text);qr.make();
|
||||
var modules=qr.getModuleCount();
|
||||
var cellSize=6;var margin=cellSize*2;
|
||||
var size=modules*cellSize+margin*2;
|
||||
canvas.width=size;canvas.height=size;
|
||||
ctx.fillStyle=bg;ctx.fillRect(0,0,size,size);
|
||||
ctx.fillStyle=fg;
|
||||
for(var r=0;r<modules;r++){
|
||||
for(var c=0;c<modules;c++){
|
||||
if(qr.isDark(r,c)){
|
||||
ctx.fillRect(margin+c*cellSize,margin+r*cellSize,cellSize,cellSize);
|
||||
}
|
||||
}
|
||||
}
|
||||
hint.textContent=modules+' x '+modules+' 模块';
|
||||
}catch(e){
|
||||
canvas.width=0;canvas.height=0;
|
||||
hint.textContent='生成失败: '+e.message;
|
||||
}
|
||||
}
|
||||
|
||||
function downloadQR(){
|
||||
var canvas=document.getElementById('qrCanvas');
|
||||
if(!canvas.width)return;
|
||||
var a=document.createElement('a');
|
||||
a.download='qrcode.png';
|
||||
a.href=canvas.toDataURL('image/png');
|
||||
a.click();
|
||||
}
|
||||
|
||||
function copyQR(){
|
||||
var canvas=document.getElementById('qrCanvas');
|
||||
if(!canvas.width)return;
|
||||
canvas.toBlob(function(blob){
|
||||
if(navigator.clipboard&&navigator.clipboard.write){
|
||||
navigator.clipboard.write([new ClipboardItem({'image/png':blob})]).then(function(){
|
||||
showToast('已复制到剪贴板');
|
||||
});
|
||||
}else{
|
||||
showToast('浏览器不支持复制图片');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function showToast(msg){
|
||||
var t=document.createElement('div');
|
||||
t.textContent=msg;
|
||||
t.style.cssText='position:fixed;top:20px;left:50%;transform:translateX(-50%);background:#0a7a1a;color:#fff;padding:10px 24px;border-radius:8px;font-size:13px;font-weight:500;box-shadow:0 4px 12px rgba(0,0,0,0.15);z-index:9999;transition:opacity 0.3s;';
|
||||
document.body.appendChild(t);
|
||||
setTimeout(function(){t.style.opacity='0';},1200);
|
||||
setTimeout(function(){document.body.removeChild(t);},1500);
|
||||
}
|
||||
|
||||
generate();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
816
flask-dev-api/templates/rvc.html
Normal file
816
flask-dev-api/templates/rvc.html
Normal file
@@ -0,0 +1,816 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='font-awesome.css') }}">
|
||||
<title>RVC 变声</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
background: #f5f5f5; color: #333; height: 100vh; overflow: hidden; display: flex;
|
||||
}
|
||||
.left-panel {
|
||||
width: 50%; flex-shrink: 0; display: flex; flex-direction: column;
|
||||
border-right: 1px solid #e8e8e8; background: #fff; height: 100vh; overflow-y: auto;
|
||||
}
|
||||
.left-panel::-webkit-scrollbar { width: 4px; }
|
||||
.left-panel::-webkit-scrollbar-thumb { background: #ddd; border-radius: 2px; }
|
||||
.input-section { padding: 20px 24px 28px; }
|
||||
|
||||
.header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 2px; }
|
||||
.header h1 { font-size: 18px; font-weight: 700; color: #1a1a1a; letter-spacing: -0.01em; }
|
||||
.settings-icon {
|
||||
width: 32px; height: 32px; border: none; background: transparent;
|
||||
color: #999; font-size: 15px; cursor: pointer; border-radius: 8px;
|
||||
display: flex; align-items: center; justify-content: center; transition: all 0.15s;
|
||||
}
|
||||
.settings-icon:hover { background: #f5f5f5; color: #333; }
|
||||
.subtitle { color: #aaa; font-size: 12px; margin-bottom: 16px; }
|
||||
.status-dot {
|
||||
display: inline-block; width: 8px; height: 8px; border-radius: 50%;
|
||||
margin-right: 6px; vertical-align: middle;
|
||||
}
|
||||
.status-dot.ready { background: #4caf50; }
|
||||
.status-dot.loading { background: #ff9800; animation: pulse 1s infinite; }
|
||||
.status-dot.error { background: #f44336; }
|
||||
.status-dot.offline { background: #ccc; }
|
||||
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } }
|
||||
|
||||
.section-card {
|
||||
background: #fafafa; border: 1px solid #f0f0f0; border-radius: 10px;
|
||||
padding: 14px 16px; margin-bottom: 12px; flex: 1; min-width: 0;
|
||||
}
|
||||
.card-pair { display: flex; gap: 8px; margin-bottom: 8px; }
|
||||
.card-pair .section-card { margin-bottom: 0; }
|
||||
.section-title {
|
||||
font-size: 11px; font-weight: 600; color: #999; text-transform: uppercase;
|
||||
letter-spacing: 0.06em; margin-bottom: 12px; display: flex; align-items: center; gap: 6px;
|
||||
}
|
||||
.section-title i { font-size: 11px; }
|
||||
|
||||
.input-group { margin-bottom: 10px; position: relative; }
|
||||
.input-group:last-child { margin-bottom: 0; }
|
||||
.input-group label { display: block; font-size: 12px; font-weight: 500; margin-bottom: 4px; color: #555; }
|
||||
.input-group select {
|
||||
width: 100%; padding: 7px 10px;
|
||||
border: 1px solid #e0e0e0; border-radius: 6px;
|
||||
background: #fff; color: #333; font-size: 13px;
|
||||
outline: none; transition: all 0.15s ease;
|
||||
}
|
||||
.input-group select:focus { border-color: #0078d4; box-shadow: 0 0 0 2px rgba(0,120,212,0.06); }
|
||||
.input-group.no-icon select { padding-left: 10px; }
|
||||
.path-row { display: flex; gap: 6px; align-items: flex-end; }
|
||||
.path-row select { width: 90px; flex-shrink: 0; padding: 7px 6px; font-size: 12px; }
|
||||
.path-row input { flex: 1; }
|
||||
|
||||
.slider-group { margin-bottom: 10px; }
|
||||
.slider-group:last-child { margin-bottom: 0; }
|
||||
.slider-label {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
font-size: 12px; font-weight: 500; margin-bottom: 4px; color: #555;
|
||||
}
|
||||
.slider-value { font-weight: 600; color: #0078d4; font-size: 12px; }
|
||||
input[type="range"] {
|
||||
width: 100%; height: 4px; -webkit-appearance: none; appearance: none;
|
||||
background: #e0e0e0; border-radius: 2px; outline: none;
|
||||
}
|
||||
input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none; width: 14px; height: 14px;
|
||||
background: #0078d4; border-radius: 50%; cursor: pointer;
|
||||
}
|
||||
.param-desc { font-size: 11px; color: #aaa; margin-top: 2px; line-height: 1.4; }
|
||||
|
||||
.btn {
|
||||
padding: 7px 14px; border: none; border-radius: 6px; font-size: 12px;
|
||||
font-weight: 600; cursor: pointer; transition: all 0.12s ease;
|
||||
display: inline-flex; align-items: center; gap: 5px;
|
||||
}
|
||||
.btn-primary { background: #0078d4; color: #fff; }
|
||||
.btn-primary:hover:not(:disabled) { background: #006cbd; }
|
||||
.btn-primary:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.btn-outline { background: #fff; color: #666; border: 1px solid #e0e0e0; }
|
||||
.btn-outline:hover { background: #f5f5f5; color: #333; }
|
||||
.btn-sm { padding: 5px 10px; font-size: 11px; }
|
||||
.btn-block { width: 100%; justify-content: center; }
|
||||
|
||||
.convert-btn {
|
||||
width: 100%; padding: 10px 18px;
|
||||
background: linear-gradient(135deg, #0078d4, #005fa3);
|
||||
color: #fff; border: none; border-radius: 8px;
|
||||
font-size: 14px; font-weight: 600; cursor: pointer; transition: all 0.15s ease;
|
||||
display: flex; align-items: center; justify-content: center; gap: 8px;
|
||||
margin-top: 14px; box-shadow: 0 2px 8px rgba(0,120,212,0.2);
|
||||
}
|
||||
.convert-btn:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #006cbd, #004e8a);
|
||||
box-shadow: 0 4px 12px rgba(0,120,212,0.3); transform: translateY(-1px);
|
||||
}
|
||||
.convert-btn:active:not(:disabled) { transform: translateY(0); }
|
||||
.convert-btn:disabled { opacity: 0.4; cursor: not-allowed; transform: none; }
|
||||
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.loading-spinner { animation: spin 1s linear infinite; }
|
||||
|
||||
.upload-area {
|
||||
border: 2px dashed #e0e0e0; border-radius: 8px; padding: 40px;
|
||||
text-align: center; cursor: pointer; transition: all 0.15s;
|
||||
background: #fafafa;
|
||||
}
|
||||
.upload-area:hover { border-color: #0078d4; background: #f0f7ff; }
|
||||
.upload-area.has-file { border-color: #4caf50; background: #f0fff0; }
|
||||
.upload-area input[type="file"] { display: none; }
|
||||
.upload-area .upload-label { font-size: 13px; color: #888; }
|
||||
.upload-area .upload-filename { font-size: 13px; color: #333; font-weight: 600; margin-top: 4px; }
|
||||
|
||||
/* Right panel */
|
||||
.right-panel {
|
||||
flex: 1; display: flex; flex-direction: column; background: #fafafa;
|
||||
height: 100vh; overflow: hidden;
|
||||
}
|
||||
.right-content {
|
||||
flex: 1; display: flex; align-items: center; justify-content: center;
|
||||
padding: 20px; min-height: 0; overflow-y: auto;
|
||||
}
|
||||
.placeholder { text-align: center; color: #ccc; }
|
||||
.placeholder i { font-size: 48px; margin-bottom: 12px; }
|
||||
.placeholder p { font-size: 13px; }
|
||||
|
||||
.audio-wrap { width: 100%; max-width: 480px; text-align: center; }
|
||||
.audio-wrap audio { width: 100%; margin-bottom: 12px; }
|
||||
.audio-meta { font-size: 12px; color: #888; margin-bottom: 12px; }
|
||||
.meta-label { font-weight: 600; }
|
||||
.meta-value { color: #0078d4; }
|
||||
.audio-actions { display: flex; gap: 8px; justify-content: center; }
|
||||
|
||||
.log-area {
|
||||
flex: 2; display: flex; flex-direction: column;
|
||||
border-top: 1px solid #e8e8e8; min-height: 0;
|
||||
}
|
||||
.log-header {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 8px 16px; background: #2d2d2d; border-bottom: 1px solid #3d3d3d;
|
||||
}
|
||||
.log-header span { font-size: 12px; color: #aaa; font-weight: 600; }
|
||||
.log-content {
|
||||
flex: 1; overflow-y: auto; background: #1e1e1e; padding: 12px 16px;
|
||||
font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
|
||||
font-size: 12px; line-height: 1.6; color: #d4d4d4;
|
||||
}
|
||||
.log-content::-webkit-scrollbar { width: 6px; }
|
||||
.log-content::-webkit-scrollbar-thumb { background: #555; border-radius: 3px; }
|
||||
.log-line { white-space: pre-wrap; word-break: break-all; }
|
||||
.log-line.error { color: #f48771; }
|
||||
.log-line.warn { color: #cca700; }
|
||||
|
||||
/* Settings modal */
|
||||
.modal-overlay {
|
||||
display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.4);
|
||||
z-index: 100; align-items: center; justify-content: center;
|
||||
}
|
||||
.modal-overlay.show { display: flex; }
|
||||
.modal {
|
||||
background: #fff; border-radius: 12px; width: 480px; max-height: 80vh;
|
||||
overflow-y: auto; box-shadow: 0 20px 60px rgba(0,0,0,0.2);
|
||||
}
|
||||
.modal-header {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 16px 20px; border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
.modal-header h3 { font-size: 15px; font-weight: 700; }
|
||||
.modal-close {
|
||||
width: 28px; height: 28px; border: none; background: transparent;
|
||||
color: #999; font-size: 16px; cursor: pointer; border-radius: 6px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.modal-close:hover { background: #f5f5f5; }
|
||||
.modal-body { padding: 16px 20px; }
|
||||
.modal-body .input-group { margin-bottom: 12px; }
|
||||
.modal-body .input-group label { font-size: 13px; }
|
||||
.modal-body .input-group input {
|
||||
width: 100%; padding: 7px 10px; border: 1px solid #e0e0e0; border-radius: 6px;
|
||||
font-size: 13px; outline: none;
|
||||
}
|
||||
.modal-body .input-group input:focus { border-color: #0078d4; }
|
||||
.engine-status-box {
|
||||
padding: 10px 12px; border-radius: 8px; margin-bottom: 12px;
|
||||
font-size: 12px; display: flex; align-items: center; gap: 8px;
|
||||
}
|
||||
.engine-status-box.ready { background: #e8f5e9; color: #2e7d32; }
|
||||
.engine-status-box.loading { background: #fff3e0; color: #e65100; }
|
||||
.engine-status-box.error { background: #fce4ec; color: #c62828; }
|
||||
.engine-status-box.offline { background: #f5f5f5; color: #999; }
|
||||
.modal-footer {
|
||||
padding: 12px 20px; border-top: 1px solid #f0f0f0;
|
||||
display: flex; justify-content: flex-end; gap: 8px;
|
||||
}
|
||||
|
||||
/* Toast */
|
||||
.toast-container { position: fixed; top: 20px; right: 20px; z-index: 200; display: flex; flex-direction: column; gap: 8px; }
|
||||
.toast {
|
||||
padding: 10px 16px; border-radius: 8px; font-size: 13px; font-weight: 500;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15); animation: slideIn 0.3s ease;
|
||||
max-width: 360px;
|
||||
}
|
||||
.toast.success { background: #e8f5e9; color: #2e7d32; border: 1px solid #c8e6c9; }
|
||||
.toast.error { background: #fce4ec; color: #c62828; border: 1px solid #f8bbd0; }
|
||||
.toast.info { background: #e3f2fd; color: #1565c0; border: 1px solid #bbdefb; }
|
||||
@keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="left-panel">
|
||||
<div class="input-section">
|
||||
<!-- Header -->
|
||||
<div class="header">
|
||||
<h1><span class="status-dot offline" id="statusDot"></span>RVC 变声</h1>
|
||||
<button class="settings-icon" onclick="openSettings()"><i class="fas fa-wrench"></i></button>
|
||||
</div>
|
||||
<div class="subtitle" id="statusText">引擎未初始化</div>
|
||||
|
||||
<!-- Model selection -->
|
||||
<div class="card-pair">
|
||||
<div class="section-card">
|
||||
<div class="section-title"><i class="fas fa-robot"></i> 模型选择</div>
|
||||
<div class="input-group no-icon">
|
||||
<label>语音模型 (.pth)</label>
|
||||
<select id="modelSelect"><option value="">请选择模型</option></select>
|
||||
</div>
|
||||
<div class="input-group no-icon" style="margin-top:8px;">
|
||||
<label>Index 文件</label>
|
||||
<select id="indexSelect"><option value="">无 (不使用检索)</option></select>
|
||||
</div>
|
||||
<div style="margin-top:10px; display:flex; gap:8px;">
|
||||
<button class="btn btn-primary btn-sm" id="loadModelBtn" style="flex:1;" onclick="switchModel()" disabled><i class="fas fa-refresh"></i> 加载模型</button>
|
||||
<button class="btn btn-outline btn-sm" style="flex:1;" onclick="saveModelConfig()"><i class="fas fa-check"></i> 保存选择</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upload audio -->
|
||||
<div class="section-card">
|
||||
<div class="section-title"><i class="fas fa-file"></i> 输入音频</div>
|
||||
<div class="upload-area" id="uploadArea" onclick="document.getElementById('audioFile').click()"
|
||||
ondragover="event.preventDefault(); this.style.borderColor='#0078d4';"
|
||||
ondragleave="this.style.borderColor='#e0e0e0';"
|
||||
ondrop="event.preventDefault(); this.style.borderColor='#e0e0e0'; handleDrop(event);">
|
||||
<input type="file" id="audioFile" accept=".wav,.mp3,.flac,.ogg,.m4a,.aac" onchange="handleFileSelect(this)">
|
||||
<div class="upload-label" id="uploadLabel"><i class="fas fa-file"></i> 点击选择或拖拽音频文件</div>
|
||||
<div class="upload-filename" id="uploadFilename" style="display:none;"></div>
|
||||
</div>
|
||||
<audio id="uploadPreview" controls style="display:none; width:100%; margin-top:8px; height:32px;"></audio>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Parameters -->
|
||||
<div class="section-card">
|
||||
<div class="section-title" style="justify-content:space-between;">
|
||||
<span><i class="fas fa-wrench"></i> 变声参数</span>
|
||||
<button class="btn btn-outline btn-sm" onclick="saveInferConfig()" style="text-transform:none; letter-spacing:0; font-size:11px;"><i class="fas fa-check"></i> 保存参数</button>
|
||||
</div>
|
||||
<div class="input-group no-icon">
|
||||
<label>F0 提取方法</label>
|
||||
<div class="param-desc" style="margin-bottom: 10px">选择音高提取算法,输入歌声可用pm提速,harvest低音好但巨慢无比,crepe效果好但吃GPU,rmvpe效果最好且微吃GPU</div>
|
||||
<select id="f0Method">
|
||||
{% for m in f0_methods %}
|
||||
<option value="{{ m }}">{{ f0_method_names[m] }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="input-group no-icon">
|
||||
<label>后处理重采样至最终采样率</label>
|
||||
<select id="resampleSr">
|
||||
<option value="0">不重采样</option>
|
||||
<option value="16000">16000 Hz</option>
|
||||
<option value="22050">22050 Hz</option>
|
||||
<option value="32000">32000 Hz</option>
|
||||
<option value="40000">40000 Hz</option>
|
||||
<option value="44100">44100 Hz</option>
|
||||
<option value="48000">48000 Hz</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="slider-group">
|
||||
<div class="slider-label"><span>音调升降</span><span class="slider-value" id="f0UpKeyValue">0</span></div>
|
||||
<div class="param-desc">整数,半音数量,升八度 +12,降八度 -12</div>
|
||||
<input type="range" id="f0UpKey" min="-24" max="24" value="0" step="1"
|
||||
oninput="document.getElementById('f0UpKeyValue').textContent=this.value">
|
||||
|
||||
</div>
|
||||
<div class="slider-group">
|
||||
<div class="slider-label"><span>辅音保护</span><span class="slider-value" id="protectValue">0.33</span></div>
|
||||
<div class="param-desc">保护清辅音和呼吸声,防止电音撕裂等 artifact。拉满 0.5 不开启,调低加大保护力度但可能降低索引效果</div>
|
||||
<input type="range" id="protect" min="0" max="0.5" value="0.33" step="0.01"
|
||||
oninput="document.getElementById('protectValue').textContent=parseFloat(this.value).toFixed(2)">
|
||||
|
||||
</div>
|
||||
<div class="slider-group">
|
||||
<div class="slider-label"><span>滤波半径</span><span class="slider-value" id="filterRadiusValue">3</span></div>
|
||||
<div class="param-desc">≥3 则对 harvest 音高识别结果使用中值滤波,数值为滤波半径,可以削弱哑音</div>
|
||||
<input type="range" id="filterRadius" min="0" max="7" value="3" step="1"
|
||||
oninput="document.getElementById('filterRadiusValue').textContent=this.value">
|
||||
|
||||
</div>
|
||||
|
||||
<div class="slider-group">
|
||||
<div class="slider-label"><span>音量包络混合</span><span class="slider-value" id="rmsMixRateValue">0.25</span></div>
|
||||
<div class="param-desc">输入源音量包络替换输出音量包络的融合比例,越靠近 1 越使用输出包络</div>
|
||||
<input type="range" id="rmsMixRate" min="0" max="1" value="0.25" step="0.01"
|
||||
oninput="document.getElementById('rmsMixRateValue').textContent=parseFloat(this.value).toFixed(2)">
|
||||
</div>
|
||||
<div class="slider-group">
|
||||
<div class="slider-label"><span>检索特征比率</span><span class="slider-value" id="indexRateValue">0.75</span></div>
|
||||
<input type="range" id="indexRate" min="0" max="1" value="0.75" step="0.01"
|
||||
oninput="document.getElementById('indexRateValue').textContent=parseFloat(this.value).toFixed(2)">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Convert button -->
|
||||
<button class="convert-btn" id="convertBtn" onclick="convert()" disabled>
|
||||
<i class="fas fa-play"></i> 开始变声
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right panel -->
|
||||
<div class="right-panel">
|
||||
<div class="right-content">
|
||||
<div class="placeholder" id="placeholder">
|
||||
<i class="fas fa-microphone"></i>
|
||||
<p>上传音频,选择模型,开始变声</p>
|
||||
</div>
|
||||
<div id="convertLoading" style="display:none; text-align:center;">
|
||||
<i class="fas fa-refresh loading-spinner" style="font-size:32px; color:#0078d4;"></i>
|
||||
<p style="margin-top:10px; color:#888; font-size:13px;">变声处理中...</p>
|
||||
</div>
|
||||
<div class="audio-wrap" id="audioWrap" style="display:none;">
|
||||
<audio id="audioPlayer" controls></audio>
|
||||
<div class="audio-meta" id="audioMeta"></div>
|
||||
<div class="audio-actions">
|
||||
<a id="downloadLink" class="btn btn-outline btn-sm" href="#" download="rvc_output.wav">
|
||||
<i class="fas fa-file"></i> 下载
|
||||
</a>
|
||||
<button class="btn btn-outline btn-sm" onclick="replay()"><i class="fas fa-play"></i> 重播</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="log-area">
|
||||
<div class="log-header">
|
||||
<span><i class="fas fa-code"></i> 控制台日志</span>
|
||||
<button class="btn btn-outline btn-sm" onclick="clearLogs()" style="padding:3px 8px; font-size:11px;">清除</button>
|
||||
</div>
|
||||
<div class="log-content" id="logContent"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings modal -->
|
||||
<div class="modal-overlay" id="settingsModal">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3><i class="fas fa-wrench"></i> RVC 设置</h3>
|
||||
<button class="modal-close" onclick="closeSettings()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="engine-status-box offline" id="engineStatusBox">
|
||||
<span class="status-dot offline" id="engineStatusDot"></span>
|
||||
<span id="engineStatusText">未初始化</span>
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<label>RVC 项目路径</label>
|
||||
<input type="text" id="rvcProjectPath" placeholder="E:\AI\RVC\RVC1006Nvidia">
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label>语音模型目录</label>
|
||||
<div class="path-row">
|
||||
<select id="modelDirMode" onchange="updatePathPlaceholder('model')">
|
||||
<option value="relative">相对路径</option>
|
||||
<option value="absolute">绝对路径</option>
|
||||
</select>
|
||||
<input type="text" id="modelDir" placeholder="assets/weights">
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label>Index 目录</label>
|
||||
<div class="path-row">
|
||||
<select id="indexDirMode" onchange="updatePathPlaceholder('index')">
|
||||
<option value="relative">相对路径</option>
|
||||
<option value="absolute">绝对路径</option>
|
||||
</select>
|
||||
<input type="text" id="indexDir" placeholder="logs">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex; gap:8px; margin-top:12px;">
|
||||
<button class="btn btn-primary btn-sm" style="flex:1;" onclick="initEngine()"><i class="fas fa-play"></i> 启动引擎</button>
|
||||
<button class="btn btn-outline btn-sm" style="flex:1;" onclick="reloadEngine()"><i class="fas fa-refresh"></i> 重启</button>
|
||||
<button class="btn btn-outline btn-sm" style="flex:1;" onclick="stopEngine()"><i class="fas fa-times-circle"></i> 停止</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-outline btn-sm" onclick="closeSettings()">取消</button>
|
||||
<button class="btn btn-primary btn-sm" onclick="saveSettings()">保存设置</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast container -->
|
||||
<div class="toast-container" id="toastContainer"></div>
|
||||
|
||||
<script>
|
||||
// === State ===
|
||||
var _uploadedPath = '';
|
||||
var _uploadedFilename = '';
|
||||
var _logPollTimer = null;
|
||||
|
||||
// === Elements ===
|
||||
var statusDot = document.getElementById('statusDot');
|
||||
var statusText = document.getElementById('statusText');
|
||||
var convertBtn = document.getElementById('convertBtn');
|
||||
var placeholder = document.getElementById('placeholder');
|
||||
var convertLoading = document.getElementById('convertLoading');
|
||||
var audioWrap = document.getElementById('audioWrap');
|
||||
var audioPlayer = document.getElementById('audioPlayer');
|
||||
var downloadLink = document.getElementById('downloadLink');
|
||||
var audioMeta = document.getElementById('audioMeta');
|
||||
var logContent = document.getElementById('logContent');
|
||||
|
||||
// === Toast ===
|
||||
function showMessage(text, type, duration) {
|
||||
type = type || 'info'; duration = duration || 3000;
|
||||
var t = document.createElement('div');
|
||||
t.className = 'toast ' + type;
|
||||
t.textContent = text;
|
||||
document.getElementById('toastContainer').appendChild(t);
|
||||
setTimeout(function() { t.style.opacity = '0'; t.style.transition = 'opacity 0.3s'; setTimeout(function(){ t.remove(); }, 300); }, duration);
|
||||
}
|
||||
|
||||
// === Settings ===
|
||||
function openSettings() { document.getElementById('settingsModal').classList.add('show'); checkEngineStatus(); }
|
||||
function closeSettings() { document.getElementById('settingsModal').classList.remove('show'); }
|
||||
|
||||
function saveSettings() {
|
||||
var cfg = {
|
||||
rvc_project_path: document.getElementById('rvcProjectPath').value.trim(),
|
||||
model_dir: document.getElementById('modelDir').value.trim(),
|
||||
model_dir_mode: document.getElementById('modelDirMode').value,
|
||||
index_dir: document.getElementById('indexDir').value.trim(),
|
||||
index_dir_mode: document.getElementById('indexDirMode').value
|
||||
};
|
||||
fetch('/rvc/config', {
|
||||
method: 'POST', headers: {'Content-Type':'application/json'},
|
||||
body: JSON.stringify(cfg)
|
||||
}).then(function(r){ return r.json(); }).then(function(data){
|
||||
if (data.success) { showMessage('设置已保存', 'success'); loadMainConfig(); }
|
||||
else showMessage(data.error || '保存失败', 'error');
|
||||
});
|
||||
}
|
||||
|
||||
function saveInferConfig() {
|
||||
var cfg = {
|
||||
f0_up_key: parseInt(document.getElementById('f0UpKey').value) || 0,
|
||||
f0_method: document.getElementById('f0Method').value,
|
||||
index_rate: parseFloat(document.getElementById('indexRate').value),
|
||||
filter_radius: parseInt(document.getElementById('filterRadius').value),
|
||||
resample_sr: parseInt(document.getElementById('resampleSr').value) || 0,
|
||||
rms_mix_rate: parseFloat(document.getElementById('rmsMixRate').value),
|
||||
protect: parseFloat(document.getElementById('protect').value)
|
||||
};
|
||||
fetch('/rvc/config', {
|
||||
method: 'POST', headers: {'Content-Type':'application/json'},
|
||||
body: JSON.stringify(cfg)
|
||||
}).then(function(r){ return r.json(); }).then(function(data){
|
||||
if (data.success) showMessage('参数已保存', 'success');
|
||||
else showMessage(data.error || '保存失败', 'error');
|
||||
});
|
||||
}
|
||||
|
||||
function saveModelConfig() {
|
||||
var cfg = {
|
||||
last_model: document.getElementById('modelSelect').value || '',
|
||||
last_index: document.getElementById('indexSelect').value || ''
|
||||
};
|
||||
fetch('/rvc/config', {
|
||||
method: 'POST', headers: {'Content-Type':'application/json'},
|
||||
body: JSON.stringify(cfg)
|
||||
}).then(function(r){ return r.json(); }).then(function(data){
|
||||
if (data.success) showMessage('模型选择已保存', 'success');
|
||||
else showMessage(data.error || '保存失败', 'error');
|
||||
});
|
||||
}
|
||||
|
||||
// === Engine ===
|
||||
function initEngine() {
|
||||
showMessage('正在启动引擎...', 'info');
|
||||
fetch('/rvc/init-engine', {method:'POST'}).then(function(r){ return r.json(); }).then(function(data){
|
||||
if (data.success) showMessage('引擎启动成功 (' + (data.device||'') + ')', 'success');
|
||||
else showMessage(data.error || '启动失败', 'error');
|
||||
startLogPolling();
|
||||
});
|
||||
}
|
||||
|
||||
function reloadEngine() {
|
||||
showMessage('正在重启引擎...', 'info');
|
||||
fetch('/rvc/reload-engine', {method:'POST'}).then(function(r){ return r.json(); }).then(function(data){
|
||||
if (data.success) showMessage('引擎重启成功', 'success');
|
||||
else showMessage(data.error || '重启失败', 'error');
|
||||
startLogPolling();
|
||||
});
|
||||
}
|
||||
|
||||
function stopEngine() {
|
||||
fetch('/rvc/stop-engine', {method:'POST'}).then(function(r){ return r.json(); }).then(function(data){
|
||||
showMessage('引擎已停止', 'info');
|
||||
stopLogPolling();
|
||||
updateEngineUI({initialized:false, loading:false, error:null});
|
||||
});
|
||||
}
|
||||
|
||||
function checkEngineStatus() {
|
||||
fetch('/rvc/engine-status').then(function(r){ return r.json(); }).then(function(data){
|
||||
updateEngineUI(data);
|
||||
updateLogs(data.logs || []);
|
||||
});
|
||||
}
|
||||
|
||||
function updateEngineUI(data) {
|
||||
var dot = statusDot;
|
||||
var txt = statusText;
|
||||
var eDot = document.getElementById('engineStatusDot');
|
||||
var eTxt = document.getElementById('engineStatusText');
|
||||
var eBox = document.getElementById('engineStatusBox');
|
||||
|
||||
dot.className = 'status-dot';
|
||||
eDot.className = 'status-dot';
|
||||
eBox.className = 'engine-status-box';
|
||||
|
||||
if (data.loading) {
|
||||
dot.classList.add('loading'); eDot.classList.add('loading');
|
||||
eBox.classList.add('loading');
|
||||
txt.textContent = '引擎加载中...';
|
||||
eTxt.textContent = '加载中...';
|
||||
document.getElementById('loadModelBtn').disabled = true;
|
||||
convertBtn.disabled = true;
|
||||
} else if (data.initialized) {
|
||||
dot.classList.add('ready'); eDot.classList.add('ready');
|
||||
eBox.classList.add('ready');
|
||||
document.getElementById('loadModelBtn').disabled = false;
|
||||
if (data.model_loaded) {
|
||||
txt.textContent = '引擎就绪 (' + (data.device || '') + ')';
|
||||
eTxt.textContent = '就绪 (' + (data.device || '') + ')';
|
||||
convertBtn.disabled = !_uploadedPath;
|
||||
} else {
|
||||
txt.textContent = '引擎就绪,请加载模型';
|
||||
eTxt.textContent = '就绪,请加载模型';
|
||||
convertBtn.disabled = true;
|
||||
}
|
||||
} else if (data.error) {
|
||||
dot.classList.add('error'); eDot.classList.add('error');
|
||||
eBox.classList.add('error');
|
||||
txt.textContent = '引擎错误';
|
||||
eTxt.textContent = data.error;
|
||||
document.getElementById('loadModelBtn').disabled = true;
|
||||
convertBtn.disabled = true;
|
||||
} else {
|
||||
dot.classList.add('offline'); eDot.classList.add('offline');
|
||||
eBox.classList.add('offline');
|
||||
txt.textContent = '引擎未初始化';
|
||||
eTxt.textContent = '未初始化';
|
||||
document.getElementById('loadModelBtn').disabled = true;
|
||||
convertBtn.disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
function updateLogs(logs) {
|
||||
if (!logs.length) {
|
||||
if (!document.querySelector('#statusDot.loading')) logContent.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
var html = '';
|
||||
logs.forEach(function(line) {
|
||||
var cls = 'log-line';
|
||||
if (/error|exception|traceback/i.test(line)) cls += ' error';
|
||||
else if (/warn/i.test(line)) cls += ' warn';
|
||||
html += '<div class="' + cls + '">' + escapeHtml(line) + '</div>';
|
||||
});
|
||||
logContent.innerHTML = html;
|
||||
logContent.scrollTop = logContent.scrollHeight;
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
|
||||
function startLogPolling() {
|
||||
stopLogPolling();
|
||||
_logPollTimer = setInterval(checkEngineStatus, 600);
|
||||
}
|
||||
function stopLogPolling() {
|
||||
if (_logPollTimer) { clearInterval(_logPollTimer); _logPollTimer = null; }
|
||||
}
|
||||
|
||||
function clearLogs() {
|
||||
logContent.innerHTML = '';
|
||||
fetch('/rvc/clear-logs', {method:'POST'});
|
||||
}
|
||||
|
||||
function updatePathPlaceholder(type) {
|
||||
var mode = document.getElementById(type + 'DirMode').value;
|
||||
var input = document.getElementById(type + 'Dir');
|
||||
if (type === 'model') {
|
||||
input.placeholder = mode === 'absolute' ? 'E:\\models\\weights' : 'assets/weights';
|
||||
} else {
|
||||
input.placeholder = mode === 'absolute' ? 'E:\\models\\logs' : 'logs';
|
||||
}
|
||||
}
|
||||
|
||||
// === Model ===
|
||||
function switchModel() {
|
||||
var model = document.getElementById('modelSelect').value;
|
||||
if (!model) { showMessage('请选择模型', 'error'); return; }
|
||||
showMessage('正在加载模型...', 'info');
|
||||
fetch('/rvc/switch-model', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({model_name: model})
|
||||
}).then(function(r){ return r.json(); }).then(function(data){
|
||||
if (data.success) showMessage(data.message || '模型已加载', 'success');
|
||||
else showMessage(data.error || '加载失败', 'error');
|
||||
});
|
||||
}
|
||||
|
||||
// === File upload ===
|
||||
function handleFileSelect(input) {
|
||||
if (!input.files.length) return;
|
||||
uploadFile(input.files[0]);
|
||||
}
|
||||
|
||||
function handleDrop(event) {
|
||||
var files = event.dataTransfer.files;
|
||||
if (files.length) uploadFile(files[0]);
|
||||
}
|
||||
|
||||
function uploadFile(file) {
|
||||
var formData = new FormData();
|
||||
formData.append('audio', file);
|
||||
showMessage('正在上传...', 'info');
|
||||
fetch('/rvc/upload-audio', {method:'POST', body: formData}).then(function(r){ return r.json(); }).then(function(data){
|
||||
if (data.success) {
|
||||
_uploadedPath = data.path;
|
||||
_uploadedFilename = data.filename;
|
||||
document.getElementById('uploadLabel').style.display = 'none';
|
||||
var fn = document.getElementById('uploadFilename');
|
||||
fn.textContent = data.filename;
|
||||
fn.style.display = 'block';
|
||||
document.getElementById('uploadArea').classList.add('has-file');
|
||||
var preview = document.getElementById('uploadPreview');
|
||||
preview.src = URL.createObjectURL(file);
|
||||
preview.style.display = '';
|
||||
convertBtn.disabled = false;
|
||||
showMessage('上传成功', 'success');
|
||||
} else {
|
||||
showMessage(data.error || '上传失败', 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// === Convert ===
|
||||
function convert() {
|
||||
if (!_uploadedPath) { showMessage('请先上传音频', 'error'); return; }
|
||||
convertBtn.disabled = true;
|
||||
placeholder.style.display = 'none';
|
||||
audioWrap.style.display = 'none';
|
||||
convertLoading.style.display = '';
|
||||
|
||||
var payload = {
|
||||
input_path: _uploadedPath,
|
||||
file_index: document.getElementById('indexSelect').value || '',
|
||||
f0_up_key: parseInt(document.getElementById('f0UpKey').value) || 0,
|
||||
f0_method: document.getElementById('f0Method').value,
|
||||
index_rate: parseFloat(document.getElementById('indexRate').value),
|
||||
filter_radius: parseInt(document.getElementById('filterRadius').value),
|
||||
resample_sr: parseInt(document.getElementById('resampleSr').value) || 0,
|
||||
rms_mix_rate: parseFloat(document.getElementById('rmsMixRate').value),
|
||||
protect: parseFloat(document.getElementById('protect').value)
|
||||
};
|
||||
|
||||
var startTime = Date.now();
|
||||
fetch('/rvc/convert', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify(payload)
|
||||
}).then(function(r){ return r.json(); }).then(function(data){
|
||||
if (!data.success) {
|
||||
showMessage(data.error || '变声失败', 'error');
|
||||
convertLoading.style.display = 'none';
|
||||
placeholder.style.display = '';
|
||||
convertBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
pollTask(data.task_id, startTime);
|
||||
}).catch(function(e) {
|
||||
showMessage('请求失败: ' + e.message, 'error');
|
||||
convertLoading.style.display = 'none';
|
||||
placeholder.style.display = '';
|
||||
convertBtn.disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
function pollTask(taskId, startTime) {
|
||||
var poll = setInterval(function() {
|
||||
fetch('/rvc/status/' + taskId).then(function(r){ return r.json(); }).then(function(data){
|
||||
if (!data.success) return;
|
||||
var p = data.progress;
|
||||
if (p.status === 'done') {
|
||||
clearInterval(poll);
|
||||
var elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||
convertLoading.style.display = 'none';
|
||||
audioWrap.style.display = '';
|
||||
audioPlayer.src = data.audio_url;
|
||||
var safeName = _uploadedFilename.replace(/\.[^.]+$/, '').replace(/[\\/:*?"<>|]/g, '_');
|
||||
var model = document.getElementById('modelSelect').value.replace(/\.[^.]+$/, '');
|
||||
var now = new Date();
|
||||
var ts = now.getFullYear() + String(now.getMonth()+1).padStart(2,'0') + String(now.getDate()).padStart(2,'0')
|
||||
+ String(now.getHours()).padStart(2,'0') + String(now.getMinutes()).padStart(2,'0')
|
||||
+ String(now.getSeconds()).padStart(2,'0') + String(now.getMilliseconds()).padStart(3,'0');
|
||||
var dlName = model + '_' + safeName + '_' + ts + '.wav';
|
||||
fetch(data.audio_url).then(function(r){ return r.blob(); }).then(function(blob){
|
||||
if (downloadLink._blobUrl) URL.revokeObjectURL(downloadLink._blobUrl);
|
||||
downloadLink._blobUrl = URL.createObjectURL(blob);
|
||||
downloadLink.href = downloadLink._blobUrl;
|
||||
downloadLink.download = dlName;
|
||||
});
|
||||
audioMeta.innerHTML = '<span class="meta-label">耗时</span> <span class="meta-value">' + elapsed + 's</span>';
|
||||
convertBtn.disabled = false;
|
||||
showMessage('变声完成,耗时 ' + elapsed + ' 秒', 'success');
|
||||
} else if (p.status === 'error') {
|
||||
clearInterval(poll);
|
||||
showMessage(p.error || '变声失败', 'error');
|
||||
convertLoading.style.display = 'none';
|
||||
placeholder.style.display = '';
|
||||
convertBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function replay() { audioPlayer.currentTime = 0; audioPlayer.play(); }
|
||||
|
||||
// === Load config ===
|
||||
function loadMainConfig() {
|
||||
fetch('/rvc/config').then(function(r){ return r.json(); }).then(function(cfg){
|
||||
// Populate models
|
||||
var mSel = document.getElementById('modelSelect');
|
||||
mSel.innerHTML = '<option value="">请选择模型</option>';
|
||||
(cfg.models || []).forEach(function(m) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = m; opt.textContent = m;
|
||||
if (m === cfg.last_model) opt.selected = true;
|
||||
mSel.appendChild(opt);
|
||||
});
|
||||
// Populate indexes
|
||||
var iSel = document.getElementById('indexSelect');
|
||||
iSel.innerHTML = '<option value="">无 (不使用检索)</option>';
|
||||
(cfg.index_files || []).forEach(function(f) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = f; opt.textContent = f.replace(/\\/g, '/').split('/').pop();
|
||||
if (f === cfg.last_index) opt.selected = true;
|
||||
iSel.appendChild(opt);
|
||||
});
|
||||
// Populate params
|
||||
document.getElementById('f0UpKey').value = cfg.f0_up_key || 0;
|
||||
document.getElementById('f0UpKeyValue').textContent = cfg.f0_up_key || 0;
|
||||
document.getElementById('f0Method').value = cfg.f0_method || 'rmvpe';
|
||||
document.getElementById('indexRate').value = cfg.index_rate != null ? cfg.index_rate : 0.75;
|
||||
document.getElementById('indexRateValue').textContent = (cfg.index_rate != null ? cfg.index_rate : 0.75).toFixed(2);
|
||||
document.getElementById('filterRadius').value = cfg.filter_radius != null ? cfg.filter_radius : 3;
|
||||
document.getElementById('filterRadiusValue').textContent = cfg.filter_radius != null ? cfg.filter_radius : 3;
|
||||
document.getElementById('resampleSr').value = cfg.resample_sr || 0;
|
||||
document.getElementById('rmsMixRate').value = cfg.rms_mix_rate != null ? cfg.rms_mix_rate : 0.25;
|
||||
document.getElementById('rmsMixRateValue').textContent = (cfg.rms_mix_rate != null ? cfg.rms_mix_rate : 0.25).toFixed(2);
|
||||
document.getElementById('protect').value = cfg.protect != null ? cfg.protect : 0.33;
|
||||
document.getElementById('protectValue').textContent = (cfg.protect != null ? cfg.protect : 0.33).toFixed(2);
|
||||
|
||||
// Populate settings modal
|
||||
document.getElementById('rvcProjectPath').value = cfg.rvc_project_path || '';
|
||||
document.getElementById('modelDir').value = cfg.model_dir || 'assets/weights';
|
||||
document.getElementById('modelDirMode').value = cfg.model_dir_mode || 'relative';
|
||||
document.getElementById('indexDir').value = cfg.index_dir || 'logs';
|
||||
document.getElementById('indexDirMode').value = cfg.index_dir_mode || 'relative';
|
||||
|
||||
// Engine status
|
||||
if (cfg.engine_status) {
|
||||
updateEngineUI(cfg.engine_status);
|
||||
if (cfg.engine_status.initialized || cfg.engine_status.loading) startLogPolling();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// === Keyboard ===
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') closeSettings();
|
||||
});
|
||||
|
||||
// === Init ===
|
||||
loadMainConfig();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
1258
flask-dev-api/templates/sovits_tts.html
Normal file
1258
flask-dev-api/templates/sovits_tts.html
Normal file
File diff suppressed because it is too large
Load Diff
740
flask-dev-api/templates/stt.html
Normal file
740
flask-dev-api/templates/stt.html
Normal file
@@ -0,0 +1,740 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='font-awesome.css') }}">
|
||||
<title>STT 语音识别</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
background: #f5f5f5;
|
||||
color: #333;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
}
|
||||
/* 左侧面板 */
|
||||
.left-panel {
|
||||
width: 420px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid #e8e8e8;
|
||||
background: #fff;
|
||||
height: 100vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.left-panel::-webkit-scrollbar { width: 4px; }
|
||||
.left-panel::-webkit-scrollbar-thumb { background: #ddd; border-radius: 2px; }
|
||||
.input-section { padding: 20px 24px 28px; }
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.header h1 {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #1a1a1a;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.subtitle { color: #aaa; font-size: 12px; margin-bottom: 16px; }
|
||||
.settings-icon {
|
||||
width: 32px; height: 32px; border: none; background: transparent;
|
||||
color: #999; font-size: 15px; cursor: pointer; border-radius: 8px;
|
||||
display: flex; align-items: center; justify-content: center; transition: all 0.15s;
|
||||
}
|
||||
.settings-icon:hover { background: #f5f5f5; color: #333; }
|
||||
|
||||
.section-card {
|
||||
background: #fafafa;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 10px;
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.section-title {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #999;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.section-title i { font-size: 11px; }
|
||||
|
||||
.input-group { margin-bottom: 10px; position: relative; }
|
||||
.input-group:last-child { margin-bottom: 0; }
|
||||
.input-group label {
|
||||
display: block; font-size: 12px; font-weight: 500;
|
||||
margin-bottom: 4px; color: #555;
|
||||
}
|
||||
.input-group select {
|
||||
width: 100%; padding: 7px 10px;
|
||||
border: 1px solid #e0e0e0; border-radius: 6px;
|
||||
background: #fff; color: #333; font-size: 13px;
|
||||
outline: none; transition: all 0.15s ease;
|
||||
}
|
||||
.input-group select:focus {
|
||||
border-color: #0078d4;
|
||||
box-shadow: 0 0 0 2px rgba(0,120,212,0.06);
|
||||
}
|
||||
|
||||
/* 文件上传 */
|
||||
.file-drop {
|
||||
border: 2px dashed #e0e0e0;
|
||||
border-radius: 8px;
|
||||
padding: 24px 16px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
background: #fff;
|
||||
}
|
||||
.file-drop:hover, .file-drop.dragover {
|
||||
border-color: #0078d4;
|
||||
background: #f0f7ff;
|
||||
}
|
||||
.file-drop i { font-size: 28px; color: #ccc; margin-bottom: 8px; }
|
||||
.file-drop p { font-size: 13px; color: #999; }
|
||||
.file-drop .filename {
|
||||
font-size: 13px; color: #0078d4; font-weight: 600;
|
||||
margin-top: 6px; word-break: break-all;
|
||||
}
|
||||
.file-drop input { display: none; }
|
||||
|
||||
/* 音频播放器 */
|
||||
.audio-player {
|
||||
display: none; margin-top: 10px;
|
||||
}
|
||||
.audio-player audio {
|
||||
width: 100%; height: 36px; border-radius: 8px;
|
||||
}
|
||||
|
||||
/* 格式选择 */
|
||||
.format-group {
|
||||
display: flex; gap: 6px;
|
||||
}
|
||||
.format-btn {
|
||||
flex: 1; padding: 6px 0;
|
||||
border: 1px solid #e0e0e0; border-radius: 6px;
|
||||
background: #fff; color: #666;
|
||||
font-size: 12px; font-weight: 500;
|
||||
cursor: pointer; text-align: center;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
.format-btn:hover { border-color: #0078d4; color: #0078d4; }
|
||||
.format-btn.active {
|
||||
background: #e8f0fe; border-color: #0078d4; color: #0078d4;
|
||||
}
|
||||
|
||||
/* 开始按钮 */
|
||||
.run-btn {
|
||||
width: 100%; padding: 10px 18px;
|
||||
background: linear-gradient(135deg, #0078d4, #005fa3);
|
||||
color: #fff; border: none; border-radius: 8px;
|
||||
font-size: 14px; font-weight: 600;
|
||||
cursor: pointer; transition: all 0.15s ease;
|
||||
display: flex; align-items: center; justify-content: center; gap: 8px;
|
||||
margin-top: 14px;
|
||||
box-shadow: 0 2px 8px rgba(0,120,212,0.2);
|
||||
}
|
||||
.run-btn:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #006cbd, #004e8a);
|
||||
box-shadow: 0 4px 12px rgba(0,120,212,0.3);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.run-btn:active:not(:disabled) { transform: translateY(0); }
|
||||
.run-btn:disabled { opacity: 0.4; cursor: not-allowed; transform: none; }
|
||||
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.spinner {
|
||||
display: inline-block; width: 16px; height: 16px;
|
||||
border: 2px solid rgba(255,255,255,0.3); border-top-color: #fff;
|
||||
border-radius: 50%; animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
/* ===== 右侧面板 ===== */
|
||||
.right-panel {
|
||||
flex: 1;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
}
|
||||
.right-header {
|
||||
padding: 20px 24px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.right-header h2 {
|
||||
font-size: 15px; font-weight: 600; color: #333;
|
||||
}
|
||||
.result-badge {
|
||||
font-size: 12px; font-weight: 600; color: #0078d4;
|
||||
background: #e8f0fe; padding: 2px 10px; border-radius: 10px;
|
||||
}
|
||||
|
||||
/* 进度 */
|
||||
.progress-wrap {
|
||||
padding: 16px 24px;
|
||||
display: none;
|
||||
}
|
||||
.progress-bar-bg {
|
||||
height: 6px; background: #f0f0f0;
|
||||
border-radius: 3px; overflow: hidden;
|
||||
}
|
||||
.progress-bar-fill {
|
||||
height: 100%; background: linear-gradient(90deg, #0078d4, #00a4ef);
|
||||
border-radius: 3px; transition: width 0.3s;
|
||||
width: 0%;
|
||||
}
|
||||
.progress-info {
|
||||
display: flex; justify-content: space-between;
|
||||
margin-top: 6px; font-size: 11px; color: #999;
|
||||
}
|
||||
|
||||
/* 结果区域 */
|
||||
.result-area {
|
||||
flex: 1; overflow-y: auto; padding: 20px 24px;
|
||||
}
|
||||
.result-area::-webkit-scrollbar { width: 4px; }
|
||||
.result-area::-webkit-scrollbar-thumb { background: #ddd; border-radius: 2px; }
|
||||
|
||||
.result-textarea {
|
||||
width: 100%; height: 100%; min-height: 300px;
|
||||
border: 1px solid #e0e0e0; border-radius: 8px;
|
||||
padding: 12px 14px; font-size: 14px; line-height: 1.8;
|
||||
color: #333; background: #fff; resize: vertical;
|
||||
font-family: inherit; outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.result-textarea:focus {
|
||||
border-color: #0078d4;
|
||||
box-shadow: 0 0 0 2px rgba(0,120,212,0.06);
|
||||
}
|
||||
|
||||
.result-text {
|
||||
font-size: 14px; line-height: 1.8;
|
||||
color: #333; white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.result-srt {
|
||||
font-size: 13px; line-height: 1.6;
|
||||
}
|
||||
.srt-item {
|
||||
padding: 8px 12px; margin-bottom: 6px;
|
||||
background: #fafafa; border: 1px solid #f0f0f0;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.srt-time {
|
||||
font-size: 11px; color: #0078d4;
|
||||
font-family: monospace; margin-bottom: 2px;
|
||||
}
|
||||
.srt-text { font-size: 14px; color: #333; }
|
||||
|
||||
/* 占位 */
|
||||
.placeholder {
|
||||
flex: 1; display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center;
|
||||
color: #ccc; gap: 12px;
|
||||
}
|
||||
.placeholder i { font-size: 48px; }
|
||||
.placeholder p { font-size: 13px; }
|
||||
|
||||
/* 操作栏 */
|
||||
.action-bar {
|
||||
padding: 12px 24px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
display: none;
|
||||
gap: 8px;
|
||||
}
|
||||
.btn {
|
||||
padding: 7px 14px; border: none; border-radius: 6px;
|
||||
font-size: 12px; font-weight: 600; cursor: pointer;
|
||||
transition: all 0.12s ease;
|
||||
display: inline-flex; align-items: center; gap: 5px;
|
||||
}
|
||||
.btn-primary { background: #0078d4; color: #fff; }
|
||||
.btn-primary:hover { background: #006cbd; }
|
||||
.btn-outline { background: #fff; color: #666; border: 1px solid #e0e0e0; }
|
||||
.btn-outline:hover { background: #f5f5f5; color: #333; }
|
||||
|
||||
/* Toast */
|
||||
.toast {
|
||||
position: fixed; top: 20px; left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: #0a7a1a; color: #fff;
|
||||
padding: 10px 24px; border-radius: 8px;
|
||||
font-size: 13px; font-weight: 500;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||
opacity: 0; transition: opacity 0.3s;
|
||||
z-index: 9999; pointer-events: none;
|
||||
}
|
||||
.toast.show { opacity: 1; }
|
||||
.toast.error { background: #c62828; }
|
||||
|
||||
/* Settings modal */
|
||||
.modal-overlay {
|
||||
display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.4);
|
||||
z-index: 100; align-items: center; justify-content: center;
|
||||
}
|
||||
.modal-overlay.show { display: flex; }
|
||||
.modal {
|
||||
background: #fff; border-radius: 12px; width: 460px;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,0.2);
|
||||
}
|
||||
.modal-header {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 16px 20px; border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
.modal-header h3 { font-size: 15px; font-weight: 700; }
|
||||
.modal-close {
|
||||
width: 28px; height: 28px; border: none; background: transparent;
|
||||
color: #999; font-size: 16px; cursor: pointer; border-radius: 6px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.modal-close:hover { background: #f5f5f5; }
|
||||
.modal-body { padding: 16px 20px; }
|
||||
.modal-body .input-group { margin-bottom: 12px; }
|
||||
.modal-body .input-group label { font-size: 13px; }
|
||||
.modal-body .input-group input {
|
||||
width: 100%; padding: 7px 10px; border: 1px solid #e0e0e0; border-radius: 6px;
|
||||
font-size: 13px; outline: none;
|
||||
}
|
||||
.modal-body .input-group input:focus { border-color: #0078d4; }
|
||||
.modal-body .input-group .input-desc { font-size: 11px; color: #aaa; margin-top: 4px; }
|
||||
.modal-footer {
|
||||
padding: 12px 20px; border-top: 1px solid #f0f0f0;
|
||||
display: flex; justify-content: flex-end; gap: 8px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Settings modal -->
|
||||
<div class="modal-overlay" id="settingsModal">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3><i class="fas fa-wrench"></i> STT 设置</h3>
|
||||
<button class="modal-close" onclick="closeSettings()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="input-group">
|
||||
<label>识别模型目录</label>
|
||||
<input type="text" id="sttModelDir" placeholder="E:\AI\models\stt_models">
|
||||
<div class="input-desc">faster-whisper 模型所在目录,填写后将自动扫描可用模型</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-outline" onclick="closeSettings()">取消</button>
|
||||
<button class="btn btn-primary" onclick="saveSettings()">保存设置</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<!-- 左侧:输入 -->
|
||||
<div class="left-panel">
|
||||
<div class="input-section">
|
||||
<div class="header">
|
||||
<h1><i class="fas fa-microphone" style="color:#0078d4;font-size:16px;margin-right:6px;"></i>STT 语音识别</h1>
|
||||
<button class="settings-icon" onclick="openSettings()"><i class="fas fa-wrench"></i></button>
|
||||
</div>
|
||||
<p class="subtitle">使用 faster-whisper 将音频转为文字</p>
|
||||
|
||||
<div class="section-card">
|
||||
<div class="section-title"><i class="fas fa-file"></i> 上传音频</div>
|
||||
<div class="file-drop" id="fileDrop" onclick="document.getElementById('audioFile').click()">
|
||||
<i class="fas fa-microphone"></i>
|
||||
<p id="dropText">点击或拖拽音频文件到此处</p>
|
||||
<div class="filename" id="fileName" style="display:none;"></div>
|
||||
<input type="file" id="audioFile" accept="audio/*,video/*">
|
||||
</div>
|
||||
<div class="audio-player" id="audioPlayer">
|
||||
<audio id="audioPreview" controls preload="metadata"></audio>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-card">
|
||||
<div class="section-title"><i class="fas fa-brain"></i> 模型与语言</div>
|
||||
<div class="input-group">
|
||||
<label>识别模型</label>
|
||||
<select id="modelSelect">
|
||||
<option value="" disabled selected>请先配置模型路径</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label>语言</label>
|
||||
<select id="langSelect">
|
||||
{% for code, name in languages.items() %}
|
||||
<option value="{{ code }}">{{ name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label>运行设备</label>
|
||||
<select id="deviceSelect">
|
||||
<option value="cpu">CPU</option>
|
||||
<option value="cuda" disabled>CUDA (检测中...)</option>
|
||||
</select>
|
||||
<span id="cudaStatus" style="font-size:11px;color:#999;margin-top:3px;display:block;"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-card">
|
||||
<div class="section-title"><i class="fas fa-list"></i> 输出格式</div>
|
||||
<div class="format-group">
|
||||
<button class="format-btn active" data-fmt="text" onclick="setFormat(this)">纯文本</button>
|
||||
<button class="format-btn" data-fmt="srt" onclick="setFormat(this)">SRT 字幕</button>
|
||||
<button class="format-btn" data-fmt="json" onclick="setFormat(this)">JSON</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="run-btn" id="startBtn" onclick="startTranscribe()" disabled>
|
||||
<i class="fas fa-play"></i> 开始识别
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:结果 -->
|
||||
<div class="right-panel">
|
||||
<div class="right-header">
|
||||
<h2><i class="fas fa-list" style="color:#0078d4;margin-right:6px;"></i>识别结果</h2>
|
||||
<span class="result-badge" id="resultBadge" style="display:none;"></span>
|
||||
</div>
|
||||
<div class="progress-wrap" id="progressWrap">
|
||||
<div class="progress-bar-bg">
|
||||
<div class="progress-bar-fill" id="progressFill"></div>
|
||||
</div>
|
||||
<div class="progress-info">
|
||||
<span id="progressStatus">等待中...</span>
|
||||
<span id="progressPercent">0%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="resultArea" class="result-area">
|
||||
<div class="placeholder">
|
||||
<i class="fas fa-microphone"></i>
|
||||
<p>上传音频文件并点击"开始识别"</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="action-bar" id="actionBar">
|
||||
<button class="btn btn-primary" onclick="copyResult()"><i class="fas fa-copy"></i> 复制结果</button>
|
||||
<button class="btn btn-outline" onclick="downloadResult()"><i class="fas fa-file"></i> 下载结果</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// === Settings ===
|
||||
function openSettings() {
|
||||
document.getElementById('settingsModal').classList.add('show');
|
||||
loadSettings();
|
||||
}
|
||||
function closeSettings() {
|
||||
document.getElementById('settingsModal').classList.remove('show');
|
||||
}
|
||||
function loadSettings() {
|
||||
fetch('/stt/config').then(function(r){ return r.json(); }).then(function(cfg){
|
||||
document.getElementById('sttModelDir').value = cfg.model_dir || '';
|
||||
});
|
||||
}
|
||||
function saveSettings() {
|
||||
var cfg = { model_dir: document.getElementById('sttModelDir').value.trim() };
|
||||
fetch('/stt/config', {
|
||||
method: 'POST', headers: {'Content-Type':'application/json'},
|
||||
body: JSON.stringify(cfg)
|
||||
}).then(function(r){ return r.json(); }).then(function(data){
|
||||
if (data.success) {
|
||||
showToast('设置已保存');
|
||||
closeSettings();
|
||||
loadModels();
|
||||
} else {
|
||||
showToast(data.error || '保存失败', true);
|
||||
}
|
||||
});
|
||||
}
|
||||
function loadModels() {
|
||||
fetch('/stt/models').then(function(r){ return r.json(); }).then(function(data){
|
||||
var sel = document.getElementById('modelSelect');
|
||||
sel.innerHTML = '';
|
||||
if (data.models && data.models.length) {
|
||||
data.models.forEach(function(m) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = m; opt.textContent = m;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
startBtn.disabled = !audioFile.files.length;
|
||||
} else {
|
||||
sel.innerHTML = '<option value="" disabled selected>请先配置模型路径</option>';
|
||||
startBtn.disabled = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
function loadMainConfig() {
|
||||
fetch('/stt/config').then(function(r){ return r.json(); }).then(function(cfg){
|
||||
var sel = document.getElementById('modelSelect');
|
||||
sel.innerHTML = '';
|
||||
if (cfg.model_dir && cfg.models && cfg.models.length) {
|
||||
cfg.models.forEach(function(m) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = m; opt.textContent = m;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
} else {
|
||||
sel.innerHTML = '<option value="" disabled selected>请先配置模型路径</option>';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
var audioFile = document.getElementById('audioFile');
|
||||
var fileDrop = document.getElementById('fileDrop');
|
||||
var fileName = document.getElementById('fileName');
|
||||
var dropText = document.getElementById('dropText');
|
||||
var startBtn = document.getElementById('startBtn');
|
||||
var progressWrap = document.getElementById('progressWrap');
|
||||
var progressFill = document.getElementById('progressFill');
|
||||
var progressStatus = document.getElementById('progressStatus');
|
||||
var progressPercent = document.getElementById('progressPercent');
|
||||
var resultArea = document.getElementById('resultArea');
|
||||
var resultBadge = document.getElementById('resultBadge');
|
||||
var actionBar = document.getElementById('actionBar');
|
||||
|
||||
var currentFormat = 'text';
|
||||
var currentResult = '';
|
||||
var pollTimer = null;
|
||||
|
||||
// 文件选择
|
||||
audioFile.addEventListener('change', function() {
|
||||
if (this.files.length > 0) {
|
||||
showFile(this.files[0]);
|
||||
}
|
||||
});
|
||||
|
||||
// 拖拽
|
||||
fileDrop.addEventListener('dragover', function(e) {
|
||||
e.preventDefault(); this.classList.add('dragover');
|
||||
});
|
||||
fileDrop.addEventListener('dragleave', function() {
|
||||
this.classList.remove('dragover');
|
||||
});
|
||||
fileDrop.addEventListener('drop', function(e) {
|
||||
e.preventDefault(); this.classList.remove('dragover');
|
||||
if (e.dataTransfer.files.length > 0) {
|
||||
audioFile.files = e.dataTransfer.files;
|
||||
showFile(e.dataTransfer.files[0]);
|
||||
}
|
||||
});
|
||||
|
||||
function showFile(f) {
|
||||
fileName.textContent = f.name + ' (' + formatSize(f.size) + ')';
|
||||
fileName.style.display = 'block';
|
||||
dropText.style.display = 'none';
|
||||
// 音频预览
|
||||
var preview = document.getElementById('audioPreview');
|
||||
var player = document.getElementById('audioPlayer');
|
||||
preview.src = URL.createObjectURL(f);
|
||||
player.style.display = 'block';
|
||||
resetBtn();
|
||||
}
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return (bytes / 1048576).toFixed(1) + ' MB';
|
||||
}
|
||||
|
||||
function setFormat(btn) {
|
||||
document.querySelectorAll('.format-btn').forEach(function(b) { b.classList.remove('active'); });
|
||||
btn.classList.add('active');
|
||||
currentFormat = btn.dataset.fmt;
|
||||
}
|
||||
|
||||
function showToast(msg, isError) {
|
||||
var t = document.getElementById('toast');
|
||||
t.textContent = msg;
|
||||
t.className = 'toast show' + (isError ? ' error' : '');
|
||||
setTimeout(function() { t.className = 'toast'; }, 1500);
|
||||
}
|
||||
|
||||
// 开始识别
|
||||
async function startTranscribe() {
|
||||
if (!audioFile.files.length) { showToast('请先上传音频文件', true); return; }
|
||||
|
||||
var formData = new FormData();
|
||||
formData.append('audio', audioFile.files[0]);
|
||||
formData.append('model', document.getElementById('modelSelect').value);
|
||||
formData.append('language', document.getElementById('langSelect').value);
|
||||
formData.append('device', document.getElementById('deviceSelect').value);
|
||||
formData.append('format', currentFormat);
|
||||
|
||||
startBtn.disabled = true;
|
||||
startBtn.innerHTML = '<span class="spinner"></span> 上传中...';
|
||||
actionBar.style.display = 'none';
|
||||
resultBadge.style.display = 'none';
|
||||
resultArea.innerHTML = '<div class="placeholder"><span class="spinner" style="border-color:#e0e0e0;border-top-color:#0078d4;width:32px;height:32px;"></span><p style="color:#999;margin-top:8px;">正在上传并启动识别...</p></div>';
|
||||
|
||||
try {
|
||||
var res = await fetch('/stt/transcribe', { method: 'POST', body: formData });
|
||||
var data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
progressWrap.style.display = 'block';
|
||||
progressFill.style.width = '0%';
|
||||
progressStatus.textContent = '排队中...';
|
||||
progressPercent.textContent = '0%';
|
||||
startBtn.innerHTML = '<span class="spinner"></span> 识别中...';
|
||||
pollStatus(data.task_id);
|
||||
} else {
|
||||
showToast(data.error || '启动失败', true);
|
||||
resetBtn();
|
||||
}
|
||||
} catch (err) {
|
||||
showToast('请求失败: ' + err.message, true);
|
||||
resetBtn();
|
||||
}
|
||||
}
|
||||
|
||||
function pollStatus(taskId) {
|
||||
if (pollTimer) clearInterval(pollTimer);
|
||||
pollTimer = setInterval(async function() {
|
||||
try {
|
||||
var res = await fetch('/stt/status/' + taskId);
|
||||
var data = await res.json();
|
||||
if (!data.success) return;
|
||||
|
||||
var p = data.progress;
|
||||
var pct = Math.round((p.percent || 0) * 100);
|
||||
progressFill.style.width = pct + '%';
|
||||
progressPercent.textContent = pct + '%';
|
||||
|
||||
var statusMap = {
|
||||
queued: '排队中...', loading: '加载模型...',
|
||||
transcribing: '识别中...', done: '识别完成', error: '识别失败'
|
||||
};
|
||||
var statusText = statusMap[p.status] || p.status;
|
||||
if (p.warning) {
|
||||
statusText += ' (' + p.warning + ')';
|
||||
progressStatus.style.color = '#e65100';
|
||||
}
|
||||
progressStatus.textContent = statusText;
|
||||
|
||||
if (p.status === 'done') {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
progressFill.style.width = '100%';
|
||||
showResult(data.result);
|
||||
resetBtn();
|
||||
} else if (p.status === 'error') {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
showToast(p.error || '识别失败', true);
|
||||
resultArea.innerHTML = '<div class="placeholder" style="color:#c62828;"><i class="fas fa-circle-exclamation"></i><p>' + (p.error || '识别失败') + '</p></div>';
|
||||
resetBtn();
|
||||
}
|
||||
} catch (err) { /* 忽略网络错误,继续轮询 */ }
|
||||
}, 800);
|
||||
}
|
||||
|
||||
function showResult(result) {
|
||||
currentResult = result;
|
||||
resultBadge.style.display = 'inline';
|
||||
|
||||
if (currentFormat === 'srt') {
|
||||
resultBadge.textContent = 'SRT 字幕';
|
||||
var items = result.trim().split(/\n\n+/);
|
||||
var html = '<div class="result-srt">';
|
||||
items.forEach(function(block) {
|
||||
var lines = block.trim().split('\n');
|
||||
if (lines.length >= 3) {
|
||||
html += '<div class="srt-item"><div class="srt-time">' + escapeHtml(lines[1]) + '</div><div class="srt-text">' + escapeHtml(lines.slice(2).join(' ')) + '</div></div>';
|
||||
}
|
||||
});
|
||||
html += '</div>';
|
||||
resultArea.innerHTML = html;
|
||||
} else if (currentFormat === 'json') {
|
||||
resultBadge.textContent = 'JSON';
|
||||
resultArea.innerHTML = '<pre class="result-text" style="font-size:12px;font-family:monospace;background:#fafafa;padding:12px;border-radius:6px;overflow:auto;">' + escapeHtml(JSON.stringify(result, null, 2)) + '</pre>';
|
||||
} else {
|
||||
resultBadge.textContent = '纯文本';
|
||||
resultArea.innerHTML = '<textarea class="result-textarea" id="resultTextarea">' + escapeHtml(result) + '</textarea>';
|
||||
}
|
||||
actionBar.style.display = 'flex';
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
if (typeof s !== 'string') return '';
|
||||
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
|
||||
function resetBtn() {
|
||||
var hasModel = document.getElementById('modelSelect').options.length > 0 && document.getElementById('modelSelect').options[0].value !== '';
|
||||
startBtn.disabled = !audioFile.files.length || !hasModel;
|
||||
startBtn.innerHTML = '<i class="fas fa-play"></i> 开始识别';
|
||||
}
|
||||
|
||||
// 复制
|
||||
function copyResult() {
|
||||
var ta = document.getElementById('resultTextarea');
|
||||
var text = (currentFormat === 'text' && ta) ? ta.value : currentResult;
|
||||
if (typeof text === 'object') text = JSON.stringify(text, null, 2);
|
||||
navigator.clipboard.writeText(text).then(function() {
|
||||
showToast('已复制到剪贴板');
|
||||
}).catch(function() {
|
||||
showToast('复制失败', true);
|
||||
});
|
||||
}
|
||||
|
||||
// 下载
|
||||
function downloadResult() {
|
||||
var ta = document.getElementById('resultTextarea');
|
||||
var text = (currentFormat === 'text' && ta) ? ta.value : currentResult;
|
||||
if (typeof text === 'object') text = JSON.stringify(text, null, 2);
|
||||
var ext = currentFormat === 'srt' ? 'srt' : currentFormat === 'json' ? 'json' : 'txt';
|
||||
var blob = new Blob([text], { type: 'text/plain;charset=utf-8' });
|
||||
var a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = 'stt_result.' + ext;
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
}
|
||||
|
||||
// 页面加载时检测 CUDA
|
||||
(function() {
|
||||
var sel = document.getElementById('deviceSelect');
|
||||
var status = document.getElementById('cudaStatus');
|
||||
fetch('/stt/cuda-check').then(function(r) { return r.json(); }).then(function(d) {
|
||||
var cudaOpt = sel.querySelector('option[value="cuda"]');
|
||||
if (d.cuda) {
|
||||
cudaOpt.disabled = false;
|
||||
cudaOpt.textContent = 'CUDA - ' + d.name;
|
||||
sel.value = 'cuda';
|
||||
status.textContent = '已检测到 GPU,已自动选择 CUDA';
|
||||
status.style.color = '#0a7a1a';
|
||||
} else {
|
||||
cudaOpt.textContent = 'CUDA (不可用)';
|
||||
status.textContent = '未检测到 CUDA,使用 CPU';
|
||||
}
|
||||
}).catch(function() {
|
||||
sel.querySelector('option[value="cuda"]').textContent = 'CUDA (不可用)';
|
||||
status.textContent = '检测失败,使用 CPU';
|
||||
});
|
||||
})();
|
||||
|
||||
// ESC 关闭弹窗
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') closeSettings();
|
||||
});
|
||||
|
||||
// 初始化加载模型列表
|
||||
loadMainConfig();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
185
flask-dev-api/templates/token_gen.html
Normal file
185
flask-dev-api/templates/token_gen.html
Normal file
@@ -0,0 +1,185 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='font-awesome.css') }}">
|
||||
<title>Token 生成器</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
background: #f5f5f5; color: #333;
|
||||
height: 100vh; overflow-y: auto;
|
||||
}
|
||||
.container { max-width: 700px; margin: 0 auto; padding: 24px 28px 40px; }
|
||||
|
||||
.header { display: flex; align-items: center; margin-bottom: 2px; }
|
||||
.header h1 { font-size: 18px; font-weight: 700; color: #1a1a1a; }
|
||||
.subtitle { color: #aaa; font-size: 12px; margin-bottom: 16px; }
|
||||
|
||||
.section-card {
|
||||
background: #fafafa; border: 1px solid #f0f0f0;
|
||||
border-radius: 10px; padding: 14px 16px; margin-bottom: 12px;
|
||||
}
|
||||
.section-title {
|
||||
font-size: 11px; font-weight: 600; color: #999;
|
||||
text-transform: uppercase; letter-spacing: 0.06em;
|
||||
margin-bottom: 12px; display: flex; align-items: center; gap: 6px;
|
||||
}
|
||||
.section-title i { font-size: 11px; }
|
||||
|
||||
/* 复选框 */
|
||||
.chk-label {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
cursor: pointer; font-size: 13px; color: #555;
|
||||
padding: 6px 10px; border-radius: 6px; transition: background 0.15s;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.chk-label:hover { background: #f0f0f0; }
|
||||
.chk-label input { display: none; }
|
||||
.chk-icon {
|
||||
width: 17px; height: 17px; border: 2px solid #d0d0d0; border-radius: 4px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
transition: all 0.15s; flex-shrink: 0;
|
||||
}
|
||||
.chk-icon i { font-size: 10px; color: #fff; opacity: 0; transition: opacity 0.15s; }
|
||||
.chk-label input:checked ~ .chk-icon { background: #0078d4; border-color: #0078d4; }
|
||||
.chk-label input:checked ~ .chk-icon i { opacity: 1; }
|
||||
|
||||
/* 滑块 */
|
||||
.slider-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
|
||||
.slider-head .label { font-size: 12px; font-weight: 500; color: #555; }
|
||||
.slider-head .val { font-size: 18px; font-weight: 700; color: #0078d4; font-family: "SFMono-Regular", Consolas, monospace; }
|
||||
input[type="range"] {
|
||||
width: 100%; height: 6px; -webkit-appearance: none; appearance: none;
|
||||
background: #e0e0e0; border-radius: 3px; outline: none;
|
||||
}
|
||||
input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none; width: 18px; height: 18px;
|
||||
background: #0078d4; border-radius: 50%; cursor: pointer;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
/* 按钮 */
|
||||
.btn {
|
||||
padding: 7px 14px; border: none; border-radius: 6px;
|
||||
font-size: 12px; font-weight: 600; cursor: pointer;
|
||||
transition: all 0.12s ease;
|
||||
display: inline-flex; align-items: center; gap: 5px;
|
||||
}
|
||||
.btn-outline { background: #fff; color: #666; border: 1px solid #e0e0e0; }
|
||||
.btn-outline:hover { background: #f5f5f5; color: #333; }
|
||||
.run-btn {
|
||||
width: 100%; padding: 10px 18px;
|
||||
background: linear-gradient(135deg, #0078d4, #005fa3);
|
||||
color: #fff; border: none; border-radius: 8px;
|
||||
font-size: 14px; font-weight: 600; cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center; gap: 8px;
|
||||
transition: all 0.15s;
|
||||
box-shadow: 0 2px 8px rgba(0,120,212,0.2);
|
||||
}
|
||||
.run-btn:hover { background: linear-gradient(135deg, #006cbd, #004e8a); transform: translateY(-1px); }
|
||||
.run-btn.copied { background: linear-gradient(135deg, #2e7d32, #1b5e20); }
|
||||
|
||||
/* 结果 */
|
||||
.result-card {
|
||||
background: #fff; border: 1px solid #f0f0f0;
|
||||
border-radius: 10px; padding: 20px 24px; text-align: center;
|
||||
}
|
||||
.token-box {
|
||||
font-family: "SFMono-Regular", Consolas, monospace;
|
||||
font-size: 14px; line-height: 1.8; color: #333;
|
||||
word-break: break-all; user-select: all;
|
||||
padding: 16px; background: #fafafa; border: 1px solid #e8e8e8;
|
||||
border-radius: 8px; max-height: 200px; overflow-y: auto;
|
||||
min-height: 60px;
|
||||
}
|
||||
.token-len { margin-top: 10px; font-size: 12px; color: #bbb; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1><i class="fas fa-key" style="color:#0078d4;font-size:16px;margin-right:6px;"></i>Token 生成器</h1>
|
||||
</div>
|
||||
<p class="subtitle">随机字符串生成</p>
|
||||
|
||||
<!-- 配置 -->
|
||||
<div class="section-card">
|
||||
<div class="section-title"><i class="fas fa-sliders"></i> 配置</div>
|
||||
<label class="chk-label"><input type="checkbox" id="optUpper" checked onchange="generate()"><span class="chk-icon"><i class="fas fa-check"></i></span><span>大写字母 A-Z</span></label>
|
||||
<label class="chk-label"><input type="checkbox" id="optLower" checked onchange="generate()"><span class="chk-icon"><i class="fas fa-check"></i></span><span>小写字母 a-z</span></label>
|
||||
<label class="chk-label"><input type="checkbox" id="optNum" checked onchange="generate()"><span class="chk-icon"><i class="fas fa-check"></i></span><span>数字 0-9</span></label>
|
||||
<label class="chk-label"><input type="checkbox" id="optSym" onchange="generate()"><span class="chk-icon"><i class="fas fa-check"></i></span><span>符号 !@#$</span></label>
|
||||
<div style="margin-top:12px;">
|
||||
<div class="slider-head">
|
||||
<span class="label">长度</span>
|
||||
<span class="val" id="lenVal">64</span>
|
||||
</div>
|
||||
<input type="range" id="lenSlider" min="1" max="512" value="64" oninput="updateLen()">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作 -->
|
||||
<div style="display:flex;gap:8px;margin-bottom:12px;">
|
||||
<button class="run-btn" id="genBtn" onclick="generate()" style="flex:1;">
|
||||
<i class="fas fa-refresh"></i> 生成
|
||||
</button>
|
||||
<button class="run-btn" id="copyBtn" onclick="copyToken()" style="flex:1;">
|
||||
<i class="fas fa-copy"></i> 复制
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 结果 -->
|
||||
<div class="result-card">
|
||||
<div class="token-box" id="tokenDisplay"></div>
|
||||
<div class="token-len" id="tokenLen"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function shuffle(arr){
|
||||
for(var i=arr.length-1;i>0;i--){
|
||||
var j=Math.floor(Math.random()*(i+1));
|
||||
var t=arr[i];arr[i]=arr[j];arr[j]=t;
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
function generate(){
|
||||
var chars='';
|
||||
if(document.getElementById('optUpper').checked) chars+='ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||
if(document.getElementById('optLower').checked) chars+='abcdefghijklmnopqrstuvwxyz';
|
||||
if(document.getElementById('optNum').checked) chars+='0123456789';
|
||||
if(document.getElementById('optSym').checked) chars+='.,;:!?./-"\'#{([-|\\@)]=}*+';
|
||||
var len=parseInt(document.getElementById('lenSlider').value);
|
||||
if(!chars){document.getElementById('tokenDisplay').textContent='请至少选择一种字符集';return;}
|
||||
var repeated='';
|
||||
while(repeated.length<len) repeated+=chars;
|
||||
var arr=shuffle(repeated.split(''));
|
||||
var token=arr.slice(0,len).join('');
|
||||
document.getElementById('tokenDisplay').textContent=token;
|
||||
document.getElementById('tokenLen').textContent=len+' 字符';
|
||||
}
|
||||
|
||||
function updateLen(){
|
||||
document.getElementById('lenVal').textContent=document.getElementById('lenSlider').value;
|
||||
generate();
|
||||
}
|
||||
|
||||
function copyToken(){
|
||||
var text=document.getElementById('tokenDisplay').textContent;
|
||||
if(!text)return;
|
||||
var el=document.createElement('textarea');
|
||||
el.value=text;el.style.cssText='position:fixed;left:-9999px';
|
||||
document.body.appendChild(el);el.select();document.execCommand('copy');document.body.removeChild(el);
|
||||
var btn=document.getElementById('copyBtn');
|
||||
btn.innerHTML='<i class="fas fa-check"></i> 已复制';btn.classList.add('copied');
|
||||
setTimeout(function(){btn.innerHTML='<i class="fas fa-copy"></i> 复制';btn.classList.remove('copied');},1500);
|
||||
}
|
||||
|
||||
generate();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
249
flask-dev-api/templates/url_parser.html
Normal file
249
flask-dev-api/templates/url_parser.html
Normal file
@@ -0,0 +1,249 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='font-awesome.css') }}">
|
||||
<title>URL 解析器</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
background: #f5f5f5; color: #333;
|
||||
height: 100vh; overflow-y: auto;
|
||||
}
|
||||
.container { max-width: 900px; margin: 0 auto; padding: 24px 28px 40px; }
|
||||
|
||||
/* 头部 */
|
||||
.header { display: flex; align-items: center; margin-bottom: 2px; }
|
||||
.header h1 { font-size: 18px; font-weight: 700; color: #1a1a1a; }
|
||||
.subtitle { color: #aaa; font-size: 12px; margin-bottom: 16px; }
|
||||
|
||||
/* 卡片 */
|
||||
.section-card {
|
||||
background: #fafafa; border: 1px solid #f0f0f0;
|
||||
border-radius: 10px; padding: 14px 16px; margin-bottom: 12px;
|
||||
}
|
||||
.section-title {
|
||||
font-size: 11px; font-weight: 600; color: #999;
|
||||
text-transform: uppercase; letter-spacing: 0.06em;
|
||||
margin-bottom: 12px; display: flex; align-items: center; gap: 6px;
|
||||
}
|
||||
.section-title i { font-size: 11px; }
|
||||
|
||||
/* 输入 */
|
||||
.input-wrap { position: relative; }
|
||||
.input-wrap i {
|
||||
position: absolute; left: 10px; top: 50%; transform: translateY(-50%);
|
||||
color: #c0c0c0; font-size: 12px; pointer-events: none;
|
||||
}
|
||||
.input-wrap input {
|
||||
width: 100%; padding: 9px 12px 9px 32px;
|
||||
border: 1px solid #e0e0e0; border-radius: 6px;
|
||||
font-size: 13px; outline: none; transition: all 0.15s;
|
||||
}
|
||||
.input-wrap input::placeholder { color: #c0c0c0; }
|
||||
.input-wrap input:focus { border-color: #0078d4; box-shadow: 0 0 0 2px rgba(0,120,212,0.06); }
|
||||
.input-wrap input.err { border-color: #c62828; }
|
||||
.err-msg { font-size: 12px; color: #c62828; margin-top: 4px; min-height: 16px; }
|
||||
|
||||
/* 按钮 */
|
||||
.btn {
|
||||
padding: 7px 14px; border: none; border-radius: 6px;
|
||||
font-size: 12px; font-weight: 600; cursor: pointer;
|
||||
transition: all 0.12s ease;
|
||||
display: inline-flex; align-items: center; gap: 5px;
|
||||
}
|
||||
.btn-outline { background: #fff; color: #666; border: 1px solid #e0e0e0; }
|
||||
.btn-outline:hover { background: #f5f5f5; color: #333; }
|
||||
.run-btn {
|
||||
width: 100%; padding: 10px 18px;
|
||||
background: linear-gradient(135deg, #0078d4, #005fa3);
|
||||
color: #fff; border: none; border-radius: 8px;
|
||||
font-size: 14px; font-weight: 600; cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center; gap: 8px;
|
||||
margin-top: 12px; transition: all 0.15s;
|
||||
box-shadow: 0 2px 8px rgba(0,120,212,0.2);
|
||||
}
|
||||
.run-btn:hover { background: linear-gradient(135deg, #006cbd, #004e8a); transform: translateY(-1px); }
|
||||
|
||||
/* 示例列表 */
|
||||
.example-list { list-style: none; }
|
||||
.example-list li {
|
||||
padding: 6px 10px; border-radius: 5px; cursor: pointer;
|
||||
font-size: 12px; color: #888; font-family: "SFMono-Regular", Consolas, monospace;
|
||||
transition: all 0.15s; margin-bottom: 2px;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.example-list li:hover { background: #f0f7ff; color: #0078d4; }
|
||||
|
||||
/* 结果 */
|
||||
.result-section { margin-bottom: 20px; }
|
||||
.result-section:last-child { margin-bottom: 0; }
|
||||
.result-title {
|
||||
font-size: 11px; font-weight: 700; color: #aaa;
|
||||
text-transform: uppercase; letter-spacing: 0.06em;
|
||||
margin-bottom: 10px; padding-bottom: 6px;
|
||||
border-bottom: 1px solid #eee;
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
}
|
||||
.result-title i { font-size: 11px; color: #ccc; }
|
||||
.fields { display: flex; flex-direction: column; gap: 4px; }
|
||||
.field-row {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
background: #fff; border: 1px solid #e8e8e8; border-radius: 6px;
|
||||
padding: 8px 12px; transition: border-color 0.15s;
|
||||
}
|
||||
.field-row:hover { border-color: #c0c0c0; }
|
||||
.field-label { width: 64px; font-size: 12px; font-weight: 600; color: #999; flex-shrink: 0; }
|
||||
.field-value {
|
||||
flex: 1; font-family: "SFMono-Regular", Consolas, monospace;
|
||||
font-size: 12px; color: #333; word-break: break-all; min-width: 0;
|
||||
}
|
||||
.field-value:empty::after { content: '—'; color: #ddd; }
|
||||
.btn-cp {
|
||||
padding: 3px 8px; border: 1px solid #e0e0e0; border-radius: 4px;
|
||||
background: #fff; color: #aaa; font-size: 11px; cursor: pointer;
|
||||
flex-shrink: 0; transition: all 0.15s;
|
||||
}
|
||||
.btn-cp:hover { background: #f5f5f5; color: #666; }
|
||||
.btn-cp.copied { background: #e8f5e9; color: #2e7d32; border-color: #a5d6a7; }
|
||||
|
||||
.params { display: flex; flex-direction: column; gap: 4px; }
|
||||
.param-row {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
background: #fff; border: 1px solid #e8e8e8; border-radius: 6px;
|
||||
padding: 6px 12px; transition: border-color 0.15s;
|
||||
}
|
||||
.param-row:hover { border-color: #c0c0c0; }
|
||||
.param-key {
|
||||
width: 120px; flex-shrink: 0;
|
||||
padding: 3px 8px; background: #f0f7ff; color: #0078d4;
|
||||
border-radius: 4px; font-family: "SFMono-Regular", Consolas, monospace;
|
||||
font-size: 12px; font-weight: 600; text-align: center;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.param-val {
|
||||
flex: 1; font-family: "SFMono-Regular", Consolas, monospace;
|
||||
font-size: 12px; color: #555; word-break: break-all; min-width: 0;
|
||||
}
|
||||
.no-params { font-size: 13px; color: #ccc; padding: 8px 0; }
|
||||
|
||||
.empty-state {
|
||||
text-align: center; padding: 40px 0; color: #ccc;
|
||||
}
|
||||
.empty-state i { font-size: 40px; margin-bottom: 12px; display: block; }
|
||||
.empty-state p { font-size: 13px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1><i class="fas fa-link" style="color:#0078d4;font-size:16px;margin-right:6px;"></i>URL 解析器</h1>
|
||||
</div>
|
||||
<p class="subtitle">拆解 URL 各组成部分</p>
|
||||
|
||||
<!-- 输入 -->
|
||||
<div class="section-card">
|
||||
<div class="section-title"><i class="fas fa-pen"></i> 输入 URL</div>
|
||||
<div class="input-wrap">
|
||||
<i class="fas fa-link"></i>
|
||||
<input type="text" id="urlInput" placeholder="粘贴 URL..."
|
||||
value="https://user:pass@example.com:8080/path/to/page?key1=value1&key2=value2#section"
|
||||
onkeydown="if(event.key==='Enter')parse()">
|
||||
</div>
|
||||
<div class="err-msg" id="errMsg"></div>
|
||||
<button class="run-btn" onclick="parse()"><i class="fas fa-search"></i> 解析</button>
|
||||
</div>
|
||||
|
||||
<!-- 示例 -->
|
||||
<div class="section-card">
|
||||
<div class="section-title"><i class="fas fa-lightbulb"></i> 示例</div>
|
||||
<ul class="example-list">
|
||||
<li onclick="fillUrl(this)">https://user:pass@example.com:8080/path?key=value#hash</li>
|
||||
<li onclick="fillUrl(this)">http://localhost:3000/api/v1/users?page=1&limit=20</li>
|
||||
<li onclick="fillUrl(this)">https://www.google.com/search?q=hello+world&lang=zh-CN</li>
|
||||
<li onclick="fillUrl(this)">ftp://files.example.com:21/pub/doc.pdf</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- 结果 -->
|
||||
<div class="section-card" id="resultCard" style="display:none;">
|
||||
<div class="result-section">
|
||||
<div class="result-title"><i class="fas fa-file"></i> URL 组成</div>
|
||||
<div class="fields" id="fields"></div>
|
||||
</div>
|
||||
<div class="result-section" style="margin-top:16px;">
|
||||
<div class="result-title"><i class="fas fa-list"></i> Query 参数</div>
|
||||
<div id="params"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="empty-state" id="emptyState">
|
||||
<i class="fas fa-link"></i>
|
||||
<p>输入 URL 并点击解析</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var defs = [
|
||||
{label:'协议',key:'protocol'},{label:'用户名',key:'username'},{label:'密码',key:'password'},
|
||||
{label:'主机名',key:'hostname'},{label:'端口',key:'port'},{label:'路径',key:'pathname'},
|
||||
{label:'查询',key:'search'},{label:'哈希',key:'hash'}
|
||||
];
|
||||
|
||||
function esc(s){return(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
|
||||
|
||||
function copyText(text,btn){
|
||||
var el=document.createElement('textarea');
|
||||
el.value=text;el.style.cssText='position:fixed;left:-9999px';
|
||||
document.body.appendChild(el);el.select();document.execCommand('copy');document.body.removeChild(el);
|
||||
btn.classList.add('copied');
|
||||
setTimeout(function(){btn.classList.remove('copied');},1500);
|
||||
}
|
||||
|
||||
function fillUrl(el){document.getElementById('urlInput').value=el.textContent;parse();}
|
||||
|
||||
function parse(){
|
||||
var raw=document.getElementById('urlInput').value.trim();
|
||||
var errMsg=document.getElementById('errMsg');
|
||||
var card=document.getElementById('resultCard');
|
||||
var empty=document.getElementById('emptyState');
|
||||
var inp=document.getElementById('urlInput');
|
||||
if(!raw){errMsg.textContent='';card.style.display='none';empty.style.display='';inp.classList.remove('err');return;}
|
||||
try{
|
||||
var u=new URL(raw);
|
||||
errMsg.textContent='';inp.classList.remove('err');
|
||||
card.style.display='';empty.style.display='none';
|
||||
var fh='';
|
||||
defs.forEach(function(f){
|
||||
var v=u[f.key]||'';
|
||||
fh+='<div class="field-row"><span class="field-label">'+f.label+'</span>'
|
||||
+'<span class="field-value" id="fv-'+f.key+'">'+esc(v)+'</span>'
|
||||
+'<button class="btn-cp" onclick="copyText(document.getElementById(\'fv-'+f.key+'\').textContent,this)">复制</button></div>';
|
||||
});
|
||||
document.getElementById('fields').innerHTML=fh;
|
||||
var params=[];
|
||||
u.searchParams.forEach(function(v,k){params.push({k:k,v:v});});
|
||||
if(params.length){
|
||||
var ph='<div class="params">';
|
||||
params.forEach(function(p){
|
||||
ph+='<div class="param-row"><span class="param-key" title="'+esc(p.k)+'">'+esc(p.k)+'</span>'
|
||||
+'<span class="param-val">'+esc(p.v)+'</span>'
|
||||
+'<button class="btn-cp" onclick="copyText(this.previousElementSibling.textContent,this)">复制</button></div>';
|
||||
});
|
||||
ph+='</div>';
|
||||
document.getElementById('params').innerHTML=ph;
|
||||
}else{
|
||||
document.getElementById('params').innerHTML='<div class="no-params">无查询参数</div>';
|
||||
}
|
||||
}catch(e){
|
||||
errMsg.textContent='无效的 URL: '+e.message;
|
||||
inp.classList.add('err');
|
||||
card.style.display='none';empty.style.display='';
|
||||
}
|
||||
}
|
||||
parse();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user