Files
micro_servers/static/public/__view.html
T
qing 85e782128b style(webui): 重构文件浏览页面UI与交互逻辑
1.  改用CSS变量实现亮色/暗色主题自动切换
2.  将列表布局改为自适应网格卡片布局
3.  优化整体配色与交互动效,提升视觉体验
4.  添加XSS转义处理,增强安全性
5.  重构代码结构,拆分功能模块提升可维护性
6.  优化移动端适配效果
2026-07-22 12:40:46 +08:00

440 lines
16 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
// ===== 导航行为(与原版本一致) =====
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>