This commit is contained in:
DelLevin-Home
2026-06-17 01:51:11 +08:00
parent bb66913eab
commit db033c661f
28 changed files with 3744 additions and 31 deletions

View File

@@ -277,6 +277,14 @@
@keyframes slideIn { from { opacity: 0; transform: translateY(-8px); } to { opacity: 1; transform: translateY(0); } }
@keyframes slideOut { from { opacity: 1; transform: translateY(0); } to { opacity: 0; transform: translateY(-8px); } }
.message.removing { animation: slideOut 0.2s ease forwards; }
@media (max-width: 768px) {
body { flex-direction: column; height: auto; overflow: auto; }
.left-panel { width: 100% !important; max-width: 100% !important; height: auto; border-right: none; border-bottom: 1px solid #e8e8e8; overflow-y: visible; }
.right-panel { height: auto; min-height: 50vh; }
.card-pair { flex-direction: column; }
.modal { width: 92vw !important; max-height: 90vh; overflow-y: auto; left: 4vw !important; transform: none !important; }
.input-section { padding: 14px 16px 20px; }
}
</style>
</head>
<body>

View File

@@ -0,0 +1,566 @@
<!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>Audio Slicer 音频分割</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: 460px; 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; 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; }
/* 上传区 */
.upload-area {
border: 2px dashed #e0e0e0; border-radius: 10px;
padding: 28px 16px; text-align: center; cursor: pointer;
transition: all 0.2s; background: #fff;
}
.upload-area:hover, .upload-area.dragover {
border-color: #0078d4; background: #f0f7ff;
}
.upload-area i { font-size: 28px; color: #ccc; margin-bottom: 8px; display: block; }
.upload-area .label { font-size: 13px; color: #999; }
.upload-area .filename { font-size: 13px; color: #0078d4; font-weight: 600; margin-top: 4px; }
.upload-area input[type="file"] { display: none; }
/* 音频信息 */
.audio-info {
display: grid; grid-template-columns: 1fr 1fr; gap: 8px;
margin-top: 8px;
}
.info-item {
background: #fff; border: 1px solid #f0f0f0; border-radius: 6px;
padding: 8px 10px; font-size: 12px;
}
.info-item .info-label { color: #999; font-size: 11px; }
.info-item .info-value { font-weight: 600; color: #333; margin-top: 2px; }
/* 参数 */
.param-row {
display: flex; align-items: center; gap: 10px; margin-bottom: 10px;
}
.param-row:last-child { margin-bottom: 0; }
.param-label {
font-size: 12px; color: #666; width: 100px; flex-shrink: 0;
}
.param-row input[type="range"] { flex: 1; accent-color: #0078d4; }
.param-val {
width: 60px; text-align: center; padding: 4px 6px;
border: 1px solid #e0e0e0; border-radius: 4px;
font-size: 12px; font-weight: 600; color: #333;
}
.param-unit { font-size: 11px; color: #999; width: 24px; }
/* 按钮 */
.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:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
.action-btn.analyze {
background: linear-gradient(135deg, #0078d4, #005fa3); color: #fff;
}
.action-btn.slice {
background: linear-gradient(135deg, #0a7a1a, #065f12); color: #fff;
}
.btn-row { display: flex; gap: 8px; margin-top: 12px; }
.btn-row .action-btn { flex: 1; }
/* 右侧面板 */
.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;
border-bottom: 1px solid #e8e8e8;
}
.right-header h2 { font-size: 15px; font-weight: 600; color: #333; }
.right-body {
flex: 1; overflow-y: auto; padding: 16px 24px 24px;
}
/* 预览表格 */
.preview-table {
width: 100%; border-collapse: collapse; font-size: 13px;
}
.preview-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: 2px solid #e0e0e0;
}
.preview-table td {
padding: 8px 12px; border-bottom: 1px solid #f0f0f0; color: #555;
}
.preview-table tr:hover td { background: #fafafa; }
/* 切片行 */
.slice-row { display: flex; align-items: center; gap: 10px; padding: 8px 0; border-bottom: 1px solid #f0f0f0; }
.slice-row:last-child { border-bottom: none; }
.slice-idx { width: 36px; font-size: 13px; font-weight: 700; color: #999; text-align: center; }
.slice-name { flex: 1; font-size: 13px; color: #333; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.slice-dur { font-size: 12px; color: #999; width: 60px; text-align: right; }
.slice-actions { display: flex; gap: 6px; }
.slice-btn {
width: 30px; height: 30px; border: 1px solid #e0e0e0; border-radius: 6px;
background: #fff; color: #666; cursor: pointer; display: flex;
align-items: center; justify-content: center; font-size: 12px;
transition: all 0.15s;
}
.slice-btn:hover { background: #f0f0f0; color: #333; border-color: #ccc; }
.slice-btn.play-btn:hover { color: #0078d4; border-color: #0078d4; }
/* 进度条 */
.progress-bar {
width: 100%; height: 6px; background: #f0f0f0; border-radius: 3px;
overflow: hidden; margin: 12px 0;
}
.progress-fill {
height: 100%; background: linear-gradient(90deg, #0078d4, #0a7a1a);
border-radius: 3px; transition: width 0.3s;
}
/* 空状态 */
.empty-state {
display: flex; flex-direction: column; align-items: center;
justify-content: center; height: 100%; color: #ccc;
}
.empty-state i { font-size: 48px; margin-bottom: 16px; }
.empty-state p { font-size: 14px; }
/* 顶部操作栏 */
.top-actions {
display: flex; gap: 8px; margin-bottom: 16px;
}
.top-btn {
padding: 7px 14px; border: 1px solid #e0e0e0; border-radius: 6px;
background: #fff; color: #666; font-size: 12px; font-weight: 600;
cursor: pointer; display: flex; align-items: center; gap: 5px;
transition: all 0.15s;
}
.top-btn:hover { background: #f0f0f0; color: #333; border-color: #ccc; }
.top-btn.primary { background: #0078d4; color: #fff; border-color: #0078d4; }
.top-btn.primary:hover { background: #006cbd; }
.status-text { font-size: 12px; color: #999; margin: 8px 0; }
/* 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; } }
@media (max-width: 768px) {
body { flex-direction: column; height: auto; overflow: auto; }
.left-panel { width: 100% !important; height: auto; border-right: none; border-bottom: 1px solid #e8e8e8; overflow-y: visible; }
.right-panel { height: auto; min-height: 50vh; }
.input-section { padding: 14px 16px 20px; }
}
</style>
</head>
<body>
<div class="toast-container" id="toastContainer"></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>Audio Slicer</h1>
</div>
<p class="subtitle">基于静音检测的智能音频分割</p>
<!-- 上传 -->
<div class="section-card">
<div class="section-title"><i class="fas fa-file"></i> 上传音频</div>
<div class="upload-area" id="uploadArea" onclick="document.getElementById('audioInput').click()">
<i class="fas fa-folder-open"></i>
<div class="label" id="uploadLabel">点击或拖拽音频文件到此处</div>
<div class="filename" id="uploadFilename" style="display:none;"></div>
<input type="file" id="audioInput" accept=".wav,.flac,.ogg,.mp3,.aac,.m4a,.wma,.aiff,.opus">
</div>
<div class="audio-info" id="audioInfo" style="display:none;">
<div class="info-item">
<div class="info-label">采样率</div>
<div class="info-value" id="infoSr">-</div>
</div>
<div class="info-item">
<div class="info-label">声道数</div>
<div class="info-value" id="infoCh">-</div>
</div>
<div class="info-item">
<div class="info-label">时长</div>
<div class="info-value" id="infoDur">-</div>
</div>
<div class="info-item">
<div class="info-label">文件名</div>
<div class="info-value" id="infoName" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">-</div>
</div>
</div>
</div>
<!-- 参数 -->
<div class="section-card">
<div class="section-title">
<i class="fas fa-wrench"></i> 分割参数
<button class="slice-btn" style="margin-left:auto;" onclick="saveConfig()" title="保存参数"><i class="fas fa-check"></i></button>
</div>
<div class="param-row">
<span class="param-label">静音阈值</span>
<input type="range" id="pThreshold" min="-80" max="-10" value="{{ config.threshold }}" oninput="syncParam(this, 'vThreshold')">
<input type="number" class="param-val" id="vThreshold" value="{{ config.threshold }}" onchange="syncSlider(this, 'pThreshold')">
<span class="param-unit">dB</span>
</div>
<div class="param-row">
<span class="param-label">最短片段</span>
<input type="range" id="pMinLength" min="1000" max="30000" step="500" value="{{ config.min_length }}" oninput="syncParam(this, 'vMinLength')">
<input type="number" class="param-val" id="vMinLength" value="{{ config.min_length }}" onchange="syncSlider(this, 'pMinLength')">
<span class="param-unit">ms</span>
</div>
<div class="param-row">
<span class="param-label">最短静音</span>
<input type="range" id="pMinInterval" min="50" max="3000" step="50" value="{{ config.min_interval }}" oninput="syncParam(this, 'vMinInterval')">
<input type="number" class="param-val" id="vMinInterval" value="{{ config.min_interval }}" onchange="syncSlider(this, 'pMinInterval')">
<span class="param-unit">ms</span>
</div>
<div class="param-row">
<span class="param-label">帧长</span>
<input type="range" id="pHopSize" min="5" max="100" step="5" value="{{ config.hop_size }}" oninput="syncParam(this, 'vHopSize')">
<input type="number" class="param-val" id="vHopSize" value="{{ config.hop_size }}" onchange="syncSlider(this, 'pHopSize')">
<span class="param-unit">ms</span>
</div>
<div class="param-row">
<span class="param-label">保留静音</span>
<input type="range" id="pMaxSilKept" min="100" max="10000" step="100" value="{{ config.max_sil_kept }}" oninput="syncParam(this, 'vMaxSilKept')">
<input type="number" class="param-val" id="vMaxSilKept" value="{{ config.max_sil_kept }}" onchange="syncSlider(this, 'pMaxSilKept')">
<span class="param-unit">ms</span>
</div>
</div>
<!-- 操作按钮 -->
<div class="btn-row">
<button class="action-btn analyze" id="btnAnalyze" onclick="doAnalyze()" disabled>
<i class="fas fa-search"></i> 分析
</button>
<button class="action-btn slice" id="btnSlice" onclick="doSlice()" disabled>
<i class="fas fa-scissors"></i> 开始切割
</button>
</div>
</div>
</div>
<div class="right-panel">
<div class="right-header">
<h2 id="rightTitle"><i class="fas fa-check-circle" style="color:#0078d4;margin-right:6px;"></i>切片结果</h2>
</div>
<div class="right-body" id="rightBody">
<div class="empty-state" id="emptyState">
<i class="fas fa-scissors"></i>
<p>上传音频文件后开始分析</p>
</div>
<div id="resultArea" style="display:none;">
<div class="top-actions" id="topActions" style="display:none;">
<button class="top-btn primary" onclick="downloadAll()">
<i class="fas fa-folder-plus"></i> 打包下载全部
</button>
</div>
<div class="status-text" id="statusText"></div>
<div class="progress-bar" id="progressWrap" style="display:none;">
<div class="progress-fill" id="progressFill" style="width:0%;"></div>
</div>
<div id="sliceList"></div>
</div>
</div>
</div>
<script>
let taskId = null;
let pollTimer = null;
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);
}
// 参数同步
function syncParam(slider, valId) {
document.getElementById(valId).value = slider.value;
}
function syncSlider(input, sliderId) {
document.getElementById(sliderId).value = input.value;
}
function getSettings() {
return {
threshold: parseInt(document.getElementById('vThreshold').value),
min_length: parseInt(document.getElementById('vMinLength').value),
min_interval: parseInt(document.getElementById('vMinInterval').value),
hop_size: parseInt(document.getElementById('vHopSize').value),
max_sil_kept: parseInt(document.getElementById('vMaxSilKept').value),
};
}
async function saveConfig() {
try {
var res = await fetch('/audio-slicer/config', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify(getSettings())
});
var data = await res.json();
if (data.success) showMessage('参数已保存', 'success');
} catch (e) {
showMessage('保存失败: ' + e.message, 'error');
}
}
function formatDur(sec) {
if (sec < 60) return sec.toFixed(1) + 's';
var m = Math.floor(sec / 60);
var s = (sec % 60).toFixed(1);
return m + 'm ' + s + 's';
}
// 上传区拖拽
var uploadArea = document.getElementById('uploadArea');
uploadArea.addEventListener('dragover', function(e) { e.preventDefault(); this.classList.add('dragover'); });
uploadArea.addEventListener('dragleave', function() { this.classList.remove('dragover'); });
uploadArea.addEventListener('drop', function(e) {
e.preventDefault(); this.classList.remove('dragover');
var files = e.dataTransfer.files;
if (files.length > 0) uploadFile(files[0]);
});
document.getElementById('audioInput').addEventListener('change', function() {
if (this.files.length > 0) uploadFile(this.files[0]);
});
async function uploadFile(file) {
var fd = new FormData();
fd.append('audio', file);
document.getElementById('uploadLabel').textContent = '上传中...';
document.getElementById('uploadFilename').style.display = 'none';
document.getElementById('btnAnalyze').disabled = true;
document.getElementById('btnSlice').disabled = true;
try {
var res = await fetch('/audio-slicer/upload', { method: 'POST', body: fd });
var data = await res.json();
if (!data.success) {
showMessage(data.error, 'error');
document.getElementById('uploadLabel').textContent = '点击或拖拽音频文件到此处';
return;
}
taskId = data.task_id;
var info = data.info;
document.getElementById('uploadLabel').textContent = '已选择文件';
document.getElementById('uploadFilename').textContent = data.filename;
document.getElementById('uploadFilename').style.display = 'block';
document.getElementById('audioInfo').style.display = 'grid';
document.getElementById('infoSr').textContent = info.sample_rate + ' Hz';
document.getElementById('infoCh').textContent = info.channels === 1 ? '单声道' : info.channels === 2 ? '立体声' : info.channels + '声道';
document.getElementById('infoDur').textContent = formatDur(info.duration);
document.getElementById('infoName').textContent = data.filename;
document.getElementById('btnAnalyze').disabled = false;
// 重置右侧面板
document.getElementById('emptyState').style.display = 'flex';
document.getElementById('resultArea').style.display = 'none';
document.getElementById('sliceList').innerHTML = '';
document.getElementById('topActions').style.display = 'none';
} catch (e) {
showMessage('上传失败: ' + e.message, 'error');
document.getElementById('uploadLabel').textContent = '点击或拖拽音频文件到此处';
}
}
async function doAnalyze() {
if (!taskId) return;
var btn = document.getElementById('btnAnalyze');
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-refresh"></i> 分析中...';
document.getElementById('emptyState').style.display = 'none';
document.getElementById('resultArea').style.display = 'block';
document.getElementById('statusText').textContent = '正在分析音频...';
document.getElementById('sliceList').innerHTML = '';
try {
var params = Object.assign({ task_id: taskId }, getSettings());
var res = await fetch('/audio-slicer/analyze', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify(params)
});
var data = await res.json();
if (!data.success) {
showMessage(data.error, 'error');
document.getElementById('statusText').textContent = '分析失败: ' + data.error;
return;
}
document.getElementById('statusText').textContent = '分析完成: 检测到 ' + data.count + ' 个片段';
renderPreview(data.preview, data.sample_rate);
document.getElementById('btnSlice').disabled = false;
} catch (e) {
showMessage('分析失败: ' + e.message, 'error');
} finally {
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-search"></i> 分析';
}
}
function renderPreview(preview, sr) {
var html = '<table class="preview-table"><thead><tr>'
+ '<th>#</th><th>时长</th><th>采样数</th></tr></thead><tbody>';
for (var i = 0; i < preview.length; i++) {
var p = preview[i];
html += '<tr><td>' + (i + 1) + '</td><td>' + formatDur(p.duration) + '</td><td>' + p.samples.toLocaleString() + '</td></tr>';
}
html += '</tbody></table>';
document.getElementById('sliceList').innerHTML = html;
}
async function doSlice() {
if (!taskId) return;
var btn = document.getElementById('btnSlice');
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-refresh"></i> 切割中...';
document.getElementById('btnAnalyze').disabled = true;
document.getElementById('progressWrap').style.display = 'block';
document.getElementById('progressFill').style.width = '0%';
document.getElementById('statusText').textContent = '正在切割...';
document.getElementById('topActions').style.display = 'none';
document.getElementById('sliceList').innerHTML = '';
try {
var res = await fetch('/audio-slicer/slice', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ task_id: taskId })
});
var data = await res.json();
if (!data.success) {
showMessage(data.error, 'error');
return;
}
// 开始轮询
startPoll();
} catch (e) {
showMessage('请求失败: ' + e.message, 'error');
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-scissors"></i> 开始切割';
document.getElementById('btnAnalyze').disabled = false;
}
}
function startPoll() {
if (pollTimer) clearInterval(pollTimer);
pollTimer = setInterval(async function() {
try {
var res = await fetch('/audio-slicer/status/' + taskId);
var data = await res.json();
if (!data.success) return;
document.getElementById('progressFill').style.width = data.progress + '%';
document.getElementById('statusText').textContent = '切割进度: ' + data.progress + '%';
if (data.status === 'done') {
clearInterval(pollTimer);
pollTimer = null;
document.getElementById('statusText').textContent = '切割完成! 共 ' + data.count + ' 个片段';
document.getElementById('progressWrap').style.display = 'none';
renderSlices(data.slices);
document.getElementById('topActions').style.display = 'flex';
document.getElementById('btnSlice').disabled = false;
document.getElementById('btnSlice').innerHTML = '<i class="fas fa-scissors"></i> 开始切割';
document.getElementById('btnAnalyze').disabled = false;
} else if (data.status === 'error') {
clearInterval(pollTimer);
pollTimer = null;
document.getElementById('statusText').textContent = '切割失败: ' + data.error;
document.getElementById('progressWrap').style.display = 'none';
document.getElementById('btnSlice').disabled = false;
document.getElementById('btnSlice').innerHTML = '<i class="fas fa-scissors"></i> 开始切割';
document.getElementById('btnAnalyze').disabled = false;
}
} catch (e) {}
}, 500);
}
function renderSlices(slices) {
var html = '';
for (var i = 0; i < slices.length; i++) {
var s = slices[i];
html += '<div class="slice-row">'
+ '<span class="slice-idx">' + (i + 1) + '</span>'
+ '<span class="slice-name" title="' + s.filename + '">' + s.filename + '</span>'
+ '<span class="slice-dur">' + formatDur(s.duration) + '</span>'
+ '<div class="slice-actions">'
+ '<button class="slice-btn play-btn" onclick="playSlice(' + i + ')" title="播放"><i class="fas fa-play"></i></button>'
+ '<button class="slice-btn" onclick="downloadSlice(' + i + ')" title="下载"><i class="fas fa-file"></i></button>'
+ '</div></div>';
}
document.getElementById('sliceList').innerHTML = html;
}
var currentAudio = null;
function playSlice(index) {
if (currentAudio) { currentAudio.pause(); currentAudio = null; }
var audio = new Audio('/audio-slicer/download/' + taskId + '/' + index);
audio.play();
currentAudio = audio;
}
function downloadSlice(index) {
var a = document.createElement('a');
a.href = '/audio-slicer/download/' + taskId + '/' + index;
a.download = '';
a.click();
}
function downloadAll() {
var a = document.createElement('a');
a.href = '/audio-slicer/download-all/' + taskId;
a.download = '';
a.click();
}
</script>
</body>
</html>

View File

@@ -119,6 +119,13 @@
z-index: 9999; pointer-events: none;
}
.toast.show { opacity: 1; }
@media (max-width: 768px) {
body { flex-direction: column; height: auto; overflow: auto; }
.left-panel { width: 100% !important; height: auto; border-right: none; border-bottom: 1px solid #e8e8e8; overflow-y: visible; }
.right-panel { height: auto; min-height: 50vh; }
.input-section { padding: 14px 16px 20px; }
}
</style>
</head>
<body>

View File

@@ -95,6 +95,10 @@
}
.btn-cp:hover { background: #006cbd; }
.btn-cp.copied { background: #2e7d32; }
@media (max-width: 768px) {
.container { padding: 14px 16px 28px; }
}
</style>
</head>
<body>

View File

@@ -340,6 +340,14 @@
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); }
@media (max-width: 768px) {
body { flex-direction: column; height: auto; overflow: auto; }
.left-panel { width: 100% !important; max-width: 100% !important; height: auto; border-right: none; border-bottom: 1px solid #e8e8e8; overflow-y: visible; }
.right-panel { height: auto; min-height: 50vh; }
.card-pair { flex-direction: column; }
.modal { width: 92vw !important; max-height: 90vh; overflow-y: auto; left: 4vw !important; transform: none !important; }
.input-section { padding: 14px 16px 20px; }
}
</style>
</head>
<body>

View File

@@ -445,6 +445,13 @@
max-height: 50px;
opacity: 1;
}
@media (max-width: 768px) {
body { flex-direction: column; height: auto; overflow: auto; }
.left-panel { width: 100% !important; height: auto; border-right: none; border-bottom: 1px solid #e8e8e8; overflow-y: visible; }
.right-panel { height: auto; min-height: 50vh; }
.input-section { padding: 14px 16px 20px; }
}
</style>
</head>
<body>

View File

@@ -219,6 +219,13 @@
}
.toast.show { opacity: 1; }
.toast.error { background: #c62828; }
@media (max-width: 768px) {
body { flex-direction: column; height: auto; overflow: auto; }
.left-panel { width: 100% !important; height: auto; border-right: none; border-bottom: 1px solid #e8e8e8; overflow-y: visible; }
.right-panel { height: auto; min-height: 50vh; }
.input-section { padding: 14px 16px 20px; }
}
</style>
</head>
<body>

View File

@@ -94,6 +94,13 @@
.match-count {
font-size: 12px; color: #999; margin-bottom: 16px;
}
@media (max-width: 768px) {
body { flex-direction: column; height: auto; overflow: auto; }
.left-panel { width: 100% !important; height: auto; border-right: none; border-bottom: 1px solid #e8e8e8; overflow-y: visible; }
.right-panel { height: auto; min-height: 50vh; }
.input-section { padding: 14px 16px 20px; }
}
</style>
</head>
<body>

View File

@@ -125,6 +125,73 @@
.sidebar-search-input input::placeholder { color: #bbb; }
.nav-item.hidden { display: none; }
/* 侧边栏底部设置按钮 */
.sidebar-footer {
margin-top: auto; padding: 12px 16px 8px;
border-top: 1px solid #e0e0e0;
}
.sidebar-settings-btn {
display: flex; align-items: center; gap: 8px;
width: 100%; padding: 8px 10px; border: none; background: none;
border-radius: 8px; cursor: pointer; font-size: 13px; color: #999;
transition: all 0.15s;
}
.sidebar-settings-btn:hover { background: #f0f0f0; color: #666; }
.sidebar-settings-btn i { width: 20px; text-align: center; }
/* 功能设置弹窗 */
.feat-modal-overlay {
display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%;
background: rgba(0,0,0,0.35); z-index: 1000;
justify-content: center; align-items: flex-start; padding-top: 60px;
}
.feat-modal-overlay.show { display: flex; }
.feat-modal {
background: #fff; border-radius: 12px; width: 380px; max-height: 70vh;
box-shadow: 0 12px 40px rgba(0,0,0,0.2); overflow: hidden;
display: flex; flex-direction: column;
}
.feat-modal-header {
padding: 14px 18px; border-bottom: 1px solid #eee;
display: flex; align-items: center; justify-content: space-between;
flex-shrink: 0;
}
.feat-modal-header h3 { font-size: 14px; font-weight: 600; color: #333; }
.feat-modal-close {
width: 26px; height: 26px; border: none; background: #f5f5f5;
border-radius: 6px; cursor: pointer; font-size: 13px; color: #999;
display: flex; align-items: center; justify-content: center;
}
.feat-modal-close:hover { background: #eee; color: #333; }
.feat-modal-body {
padding: 12px 18px; overflow-y: auto; flex: 1;
}
.feat-group-title {
font-size: 11px; font-weight: 600; color: #999; text-transform: uppercase;
letter-spacing: 0.05em; margin: 12px 0 6px;
}
.feat-group-title:first-child { margin-top: 0; }
.feat-item {
display: flex; align-items: center; gap: 8px; padding: 7px 0;
font-size: 13px; color: #333; cursor: pointer;
}
.feat-item input[type="checkbox"] {
width: 16px; height: 16px; accent-color: #0078d4; cursor: pointer;
flex-shrink: 0;
}
.feat-modal-footer {
padding: 10px 18px; border-top: 1px solid #eee;
display: flex; justify-content: flex-end; gap: 8px; flex-shrink: 0;
}
.feat-modal-footer button {
padding: 6px 16px; font-size: 12px; font-weight: 600;
border-radius: 6px; cursor: pointer; transition: all 0.15s;
}
.feat-btn-cancel { border: 1px solid #e0e0e0; background: #fff; color: #666; }
.feat-btn-cancel:hover { background: #f5f5f5; color: #333; }
.feat-btn-save { border: none; background: #0078d4; color: #fff; }
.feat-btn-save:hover { background: #006cbd; }
/* 主内容区 */
.main {
flex: 1;
@@ -424,18 +491,68 @@
.status-badge.s4xx { background: #fff3e0; color: #e65100; }
.status-badge.s5xx { background: #fce4ec; color: #c62828; }
/* 汉堡按钮 */
.hamburger {
display: none;
width: 36px; height: 36px;
background: none; border: none; cursor: pointer;
font-size: 18px; color: #333;
align-items: center; justify-content: center;
border-radius: 6px; transition: background 0.15s;
}
.hamburger:hover { background: #f0f0f0; }
/* 侧边栏遮罩 */
.sidebar-overlay {
display: none;
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.3); z-index: 999;
}
@media (max-width: 768px) {
.hamburger { display: flex; }
.sidebar {
display: none;
z-index: 1000;
transform: translateX(-100%);
transition: transform 0.25s ease;
}
.sidebar.open {
transform: translateX(0);
}
.sidebar-overlay.open { display: block; }
.main {
margin-left: 0;
}
.stat-cards {
flex-wrap: wrap;
}
.stat-card {
min-width: calc(50% - 8px);
flex: 1 1 calc(50% - 8px);
}
.chart-row {
flex-direction: column;
}
.chart-row .chart-section {
min-width: 0;
margin-bottom: 12px;
}
.chart-row .chart-section:last-child { margin-bottom: 0; }
.pie-chart-wrap { flex-direction: column; height: auto; }
.pie-legend { width: 100%; max-height: 120px; flex-direction: row; flex-wrap: wrap; }
.data-table-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; }
}
</style>
</head>
<body>
<!-- 侧边栏遮罩 -->
<div class="sidebar-overlay" id="sidebar-overlay" onclick="closeSidebar()"></div>
<!-- 侧边栏 -->
<aside class="sidebar">
<div class="sidebar-header">
@@ -452,11 +569,11 @@
<nav class="nav-section">
<div class="nav-section-title">概览</div>
<a class="nav-item active" id="nav-dashboard" onclick="showDashboard()">
<a class="nav-item active" id="nav-dashboard" data-feature="dashboard" onclick="showDashboard()">
<i class="fas fa-chart-bar"></i>
仪表盘
</a>
<a class="nav-item" data-url="/data-manage" onclick="loadTool(this, '/data-manage')">
<a class="nav-item" data-url="/data-manage" data-feature="data-manage" onclick="loadTool(this, '/data-manage')">
<i class="fas fa-database"></i>
数据管理
</a>
@@ -465,79 +582,110 @@
<nav class="nav-section">
<div class="nav-section-title">工具</div>
<a class="nav-item" data-url="/pin-tu" onclick="loadTool(this, '/pin-tu')">
<a class="nav-item" data-url="/pin-tu" data-feature="pin-tu" onclick="loadTool(this, '/pin-tu')">
<i class="fas fa-images"></i>
路径文件查阅器
</a>
<a class="nav-item" data-url="/down-video" onclick="loadTool(this, '/down-video')">
<a class="nav-item" data-url="/down-video" data-feature="down-video" onclick="loadTool(this, '/down-video')">
<i class="fas fa-video"></i>
XBIY视频下载器
</a>
<a class="nav-item" data-url="/content-tag" onclick="loadTool(this, '/content-tag')">
<a class="nav-item" data-url="/content-tag" data-feature="content-tag" onclick="loadTool(this, '/content-tag')">
<i class="fas fa-tags"></i>
AI生成文章标签
</a>
<a class="nav-item" data-url="/stt" onclick="loadTool(this, '/stt')">
<a class="nav-item" data-url="/audio-slicer" data-feature="audio-slicer" onclick="loadTool(this, '/audio-slicer')">
<i class="fas fa-scissors"></i>
AS 音频分割
</a>
<a class="nav-item" data-url="/stt" data-feature="stt" onclick="loadTool(this, '/stt')">
<i class="fas fa-microphone"></i>
STT 语音转文字
</a>
<a class="nav-item" data-url="/rvc" onclick="loadTool(this, '/rvc')">
<i class="fas fa-microphone"></i>
RVC 语音音色变换
<a class="nav-item" data-url="/uvr-sep" data-feature="uvr-sep" onclick="loadTool(this, '/uvr-sep')">
<i class="fas fa-exchange-alt"></i>
UVR 人声分离
</a>
<a class="nav-item" data-url="/ai-dubbing" onclick="loadTool(this, '/ai-dubbing')">
<a class="nav-item" data-url="/rvc" data-feature="rvc" onclick="loadTool(this, '/rvc')">
<i class="fas fa-microphone"></i>
RVC 音色转换
</a>
<a class="nav-item" data-url="/ai-dubbing" data-feature="ai-dubbing" onclick="loadTool(this, '/ai-dubbing')">
<i class="fas fa-microphone"></i>
GPT-SoVITS (引擎版)
</a>
<a class="nav-item" data-url="/fen-ci" onclick="loadTool(this, '/fen-ci')">
<a class="nav-item" data-url="/sovits-tts" data-feature="sovits-tts" onclick="loadTool(this, '/sovits-tts')">
<i class="fas fa-microphone"></i>
GPT-SoVITS (接口版)
</a>
<a class="nav-item" data-url="/fen-ci" data-feature="fen-ci" onclick="loadTool(this, '/fen-ci')">
<i class="fas fa-scissors"></i>
Jieba 分词网页版
</a>
<a class="nav-item" data-url="/base64-de-in-code" onclick="loadTool(this, '/base64-de-in-code')">
<a class="nav-item" data-url="/base64-de-in-code" data-feature="base64" onclick="loadTool(this, '/base64-de-in-code')">
<i class="fas fa-lock"></i>
Base64 编码/解码
</a>
<a class="nav-item" data-url="/json-format" onclick="loadTool(this, '/json-format')">
<a class="nav-item" data-url="/json-format" data-feature="json-format" onclick="loadTool(this, '/json-format')">
<i class="fas fa-code"></i>
JSON 美化/压缩
</a>
<a class="nav-item" data-url="/http-status" onclick="loadTool(this, '/http-status')">
<a class="nav-item" data-url="/http-status" data-feature="http-status" onclick="loadTool(this, '/http-status')">
<i class="fas fa-info-circle"></i>
HTTP 状态码查询
</a>
<a class="nav-item" data-url="/url-parser" onclick="loadTool(this, '/url-parser')">
<a class="nav-item" data-url="/url-parser" data-feature="url-parser" onclick="loadTool(this, '/url-parser')">
<i class="fas fa-link"></i>
URL 路径解析器
</a>
<a class="nav-item" data-url="/chmod-calc" onclick="loadTool(this, '/chmod-calc')">
<a class="nav-item" data-url="/chmod-calc" data-feature="chmod-calc" onclick="loadTool(this, '/chmod-calc')">
<i class="fas fa-key"></i>
Chmod 计算器
</a>
<a class="nav-item" data-url="/token-gen" onclick="loadTool(this, '/token-gen')">
<a class="nav-item" data-url="/token-gen" data-feature="token-gen" onclick="loadTool(this, '/token-gen')">
<i class="fas fa-lock"></i>
Token 随机生成器
</a>
<a class="nav-item" data-url="/qr-code" onclick="loadTool(this, '/qr-code')">
<a class="nav-item" data-url="/qr-code" data-feature="qr-code" onclick="loadTool(this, '/qr-code')">
<i class="fas fa-qrcode"></i>
Qr-code 生成器
</a>
<div class="nav-section-title" style="margin-top: 20px">不常用</div>
<a class="nav-item" data-url="/sovits-tts" onclick="loadTool(this, '/sovits-tts')">
<i class="fas fa-microphone"></i>
GPT-SoVITS (接口版)
</a>
</nav>
<div class="sidebar-footer">
<button class="sidebar-settings-btn" onclick="openFeatModal()">
<i class="fas fa-eye"></i> 功能显示设置
</button>
</div>
</aside>
<!-- 功能显示设置弹窗 -->
<div class="feat-modal-overlay" id="featModalOverlay" onclick="closeFeatModal(event)">
<div class="feat-modal" onclick="event.stopPropagation()">
<div class="feat-modal-header">
<h3><i class="fas fa-eye" style="margin-right:6px;color:#999;"></i>功能显示设置</h3>
<button class="feat-modal-close" onclick="closeFeatModal()"><i class="fas fa-times"></i></button>
</div>
<div class="feat-modal-body" id="featModalBody"></div>
<div class="feat-modal-footer">
<button class="feat-btn-cancel" onclick="closeFeatModal()">取消</button>
<button class="feat-btn-save" onclick="saveFeatSettings()">保存</button>
</div>
</div>
</div>
<!-- 主内容区 -->
<main class="main">
<!-- 顶部导航 -->
<header class="topbar">
<div class="topbar-title" id="topbar-title">
<button class="hamburger" id="hamburger" onclick="toggleSidebar()">
<i class="fas fa-list"></i>
</button>
<button class="back-btn" id="back-btn" onclick="goBack()">
<i class="fas fa-arrow-left"></i>
返回
@@ -688,6 +836,7 @@
<i class="fas fa-list"></i> 最近调用记录
</div>
<div class="chart-body" style="max-height:300px; overflow-y:auto;">
<div class="data-table-wrap">
<table class="data-table" id="recent-table">
<thead>
<tr>
@@ -699,6 +848,7 @@
</thead>
<tbody id="recent-tbody"></tbody>
</table>
</div>
</div>
</div>
</div>
@@ -712,12 +862,23 @@
const dashboardHTML = document.getElementById('dashboard').outerHTML;
let charts = {};
// 侧边栏 移动端开关
function toggleSidebar() {
document.querySelector('.sidebar').classList.toggle('open');
document.getElementById('sidebar-overlay').classList.toggle('open');
}
function closeSidebar() {
document.querySelector('.sidebar').classList.remove('open');
document.getElementById('sidebar-overlay').classList.remove('open');
}
function disposeCharts() {
Object.values(charts).forEach(c => c && c.dispose());
charts = {};
}
function showDashboard() {
closeSidebar();
document.querySelectorAll('.nav-item').forEach(item => item.classList.remove('active'));
document.getElementById('nav-dashboard').classList.add('active');
document.getElementById('title-text').textContent = '仪表盘';
@@ -732,6 +893,7 @@
}
function loadTool(element, url) {
closeSidebar();
disposeCharts();
document.querySelectorAll('.nav-item').forEach(item => item.classList.remove('active'));
element.classList.add('active');
@@ -1180,6 +1342,87 @@
Object.values(charts).forEach(c => c && c.resize());
});
// ===== 功能显示设置 =====
var FEATURES = [
{ id: 'dashboard', name: '仪表盘', section: '概览' },
{ id: 'data-manage', name: '数据管理', section: '概览' },
{ id: 'pin-tu', name: '路径文件查阅器', section: '工具' },
{ id: 'down-video', name: 'XBIY视频下载器', section: '工具' },
{ id: 'content-tag', name: 'AI生成文章标签', section: '工具' },
{ id: 'stt', name: 'STT 语音转文字', section: '工具' },
{ id: 'audio-slicer', name: 'AudioSlicer 音频分割', section: '工具' },
{ id: 'uvr-sep', name: 'UVR 人声分离', section: '工具' },
{ id: 'ai-dubbing', name: 'GPT-SoVITS (引擎版)', section: '工具' },
{ id: 'rvc', name: 'RVC 语音音色转换', section: '工具' },
{ id: 'fen-ci', name: 'Jieba 分词网页版', section: '工具' },
{ id: 'base64', name: 'Base64 编码/解码', section: '工具' },
{ id: 'json-format', name: 'JSON 美化/压缩', section: '工具' },
{ id: 'http-status', name: 'HTTP 状态码查询', section: '工具' },
{ id: 'url-parser', name: 'URL 路径解析器', section: '工具' },
{ id: 'chmod-calc', name: 'Chmod 计算器', section: '工具' },
{ id: 'token-gen', name: 'Token 随机生成器', section: '工具' },
{ id: 'qr-code', name: 'Qr-code 生成器', section: '工具' },
{ id: 'sovits-tts', name: 'GPT-SoVITS (接口版)', section: '不常用' }
];
function getHiddenFeatures() {
try { return JSON.parse(localStorage.getItem('hidden_features') || '[]'); } catch(e) { return []; }
}
function applyFeatSettings() {
var hidden = getHiddenFeatures();
document.querySelectorAll('.nav-item[data-feature]').forEach(function(item) {
var fid = item.getAttribute('data-feature');
item.style.display = hidden.indexOf(fid) !== -1 ? 'none' : '';
});
// 隐藏空的 section-title
document.querySelectorAll('.nav-section').forEach(function(section) {
var hasVisible = false;
section.querySelectorAll('.nav-item[data-feature]').forEach(function(item) {
if (item.style.display !== 'none') hasVisible = true;
});
var title = section.querySelector('.nav-section-title');
if (title && title.textContent.trim() !== '概览') {
title.style.display = hasVisible ? '' : 'none';
}
});
}
function openFeatModal() {
var hidden = getHiddenFeatures();
var body = document.getElementById('featModalBody');
var html = '';
var currentSection = '';
FEATURES.forEach(function(f) {
if (f.section !== currentSection) {
currentSection = f.section;
html += '<div class="feat-group-title">' + currentSection + '</div>';
}
var checked = hidden.indexOf(f.id) === -1 ? 'checked' : '';
html += '<label class="feat-item"><input type="checkbox" data-feature-id="' + f.id + '" ' + checked + '><span>' + f.name + '</span></label>';
});
body.innerHTML = html;
document.getElementById('featModalOverlay').classList.add('show');
}
function closeFeatModal(e) {
if (e && e.target !== document.getElementById('featModalOverlay')) return;
document.getElementById('featModalOverlay').classList.remove('show');
}
function saveFeatSettings() {
var hidden = [];
document.querySelectorAll('#featModalBody input[type="checkbox"]').forEach(function(cb) {
if (!cb.checked) hidden.push(cb.getAttribute('data-feature-id'));
});
localStorage.setItem('hidden_features', JSON.stringify(hidden));
applyFeatSettings();
document.getElementById('featModalOverlay').classList.remove('show');
}
// 页面加载时应用功能显示设置
applyFeatSettings();
loadDashboard();
</script>
</body>

View File

@@ -100,6 +100,13 @@
display: none; align-items: center; gap: 6px;
}
.err-bar.on { display: flex; }
@media (max-width: 768px) {
body { flex-direction: column; height: auto; overflow: auto; }
.left-panel { width: 100% !important; height: auto; border-right: none; border-bottom: 1px solid #e8e8e8; overflow-y: visible; }
.right-panel { height: auto; min-height: 50vh; }
.input-section { padding: 14px 16px 20px; }
}
</style>
</head>
<body>

View File

@@ -50,6 +50,10 @@
border-radius: 6px; padding: 8px 12px; font-size: 13px;
text-align: center; margin-bottom: 16px;
}
@media (max-width: 768px) {
.login-card { width: 92vw; padding: 28px 20px; }
}
</style>
</head>
<body>

View File

@@ -103,6 +103,10 @@
}
.btn-outline { background: #fff; color: #666; border: 1px solid #e0e0e0; }
.btn-outline:hover { background: #f5f5f5; color: #333; }
@media (max-width: 768px) {
.container { padding: 14px 16px 28px; }
}
</style>
</head>
<body>

View File

@@ -217,6 +217,14 @@
.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; } }
@media (max-width: 768px) {
body { flex-direction: column; height: auto; overflow: auto; }
.left-panel { width: 100% !important; max-width: 100% !important; height: auto; border-right: none; border-bottom: 1px solid #e8e8e8; overflow-y: visible; }
.right-panel { height: auto; min-height: 50vh; }
.card-pair { flex-direction: column; }
.modal { width: 92vw !important; max-height: 90vh; overflow-y: auto; left: 4vw !important; transform: none !important; }
.input-section { padding: 14px 16px 20px; }
}
</style>
</head>
<body>

View File

@@ -328,6 +328,14 @@
@keyframes slideIn { from { opacity: 0; transform: translateY(-8px); } to { opacity: 1; transform: translateY(0); } }
@keyframes slideOut { from { opacity: 1; transform: translateY(0); } to { opacity: 0; transform: translateY(-8px); } }
.message.removing { animation: slideOut 0.2s ease forwards; }
@media (max-width: 768px) {
body { flex-direction: column; height: auto; overflow: auto; }
.left-panel { width: 100% !important; max-width: 100% !important; height: auto; border-right: none; border-bottom: 1px solid #e8e8e8; overflow-y: visible; }
.right-panel { height: auto; min-height: 50vh; }
.card-pair { flex-direction: column; }
.modal { width: 92vw !important; max-height: 90vh; overflow-y: auto; left: 4vw !important; transform: none !important; }
.input-section { padding: 14px 16px 20px; }
}
</style>
</head>
<body>

View File

@@ -17,7 +17,7 @@
}
/* 左侧面板 */
.left-panel {
width: 420px;
max-width: 420px; width: 100%;
flex-shrink: 0;
display: flex;
flex-direction: column;
@@ -316,6 +316,14 @@
padding: 12px 20px; border-top: 1px solid #f0f0f0;
display: flex; justify-content: flex-end; gap: 8px;
}
@media (max-width: 768px) {
body { flex-direction: column; height: auto; overflow: auto; }
.left-panel { width: 100% !important; max-width: 100% !important; height: auto; border-right: none; border-bottom: 1px solid #e8e8e8; overflow-y: visible; }
.right-panel { height: auto; min-height: 50vh; }
.card-pair { flex-direction: column; }
.modal { width: 92vw !important; max-height: 90vh; overflow-y: auto; left: 4vw !important; transform: none !important; }
.input-section { padding: 14px 16px 20px; }
}
</style>
</head>
<body>

View File

@@ -96,6 +96,10 @@
min-height: 60px;
}
.token-len { margin-top: 10px; font-size: 12px; color: #bbb; }
@media (max-width: 768px) {
.container { padding: 14px 16px 28px; }
}
</style>
</head>
<body>

View File

@@ -134,6 +134,10 @@
}
.empty-state i { font-size: 40px; margin-bottom: 12px; display: block; }
.empty-state p { font-size: 13px; }
@media (max-width: 768px) {
.container { padding: 14px 16px 28px; }
}
</style>
</head>
<body>

File diff suppressed because it is too large Load Diff