!4 merge wei into main
修改UI,改进dnfs Created-by: qingss0 Commit-by: qing;shufeng2012;qingss0 Merged-by: qingss0 Description: 🐛 修复js脚本未加载 🔀 Merge branch 'wei' of gitcode.com:qingss0/micro_servers into wei 解耦视图模块 fix(fsys): 优化过滤规则,过滤所有双下划线开头的文件文件夹 style(webui): 重构文件浏览页面UI与交互逻辑 ... See merge request: qingss0/micro_servers!4
This commit is contained in:
8 files changed
+705
-480
No files matched your search
+1
-1
@@ -9,7 +9,7 @@
|
|||||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
|
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
|
||||||
|
|
||||||
<!-- 外部CSS文件 -->
|
<!-- 外部CSS文件 -->
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
<link rel="stylesheet" href="static/css/style.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
|
|||||||
+49
-358
@@ -60,6 +60,9 @@ SSL_ENABLED = file_config['ssl'].get('enabled', 'false').lower() == 'true'
|
|||||||
SSL_CERT_FILE = file_config['ssl'].get('cert_file', 'cert.pem')
|
SSL_CERT_FILE = file_config['ssl'].get('cert_file', 'cert.pem')
|
||||||
SSL_KEY_FILE = file_config['ssl'].get('key_file', 'key.pem')
|
SSL_KEY_FILE = file_config['ssl'].get('key_file', 'key.pem')
|
||||||
|
|
||||||
|
# 视图文件名:根路径与目录访问时固定返回此静态文件,由前端 JS 调用 /api/filelist 拉取数据
|
||||||
|
VIEW_FILE = '__view.html'
|
||||||
|
|
||||||
# ==================== 视频文件支持 ====================
|
# ==================== 视频文件支持 ====================
|
||||||
VIDEO_MIME_TYPES = {
|
VIDEO_MIME_TYPES = {
|
||||||
'.mp4': 'video/mp4', '.webm': 'video/webm', '.ogg': 'video/ogg',
|
'.mp4': 'video/mp4', '.webm': 'video/webm', '.ogg': 'video/ogg',
|
||||||
@@ -106,29 +109,30 @@ def get_file_list():
|
|||||||
try:
|
try:
|
||||||
if not FILESYSTEM_ENABLED:
|
if not FILESYSTEM_ENABLED:
|
||||||
return jsonify({'error': '文件系统未启用'}), 403
|
return jsonify({'error': '文件系统未启用'}), 403
|
||||||
|
|
||||||
requested_path = request.args.get('path', '')
|
requested_path = request.args.get('path', '')
|
||||||
full_path = safe_path_join(HTML_ROOT_DIR, requested_path)
|
full_path = safe_path_join(HTML_ROOT_DIR, requested_path)
|
||||||
|
|
||||||
if full_path is None:
|
if full_path is None:
|
||||||
return jsonify({'error': '无效的路径'}), 403
|
return jsonify({'error': '无效的路径'}), 403
|
||||||
|
|
||||||
if not os.path.exists(full_path) or not os.path.isdir(full_path):
|
if not os.path.exists(full_path) or not os.path.isdir(full_path):
|
||||||
return jsonify({'error': '目录不存在'}), 404
|
return jsonify({'error': '目录不存在'}), 404
|
||||||
|
|
||||||
items = []
|
items = []
|
||||||
for item in os.listdir(full_path):
|
for item in os.listdir(full_path):
|
||||||
if item == '__pycache__':
|
# 过滤系统项目:以双下划线开头的文件和文件夹(如 __pycache__、__view.html、__icons 等)
|
||||||
|
if item.startswith('__'):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
item_path = os.path.join(full_path, item)
|
item_path = os.path.join(full_path, item)
|
||||||
item_stat = os.stat(item_path)
|
item_stat = os.stat(item_path)
|
||||||
is_dir = os.path.isdir(item_path)
|
is_dir = os.path.isdir(item_path)
|
||||||
|
|
||||||
item_rel_path = os.path.join(requested_path, item) if requested_path else item
|
item_rel_path = os.path.join(requested_path, item) if requested_path else item
|
||||||
|
|
||||||
file_type = 'dir' if is_dir else ('video' if is_video_file(item) else ('audio' if is_audio_file(item) else 'file'))
|
file_type = 'dir' if is_dir else ('video' if is_video_file(item) else ('audio' if is_audio_file(item) else 'file'))
|
||||||
|
|
||||||
items.append({
|
items.append({
|
||||||
'name': item,
|
'name': item,
|
||||||
'type': file_type,
|
'type': file_type,
|
||||||
@@ -137,9 +141,9 @@ def get_file_list():
|
|||||||
'size_formatted': format_file_size(item_stat.st_size) if not is_dir else '',
|
'size_formatted': format_file_size(item_stat.st_size) if not is_dir else '',
|
||||||
'modified': datetime.datetime.fromtimestamp(item_stat.st_mtime).strftime('%Y-%m-%d %H:%M:%S')
|
'modified': datetime.datetime.fromtimestamp(item_stat.st_mtime).strftime('%Y-%m-%d %H:%M:%S')
|
||||||
})
|
})
|
||||||
|
|
||||||
items.sort(key=lambda x: (x['type'] != 'dir', x['name'].lower()))
|
items.sort(key=lambda x: (x['type'] != 'dir', x['name'].lower()))
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'success': True,
|
'success': True,
|
||||||
'path': requested_path or '/',
|
'path': requested_path or '/',
|
||||||
@@ -155,33 +159,33 @@ def stream_video(file_path):
|
|||||||
try:
|
try:
|
||||||
if not FILESYSTEM_ENABLED:
|
if not FILESYSTEM_ENABLED:
|
||||||
return jsonify({'error': '文件系统未启用'}), 403
|
return jsonify({'error': '文件系统未启用'}), 403
|
||||||
|
|
||||||
full_path = safe_path_join(HTML_ROOT_DIR, file_path)
|
full_path = safe_path_join(HTML_ROOT_DIR, file_path)
|
||||||
if full_path is None or not os.path.exists(full_path) or not os.path.isfile(full_path):
|
if full_path is None or not os.path.exists(full_path) or not os.path.isfile(full_path):
|
||||||
return jsonify({'error': '文件不存在'}), 404
|
return jsonify({'error': '文件不存在'}), 404
|
||||||
|
|
||||||
if not is_video_file(full_path) and not is_audio_file(full_path):
|
if not is_video_file(full_path) and not is_audio_file(full_path):
|
||||||
return jsonify({'error': '不支持的文件类型'}), 400
|
return jsonify({'error': '不支持的文件类型'}), 400
|
||||||
|
|
||||||
file_size = os.path.getsize(full_path)
|
file_size = os.path.getsize(full_path)
|
||||||
mime_type = get_media_mime_type(full_path)
|
mime_type = get_media_mime_type(full_path)
|
||||||
|
|
||||||
range_header = request.headers.get('Range', None)
|
range_header = request.headers.get('Range', None)
|
||||||
|
|
||||||
if range_header:
|
if range_header:
|
||||||
byte_range = range_header.replace('bytes=', '').split('-')
|
byte_range = range_header.replace('bytes=', '').split('-')
|
||||||
start = int(byte_range[0]) if byte_range[0] else 0
|
start = int(byte_range[0]) if byte_range[0] else 0
|
||||||
end = int(byte_range[1]) if len(byte_range) > 1 and byte_range[1] else file_size - 1
|
end = int(byte_range[1]) if len(byte_range) > 1 and byte_range[1] else file_size - 1
|
||||||
|
|
||||||
if start >= file_size:
|
if start >= file_size:
|
||||||
return jsonify({'error': '请求范围超出文件大小'}), 416
|
return jsonify({'error': '请求范围超出文件大小'}), 416
|
||||||
|
|
||||||
length = end - start + 1
|
length = end - start + 1
|
||||||
|
|
||||||
with open(full_path, 'rb') as f:
|
with open(full_path, 'rb') as f:
|
||||||
f.seek(start)
|
f.seek(start)
|
||||||
data = f.read(length)
|
data = f.read(length)
|
||||||
|
|
||||||
response = Response(data, 206, mimetype=mime_type, direct_passthrough=True)
|
response = Response(data, 206, mimetype=mime_type, direct_passthrough=True)
|
||||||
response.headers.add('Content-Range', f'bytes {start}-{end}/{file_size}')
|
response.headers.add('Content-Range', f'bytes {start}-{end}/{file_size}')
|
||||||
response.headers.add('Accept-Ranges', 'bytes')
|
response.headers.add('Accept-Ranges', 'bytes')
|
||||||
@@ -194,342 +198,38 @@ def stream_video(file_path):
|
|||||||
logger.error(f"Video streaming error: {str(e)}")
|
logger.error(f"Video streaming error: {str(e)}")
|
||||||
return jsonify({'error': str(e)}), 500
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
# ==================== 极简版HTML页面(使用字符串拼接,避免花括号问题) ====================
|
|
||||||
def get_html_template(path, breadcrumb_html):
|
|
||||||
"""生成HTML页面"""
|
|
||||||
return f'''<!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">
|
|
||||||
<a href="/">根目录</a>
|
|
||||||
{breadcrumb_html}
|
|
||||||
</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>
|
|
||||||
const currentPath = "{path}";
|
|
||||||
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");
|
|
||||||
|
|
||||||
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"; }}
|
|
||||||
|
|
||||||
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);
|
|
||||||
loadData();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>'''
|
|
||||||
|
|
||||||
# ==================== 主路由 ====================
|
# ==================== 主路由 ====================
|
||||||
@app.route('/', defaults={'path': ''})
|
@app.route('/')
|
||||||
|
def serve_view():
|
||||||
|
"""根路径固定返回 __view.html 静态文件,由前端 JS 调用 /api/filelist 拉取文件系统信息"""
|
||||||
|
if not FILESYSTEM_ENABLED:
|
||||||
|
return "文件系统未启用", 403
|
||||||
|
view_path = os.path.join(HTML_ROOT_DIR, VIEW_FILE)
|
||||||
|
if not os.path.exists(view_path):
|
||||||
|
return f"视图文件 {VIEW_FILE} 不存在", 500
|
||||||
|
return send_file(view_path, mimetype='text/html')
|
||||||
|
|
||||||
@app.route('/<path:path>')
|
@app.route('/<path:path>')
|
||||||
def serve_file(path=''):
|
def serve_file(path):
|
||||||
try:
|
try:
|
||||||
if not FILESYSTEM_ENABLED:
|
if not FILESYSTEM_ENABLED:
|
||||||
return "文件系统未启用", 403
|
return "文件系统未启用", 403
|
||||||
|
|
||||||
full_path = safe_path_join(HTML_ROOT_DIR, path)
|
full_path = safe_path_join(HTML_ROOT_DIR, path)
|
||||||
if full_path is None:
|
if full_path is None:
|
||||||
return "无效的路径", 403
|
return "无效的路径", 403
|
||||||
|
|
||||||
if not os.path.exists(full_path):
|
if not os.path.exists(full_path):
|
||||||
return "路径不存在", 404
|
return "路径不存在", 404
|
||||||
|
|
||||||
# 文件处理
|
# 文件处理
|
||||||
if os.path.isfile(full_path):
|
if os.path.isfile(full_path):
|
||||||
if request.args.get('download') == 'true':
|
if request.args.get('download') == 'true':
|
||||||
return send_file(full_path, as_attachment=True, download_name=os.path.basename(full_path))
|
return send_file(full_path, as_attachment=True, download_name=os.path.basename(full_path))
|
||||||
|
|
||||||
if is_video_file(full_path) or is_audio_file(full_path):
|
if is_video_file(full_path) or is_audio_file(full_path):
|
||||||
return send_file(full_path, mimetype=get_media_mime_type(full_path))
|
return send_file(full_path, mimetype=get_media_mime_type(full_path))
|
||||||
|
|
||||||
# 普通文件
|
# 普通文件
|
||||||
with open(full_path, 'rb') as f:
|
with open(full_path, 'rb') as f:
|
||||||
content = f.read()
|
content = f.read()
|
||||||
@@ -541,23 +241,13 @@ def serve_file(path=''):
|
|||||||
'.pdf': 'application/pdf', '.svg': 'image/svg+xml', '.ico': 'image/x-icon'
|
'.pdf': 'application/pdf', '.svg': 'image/svg+xml', '.ico': 'image/x-icon'
|
||||||
}
|
}
|
||||||
return Response(content, mimetype=mime_map.get(ext, 'application/octet-stream'))
|
return Response(content, mimetype=mime_map.get(ext, 'application/octet-stream'))
|
||||||
|
|
||||||
# 目录处理 - 生成面包屑
|
# 目录处理:返回 __view.html,由前端 JS 根据 URL 路径调用 /api/filelist 拉取文件列表
|
||||||
breadcrumb = ''
|
view_path = os.path.join(HTML_ROOT_DIR, VIEW_FILE)
|
||||||
if path:
|
if os.path.exists(view_path):
|
||||||
parts = path.split('/')
|
return send_file(view_path, mimetype='text/html')
|
||||||
current = ''
|
return "目录无法浏览", 404
|
||||||
for i, part in enumerate(parts):
|
|
||||||
if i == len(parts) - 1:
|
|
||||||
breadcrumb += f'<span class="sep">›</span><span class="current">{part}</span>'
|
|
||||||
else:
|
|
||||||
current += part + '/'
|
|
||||||
breadcrumb += f'<span class="sep">›</span><a href="/{current}">{part}</a>'
|
|
||||||
|
|
||||||
# 使用函数生成HTML,避免字符串格式化问题
|
|
||||||
html = get_html_template(path, breadcrumb)
|
|
||||||
return Response(html, mimetype='text/html')
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error: {str(e)}")
|
logger.error(f"Error: {str(e)}")
|
||||||
import traceback
|
import traceback
|
||||||
@@ -575,7 +265,7 @@ def health_check():
|
|||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
host = file_config['server']['host']
|
host = file_config['server']['host']
|
||||||
port = int(file_config['server']['port'])
|
port = int(file_config['server']['port'])
|
||||||
|
|
||||||
print("\n" + "="*50)
|
print("\n" + "="*50)
|
||||||
print("📁 文件托管服务器 (极简版)")
|
print("📁 文件托管服务器 (极简版)")
|
||||||
print("="*50)
|
print("="*50)
|
||||||
@@ -589,3 +279,4 @@ if __name__ == '__main__':
|
|||||||
app.run(host=host, port=port, ssl_context=(SSL_CERT_FILE, SSL_KEY_FILE), debug=False)
|
app.run(host=host, port=port, ssl_context=(SSL_CERT_FILE, SSL_KEY_FILE), debug=False)
|
||||||
else:
|
else:
|
||||||
app.run(host=host, port=port, debug=False)
|
app.run(host=host, port=port, debug=False)
|
||||||
|
|
||||||
@@ -0,0 +1,485 @@
|
|||||||
|
<!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, "&")
|
||||||
|
.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>
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
code {
|
||||||
|
color: red;
|
||||||
|
background-color: rgb(254, 235, 238);
|
||||||
|
padding: 5px;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
pre > code {
|
||||||
|
display: block;
|
||||||
|
color: white;
|
||||||
|
background-color: rgb(96, 96, 96);
|
||||||
|
padding: 5px;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
blockquote {
|
||||||
|
margin-right: 0px;
|
||||||
|
background-color: #cfcfcf;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* by qing */
|
||||||
|
body {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
background: #fffbf0;
|
||||||
|
font-family: system-ui, -apple-system, "Segoe UI", Roboto,sans-serif;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.box {
|
||||||
|
width: 90%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background-color: #eedeb0;
|
||||||
|
border: 1px solid #000000;
|
||||||
|
border-radius: 1.125rem;
|
||||||
|
padding: 1.5rem 1.25rem;
|
||||||
|
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.04), 0 2px 4px rgba(0, 0, 0, 0.02);
|
||||||
|
/*max-width: 480px;*/
|
||||||
|
width: 90%;
|
||||||
|
color: #4d3e2b;
|
||||||
|
transition: box-shadow 0.2s, border-color 0.2s;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover {
|
||||||
|
border-color: #c7b38a;
|
||||||
|
box-shadow: 0 10px 24px rgba(0,0,0,0.06), 0 2px 6px rgba(0,0,0,0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 移动端微调 */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
body {
|
||||||
|
padding: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
padding: 1.2rem, 1rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
border-width: 1px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 极小屏 (宽度 < 360px) */
|
||||||
|
@media (max-width: 360px) {
|
||||||
|
.card {
|
||||||
|
padding: 1rem 0.8rem;
|
||||||
|
border-radius: 0.9rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
+28
-57
@@ -5,66 +5,37 @@
|
|||||||
<!-- 移动端适配核心标签 -->
|
<!-- 移动端适配核心标签 -->
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0,user-scalable=no">
|
<meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0,user-scalable=no">
|
||||||
<title>DNFS · 树状笔记</title>
|
<title>DNFS · 树状笔记</title>
|
||||||
<style>
|
<link rel="stylesheet" href="css/qing.css">
|
||||||
code {
|
|
||||||
color: red;
|
|
||||||
background-color: rgb(254, 235, 238);
|
|
||||||
padding: 5px;
|
|
||||||
border-radius: 5px;
|
|
||||||
}
|
|
||||||
pre > code {
|
|
||||||
display: block;
|
|
||||||
color: white;
|
|
||||||
background-color: rgb(96, 96, 96);
|
|
||||||
padding: 5px;
|
|
||||||
border-radius: 5px;
|
|
||||||
}
|
|
||||||
blockquote {
|
|
||||||
margin-right: 0px;
|
|
||||||
background-color: #cfcfcf;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<center>
|
<div class="box">
|
||||||
<!-- 文章主标题 -->
|
<center>
|
||||||
<h1><div id="article-title">title</div></h1>
|
<!-- 文章主标题 -->
|
||||||
|
<h1><div id="article-title">title</div></h1>
|
||||||
|
|
||||||
<!-- 文章详情 -->
|
<!-- 文章详情 -->
|
||||||
<div>
|
<div>
|
||||||
发布时间:<span id="publish-time">2026-1-1</span>
|
发布时间:<span id="publish-time">2026-1-1</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
浏览:<span id="visit-count">999</span>
|
||||||
|
点赞:<span id="like-count">999</span>
|
||||||
|
分享:<span id="share-count">999</span>
|
||||||
|
</div>
|
||||||
|
<div id="article-content">hello</div>
|
||||||
|
<div class="recommend-list" id="recommend-list"></div>
|
||||||
|
<br>
|
||||||
|
<div class="card">
|
||||||
|
<h3>节点操作</h3>
|
||||||
|
<label>新增节点</label><input id="input-note-title"></input><br>
|
||||||
|
<label>节点内容</label><textarea id="input-note-content"></textarea><br>
|
||||||
|
<button id="input-note-submit">提交</button>
|
||||||
|
<button id="input-note-clear">重置</button>
|
||||||
|
</div>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/markdown-it/12.0.0/markdown-it.min.js"></script>
|
||||||
|
<script src="js/validation.js"></script>
|
||||||
|
<script src="js/dnfs.js"></script>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
浏览:<span id="visit-count">999</span>
|
|
||||||
点赞:<span id="like-count">999</span>
|
|
||||||
分享:<span id="share-count">999</span>
|
|
||||||
</div>
|
|
||||||
</center>
|
|
||||||
<!-- 文章正文-->
|
|
||||||
<div id="article-content">hello</div>
|
|
||||||
<hr>
|
|
||||||
<div class="other-recommend">
|
|
||||||
<h3>其他推荐</h3>
|
|
||||||
</div>
|
|
||||||
<div class="recommend-list" id="recommend-list">
|
|
||||||
<ul>
|
|
||||||
<li><a href="https://www.baidu.com">百度推荐</a></li>
|
|
||||||
<li><a href="https://www.baidu.com">百度推荐</a></li>
|
|
||||||
<li><a href="https://www.baidu.com">百度推荐</a></li>
|
|
||||||
<li><a href="https://www.baidu.com">百度推荐</a></li>
|
|
||||||
<li><a href="https://www.baidu.com">百度推荐</a></li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<br>
|
|
||||||
<h3>节点操作</h3>
|
|
||||||
<div>
|
|
||||||
<label>新增节点</label><input id="input-note-title"></input><br>
|
|
||||||
<label>节点内容</label><textarea id="input-note-content"></textarea><br>
|
|
||||||
<button id="input-note-submit">提交</button>
|
|
||||||
<button id="input-note-clear">重置</button>
|
|
||||||
</div>
|
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/markdown-it/12.0.0/markdown-it.min.js"></script>
|
|
||||||
<script src="/js/validation.js"></script>
|
|
||||||
<script src="/js/dnfs.js"></script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
@@ -131,8 +131,7 @@ function load_notes(main_node) {
|
|||||||
recommend_list.replaceChildren();
|
recommend_list.replaceChildren();
|
||||||
//alert(stack.length)
|
//alert(stack.length)
|
||||||
if (stack.length > 0) { /* 如果不在根节点 */
|
if (stack.length > 0) { /* 如果不在根节点 */
|
||||||
var bak = document.createElement('a');
|
var bak = document.createElement('center');
|
||||||
//ttl.href = '/dnfs.html';
|
|
||||||
bak.addEventListener('click', function (event) {
|
bak.addEventListener('click', function (event) {
|
||||||
stack.pop();
|
stack.pop();
|
||||||
var id = (stack.length > 1) ? stack[stack.length - 1] : 0;
|
var id = (stack.length > 1) ? stack[stack.length - 1] : 0;
|
||||||
@@ -145,20 +144,22 @@ function load_notes(main_node) {
|
|||||||
}
|
}
|
||||||
for (i in main_node) {
|
for (i in main_node) {
|
||||||
item = main_node[i]
|
item = main_node[i]
|
||||||
var ttl = document.createElement('a');
|
var card = document.createElement('div');
|
||||||
//ttl.href ="/dnfs.html";
|
card.className = "card";
|
||||||
|
var ttl = document.createElement('center');
|
||||||
ttl.addEventListener('click', function(event) {
|
ttl.addEventListener('click', function(event) {
|
||||||
var id = Number(item.id)
|
var id = Number(item.id)
|
||||||
stack.push(id)
|
stack.push(id)
|
||||||
getNode(token, id).then(load_notes)
|
getNode(token, id).then(load_notes)
|
||||||
});
|
});
|
||||||
var ttl1 =document.createElement('h1');
|
var ttl1 =document.createElement('h3');
|
||||||
ttl1.textContent = item.title
|
ttl1.textContent = item.title
|
||||||
ttl.appendChild(ttl1)
|
ttl.appendChild(ttl1)
|
||||||
recommend_list.appendChild(ttl)
|
card.appendChild(ttl)
|
||||||
var ctt = document.createElement('p');
|
var ctt = document.createElement('p');
|
||||||
renderMarkdown(ctt, item.content);
|
renderMarkdown(ctt, item.content);
|
||||||
recommend_list.appendChild(ctt)
|
card.appendChild(ctt)
|
||||||
|
recommend_list.appendChild(card)
|
||||||
recommend_list.appendChild(document.createElement('br'))
|
recommend_list.appendChild(document.createElement('br'))
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
+33
-30
@@ -5,6 +5,7 @@
|
|||||||
<!-- 移动端适配核心标签 -->
|
<!-- 移动端适配核心标签 -->
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0,user-scalable=no">
|
<meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0,user-scalable=no">
|
||||||
<title>登录</title>
|
<title>登录</title>
|
||||||
|
<link rel="stylesheet" href="css/qing.css">
|
||||||
<style>
|
<style>
|
||||||
.tip-red {color:red;}
|
.tip-red {color:red;}
|
||||||
.tip-yellow {color: orange;}
|
.tip-yellow {color: orange;}
|
||||||
@@ -12,36 +13,38 @@
|
|||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h2>用户登录</h2>
|
<div class="card">
|
||||||
<form action="" id="form" method="post">
|
<h2>用户登录</h2>
|
||||||
<div>
|
<form action="" id="form" method="post">
|
||||||
<label>用户名/推荐邮箱:</label>
|
<div>
|
||||||
<input type="text" id="loginUsername" name="username" placeholder="请输入用户名">
|
<label>用户名/推荐邮箱:</label>
|
||||||
<span id="msgTip" ></span>
|
<input type="text" id="loginUsername" name="username" placeholder="请输入用户名">
|
||||||
<br>
|
<span id="msgTip" ></span>
|
||||||
<label>密码:</label>
|
<br>
|
||||||
<input type="password" id="loginPassword" name="password" placeholder="请输入密码">
|
<label>密码:</label>
|
||||||
<span id="pwdTip"></span>
|
<input type="password" id="loginPassword" name="password" placeholder="请输入密码">
|
||||||
</div>
|
<span id="pwdTip"></span>
|
||||||
<div>
|
</div>
|
||||||
<label>验证邮箱:</label>
|
<div>
|
||||||
<input type="email" id="loginEmail" name="email" placeholder="请输入验证邮箱">
|
<label>验证邮箱:</label>
|
||||||
<span id="emlSub">
|
<input type="email" id="loginEmail" name="email" placeholder="请输入验证邮箱">
|
||||||
<input type="submit" value="发送验证码">
|
<span id="emlSub">
|
||||||
</span>
|
<input type="submit" value="发送验证码">
|
||||||
<span id="emlTip"></span>
|
</span>
|
||||||
</div>
|
<span id="emlTip"></span>
|
||||||
<div>
|
</div>
|
||||||
<button type="button" id="registerBtn">注册</button>
|
<div>
|
||||||
<input type="submit" id="loginBtn" value="登录">
|
<button type="button" id="registerBtn">注册</button>
|
||||||
<input type="reset" id="resetUser" value="重置">
|
<input type="submit" id="loginBtn" value="登录">
|
||||||
</div>
|
<input type="reset" id="resetUser" value="重置">
|
||||||
<div>
|
</div>
|
||||||
<input type="checkbox" id="agreeBox" name="agree" value="access">
|
<div>
|
||||||
<label id="agreement">同意该系统相关用户使用条例</label>
|
<input type="checkbox" id="agreeBox" name="agree" value="access">
|
||||||
<span id="agreeTip"></span>
|
<label id="agreement">同意该系统相关用户使用条例</label>
|
||||||
</div>
|
<span id="agreeTip"></span>
|
||||||
</form>
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
<script src="js/validation.js"></script>
|
<script src="js/validation.js"></script>
|
||||||
<script src="js/login.js"></script>
|
<script src="js/login.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
+30
-27
@@ -4,6 +4,7 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0,user-scalable=no">
|
<meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0,user-scalable=no">
|
||||||
<title>注册页面</title>
|
<title>注册页面</title>
|
||||||
|
<link rel="stylesheet" href="css/qing.css">
|
||||||
<style>
|
<style>
|
||||||
.tip-red {color:red;}
|
.tip-red {color:red;}
|
||||||
.tip-yellow {color: orange;}
|
.tip-yellow {color: orange;}
|
||||||
@@ -11,34 +12,36 @@
|
|||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h2>注册页面</h2>
|
<div class="card">
|
||||||
<form id="form">
|
<h2>注册页面</h2>
|
||||||
<div>
|
<form id="form">
|
||||||
<label>用户名/推荐邮箱:</label>
|
<div>
|
||||||
<input type="text" id="regusername" placeholder="请输入用户名">
|
<label>用户名/推荐邮箱:</label>
|
||||||
<span id="msgTip" ></span>
|
<input type="text" id="regusername" placeholder="请输入用户名">
|
||||||
<br>
|
<span id="msgTip" ></span>
|
||||||
<label>邮箱号:</label>
|
<br>
|
||||||
<input type="email" id="regemail" placeholder="请输入邮箱号">
|
<label>邮箱号:</label>
|
||||||
<div class="code-box">验证码:
|
<input type="email" id="regemail" placeholder="请输入邮箱号">
|
||||||
<input type="text" id="code" placeholder="请输入6位验证码" inputmode="numeric" maxlength="6">
|
<div class="code-box">验证码:
|
||||||
<input type="button" id="getCode" value="获取验证码">
|
<input type="text" id="code" placeholder="请输入6位验证码" inputmode="numeric" maxlength="6">
|
||||||
<span id="codeTip"></span>
|
<input type="button" id="getCode" value="获取验证码">
|
||||||
|
<span id="codeTip"></span>
|
||||||
|
</div>
|
||||||
|
<span id="emlTip"></span>
|
||||||
|
<br>
|
||||||
|
<label>密码:</label>
|
||||||
|
<input type="password" id="regpsw" placeholder="密码至少6位">
|
||||||
|
<label>验证密码:</label>
|
||||||
|
<input type="password" id="repwd" placeholder="请再次输入密码">
|
||||||
|
<span id="pwdTip"></span>
|
||||||
</div>
|
</div>
|
||||||
<span id="emlTip"></span>
|
<div>
|
||||||
<br>
|
<input id="formsub" type="submit" value="提交">
|
||||||
<label>密码:</label>
|
<input id="resetUser" type="reset" value="重置">
|
||||||
<input type="password" id="regpsw" placeholder="密码至少6位">
|
<input type="button" value="返回登录" onclick="location.href='login.html'">
|
||||||
<label>验证密码:</label>
|
</div>
|
||||||
<input type="password" id="repwd" placeholder="请再次输入密码">
|
</form>
|
||||||
<span id="pwdTip"></span>
|
</div>
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<input id="formsub" type="submit" value="提交">
|
|
||||||
<input id="resetUser" type="reset" value="重置">
|
|
||||||
<input type="button" value="返回登录" onclick="location.href='login.html'">
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
<script src="js/validation.js"></script>
|
<script src="js/validation.js"></script>
|
||||||
<script src="js/register.js?v=6"></script>
|
<script src="js/register.js?v=6"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
Reference in new issue
Block a user