Files
blackbird/static/public/__view.html
T
qing 004f0cd3df style: 优化前端页面整体UI样式与交互细节
调整了头部按钮、面包屑、搜索框、文件卡片等组件的内边距、圆角和间距,新增了hover交互效果,统一了卡片和加载空态的视觉样式,适配移动端布局细节
2026-07-22 12:46:50 +08:00

486 lines
17 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: 6px 14px;
background: var(--accent-bg);
border-radius: 20px;
font-size: 14px;
transition: background 0.15s;
}
.header-actions a:active { background: var(--accent-bg-active); }
.breadcrumb {
padding: 10px 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: 3px 10px;
border-radius: 12px;
transition: background 0.15s;
}
.breadcrumb a:hover { background: var(--card-hover); }
.breadcrumb .sep { color: var(--muted); margin: 0 4px; }
.breadcrumb .current { color: var(--fg); font-weight: 500; padding: 3px 10px; }
.search-box {
padding: 10px 16px;
background: var(--card);
border-bottom: 1px solid var(--border);
}
.search-box input {
width: 100%;
padding: 10px 16px;
border: 1px solid var(--border-light);
border-radius: 24px;
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 3px rgba(230, 126, 34, 0.2);
}
.stats {
padding: 8px 16px;
background: var(--bg);
font-size: 12px;
color: var(--subtle);
border-bottom: 1px solid var(--border);
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.stats span {
padding: 3px 10px;
background: var(--card);
border-radius: 12px;
border: 1px solid var(--border-light);
}
.container {
padding: 14px;
max-width: 1200px;
margin: 0 auto;
}
/* 方块网格布局 */
.file-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 14px;
}
.item {
background: var(--card);
border-radius: 20px;
padding: 16px 12px;
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: 8px;
}
.item:hover { background: var(--card-hover); box-shadow: var(--shadow-hover); }
.item:active { transform: scale(0.97); }
.item .icon {
font-size: 42px;
width: 64px;
height: 64px;
line-height: 64px;
text-align: center;
background: var(--bg);
border-radius: 50%;
border: 1px solid var(--border-light);
}
.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: 6px;
justify-content: center;
margin-top: 6px;
}
.item .actions button {
padding: 6px 14px;
font-size: 12px;
border: none;
border-radius: 16px;
cursor: pointer;
color: white;
transition: opacity 0.15s, transform 0.1s;
}
.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: 50px; color: var(--muted); }
.loading .spinner {
display: inline-block;
animation: spin 1s linear infinite;
font-size: 32px;
width: 56px;
height: 56px;
line-height: 56px;
background: var(--card);
border-radius: 50%;
box-shadow: var(--shadow);
margin-bottom: 8px;
}
@keyframes spin { 100% { transform: rotate(360deg); } }
.empty { text-align: center; padding: 70px 20px; color: var(--muted); }
.empty .icon {
font-size: 56px;
display: inline-block;
width: 80px;
height: 80px;
line-height: 80px;
background: var(--card);
border-radius: 50%;
box-shadow: var(--shadow);
margin-bottom: 16px;
}
.error {
text-align: center;
padding: 50px 20px;
color: var(--error);
background: var(--card);
border-radius: 20px;
margin: 20px 0;
}
@media (max-width: 480px) {
.file-grid { grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); gap: 10px; }
.item { padding: 12px 8px; border-radius: 16px; }
.item .icon { font-size: 32px; width: 52px; height: 52px; line-height: 52px; }
.item .name { font-size: 13px; }
.item .meta { font-size: 10px; gap: 4px; }
.item .actions button { padding: 5px 10px; font-size: 11px; border-radius: 14px; }
.header h1 { font-size: 16px; }
.search-box input { border-radius: 20px; }
}
</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>