440 lines
16 KiB
HTML
440 lines
16 KiB
HTML
<!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>
|
||
:root {
|
||
--bg: #fff8f0;
|
||
--fg: #5a3e2b;
|
||
--card: #fff5e6;
|
||
--card-hover: #ffe8cc;
|
||
--border: #f0d9b5;
|
||
--border-light: #f5e6d3;
|
||
--muted: #b08968;
|
||
--subtle: #8b6f47;
|
||
--accent: #e67e22;
|
||
--accent-dark: #d35400;
|
||
--accent-bg: rgba(255,255,255,0.25);
|
||
--accent-bg-active: rgba(255,255,255,0.45);
|
||
--play: #f39c12;
|
||
--error: #c0392b;
|
||
--shadow: 0 2px 6px rgba(211, 84, 0, 0.1);
|
||
--shadow-hover: 0 4px 12px rgba(211, 84, 0, 0.2);
|
||
}
|
||
@media (prefers-color-scheme: dark) {
|
||
:root {
|
||
--bg: #2b1d14;
|
||
--fg: #f5e6d3;
|
||
--card: #3d2817;
|
||
--card-hover: #4a3020;
|
||
--border: #5a3e2b;
|
||
--border-light: #4a3020;
|
||
--muted: #a08868;
|
||
--subtle: #c4a67a;
|
||
--accent: #e67e22;
|
||
--accent-dark: #d35400;
|
||
--accent-bg: rgba(255,255,255,0.15);
|
||
--accent-bg-active: rgba(255,255,255,0.3);
|
||
--shadow: 0 2px 6px rgba(0,0,0,0.3);
|
||
--shadow-hover: 0 4px 12px rgba(0,0,0,0.5);
|
||
}
|
||
}
|
||
|
||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||
body {
|
||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif;
|
||
background: var(--bg);
|
||
color: var(--fg);
|
||
min-height: 100vh;
|
||
}
|
||
|
||
.header {
|
||
background: linear-gradient(135deg, var(--accent), var(--accent-dark));
|
||
color: white;
|
||
padding: 14px 16px;
|
||
position: sticky;
|
||
top: 0;
|
||
z-index: 10;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
box-shadow: var(--shadow);
|
||
}
|
||
.header h1 { font-size: 18px; font-weight: 600; }
|
||
.header-actions { display: flex; gap: 6px; }
|
||
.header-actions a {
|
||
color: white;
|
||
text-decoration: none;
|
||
padding: 4px 10px;
|
||
background: var(--accent-bg);
|
||
border-radius: 6px;
|
||
font-size: 14px;
|
||
transition: background 0.15s;
|
||
}
|
||
.header-actions a:active { background: var(--accent-bg-active); }
|
||
|
||
.breadcrumb {
|
||
padding: 8px 16px;
|
||
background: var(--card);
|
||
font-size: 13px;
|
||
overflow-x: auto;
|
||
white-space: nowrap;
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
.breadcrumb a { color: var(--accent); text-decoration: none; padding: 2px 6px; }
|
||
.breadcrumb .sep { color: var(--muted); margin: 0 4px; }
|
||
.breadcrumb .current { color: var(--fg); font-weight: 500; }
|
||
|
||
.search-box {
|
||
padding: 8px 16px;
|
||
background: var(--card);
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
.search-box input {
|
||
width: 100%;
|
||
padding: 8px 12px;
|
||
border: 1px solid var(--border-light);
|
||
border-radius: 6px;
|
||
font-size: 14px;
|
||
outline: none;
|
||
background: var(--bg);
|
||
color: var(--fg);
|
||
transition: border-color 0.15s, box-shadow 0.15s;
|
||
}
|
||
.search-box input:focus {
|
||
border-color: var(--accent);
|
||
box-shadow: 0 0 0 2px rgba(230, 126, 34, 0.2);
|
||
}
|
||
|
||
.stats {
|
||
padding: 6px 16px;
|
||
background: var(--bg);
|
||
font-size: 12px;
|
||
color: var(--subtle);
|
||
border-bottom: 1px solid var(--border);
|
||
display: flex;
|
||
gap: 12px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.container {
|
||
padding: 12px;
|
||
max-width: 1200px;
|
||
margin: 0 auto;
|
||
}
|
||
|
||
/* 方块网格布局 */
|
||
.file-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||
gap: 12px;
|
||
}
|
||
|
||
.item {
|
||
background: var(--card);
|
||
border-radius: 10px;
|
||
padding: 14px 10px;
|
||
cursor: pointer;
|
||
border: 1px solid var(--border-light);
|
||
box-shadow: var(--shadow);
|
||
transition: background 0.15s, box-shadow 0.15s, transform 0.1s;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
text-align: center;
|
||
gap: 6px;
|
||
}
|
||
.item:hover { background: var(--card-hover); box-shadow: var(--shadow-hover); }
|
||
.item:active { transform: scale(0.97); }
|
||
|
||
.item .icon {
|
||
font-size: 40px;
|
||
width: 100%;
|
||
text-align: center;
|
||
line-height: 1;
|
||
}
|
||
.item .info {
|
||
width: 100%;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
}
|
||
.item .name {
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
width: 100%;
|
||
}
|
||
.item .meta {
|
||
font-size: 11px;
|
||
color: var(--muted);
|
||
display: flex;
|
||
justify-content: center;
|
||
gap: 6px;
|
||
flex-wrap: wrap;
|
||
}
|
||
.item .actions {
|
||
display: flex;
|
||
gap: 4px;
|
||
justify-content: center;
|
||
margin-top: 4px;
|
||
}
|
||
.item .actions button {
|
||
padding: 5px 10px;
|
||
font-size: 12px;
|
||
border: none;
|
||
border-radius: 5px;
|
||
cursor: pointer;
|
||
color: white;
|
||
transition: opacity 0.15s;
|
||
}
|
||
.item .actions button:hover { opacity: 0.85; }
|
||
.item .actions button:active { transform: scale(0.92); }
|
||
.btn-play { background: var(--play); }
|
||
.btn-download { background: var(--accent); }
|
||
|
||
.loading { text-align: center; padding: 40px; color: var(--muted); }
|
||
.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: var(--muted); }
|
||
.empty .icon { font-size: 48px; display: block; margin-bottom: 12px; }
|
||
|
||
.error { text-align: center; padding: 40px 20px; color: var(--error); }
|
||
|
||
@media (max-width: 480px) {
|
||
.file-grid { grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); gap: 8px; }
|
||
.item { padding: 10px 8px; }
|
||
.item .icon { font-size: 32px; }
|
||
.item .name { font-size: 13px; }
|
||
.item .meta { font-size: 10px; gap: 4px; }
|
||
.item .actions button { padding: 4px 8px; font-size: 11px; }
|
||
.header h1 { font-size: 16px; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<header class="header">
|
||
<h1>📁 文件浏览</h1>
|
||
<nav class="header-actions">
|
||
<a href="/">🏠</a>
|
||
<a href="#" id="btnRefresh">🔄</a>
|
||
</nav>
|
||
</header>
|
||
|
||
<nav class="breadcrumb" id="breadcrumb">
|
||
<a href="/">根目录</a>
|
||
</nav>
|
||
|
||
<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>
|
||
|
||
<main class="container">
|
||
<div class="loading" id="loading">
|
||
<span class="spinner">⏳</span>
|
||
<div>加载中...</div>
|
||
</div>
|
||
<div id="fileList" class="file-grid"></div>
|
||
<div class="empty" id="empty" style="display:none;">
|
||
<span class="icon">📭</span>
|
||
<div>此目录为空</div>
|
||
</div>
|
||
</main>
|
||
|
||
<script>
|
||
// ===== 路径解析:从 URL 取当前目录(去除首尾斜杠) =====
|
||
const currentPath = decodeURIComponent(window.location.pathname).replace(/^\/+|\/+$/g, '');
|
||
|
||
// ===== DOM 引用(id 与原版本完全一致,保持后端/前端契约不变) =====
|
||
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");
|
||
|
||
// ===== 状态 =====
|
||
let items = [];
|
||
let searchQuery = "";
|
||
|
||
// ===== 工具函数 =====
|
||
const SIZE_UNITS = ["B", "KB", "MB", "GB"];
|
||
function formatSize(bytes) {
|
||
if (bytes === 0) return "0B";
|
||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||
return (bytes / Math.pow(1024, i)).toFixed(1) + SIZE_UNITS[i];
|
||
}
|
||
|
||
const FILE_ICONS = {
|
||
pdf: "📄", doc: "📝", docx: "📝", xls: "📊", xlsx: "📊",
|
||
zip: "📦", rar: "📦", "7z": "📦",
|
||
jpg: "🖼️", jpeg: "🖼️", png: "🖼️", gif: "🖼️",
|
||
txt: "📃", json: "📋", html: "🌐", css: "🎨", js: "⚡"
|
||
};
|
||
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();
|
||
return FILE_ICONS[ext] || "📄";
|
||
}
|
||
|
||
// 转义文本,防止文件名含特殊字符导致 XSS 或模板断裂
|
||
function esc(str) {
|
||
return String(str)
|
||
.replace(/&/g, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """)
|
||
.replace(/'/g, "'");
|
||
}
|
||
|
||
// ===== 导航行为(与原版本一致) =====
|
||
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"; }
|
||
|
||
// ===== 面包屑 =====
|
||
function renderBreadcrumb() {
|
||
const parts = currentPath ? currentPath.split('/') : [];
|
||
const frags = ['<a href="/">根目录</a>'];
|
||
let acc = '';
|
||
parts.forEach((part, i) => {
|
||
if (i === parts.length - 1) {
|
||
frags.push(`<span class="sep">›</span><span class="current">${esc(part)}</span>`);
|
||
} else {
|
||
acc += (i === 0 ? '' : '/') + part;
|
||
frags.push(`<span class="sep">›</span><a href="/${encodeURIComponent(acc)}/">${esc(part)}</a>`);
|
||
}
|
||
});
|
||
breadcrumb.innerHTML = frags.join('');
|
||
}
|
||
|
||
// ===== 列表渲染(使用转义,避免文件名注入) =====
|
||
function render() {
|
||
const q = searchQuery.toLowerCase();
|
||
const filtered = searchQuery
|
||
? items.filter(i => i.name.toLowerCase().includes(q))
|
||
: items;
|
||
|
||
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 => {
|
||
const path = esc(item.path);
|
||
const type = esc(item.type);
|
||
const name = esc(item.name);
|
||
const sizeOrDir = item.type !== "dir"
|
||
? `<span>${formatSize(item.size)}</span>`
|
||
: '<span>📁 文件夹</span>';
|
||
const playBtn = (item.type === "video" || item.type === "audio")
|
||
? `<button class="btn-play" onclick="event.stopPropagation(); playMedia('${path}')">▶</button>`
|
||
: '';
|
||
return `
|
||
<div class="item">
|
||
<span class="icon">${getIcon(item)}</span>
|
||
<div class="info" onclick="navigate('${path}', '${type}')">
|
||
<div class="name">${name}</div>
|
||
<div class="meta">
|
||
${sizeOrDir}
|
||
<span>${esc(item.modified)}</span>
|
||
</div>
|
||
</div>
|
||
<div class="actions">
|
||
${playBtn}
|
||
<button class="btn-download" onclick="event.stopPropagation(); downloadFile('${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">❌ 加载失败: ${esc(e.message)}</div>`;
|
||
loading.style.display = "none";
|
||
}
|
||
}
|
||
|
||
// ===== 事件绑定(快捷键 / 搜索 / 刷新 与原版本一致) =====
|
||
let searchTimer;
|
||
searchInput.addEventListener("input", function () {
|
||
clearTimeout(searchTimer);
|
||
searchTimer = 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>
|