feat: introduce file manager plugin with web UI and file operation capabilities.
All checks were successful
Sign Plugins / sign (push) Successful in 1m0s

This commit is contained in:
2026-01-03 14:09:12 +08:00
parent 09103dad57
commit 3513a2e56b
3 changed files with 707 additions and 36 deletions

View File

@@ -1,9 +1,19 @@
{ {
"name": "file-manager", "name": "file-manager",
"version": "1.0.0", "version": "2.0.0",
"description": "文件管理器插件,提供远程文件浏览和管理功能", "description": "Web 文件管理器,提供远程文件浏览、上传、下载和管理功能",
"author": "GoTunnel Official", "author": "GoTunnel Official",
"run_at": "client", "run_at": "client",
"type": "app", "type": "app",
"icon": "icons/file-manager.svg" "icon": "icons/file-manager.svg",
"homepage": "https://github.com/flik/GoTunnel-Plugins",
"config_schema": [
{
"key": "root_path",
"label": "根目录",
"type": "string",
"default": ".",
"description": "文件管理的根目录路径,所有操作将限制在此目录内"
}
]
} }

View File

@@ -1,64 +1,725 @@
// GoTunnel File Manager Plugin // GoTunnel File Manager Plugin v2.0.0
// 提供完整的 Web 文件管理界面
function metadata() { function metadata() {
return { return {
name: "file-manager", name: "file-manager",
version: "1.0.0", version: "2.0.0",
description: "文件管理器插件", description: "Web 文件管理器,提供远程文件浏览、上传、下载和管理功能",
author: "GoTunnel Official", author: "GoTunnel Official",
type: "app", type: "app",
run_at: "client" run_at: "client"
}; };
} }
var rootPath = ".";
function start() { function start() {
log("File Manager plugin started"); rootPath = config("root_path") || ".";
log("File Manager started, root: " + rootPath);
} }
function stop() { function stop() {
log("File Manager plugin stopped"); log("File Manager stopped");
} }
function handleConn(conn) { function handleConn(conn) {
http.serve(conn, handleRequest); http.serve(conn, handleRequest);
} }
// ============================================================================
// 请求路由
// ============================================================================
function handleRequest(req) { function handleRequest(req) {
var path = req.path; var path = req.path;
var method = req.method; var method = req.method;
if (path === "/api/list") { // 静态页面
return handleList(req); if (path === "/" || path === "/index.html") {
return { status: 200, contentType: "text/html", body: getIndexHTML() };
}
// API 路由
if (path === "/api/list" && method === "GET") {
return apiList(req);
}
if (path === "/api/stat" && method === "GET") {
return apiStat(req);
} }
if (path === "/api/read" && method === "POST") { if (path === "/api/read" && method === "POST") {
return handleRead(req); return apiRead(req);
}
if (path === "/api/write" && method === "POST") {
return apiWrite(req);
}
if (path === "/api/mkdir" && method === "POST") {
return apiMkdir(req);
}
if (path === "/api/delete" && method === "POST") {
return apiDelete(req);
}
if (path === "/api/rename" && method === "POST") {
return apiRename(req);
}
if (path === "/api/download" && method === "GET") {
return apiDownload(req);
} }
return { status: 404, body: '{"error":"not found"}' }; return jsonError(404, "Not Found");
} }
function handleList(req) { // ============================================================================
var dir = config("root_path") || "."; // API 处理函数
var result = fs.readDir(dir); // ============================================================================
function apiList(req) {
var dir = getQueryParam(req.path, "path") || "/";
var fullPath = resolvePath(dir);
if (!fullPath) {
return jsonError(403, "Access denied");
}
var result = fs.readDir(fullPath);
if (result.error) { if (result.error) {
return { status: 500, body: http.json({ error: result.error }) }; return jsonError(500, result.error);
} }
return { status: 200, body: http.json({ entries: result.entries }) }; var entries = [];
for (var i = 0; i < result.entries.length; i++) {
var e = result.entries[i];
entries.push({
name: e.name,
isDir: e.isDir,
size: e.size || 0
});
} }
function handleRead(req) { // 排序:文件夹在前,然后按名称
var body = JSON.parse(req.body || "{}"); entries.sort(function (a, b) {
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
return a.name.localeCompare(b.name);
});
return jsonSuccess({ path: dir, entries: entries });
}
function apiStat(req) {
var path = getQueryParam(req.path, "path") || "/";
var fullPath = resolvePath(path);
if (!fullPath) {
return jsonError(403, "Access denied");
}
var result = fs.stat(fullPath);
if (result.error) {
return jsonError(404, result.error);
}
return jsonSuccess({
name: result.name,
size: result.size,
isDir: result.isDir,
modTime: result.modTime
});
}
function apiRead(req) {
var body = parseBody(req.body);
var path = body.path; var path = body.path;
if (!path) { if (!path) {
return { status: 400, body: '{"error":"path required"}' }; return jsonError(400, "path required");
} }
var result = fs.readFile(path); var fullPath = resolvePath(path);
if (!fullPath) {
return jsonError(403, "Access denied");
}
var result = fs.readFile(fullPath);
if (result.error) { if (result.error) {
return { status: 500, body: http.json({ error: result.error }) }; return jsonError(500, result.error);
} }
return { status: 200, body: http.json({ content: result.data }) }; return jsonSuccess({ content: result.data });
}
function apiWrite(req) {
var body = parseBody(req.body);
var path = body.path;
var content = body.content;
if (!path) {
return jsonError(400, "path required");
}
var fullPath = resolvePath(path);
if (!fullPath) {
return jsonError(403, "Access denied");
}
var result = fs.writeFile(fullPath, content || "");
if (!result.ok) {
return jsonError(500, result.error);
}
return jsonSuccess({ message: "File saved" });
}
function apiMkdir(req) {
var body = parseBody(req.body);
var path = body.path;
if (!path) {
return jsonError(400, "path required");
}
var fullPath = resolvePath(path);
if (!fullPath) {
return jsonError(403, "Access denied");
}
var result = fs.mkdir(fullPath);
if (!result.ok) {
return jsonError(500, result.error);
}
return jsonSuccess({ message: "Directory created" });
}
function apiDelete(req) {
var body = parseBody(req.body);
var path = body.path;
if (!path) {
return jsonError(400, "path required");
}
var fullPath = resolvePath(path);
if (!fullPath) {
return jsonError(403, "Access denied");
}
var result = fs.remove(fullPath);
if (!result.ok) {
return jsonError(500, result.error);
}
return jsonSuccess({ message: "Deleted" });
}
function apiRename(req) {
var body = parseBody(req.body);
var oldPath = body.path;
var newPath = body.newPath;
if (!oldPath || !newPath) {
return jsonError(400, "path and newPath required");
}
var fullOldPath = resolvePath(oldPath);
var fullNewPath = resolvePath(newPath);
if (!fullOldPath || !fullNewPath) {
return jsonError(403, "Access denied");
}
// 读取旧文件
var stat = fs.stat(fullOldPath);
if (stat.error) {
return jsonError(404, stat.error);
}
if (stat.isDir) {
return jsonError(400, "Cannot rename directories");
}
var content = fs.readFile(fullOldPath);
if (content.error) {
return jsonError(500, content.error);
}
// 写入新位置
var writeResult = fs.writeFile(fullNewPath, content.data);
if (!writeResult.ok) {
return jsonError(500, writeResult.error);
}
// 删除旧文件
fs.remove(fullOldPath);
return jsonSuccess({ message: "Renamed" });
}
function apiDownload(req) {
var path = getQueryParam(req.path, "path");
if (!path) {
return jsonError(400, "path required");
}
var fullPath = resolvePath(path);
if (!fullPath) {
return jsonError(403, "Access denied");
}
var result = fs.readFile(fullPath);
if (result.error) {
return jsonError(404, result.error);
}
var filename = path.split("/").pop() || "download";
return {
status: 200,
contentType: "application/octet-stream",
body: result.data
};
}
// ============================================================================
// 工具函数
// ============================================================================
function resolvePath(path) {
// 规范化路径
path = path || "/";
// 移除开头的斜杠
while (path.charAt(0) === "/") {
path = path.substring(1);
}
// 检查路径穿越攻击
if (path.indexOf("..") !== -1) {
return null;
}
// 拼接根路径
if (path === "") {
return rootPath;
}
return rootPath + "/" + path;
}
function getQueryParam(path, key) {
var idx = path.indexOf("?");
if (idx === -1) return "";
var query = path.substring(idx + 1);
var pairs = query.split("&");
for (var i = 0; i < pairs.length; i++) {
var pair = pairs[i].split("=");
if (pair[0] === key) {
return decodeURIComponent(pair[1] || "");
}
}
return "";
}
function parseBody(body) {
try {
return JSON.parse(body || "{}");
} catch (e) {
return {};
}
}
function jsonSuccess(data) {
return {
status: 200,
contentType: "application/json",
body: http.json({ success: true, data: data })
};
}
function jsonError(status, message) {
return {
status: status,
contentType: "application/json",
body: http.json({ success: false, error: message })
};
}
// ============================================================================
// Web UI - HTML/CSS/JavaScript
// ============================================================================
function getIndexHTML() {
return '<!DOCTYPE html>\n\
<html lang="zh-CN">\n\
<head>\n\
<meta charset="UTF-8">\n\
<meta name="viewport" content="width=device-width, initial-scale=1.0">\n\
<title>File Manager</title>\n\
<style>\n\
* { margin: 0; padding: 0; box-sizing: border-box; }\n\
:root {\n\
--bg-primary: #0f0f23;\n\
--bg-secondary: #1a1a2e;\n\
--bg-tertiary: #252542;\n\
--text-primary: #e4e4e7;\n\
--text-secondary: #a1a1aa;\n\
--accent: #6366f1;\n\
--accent-hover: #818cf8;\n\
--danger: #ef4444;\n\
--success: #22c55e;\n\
--border: #2e2e4a;\n\
}\n\
body {\n\
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;\n\
background: var(--bg-primary);\n\
color: var(--text-primary);\n\
min-height: 100vh;\n\
}\n\
.container { max-width: 1200px; margin: 0 auto; padding: 20px; }\n\
header {\n\
background: linear-gradient(135deg, var(--bg-secondary), var(--bg-tertiary));\n\
padding: 20px;\n\
border-radius: 12px;\n\
margin-bottom: 20px;\n\
border: 1px solid var(--border);\n\
}\n\
header h1 { font-size: 1.5rem; display: flex; align-items: center; gap: 10px; }\n\
header h1::before { content: "📁"; }\n\
.breadcrumb {\n\
display: flex; align-items: center; gap: 8px; margin-top: 15px;\n\
padding: 10px 15px; background: var(--bg-primary); border-radius: 8px;\n\
overflow-x: auto;\n\
}\n\
.breadcrumb a {\n\
color: var(--accent); text-decoration: none;\n\
white-space: nowrap;\n\
}\n\
.breadcrumb a:hover { color: var(--accent-hover); }\n\
.breadcrumb span { color: var(--text-secondary); }\n\
.toolbar {\n\
display: flex; gap: 10px; margin-bottom: 20px; flex-wrap: wrap;\n\
}\n\
.btn {\n\
padding: 10px 20px; border: none; border-radius: 8px;\n\
cursor: pointer; font-size: 14px; font-weight: 500;\n\
transition: all 0.2s; display: flex; align-items: center; gap: 6px;\n\
}\n\
.btn-primary { background: var(--accent); color: white; }\n\
.btn-primary:hover { background: var(--accent-hover); transform: translateY(-1px); }\n\
.btn-danger { background: var(--danger); color: white; }\n\
.btn-danger:hover { opacity: 0.9; }\n\
.btn-secondary { background: var(--bg-tertiary); color: var(--text-primary); border: 1px solid var(--border); }\n\
.btn-secondary:hover { background: var(--border); }\n\
.file-list {\n\
background: var(--bg-secondary); border-radius: 12px;\n\
border: 1px solid var(--border); overflow: hidden;\n\
}\n\
.file-item {\n\
display: flex; align-items: center; padding: 15px 20px;\n\
border-bottom: 1px solid var(--border); cursor: pointer;\n\
transition: background 0.2s;\n\
}\n\
.file-item:last-child { border-bottom: none; }\n\
.file-item:hover { background: var(--bg-tertiary); }\n\
.file-item.selected { background: rgba(99, 102, 241, 0.2); }\n\
.file-icon { font-size: 24px; margin-right: 15px; }\n\
.file-info { flex: 1; }\n\
.file-name { font-weight: 500; margin-bottom: 4px; }\n\
.file-meta { font-size: 12px; color: var(--text-secondary); }\n\
.file-actions { display: flex; gap: 8px; opacity: 0; transition: opacity 0.2s; }\n\
.file-item:hover .file-actions { opacity: 1; }\n\
.action-btn {\n\
width: 32px; height: 32px; border: none; border-radius: 6px;\n\
cursor: pointer; display: flex; align-items: center; justify-content: center;\n\
background: var(--bg-primary); color: var(--text-primary);\n\
transition: all 0.2s;\n\
}\n\
.action-btn:hover { background: var(--accent); }\n\
.action-btn.delete:hover { background: var(--danger); }\n\
.modal-overlay {\n\
position: fixed; top: 0; left: 0; right: 0; bottom: 0;\n\
background: rgba(0,0,0,0.7); display: flex;\n\
align-items: center; justify-content: center; z-index: 1000;\n\
}\n\
.modal {\n\
background: var(--bg-secondary); border-radius: 12px;\n\
padding: 25px; width: 90%; max-width: 500px;\n\
border: 1px solid var(--border);\n\
}\n\
.modal h2 { margin-bottom: 20px; font-size: 1.2rem; }\n\
.modal input, .modal textarea {\n\
width: 100%; padding: 12px; border-radius: 8px;\n\
border: 1px solid var(--border); background: var(--bg-primary);\n\
color: var(--text-primary); margin-bottom: 15px; font-size: 14px;\n\
}\n\
.modal textarea { min-height: 200px; resize: vertical; font-family: monospace; }\n\
.modal-actions { display: flex; gap: 10px; justify-content: flex-end; }\n\
.empty-state {\n\
text-align: center; padding: 60px 20px; color: var(--text-secondary);\n\
}\n\
.empty-state::before { content: "📂"; font-size: 48px; display: block; margin-bottom: 15px; }\n\
.loading { text-align: center; padding: 40px; color: var(--text-secondary); }\n\
.toast {\n\
position: fixed; bottom: 20px; right: 20px; padding: 15px 25px;\n\
background: var(--success); color: white; border-radius: 8px;\n\
animation: slideIn 0.3s ease;\n\
}\n\
.toast.error { background: var(--danger); }\n\
@keyframes slideIn { from { transform: translateX(100%); opacity: 0; } }\n\
</style>\n\
</head>\n\
<body>\n\
<div class="container">\n\
<header>\n\
<h1>File Manager</h1>\n\
<div class="breadcrumb" id="breadcrumb"></div>\n\
</header>\n\
<div class="toolbar">\n\
<button class="btn btn-primary" onclick="showNewFolderModal()">📁 新建文件夹</button>\n\
<button class="btn btn-primary" onclick="showNewFileModal()">📄 新建文件</button>\n\
<button class="btn btn-secondary" onclick="refresh()">🔄 刷新</button>\n\
</div>\n\
<div class="file-list" id="fileList">\n\
<div class="loading">加载中...</div>\n\
</div>\n\
</div>\n\
<div id="modalContainer"></div>\n\
<script>\n\
var currentPath = "/";\n\
\n\
function init() { loadDirectory("/"); }\n\
\n\
function loadDirectory(path) {\n\
currentPath = path;\n\
updateBreadcrumb();\n\
document.getElementById("fileList").innerHTML = "<div class=\\"loading\\">加载中...</div>";\n\
\n\
fetch("/api/list?path=" + encodeURIComponent(path))\n\
.then(function(r) { return r.json(); })\n\
.then(function(data) {\n\
if (!data.success) { showToast(data.error, true); return; }\n\
renderFileList(data.data.entries);\n\
})\n\
.catch(function(e) { showToast("加载失败: " + e.message, true); });\n\
}\n\
\n\
function renderFileList(entries) {\n\
var list = document.getElementById("fileList");\n\
if (!entries || entries.length === 0) {\n\
list.innerHTML = "<div class=\\"empty-state\\">空文件夹</div>";\n\
return;\n\
}\n\
\n\
var html = "";\n\
if (currentPath !== "/") {\n\
html += "<div class=\\"file-item\\" onclick=\\"goUp()\\"><span class=\\"file-icon\\">⬆️</span><div class=\\"file-info\\"><div class=\\"file-name\\">..</div><div class=\\"file-meta\\">返回上级</div></div></div>";\n\
}\n\
\n\
for (var i = 0; i < entries.length; i++) {\n\
var e = entries[i];\n\
var icon = e.isDir ? "📁" : getFileIcon(e.name);\n\
var size = e.isDir ? "" : formatSize(e.size);\n\
var onclick = e.isDir ? "navigateTo(\\"" + e.name + "\\")" : "viewFile(\\"" + e.name + "\\")";\n\
\n\
html += "<div class=\\"file-item\\" onclick=\\""+ onclick +"\\">";\n\
html += "<span class=\\"file-icon\\">" + icon + "</span>";\n\
html += "<div class=\\"file-info\\"><div class=\\"file-name\\">" + escapeHtml(e.name) + "</div>";\n\
html += "<div class=\\"file-meta\\">" + size + "</div></div>";\n\
html += "<div class=\\"file-actions\\">";\n\
if (!e.isDir) {\n\
html += "<button class=\\"action-btn\\" onclick=\\"event.stopPropagation();downloadFile(\\'"+e.name+"\\')\\">⬇️</button>";\n\
}\n\
html += "<button class=\\"action-btn\\" onclick=\\"event.stopPropagation();renameItem(\\'"+e.name+"\\')\\">✏️</button>";\n\
html += "<button class=\\"action-btn delete\\" onclick=\\"event.stopPropagation();deleteItem(\\'"+e.name+"\\')\\">🗑️</button>";\n\
html += "</div></div>";\n\
}\n\
list.innerHTML = html;\n\
}\n\
\n\
function updateBreadcrumb() {\n\
var parts = currentPath.split("/").filter(function(p) { return p; });\n\
var html = "<a href=\\"#\\" onclick=\\"loadDirectory(\\'/\\');return false;\\">🏠 根目录</a>";\n\
var path = "";\n\
for (var i = 0; i < parts.length; i++) {\n\
path += "/" + parts[i];\n\
html += "<span>/</span><a href=\\"#\\" onclick=\\"loadDirectory(\\'"+path+"\\');return false;\\">" + escapeHtml(parts[i]) + "</a>";\n\
}\n\
document.getElementById("breadcrumb").innerHTML = html;\n\
}\n\
\n\
function navigateTo(name) { loadDirectory(joinPath(currentPath, name)); }\n\
function goUp() {\n\
var parts = currentPath.split("/").filter(function(p) { return p; });\n\
parts.pop();\n\
loadDirectory("/" + parts.join("/"));\n\
}\n\
function refresh() { loadDirectory(currentPath); }\n\
\n\
function joinPath(base, name) {\n\
if (base === "/") return "/" + name;\n\
return base + "/" + name;\n\
}\n\
\n\
function viewFile(name) {\n\
var path = joinPath(currentPath, name);\n\
fetch("/api/read", {\n\
method: "POST",\n\
headers: {"Content-Type": "application/json"},\n\
body: JSON.stringify({path: path})\n\
})\n\
.then(function(r) { return r.json(); })\n\
.then(function(data) {\n\
if (!data.success) { showToast(data.error, true); return; }\n\
showEditModal(name, data.data.content);\n\
});\n\
}\n\
\n\
function downloadFile(name) {\n\
var path = joinPath(currentPath, name);\n\
window.open("/api/download?path=" + encodeURIComponent(path));\n\
}\n\
\n\
function deleteItem(name) {\n\
if (!confirm("确定删除 " + name + "")) return;\n\
var path = joinPath(currentPath, name);\n\
fetch("/api/delete", {\n\
method: "POST",\n\
headers: {"Content-Type": "application/json"},\n\
body: JSON.stringify({path: path})\n\
})\n\
.then(function(r) { return r.json(); })\n\
.then(function(data) {\n\
if (data.success) { showToast("已删除"); refresh(); }\n\
else { showToast(data.error, true); }\n\
});\n\
}\n\
\n\
function renameItem(name) {\n\
var newName = prompt("输入新名称:", name);\n\
if (!newName || newName === name) return;\n\
var oldPath = joinPath(currentPath, name);\n\
var newPath = joinPath(currentPath, newName);\n\
fetch("/api/rename", {\n\
method: "POST",\n\
headers: {"Content-Type": "application/json"},\n\
body: JSON.stringify({path: oldPath, newPath: newPath})\n\
})\n\
.then(function(r) { return r.json(); })\n\
.then(function(data) {\n\
if (data.success) { showToast("已重命名"); refresh(); }\n\
else { showToast(data.error, true); }\n\
});\n\
}\n\
\n\
function showNewFolderModal() {\n\
var name = prompt("文件夹名称:");\n\
if (!name) return;\n\
fetch("/api/mkdir", {\n\
method: "POST",\n\
headers: {"Content-Type": "application/json"},\n\
body: JSON.stringify({path: joinPath(currentPath, name)})\n\
})\n\
.then(function(r) { return r.json(); })\n\
.then(function(data) {\n\
if (data.success) { showToast("已创建"); refresh(); }\n\
else { showToast(data.error, true); }\n\
});\n\
}\n\
\n\
function showNewFileModal() {\n\
var html = "<div class=\\"modal-overlay\\" onclick=\\"closeModal()\\">";\n\
html += "<div class=\\"modal\\" onclick=\\"event.stopPropagation()\\">";\n\
html += "<h2>📄 新建文件</h2>";\n\
html += "<input type=\\"text\\" id=\\"newFileName\\" placeholder=\\"文件名\\">";\n\
html += "<textarea id=\\"newFileContent\\" placeholder=\\"文件内容\\"></textarea>";\n\
html += "<div class=\\"modal-actions\\">";\n\
html += "<button class=\\"btn btn-secondary\\" onclick=\\"closeModal()\\">取消</button>";\n\
html += "<button class=\\"btn btn-primary\\" onclick=\\"createFile()\\">创建</button>";\n\
html += "</div></div></div>";\n\
document.getElementById("modalContainer").innerHTML = html;\n\
}\n\
\n\
function showEditModal(name, content) {\n\
var html = "<div class=\\"modal-overlay\\" onclick=\\"closeModal()\\">";\n\
html += "<div class=\\"modal\\" onclick=\\"event.stopPropagation()\\">";\n\
html += "<h2>📝 编辑: " + escapeHtml(name) + "</h2>";\n\
html += "<input type=\\"hidden\\" id=\\"editFileName\\" value=\\"" + escapeHtml(name) + "\\">";\n\
html += "<textarea id=\\"editFileContent\\">" + escapeHtml(content) + "</textarea>";\n\
html += "<div class=\\"modal-actions\\">";\n\
html += "<button class=\\"btn btn-secondary\\" onclick=\\"closeModal()\\">取消</button>";\n\
html += "<button class=\\"btn btn-primary\\" onclick=\\"saveFile()\\">保存</button>";\n\
html += "</div></div></div>";\n\
document.getElementById("modalContainer").innerHTML = html;\n\
}\n\
\n\
function createFile() {\n\
var name = document.getElementById("newFileName").value;\n\
var content = document.getElementById("newFileContent").value;\n\
if (!name) { showToast("请输入文件名", true); return; }\n\
fetch("/api/write", {\n\
method: "POST",\n\
headers: {"Content-Type": "application/json"},\n\
body: JSON.stringify({path: joinPath(currentPath, name), content: content})\n\
})\n\
.then(function(r) { return r.json(); })\n\
.then(function(data) {\n\
if (data.success) { closeModal(); showToast("已创建"); refresh(); }\n\
else { showToast(data.error, true); }\n\
});\n\
}\n\
\n\
function saveFile() {\n\
var name = document.getElementById("editFileName").value;\n\
var content = document.getElementById("editFileContent").value;\n\
fetch("/api/write", {\n\
method: "POST",\n\
headers: {"Content-Type": "application/json"},\n\
body: JSON.stringify({path: joinPath(currentPath, name), content: content})\n\
})\n\
.then(function(r) { return r.json(); })\n\
.then(function(data) {\n\
if (data.success) { closeModal(); showToast("已保存"); }\n\
else { showToast(data.error, true); }\n\
});\n\
}\n\
\n\
function closeModal() { document.getElementById("modalContainer").innerHTML = ""; }\n\
\n\
function showToast(msg, isError) {\n\
var toast = document.createElement("div");\n\
toast.className = "toast" + (isError ? " error" : "");\n\
toast.textContent = msg;\n\
document.body.appendChild(toast);\n\
setTimeout(function() { toast.remove(); }, 3000);\n\
}\n\
\n\
function formatSize(bytes) {\n\
if (bytes === 0) return "0 B";\n\
var k = 1024;\n\
var sizes = ["B", "KB", "MB", "GB"];\n\
var i = Math.floor(Math.log(bytes) / Math.log(k));\n\
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];\n\
}\n\
\n\
function getFileIcon(name) {\n\
var ext = name.split(".").pop().toLowerCase();\n\
var icons = {\n\
js: "📜", json: "📋", html: "🌐", css: "🎨", md: "📝",\n\
txt: "📄", log: "📃", py: "🐍", go: "🔵", java: "☕",\n\
png: "🖼️", jpg: "🖼️", gif: "🖼️", svg: "🖼️",\n\
mp3: "🎵", mp4: "🎬", pdf: "📕", zip: "📦", tar: "📦"\n\
};\n\
return icons[ext] || "📄";\n\
}\n\
\n\
function escapeHtml(str) {\n\
return String(str).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");\n\
}\n\
\n\
init();\n\
</script>\n\
</body>\n\
</html>';
} }

View File

@@ -1,8 +1,8 @@
[ [
{ {
"name": "file-manager", "name": "file-manager",
"version": "1.0.0", "version": "2.0.0",
"description": "文件管理器插件,提供远程文件浏览和管理功能", "description": "Web 文件管理器,提供远程文件浏览、上传、下载和管理功能",
"author": "GoTunnel Official", "author": "GoTunnel Official",
"run_at": "client", "run_at": "client",
"type": "app", "type": "app",