解耦视图模块
This commit is contained in:
1 parent
0f661a046c
commit
779d6ec887
2 files changed
+378
-357
No files matched your search
@@ -0,0 +1,331 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=yes">
|
||||
<title>文件浏览</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif;
|
||||
background: #f5f5f5;
|
||||
color: #333;
|
||||
padding: 0;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.header {
|
||||
background: #4A90D9;
|
||||
color: white;
|
||||
padding: 12px 16px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.header h1 { font-size: 18px; font-weight: 600; }
|
||||
.header a {
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
padding: 4px 10px;
|
||||
background: rgba(255,255,255,0.2);
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.header a:active { background: rgba(255,255,255,0.4); }
|
||||
.breadcrumb {
|
||||
padding: 8px 16px;
|
||||
background: white;
|
||||
font-size: 13px;
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
.breadcrumb a { color: #4A90D9; text-decoration: none; padding: 2px 6px; }
|
||||
.breadcrumb .sep { color: #999; margin: 0 4px; }
|
||||
.breadcrumb .current { color: #333; font-weight: 500; }
|
||||
.search-box {
|
||||
padding: 8px 16px;
|
||||
background: white;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
.search-box input {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
}
|
||||
.search-box input:focus { border-color: #4A90D9; }
|
||||
.stats {
|
||||
padding: 6px 16px;
|
||||
background: #fafafa;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.container {
|
||||
padding: 8px 12px 60px 12px;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.item {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
margin-bottom: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
.item:active { background: #f0f0f0; }
|
||||
.item .icon { font-size: 24px; flex-shrink: 0; width: 36px; text-align: center; }
|
||||
.item .info { flex: 1; min-width: 0; }
|
||||
.item .name { font-size: 15px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.item .meta { font-size: 12px; color: #999; display: flex; gap: 10px; flex-wrap: wrap; }
|
||||
.item .actions { display: flex; gap: 4px; flex-shrink: 0; }
|
||||
.item .actions button {
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.item .actions button:active { transform: scale(0.95); }
|
||||
.btn-play { background: #4CAF50; color: white; }
|
||||
.btn-download { background: #4A90D9; color: white; }
|
||||
.loading { text-align: center; padding: 40px; color: #999; }
|
||||
.loading .spinner { display: inline-block; animation: spin 1s linear infinite; font-size: 28px; }
|
||||
@keyframes spin { 100% { transform: rotate(360deg); } }
|
||||
.empty { text-align: center; padding: 60px 20px; color: #999; }
|
||||
.empty .icon { font-size: 48px; display: block; margin-bottom: 12px; }
|
||||
.error { text-align: center; padding: 40px 20px; color: #e74c3c; }
|
||||
@media (max-width: 480px) {
|
||||
.item { padding: 8px 10px; }
|
||||
.item .icon { font-size: 20px; width: 30px; }
|
||||
.item .name { font-size: 13px; }
|
||||
.item .meta { font-size: 11px; gap: 6px; }
|
||||
.item .actions button { padding: 3px 8px; font-size: 11px; }
|
||||
.header h1 { font-size: 16px; }
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body { background: #1a1a2e; color: #e0e0e0; }
|
||||
.header { background: #2d2d5e; }
|
||||
.breadcrumb { background: #16213e; border-color: #2a2a4a; }
|
||||
.breadcrumb .current { color: #e0e0e0; }
|
||||
.stats { background: #16213e; color: #aaa; border-color: #2a2a4a; }
|
||||
.search-box { background: #16213e; border-color: #2a2a4a; }
|
||||
.search-box input { background: #2a2a4a; border-color: #3a3a5a; color: #e0e0e0; }
|
||||
.item { background: #16213e; border-color: #2a2a4a; }
|
||||
.item:active { background: #2a2a4a; }
|
||||
.item .meta { color: #888; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>📁 文件浏览</h1>
|
||||
<div>
|
||||
<a href="/">🏠</a>
|
||||
<a href="#" id="btnRefresh" style="margin-left:6px;">🔄</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="breadcrumb" id="breadcrumb">
|
||||
<a href="/">根目录</a>
|
||||
</div>
|
||||
|
||||
<div class="search-box">
|
||||
<input type="text" id="searchInput" placeholder="搜索文件..." autocomplete="off">
|
||||
</div>
|
||||
|
||||
<div class="stats">
|
||||
<span>📊 共 <span id="totalItems">0</span> 项</span>
|
||||
<span>📁 <span id="totalDirs">0</span></span>
|
||||
<span>📄 <span id="totalFiles">0</span></span>
|
||||
<span>🎬 <span id="totalVideos">0</span></span>
|
||||
<span>🎵 <span id="totalAudios">0</span></span>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<div class="loading" id="loading">
|
||||
<span class="spinner">⏳</span>
|
||||
<div>加载中...</div>
|
||||
</div>
|
||||
<div id="fileList"></div>
|
||||
<div class="empty" id="empty" style="display:none;">
|
||||
<span class="icon">📭</span>
|
||||
<div>此目录为空</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 从 URL 解析当前路径(去除首尾斜杠),不再依赖后端注入
|
||||
const currentPath = decodeURIComponent(window.location.pathname).replace(/^\/+|\/+$/g, '');
|
||||
let items = [];
|
||||
let searchQuery = "";
|
||||
|
||||
const fileList = document.getElementById("fileList");
|
||||
const loading = document.getElementById("loading");
|
||||
const empty = document.getElementById("empty");
|
||||
const searchInput = document.getElementById("searchInput");
|
||||
const totalItems = document.getElementById("totalItems");
|
||||
const totalDirs = document.getElementById("totalDirs");
|
||||
const totalFiles = document.getElementById("totalFiles");
|
||||
const totalVideos = document.getElementById("totalVideos");
|
||||
const totalAudios = document.getElementById("totalAudios");
|
||||
const btnRefresh = document.getElementById("btnRefresh");
|
||||
const breadcrumb = document.getElementById("breadcrumb");
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (bytes === 0) return "0B";
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return (bytes / Math.pow(k, i)).toFixed(1) + sizes[i];
|
||||
}
|
||||
|
||||
function getIcon(item) {
|
||||
if (item.type === "dir") return "📁";
|
||||
if (item.type === "video") return "🎬";
|
||||
if (item.type === "audio") return "🎵";
|
||||
const ext = item.name.split(".").pop().toLowerCase();
|
||||
const icons = {
|
||||
"pdf": "📄", "doc": "📝", "docx": "📝", "xls": "📊", "xlsx": "📊",
|
||||
"zip": "📦", "rar": "📦", "7z": "📦",
|
||||
"jpg": "🖼️", "jpeg": "🖼️", "png": "🖼️", "gif": "🖼️",
|
||||
"txt": "📃", "json": "📋", "html": "🌐", "css": "🎨", "js": "⚡"
|
||||
};
|
||||
return icons[ext] || "📄";
|
||||
}
|
||||
|
||||
function navigate(path, type) {
|
||||
if (type === "dir") window.location.href = "/" + path;
|
||||
else if (type === "video" || type === "audio") window.location.href = "/video/" + path;
|
||||
else window.location.href = "/" + path;
|
||||
}
|
||||
|
||||
function playMedia(path) { window.location.href = "/video/" + path; }
|
||||
function downloadFile(path) { window.location.href = "/" + path + "?download=true"; }
|
||||
|
||||
// 面包屑由 JS 根据当前路径动态生成
|
||||
function renderBreadcrumb() {
|
||||
let html = '<a href="/">根目录</a>';
|
||||
if (currentPath) {
|
||||
const parts = currentPath.split('/');
|
||||
let acc = '';
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const part = parts[i];
|
||||
if (i === parts.length - 1) {
|
||||
html += `<span class="sep">›</span><span class="current">${part}</span>`;
|
||||
} else {
|
||||
acc += (i === 0 ? '' : '/') + part;
|
||||
html += `<span class="sep">›</span><a href="/${acc}/">${part}</a>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
breadcrumb.innerHTML = html;
|
||||
}
|
||||
|
||||
function render() {
|
||||
const filtered = items.filter(item => {
|
||||
if (!searchQuery) return true;
|
||||
return item.name.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
});
|
||||
|
||||
totalItems.textContent = filtered.length;
|
||||
totalDirs.textContent = filtered.filter(i => i.type === "dir").length;
|
||||
totalFiles.textContent = filtered.filter(i => i.type === "file").length;
|
||||
totalVideos.textContent = filtered.filter(i => i.type === "video").length;
|
||||
totalAudios.textContent = filtered.filter(i => i.type === "audio").length;
|
||||
|
||||
if (filtered.length === 0) {
|
||||
fileList.innerHTML = "";
|
||||
empty.style.display = "block";
|
||||
loading.style.display = "none";
|
||||
return;
|
||||
}
|
||||
empty.style.display = "none";
|
||||
loading.style.display = "none";
|
||||
|
||||
fileList.innerHTML = filtered.map(item => `
|
||||
<div class="item">
|
||||
<span class="icon">${getIcon(item)}</span>
|
||||
<div class="info" onclick="navigate('${item.path}', '${item.type}')">
|
||||
<div class="name">${item.name}</div>
|
||||
<div class="meta">
|
||||
${item.type !== "dir" ? `<span>${formatSize(item.size)}</span>` : '<span>📁 文件夹</span>'}
|
||||
<span>${item.modified}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
${(item.type === "video" || item.type === "audio") ?
|
||||
`<button class="btn-play" onclick="event.stopPropagation(); playMedia('${item.path}')">▶</button>` : ''}
|
||||
<button class="btn-download" onclick="event.stopPropagation(); downloadFile('${item.path}')">⬇</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
loading.style.display = "block";
|
||||
fileList.innerHTML = "";
|
||||
empty.style.display = "none";
|
||||
|
||||
try {
|
||||
const url = `/api/filelist?path=${encodeURIComponent(currentPath)}`;
|
||||
const res = await fetch(url);
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
items = data.items || [];
|
||||
render();
|
||||
} else {
|
||||
throw new Error(data.error || "加载失败");
|
||||
}
|
||||
} catch (e) {
|
||||
fileList.innerHTML = `<div class="error">❌ 加载失败: ${e.message}</div>`;
|
||||
loading.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
let timeout;
|
||||
searchInput.addEventListener("input", function() {
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(() => {
|
||||
searchQuery = this.value.trim();
|
||||
render();
|
||||
}, 200);
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", function(e) {
|
||||
if ((e.ctrlKey && e.key === "f") || (e.key === "/" && !e.ctrlKey && !e.metaKey)) {
|
||||
e.preventDefault();
|
||||
searchInput.focus();
|
||||
searchInput.select();
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
searchInput.value = "";
|
||||
searchQuery = "";
|
||||
render();
|
||||
searchInput.blur();
|
||||
}
|
||||
});
|
||||
|
||||
btnRefresh.addEventListener("click", loadData);
|
||||
renderBreadcrumb();
|
||||
loadData();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in new issue
Block a user