294 lines
16 KiB
HTML
294 lines
16 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<title>Rclone 报告查看器</title>
|
|
<script src="languages.js"></script>
|
|
<style>
|
|
:root {
|
|
--success: #28a745; --danger: #dc3545; --warning: #ffc107; --info: #17a2b8;
|
|
--bg: #f0f2f5; --card-bg: #ffffff; --text: #2d3436; --primary: #0984e3;
|
|
}
|
|
body { font-family: 'Segoe UI', system-ui, -apple-system, sans-serif; background: var(--bg); color: var(--text); padding: 20px; margin: 0; }
|
|
.container { max-width: 1100px; margin: auto; padding-top: 20px; }
|
|
|
|
/* 顶部语言选择框 */
|
|
.lang-switch { position: absolute; top: 20px; right: 20px; z-index: 100; display: flex; align-items: center; gap: 8px; }
|
|
.select-lang { padding: 6px 12px; border-radius: 20px; border: 1px solid #ddd; outline: none; cursor: pointer; font-size: 13px; }
|
|
|
|
#drop-zone {
|
|
border: 3px dashed #bdc3c7; border-radius: 15px; padding: 15px 40px; text-align: center;
|
|
background: white; margin-bottom: 30px; transition: all 0.2s; cursor: pointer;
|
|
}
|
|
#drop-zone.hover { border-color: var(--primary); background: #e1f5fe; }
|
|
#drop-zone p { font-size: 1.3em; color: #7f8c8d; pointer-events: none; margin-bottom: 10px; }
|
|
|
|
#report-content { display: none; animation: fadeIn 0.4s ease-out; }
|
|
@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
|
|
|
|
.stat-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 15px; margin-bottom: 30px; }
|
|
.stat-card { background: var(--card-bg); padding: 20px; border-radius: 12px; box-shadow: 0 4px 6px rgba(0,0,0,0.05); text-align: center; border-bottom: 4px solid #ddd; }
|
|
.stat-value { display: block; font-size: 28px; font-weight: 800; margin-bottom: 5px; }
|
|
.stat-label { font-size: 11px; color: #636e72; text-transform: uppercase; font-weight: 600; }
|
|
|
|
.report-card { background: var(--card-bg); padding: 30px; border-radius: 15px; box-shadow: 0 10px 30px rgba(0,0,0,0.08); }
|
|
.actions { display: flex; gap: 10px; margin-bottom: 20px; }
|
|
.btn { padding: 10px 20px; border: none; border-radius: 8px; font-weight: 600; cursor: pointer; background: #dfe6e9; transition: 0.2s; font-size: 14px; }
|
|
.btn:hover { filter: brightness(0.95); }
|
|
.btn-danger { background: var(--danger); color: white; }
|
|
.btn-primary { background: var(--primary); color: white; }
|
|
|
|
ul { list-style: none; padding-left: 25px; border-left: 1px dashed #dfe6e9; }
|
|
li { margin: 8px 0; }
|
|
summary { cursor: pointer; padding: 6px; border-radius: 6px; font-weight: 600; display: flex; align-items: center; }
|
|
summary:hover { background: #f1f2f6; color: var(--primary); }
|
|
.badge { display: inline-block; min-width: 22px; padding: 2px 6px; text-align: center; border-radius: 4px; color: white; font-size: 11px; margin-right: 10px; font-weight: bold; }
|
|
.error-dot { color: var(--danger); margin-left: 8px; font-size: 14px; animation: blink 2s infinite; }
|
|
@keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
|
|
|
|
.filter-mode-errors .is-match, .filter-mode-errors .is-pure-match { display: none; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<div class="lang-switch">
|
|
<select class="select-lang" id="langSelect" onchange="changeLang(this.value)">
|
|
</select>
|
|
</div>
|
|
|
|
<div class="container">
|
|
<div id="file-selector-container" style="margin-bottom: 20px; background: white; padding: 15px; border-radius: 12px; box-shadow: 0 4px 6px rgba(0,0,0,0.05); display: flex; align-items: center; gap: 15px;">
|
|
<button id="btnSelectDir" class="btn btn-primary" onclick="pickDirectory()"></button>
|
|
<span id="t-label-files" style="font-weight: 600; color: #636e72;"></span>
|
|
<select id="fileDropdown" style="flex-grow: 1; padding: 8px 12px; border-radius: 8px; border: 1px solid #ddd; outline: none; cursor: pointer;" onchange="loadSelectedFile(this.value)">
|
|
<option value="" disabled selected id="t-select-placeholder"></option>
|
|
</select>
|
|
</div>
|
|
|
|
<div id="drop-zone">
|
|
<p id="t-drop-hint"></p>
|
|
<span id="t-click-hint" style="color: #95a5a6;"></span>
|
|
<input type="file" id="fileInput" style="display: none;" accept=".txt">
|
|
</div>
|
|
|
|
<div id="report-content">
|
|
<div class="stat-grid">
|
|
<div class="stat-card" style="border-color: var(--success)"><span id="s-match" class="stat-value" style="color: var(--success)">0</span><span class="stat-label" id="t-label-match"></span></div>
|
|
<div class="stat-card" style="border-color: var(--danger)"><span id="s-mismatch" class="stat-value" style="color: var(--danger)">0</span><span class="stat-label" id="t-label-mismatch"></span></div>
|
|
<div class="stat-card" style="border-color: var(--warning)"><span id="s-missing" class="stat-value" style="color: var(--warning)">0</span><span class="stat-label" id="t-label-missing"></span></div>
|
|
<div class="stat-card" style="border-color: var(--info)"><span id="s-extra" class="stat-value" style="color: var(--info)">0</span><span class="stat-label" id="t-label-extra"></span></div>
|
|
</div>
|
|
|
|
<div class="actions">
|
|
<button id="toggleFilterBtn" class="btn btn-primary" onclick="toggleFilter()"></button>
|
|
<button class="btn" id="t-btn-expand" onclick="toggleAll(true)"></button>
|
|
<button class="btn" id="t-btn-collapse" onclick="toggleAll(false)"></button>
|
|
</div>
|
|
|
|
<div class="report-card">
|
|
<div id="treeContainer"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
// 内置默认语言
|
|
const defaultI18n = {
|
|
zh: { name: "简体中文", dropHint: "📂 <b>拖入</b>生成的文件到这里", clickHint: "或者 点击此处手动选择", labelMatch: "一致 (Match)", labelMismatch: "损坏 (Mismatch)", labelMissing: "缺失 (Missing)", labelExtra: "多余 (Extra)", btnExpand: "全部展开", btnCollapse: "全部收起", modeFull: "显示模式:完整清单", modeError: "显示模式:只看异常", loadSuccess: "✅ 已加载:", files: " 个文件", labelFiles: "切换文件:", selectPlaceholder: "--- 请选择已加载的报告 ---", btnPickDir: "📂 选择结果目录", loadComplete: "已成功加载 {n} 个报告文件。" },
|
|
en: { name: "English", dropHint: "📂 <b>Drag</b> your file here", clickHint: "or click to select manually", labelMatch: "Match", labelMismatch: "Mismatch", labelMissing: "Missing", labelExtra: "Extra", btnExpand: "Expand All", btnCollapse: "Collapse All", modeFull: "View: All Files", modeError: "View: Errors Only", loadSuccess: "✅ Loaded: ", files: " files", labelFiles: "Switch File:", selectPlaceholder: "--- Select a loaded report ---", btnPickDir: "📂 Open Directory", loadComplete: "Successfully loaded {n} report files." }
|
|
};
|
|
|
|
// 合并外部语言
|
|
const allI18n = Object.assign({}, defaultI18n, window.externalI18n || {});
|
|
let currentLang = 'zh';
|
|
let globalData = "";
|
|
let errorMode = false;
|
|
let fileHandles = {}; // 存储文件句柄
|
|
|
|
// 文件夹选择器逻辑 (File System Access API)
|
|
async function pickDirectory() {
|
|
try {
|
|
const directoryHandle = await window.showDirectoryPicker();
|
|
const dropdown = document.getElementById('fileDropdown');
|
|
dropdown.innerHTML = `<option value="" disabled selected id="t-select-placeholder">${allI18n[currentLang].selectPlaceholder}</option>`;
|
|
fileHandles = {};
|
|
|
|
for await (const entry of directoryHandle.values()) {
|
|
if (entry.kind === 'file' && entry.name.endsWith('.txt')) {
|
|
fileHandles[entry.name] = entry;
|
|
const opt = document.createElement('option');
|
|
opt.value = entry.name;
|
|
opt.innerText = entry.name;
|
|
dropdown.appendChild(opt);
|
|
}
|
|
}
|
|
const msg = allI18n[currentLang].loadComplete.replace('{n}', Object.keys(fileHandles).length);
|
|
alert(msg);
|
|
} catch (err) {
|
|
if (err.name !== 'AbortError') {
|
|
console.error('选择目录失败:', err);
|
|
alert('浏览器不支持该功能或选择失败,请确保使用 Chrome/Edge 并授予权限。');
|
|
}
|
|
}
|
|
}
|
|
|
|
async function loadSelectedFile(fileName) {
|
|
const handle = fileHandles[fileName];
|
|
if (handle) {
|
|
const file = await handle.getFile();
|
|
const text = await file.text();
|
|
globalData = text;
|
|
processData(globalData);
|
|
}
|
|
}
|
|
|
|
// 初始化语言选择器
|
|
|
|
const langSelect = document.getElementById('langSelect');
|
|
Object.keys(allI18n).forEach(key => {
|
|
const opt = document.createElement('option');
|
|
opt.value = key;
|
|
opt.innerText = allI18n[key].name;
|
|
langSelect.appendChild(opt);
|
|
});
|
|
|
|
function changeLang(lang) {
|
|
currentLang = lang;
|
|
updateUI();
|
|
if (globalData) processData(globalData);
|
|
}
|
|
|
|
function updateUI() {
|
|
const t = allI18n[currentLang];
|
|
const setTxt = (id, txt, isHtml) => {
|
|
const el = document.getElementById(id);
|
|
if (el) isHtml ? el.innerHTML = txt : el.innerText = txt;
|
|
};
|
|
|
|
setTxt('t-drop-hint', t.dropHint, true);
|
|
setTxt('t-click-hint', t.clickHint);
|
|
setTxt('t-label-match', t.labelMatch);
|
|
setTxt('t-label-mismatch', t.labelMismatch);
|
|
setTxt('t-label-missing', t.labelMissing);
|
|
setTxt('t-label-extra', t.labelExtra);
|
|
setTxt('t-btn-expand', t.btnExpand);
|
|
setTxt('t-btn-collapse', t.btnCollapse);
|
|
setTxt('t-label-files', t.labelFiles);
|
|
setTxt('t-select-placeholder', t.selectPlaceholder);
|
|
setTxt('btnSelectDir', t.btnPickDir);
|
|
|
|
const filterBtn = document.getElementById('toggleFilterBtn');
|
|
if (filterBtn) {
|
|
filterBtn.innerText = errorMode ? t.modeError : t.modeFull;
|
|
filterBtn.className = errorMode ? 'btn btn-danger' : 'btn btn-primary';
|
|
}
|
|
}
|
|
|
|
// --- Rclone 处理逻辑 ---
|
|
const dropZone = document.getElementById('drop-zone');
|
|
const fileInput = document.getElementById('fileInput');
|
|
|
|
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(e => {
|
|
dropZone.addEventListener(e, (ev) => { ev.preventDefault(); ev.stopPropagation(); });
|
|
});
|
|
|
|
dropZone.addEventListener('dragover', () => dropZone.classList.add('hover'));
|
|
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('hover'));
|
|
dropZone.addEventListener('drop', (e) => {
|
|
dropZone.classList.remove('hover');
|
|
handleFile(e.dataTransfer.files[0]);
|
|
});
|
|
|
|
dropZone.onclick = () => fileInput.click();
|
|
fileInput.onchange = (e) => handleFile(e.target.files[0]);
|
|
|
|
function handleFile(file) {
|
|
if (!file) return;
|
|
// 重置下拉框到默认占位符选项
|
|
const dropdown = document.getElementById('fileDropdown');
|
|
if (dropdown) dropdown.selectedIndex = 0;
|
|
|
|
const reader = new FileReader();
|
|
reader.onload = (e) => { globalData = e.target.result; processData(globalData); };
|
|
reader.readAsText(file, 'UTF-8');
|
|
}
|
|
|
|
function processData(text) {
|
|
if (!text) return;
|
|
// 处理 PowerShell 偶尔可能返回数组或非字符串对象的情况
|
|
if (Array.isArray(text)) text = text.join('\n');
|
|
if (typeof text !== 'string') text = String(text);
|
|
|
|
const lines = text.split(/\r?\n/);
|
|
const tree = {};
|
|
const stats = {'=':0, '*':0, '-':0, '+':0};
|
|
const folderErrors = new Set();
|
|
|
|
lines.forEach(line => {
|
|
if (line.length < 3) return;
|
|
const symbol = line[0];
|
|
const path = line.substring(2).replace(/\\/g, '/');
|
|
const parts = path.split('/');
|
|
if (stats[symbol] !== undefined) stats[symbol]++;
|
|
let curr = tree;
|
|
let pathAcc = "";
|
|
parts.forEach((part, i) => {
|
|
pathAcc = pathAcc ? `${pathAcc}/${part}` : part;
|
|
if (i === parts.length - 1) { curr[part] = symbol; }
|
|
else {
|
|
if (symbol !== '=') folderErrors.add(pathAcc);
|
|
if (!curr[part]) curr[part] = {};
|
|
curr = curr[part];
|
|
}
|
|
});
|
|
});
|
|
|
|
document.getElementById('s-match').innerText = stats['='];
|
|
document.getElementById('s-mismatch').innerText = stats['*'];
|
|
document.getElementById('s-missing').innerText = stats['+'];
|
|
document.getElementById('s-extra').innerText = stats['-'];
|
|
document.getElementById('report-content').style.display = 'block';
|
|
document.getElementById('treeContainer').innerHTML = buildHtml(tree, "", folderErrors);
|
|
const t = allI18n[currentLang];
|
|
dropZone.querySelector('p').innerHTML = t.loadSuccess + (stats['=']+stats['*']+stats['-']+stats['+']) + t.files;
|
|
}
|
|
|
|
function buildHtml(node, currentPath, folderErrors) {
|
|
let html = "<ul>";
|
|
const keys = Object.keys(node).sort((a, b) => {
|
|
const aIsFile = typeof node[a] === 'string';
|
|
const bIsFile = typeof node[b] === 'string';
|
|
return aIsFile === bIsFile ? a.localeCompare(b) : aIsFile - bIsFile;
|
|
});
|
|
keys.forEach(name => {
|
|
const val = node[name];
|
|
const thisPath = currentPath ? `${currentPath}/${name}` : name;
|
|
if (typeof val === 'object') {
|
|
const hasErr = folderErrors.has(thisPath);
|
|
html += `<li class="folder ${hasErr?'has-error-branch':'is-pure-match'}">
|
|
<details><summary>📁 ${name}/ ${hasErr?'<span class="error-dot">●</span>':''}</summary>${buildHtml(val, thisPath, folderErrors)}</details>
|
|
</li>`;
|
|
} else {
|
|
const colors = {'=':'#28a745', '*':'#dc3545', '+': '#ffc107', '-': '#17a2b8'};
|
|
html += `<li class="file ${val==='='?'is-match':'is-error'}">
|
|
<span class="badge" style="background:${colors[val]}">${val}</span>${name}
|
|
</li>`;
|
|
}
|
|
});
|
|
return html + "</ul>";
|
|
}
|
|
|
|
function toggleFilter() {
|
|
errorMode = !errorMode;
|
|
document.getElementById('treeContainer').classList.toggle('filter-mode-errors', errorMode);
|
|
updateUI();
|
|
if(errorMode) document.querySelectorAll('.has-error-branch > details').forEach(d => d.open = true);
|
|
}
|
|
|
|
function toggleAll(open) { document.querySelectorAll('details').forEach(d => d.open = open); }
|
|
|
|
// 首次加载初始化
|
|
updateUI();
|
|
</script>
|
|
</body>
|
|
</html> |