feat: complete local version to overwrite remote

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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