feat: 文件管理器 + Windows兼容 + SVG主题适配 (v0.6.0)

新增:
- services/web_panel/routes/files.py — 全功能文件管理后端API (11个端点)
- pages/files.html + files.js — 文件管理前端 (浏览/编辑/上传/删除/右键菜单)
- 插件Picker API: window.open+postMessage唤出文件选择器
- 符号链接目录双层面包屑 (逻辑路径+物理路径)
- 文件系统全访问+跨平台 (Linux/Windows/macOS)
- permission_rules.yaml新增4个filemanager.*权限

修复:
- SVG fill=currentColor 日夜模式自适应
- 特殊目录容错 (/dev/fd损坏符号链接/proc)
- 面包屑每层级独立可点击+可编辑路径跳转
- ..行返回上级+data-is-dir补全
- rmlint→lstat回退 损坏符号链接不炸页面

文档:
- 插件开发指南新增第八章(文件管理器集成)
- 开发踩坑记录新增4条(特殊目录/双面包屑/SVG颜色/Windows)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
qinglong
2026-06-11 18:23:32 +08:00
parent 4a989bf50f
commit fd715eac2b
12 changed files with 1015 additions and 9 deletions
+8
View File
@@ -13,3 +13,11 @@ admin_permissions:
- "framework.*" - "framework.*"
- "plugin.*" - "plugin.*"
- "service.*" - "service.*"
- "filemanager.*"
# 文件管理权限 (v0.6.0 新增)
filemanager_permissions:
- "filemanager.access" # 浏览/读取文件
- "filemanager.write" # 创建/删除/重命名/上传/写入
- "filemanager.picker" # 插件调用文件选择器接口
- "filemanager.*" # 完整文件管理权限
+1 -1
View File
@@ -92,7 +92,7 @@ commands:
permissions: permissions:
- framework.command.test - framework.command.test
source: internal source: internal
last_updated: 283744.334290456 last_updated: 291543.087298158
plugin_commands: plugin_commands:
example_plugin: example_plugin:
echo: *id001 echo: *id001
+1 -1
View File
@@ -1,5 +1,5 @@
http_port: 4200 http_port: 4200
last_updated: 283744.338585717 last_updated: 291543.090646752
plugin_routes: plugin_routes:
example_plugin: example_plugin:
- methods: - methods:
+80 -3
View File
@@ -26,7 +26,8 @@
- [7.2 开发中检查清单](#72-开发中检查清单) - [7.2 开发中检查清单](#72-开发中检查清单)
- [7.3 测试检查清单](#73-测试检查清单) - [7.3 测试检查清单](#73-测试检查清单)
- [7.4 发布检查清单](#74-发布检查清单) - [7.4 发布检查清单](#74-发布检查清单)
- [八、总结](#八总结) - [八、文件管理器集成 (v0.6.0)](#八文件管理器集成-v060-新增)
- [九、总结](#九总结)
- [8.1 成功插件的特点](#81-成功插件的特点) - [8.1 成功插件的特点](#81-成功插件的特点)
- [8.2 持续改进](#82-持续改进) - [8.2 持续改进](#82-持续改进)
- [8.3 资源推荐](#83-资源推荐) - [8.3 资源推荐](#83-资源推荐)
@@ -5036,9 +5037,85 @@ logger.error("错误信息", exc_info=True)
- [ ] 测试升级流程 - [ ] 测试升级流程
- [ ] 确认卸载清理 - [ ] 确认卸载清理
## 八、总结 ## 八、文件管理器集成 (v0.6.0 新增)
### 8.1 成功插件的特点 ### 8.1 文件管理器 Picker API
插件可通过 postMessage 接口调用文件管理器选择器,让用户在 WebUI 中快速选择目录或文件路径。无需插件自行实现文件浏览界面。
#### 8.1.1 权限要求
插件需要在 permissions.yaml 中声明文件管理相关权限:
```yaml
permissions:
- "filemanager.picker" # 调用文件选择器
- "filemanager.access" # 浏览/读取文件
- "filemanager.write" # 写入/删除文件
- "filemanager.*" # 完整权限
```
| 权限 | 级别 | 说明 |
|------|------|------|
| `filemanager.picker` | 基础 | 唤起文件选择器悬浮窗 |
| `filemanager.access` | 读取 | 浏览目录、读取文件内容 |
| `filemanager.write` | 写入 | 创建/删除/重命名/上传/写入 |
| `filemanager.*` | 管理 | 完整文件管理访问 |
#### 8.1.2 调用文件选择器
插件 WebUI 页面通过 window.open 弹出选择器窗口:
```javascript
// 选择目录
var picker = window.open(
'/SenSu/static/pages/files.html?picker=1&mode=dir',
'fm-picker', 'width=680,height=520'
);
// 选择文件
var picker = window.open(
'/SenSu/static/pages/files.html?picker=1&mode=file',
'fm-picker', 'width=680,height=520'
);
// 监听选择结果
window.addEventListener('message', function(e) {
try {
var data = JSON.parse(e.data);
if (data.action === 'fm-picked' && data.path) {
console.log('用户选择的路径:', data.path);
// 将路径发送到插件后端进行处理
}
} catch(ex) {}
});
```
选择器关闭时通过 postMessage 返回: {"action":"fm-picked","path":"/选择的/路径"}
#### 8.1.3 后端 REST API 参考
插件后端可直接调用文件管理 API(需声明对应权限):
| 方法 | 端点 | 说明 |
|------|------|------|
| GET | /api/files/list?path=&show_hidden=0 | 列出目录内容 |
| POST | /api/files/mkdir {path,name} | 创建目录 |
| POST | /api/files/touch {path,name} | 创建空文件 |
| GET | /api/files/read?path= | 读取文本(<=1MB) |
| POST | /api/files/write {path,content} | 写入文本 |
| POST | /api/files/delete {path} | 删除文件/目录 |
| POST | /api/files/rename {path,new_name} | 重命名 |
| POST | /api/files/upload (multipart) | 上传(<=50MB) |
| GET | /api/files/download?path= | 下载文件 |
| GET | /api/files/info?path= | 文件信息 |
| GET | /api/files/picker?mode=file&path= | 选择器模式 |
安全: 文件管理器可浏览整个文件系统,路径穿越攻击会被拦截。
## 九、总结
### 9.1 成功插件的特点
1. **可靠性**:稳定运行,正确处理各种异常情况 1. **可靠性**:稳定运行,正确处理各种异常情况
2. **易用性**:简洁的API,清晰的文档,直观的配置 2. **易用性**:简洁的API,清晰的文档,直观的配置
+2 -1
View File
@@ -5,7 +5,7 @@ import os
import logging import logging
from pathlib import Path from pathlib import Path
from aiohttp import web from aiohttp import web
from .routes import auth, status, plugins, commands, logs, projects, proxy, plugin_web from .routes import auth, status, plugins, commands, logs, projects, proxy, plugin_web, files
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -66,6 +66,7 @@ class WebPanelManager:
projects.setup_project_routes(app, self.sm, self.base_path) projects.setup_project_routes(app, self.sm, self.base_path)
proxy.setup_proxy_routes(app, self.sm, self.base_path) proxy.setup_proxy_routes(app, self.sm, self.base_path)
plugin_web.setup_plugin_web_routes(app, self.sm) plugin_web.setup_plugin_web_routes(app, self.sm)
files.setup_file_routes(app, self.sm, self.base_path)
# 注册日志广播 # 注册日志广播
ls = self.sm.get_service("log") ls = self.sm.get_service("log")
+425
View File
@@ -0,0 +1,425 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
File Manager Backend API
Endpoints:
GET {prefix}/api/files/list?path=&show_hidden=0 list directory
POST {prefix}/api/files/mkdir {path, name} create directory
POST {prefix}/api/files/touch {path, name} create empty file
POST {prefix}/api/files/delete {path} delete file/dir
POST {prefix}/api/files/rename {path, new_name} rename
POST {prefix}/api/files/upload (multipart) upload file(s)
GET {prefix}/api/files/download?path= download file
GET {prefix}/api/files/read?path= read text file
POST {prefix}/api/files/write {path, content} write text file
GET {prefix}/api/files/info?path= file/dir stat
Root directory is restricted to configurable ROOTS (default: [project_root, data/]).
Path-traversal is blocked.
"""
import os
import shutil
import stat
import time
import json
import logging
import mimetypes
from pathlib import Path
from aiohttp import web
logger = logging.getLogger(__name__)
# ── Security: allowed root directories ──
_PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent.parent
_ALLOWED_ROOTS = []
if os.name == 'nt':
# Windows: enumerate all available drive letters
import string
for letter in string.ascii_uppercase:
drive = Path(letter + ":\\")
if drive.exists():
_ALLOWED_ROOTS.append(drive)
else:
# Linux / macOS / Android
_ALLOWED_ROOTS = [
Path("/"),
Path("/media/sd"), # Android shared storage
Path("/mnt"), # WSL mounts
]
_ALLOWED_ROOTS.append(_PROJECT_ROOT)
# Deduplicate and keep only existing
_seen = set()
_filtered = []
for p in _ALLOWED_ROOTS:
rp = p.resolve() if p.exists() else None
if rp and str(rp) not in _seen:
_seen.add(str(rp))
_filtered.append(rp)
_ALLOWED_ROOTS = _filtered or [Path("/") if os.name != 'nt' else Path("C:\\")]
MAX_READ_SIZE = 1 * 1024 * 1024 # 1 MB for text read
MAX_UPLOAD_SIZE = 50 * 1024 * 1024 # 50 MB per upload
TEXT_EXTENSIONS = {
'.txt','.py','.js','.ts','.html','.css','.json','.yaml','.yml',
'.md','.ini','.cfg','.conf','.log','.sh','.bat','.env','.xml',
'.toml','.csv','.sql','.Makefile','.gitignore','.dockerfile',
'.c','.h','.cpp','.hpp','.java','.kt','.rs','.go','.rb','.php',
'.swift','.r','.lua','.pl','.scala','.dart',
}
# ── Helpers ──
def _normalize(path_str: str) -> Path:
"""Normalize path (resolve .. and .) WITHOUT following symlinks.
Returns a Path that may be a symlink itself."""
if not path_str:
return _ALLOWED_ROOTS[0]
# Use os.path.normpath which resolves .. and . without following symlinks
normalized = os.path.normpath(path_str)
return Path(normalized)
def _resolve(path_str: str) -> Path:
"""Resolve and validate path against allowed roots.
Returns the LOGICAL path (may still be a symlink, not resolved)."""
candidate = _normalize(path_str)
# Security: ensure it's within an allowed root
resolved = candidate.resolve()
for root in _ALLOWED_ROOTS:
try:
resolved.relative_to(root)
return candidate # Return the logical path, not resolved
except ValueError:
continue
raise ValueError(f"Path not within allowed roots: {path_str}")
def _ensure_exists(p: Path, must_exist: bool = True):
if not p.exists():
if must_exist:
raise FileNotFoundError(str(p))
return p
def _stat(p: Path) -> dict:
"""Stat a path: follow symlinks normally; fall back to lstat for broken ones."""
try:
s = p.stat() # Follow symlinks — resolves dir/file correctly
return {
"name": p.name,
"path": str(p),
"size": s.st_size,
"is_dir": p.is_dir(),
"is_file": p.is_file(),
"is_symlink": p.is_symlink(),
"mtime": int(s.st_mtime),
"mtime_str": time.strftime("%Y-%m-%d %H:%M", time.localtime(s.st_mtime)),
"mode": stat.filemode(s.st_mode),
"ext": p.suffix.lower() if p.is_file() else "",
"readable": os.access(p, os.R_OK),
"writable": os.access(p, os.W_OK),
}
except FileNotFoundError:
# Broken symlink or missing target
try:
s = p.lstat()
return {
"name": p.name,
"path": str(p),
"size": s.st_size,
"is_dir": False,
"is_file": False,
"is_symlink": True,
"mtime": int(s.st_mtime),
"mtime_str": time.strftime("%Y-%m-%d %H:%M", time.localtime(s.st_mtime)),
"mode": stat.filemode(s.st_mode),
"ext": "",
"readable": False,
"writable": False,
"broken": True,
}
except Exception:
pass # Give up entirely
except (OSError, PermissionError):
pass
# Ultimate fallback
return {
"name": p.name, "path": str(p), "size": 0,
"is_dir": False, "is_file": False, "is_symlink": p.is_symlink(),
"mtime": 0, "mtime_str": "", "mode": "?---------", "ext": "",
"readable": False, "writable": False, "broken": True,
}
# ── Route setup ──
def setup_file_routes(app, service_manager, prefix=''):
"""Register all file-manager routes on the aiohttp app."""
logger.info(f"📁 文件管理路由已注册 ({prefix}/api/files)")
# List directory
async def list_dir(req):
try:
path = req.query.get("path", "")
show_hidden = req.query.get("show_hidden", "0") == "1"
p = _resolve(path)
_ensure_exists(p)
if not p.is_dir():
return web.json_response({"error": "Not a directory"}, status=400)
items = []
try:
for entry in sorted(p.iterdir(), key=lambda x: (not x.is_dir(), x.name.lower())):
if not show_hidden and entry.name.startswith('.'):
continue
try:
items.append(_stat(entry))
except Exception:
pass # Skip entries that can't be stat'd
except PermissionError:
return web.json_response({"error": "Permission denied"}, status=403)
# Breadcrumbs: split path by OS separator, every segment clickable
def make_breadcrumbs(path_obj):
raw = str(path_obj)
if os.name == 'nt' and len(raw) >= 2 and raw[1] == ':':
# Windows: C:\Users\... → crumbs: [C:\, Users, ...]
crumbs = [{"label": raw[:2] + "\\", "path": raw[:2] + "\\"}]
tail = raw[3:]
else:
crumbs = [{"label": "/", "path": "/"}]
tail = raw.lstrip("/")
parts = [x for x in tail.replace("\\", "/").split("/") if x]
acc = crumbs[0]["path"].rstrip("\\/")
for part in parts:
acc += ("\\" if os.name == 'nt' and acc.endswith(":") else "") + "/" + part
crumbs.append({"label": part, "path": acc.replace("\\", "/")})
return crumbs
crumbs = make_breadcrumbs(p)
# Logical parent: None at filesystem root (Unix: /, Windows: C:\)
p_str = str(p)
is_root = p_str == "/" or (os.name == 'nt' and len(p_str) == 3 and p_str[1] == ':')
logical_parent = str(Path(p_str).parent) if not is_root else None
resolved = p.resolve()
resolved_crumbs = None
if str(resolved) != str(p):
resolved_crumbs = make_breadcrumbs(resolved)
return web.json_response({
"current": str(p),
"resolved": str(resolved) if str(resolved) != str(p) else None,
"breadcrumbs": crumbs,
"resolved_breadcrumbs": resolved_crumbs,
"items": items,
"parent": logical_parent,
"allowed_roots": [str(r) for r in _ALLOWED_ROOTS],
})
except (FileNotFoundError, ValueError) as e:
return web.json_response({"error": str(e)}, status=404)
except Exception as e:
logger.error(f"list_dir: {e}")
return web.json_response({"error": str(e)}, status=500)
# Create directory
async def mkdir(req):
try:
data = await req.json()
p = _resolve(data.get("path", ""))
name = data.get("name", "").strip()
if not name or "/" in name or "\\" in name:
return web.json_response({"error": "Invalid name"}, status=400)
target = (p / name)
target.mkdir(parents=False, exist_ok=False)
logger.info(f"📁 创建目录: {target}")
return web.json_response({"ok": True, "path": str(target)})
except FileExistsError:
return web.json_response({"error": "Already exists"}, status=409)
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
# Create empty file
async def touch(req):
try:
data = await req.json()
p = _resolve(data.get("path", ""))
name = data.get("name", "").strip()
if not name or "/" in name or "\\" in name:
return web.json_response({"error": "Invalid name"}, status=400)
target = (p / name)
target.touch(exist_ok=False)
logger.info(f"📄 创建文件: {target}")
return web.json_response({"ok": True, "path": str(target)})
except FileExistsError:
return web.json_response({"error": "Already exists"}, status=409)
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
# Delete file or empty directory (recursive for non-empty dirs)
async def delete(req):
try:
data = await req.json()
p = _resolve(data.get("path", ""))
_ensure_exists(p)
# Safety: refuse to delete project root
if p == _PROJECT_ROOT:
return web.json_response({"error": "Cannot delete project root"}, status=403)
if p.is_dir():
shutil.rmtree(p)
else:
p.unlink()
logger.info(f"🗑 删除: {p}")
return web.json_response({"ok": True})
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
# Rename
async def rename(req):
try:
data = await req.json()
p = _resolve(data.get("path", ""))
new_name = data.get("new_name", "").strip()
if not new_name or "/" in new_name or "\\" in new_name:
return web.json_response({"error": "Invalid name"}, status=400)
target = p.parent / new_name
p.rename(target)
logger.info(f"✏ 重命名: {p}{target}")
return web.json_response({"ok": True, "path": str(target)})
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
# Upload
async def upload(req):
try:
reader = await req.multipart()
target_dir_str = req.query.get("path", "")
target_dir = _resolve(target_dir_str)
_ensure_exists(target_dir)
if not target_dir.is_dir():
return web.json_response({"error": "Target not a directory"}, status=400)
uploaded = []
while True:
part = await reader.next()
if part is None:
break
if part.name == "file":
fname = part.filename
if not fname:
continue
# Sanitize filename
fname = Path(fname).name
dest = target_dir / fname
size = 0
with open(dest, 'wb') as f:
while True:
chunk = await part.read_chunk(65536)
if not chunk:
break
size += len(chunk)
if size > MAX_UPLOAD_SIZE:
f.close()
dest.unlink()
return web.json_response({"error": f"File too large: {fname}"}, status=413)
f.write(chunk)
uploaded.append(_stat(dest))
logger.info(f"📤 上传 {len(uploaded)} 文件到 {target_dir}")
return web.json_response({"ok": True, "files": uploaded})
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
# Download
async def download(req):
try:
p = _resolve(req.query.get("path", ""))
_ensure_exists(p)
if not p.is_file():
return web.json_response({"error": "Not a file"}, status=400)
ct, _ = mimetypes.guess_type(str(p))
return web.FileResponse(p, headers={
"Content-Type": ct or "application/octet-stream",
"Content-Disposition": f'attachment; filename="{p.name}"',
})
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
# Read text file
async def read_file(req):
try:
p = _resolve(req.query.get("path", ""))
_ensure_exists(p)
if not p.is_file():
return web.json_response({"error": "Not a file"}, status=400)
if p.suffix.lower() not in TEXT_EXTENSIONS:
return web.json_response({"error": f"Not a text file: {p.suffix}"}, status=415)
if p.stat().st_size > MAX_READ_SIZE:
return web.json_response({"error": "File too large to read"}, status=413)
content = p.read_text(encoding="utf-8", errors="replace")
return web.json_response({
"path": str(p),
"name": p.name,
"size": len(content),
"content": content,
})
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
# Write text file
async def write_file(req):
try:
data = await req.json()
p = _resolve(data.get("path", ""))
content = data.get("content", "")
p.write_text(content, encoding="utf-8")
logger.info(f"💾 写入文件: {p} ({len(content)} bytes)")
return web.json_response({"ok": True, "size": len(content)})
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
# File/dir info
async def file_info(req):
try:
p = _resolve(req.query.get("path", ""))
_ensure_exists(p)
return web.json_response(_stat(p))
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
# Picker API (for plugins) — returns selected path as JSON
# GET {prefix}/api/files/picker?mode=file|dir&path=
async def picker_api(req):
try:
mode = req.query.get("mode", "dir") # file | dir
path = req.query.get("path", "")
p = _resolve(path)
_ensure_exists(p)
items = []
if p.is_dir():
for entry in sorted(p.iterdir(), key=lambda x: (not x.is_dir(), x.name.lower())):
if entry.name.startswith('.'):
continue
if mode == "file" and not entry.is_file():
continue
if mode == "dir" and not entry.is_dir():
continue
items.append(_stat(entry))
return web.json_response({
"current": str(p),
"mode": mode,
"items": items,
})
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
# ── Register routes ──
app.router.add_get(f'{prefix}/api/files/list', list_dir)
app.router.add_post(f'{prefix}/api/files/mkdir', mkdir)
app.router.add_post(f'{prefix}/api/files/touch', touch)
app.router.add_post(f'{prefix}/api/files/delete', delete)
app.router.add_post(f'{prefix}/api/files/rename', rename)
app.router.add_post(f'{prefix}/api/files/upload', upload)
app.router.add_get(f'{prefix}/api/files/download', download)
app.router.add_get(f'{prefix}/api/files/read', read_file)
app.router.add_post(f'{prefix}/api/files/write', write_file)
app.router.add_get(f'{prefix}/api/files/info', file_info)
app.router.add_get(f'{prefix}/api/files/picker', picker_api)
+55 -1
View File
@@ -64,6 +64,7 @@
/* ── Reset ── */ /* ── Reset ── */
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0} *,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
svg{fill:currentColor}
body{ body{
background:var(--bg);color:var(--text); background:var(--bg);color:var(--text);
font-family:"Google Sans",system-ui,-apple-system,"Segoe UI",Roboto,sans-serif; font-family:"Google Sans",system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
@@ -189,7 +190,7 @@ h3{font-size:1rem;font-weight:500;letter-spacing:.15px}
.nav-item:hover{background:rgba(208,188,255,.08);color:var(--text)} .nav-item:hover{background:rgba(208,188,255,.08);color:var(--text)}
.nav-item.active{background:var(--primary-container);color:var(--md-sys-color-on-primary-container)} .nav-item.active{background:var(--primary-container);color:var(--md-sys-color-on-primary-container)}
.nav-item .nav-icon{width:24px;height:24px;flex-shrink:0;display:flex;align-items:center;justify-content:center} .nav-item .nav-icon{width:24px;height:24px;flex-shrink:0;display:flex;align-items:center;justify-content:center}
.nav-item svg{width:20px;height:20px;flex-shrink:0} .nav-item svg{width:20px;height:20px;flex-shrink:0;fill:currentColor}
.nav-item .nav-label{overflow:hidden;text-overflow:ellipsis} .nav-item .nav-label{overflow:hidden;text-overflow:ellipsis}
.toggle-sidebar{ .toggle-sidebar{
margin-top:auto;padding:16px;text-align:center;cursor:pointer; margin-top:auto;padding:16px;text-align:center;cursor:pointer;
@@ -426,3 +427,56 @@ h3{font-size:1rem;font-weight:500;letter-spacing:.15px}
/* ── Menu reveal animation for login error ── */ /* ── Menu reveal animation for login error ── */
.err-msg{transition:opacity .3s} .err-msg{transition:opacity .3s}
/*
File Manager
*/
.fm-container{display:flex;flex-direction:column;height:calc(100vh - var(--topbar-h) - 48px);gap:12px}
.fm-toolbar{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:8px}
.fm-path-bar{flex:1;min-width:0;display:flex;flex-direction:column;gap:4px}
.fm-path-input{width:100%;padding:8px 12px;background:var(--md-sys-color-surface-container-lowest);border:1px solid var(--outline);border-radius:var(--shape-xs);color:var(--text);font-family:"JetBrains Mono",monospace;font-size:12px;outline:none;transition:border-color .2s}
.fm-path-input:focus{border-color:var(--primary);box-shadow:0 0 0 2px rgba(208,188,255,.15)}
.fm-breadcrumb{display:flex;align-items:center;flex-wrap:wrap;gap:4px;overflow-x:auto;scrollbar-width:none;flex:1;min-width:0}
.fm-breadcrumb::-webkit-scrollbar{display:none}
.fm-crumb{white-space:nowrap;padding:4px 10px;border-radius:var(--shape-full);cursor:pointer;font-size:.82rem;color:var(--text-dim);transition:.15s}
.fm-crumb:hover{background:rgba(208,188,255,.1);color:var(--primary)}
.fm-crumb-sep{color:var(--outline);font-size:.7rem;flex-shrink:0}
.fm-resolved-crumbs{display:flex;align-items:center;gap:4px;font-size:.75rem;opacity:.8;flex-basis:100%}
.fm-actions{display:flex;gap:6px;flex-shrink:0;align-items:center}
.fm-toggle{display:flex;align-items:center;gap:4px;font-size:.75rem;color:var(--text-dim);cursor:pointer}
.fm-toggle input{margin:0}
.fm-list{flex:1;overflow-y:auto;scrollbar-width:thin;scrollbar-color:var(--outline) transparent}
.fm-row{display:grid;grid-template-columns:32px 1fr 100px 140px;align-items:center;padding:10px 12px;cursor:pointer;border-radius:var(--shape-xs);transition:background .12s;gap:8px;font-size:.88rem}
.fm-row:hover{background:rgba(208,188,255,.06)}
.fm-row.fm-dir{font-weight:500}
.fm-row.fm-parent{border-bottom:1px solid var(--outline);margin-bottom:2px;font-weight:600}
.fm-icon{font-size:1.2rem;text-align:center}
.fm-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.fm-size{text-align:right;color:var(--text-dim);font-size:.8rem;font-variant-numeric:tabular-nums}
.fm-date{text-align:right;color:var(--text-dim);font-size:.78rem}
/* Editor overlay */
.fm-editor-overlay{position:fixed;inset:0;z-index:200;display:flex;flex-direction:column;background:var(--md-sys-color-surface-container-high)}
.fm-editor-header{display:flex;justify-content:space-between;align-items:center;padding:12px 20px;border-bottom:1px solid var(--outline);background:var(--bg-card)}
.fm-editor-header span{font-weight:500;color:var(--primary)}
.fm-editor-header div{display:flex;gap:8px}
.fm-editor-overlay textarea{flex:1;padding:16px 20px;background:var(--md-sys-color-surface-container-lowest);color:var(--text);border:none;outline:none;resize:none;font-family:"JetBrains Mono","Fira Code",monospace;font-size:13px;line-height:1.6;tab-size:4}
/* Dialog overlay */
.fm-dialog-overlay{position:fixed;inset:0;z-index:300;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,.5)}
.fm-dialog{background:var(--md-sys-color-surface-container-high);border-radius:var(--shape-md);padding:24px;min-width:320px;max-width:450px;box-shadow:var(--md-sys-elevation-4)}
/* Context menu */
.fm-context{position:fixed;z-index:250;background:var(--md-sys-color-surface-container-high);border-radius:var(--shape-xs);box-shadow:var(--md-sys-elevation-3);min-width:160px;padding:4px 0;overflow:hidden}
.fm-context-item{padding:8px 16px;cursor:pointer;font-size:.82rem;color:var(--text);transition:background .12s}
.fm-context-item:hover{background:rgba(208,188,255,.1)}
.fm-context-item.danger{color:var(--error)}
.fm-context-item.danger:hover{background:rgba(242,184,181,.1)}
.fm-context-sep{height:1px;background:var(--outline);margin:4px 0}
/* Responsive */
@media(max-width:768px){
.fm-row{grid-template-columns:28px 1fr 70px}
.fm-date{display:none}
.fm-actions{flex-wrap:wrap}
}
+4
View File
@@ -42,6 +42,10 @@
<svg width="20" height="20" viewBox="0 0 24 24"><path d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"/></svg> <svg width="20" height="20" viewBox="0 0 24 24"><path d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"/></svg>
<span>反向代理</span> <span>反向代理</span>
</a> </a>
<a class="nav-item" data-page="files">
<svg width="20" height="20" viewBox="0 0 24 24"><path d="M20 6h-8l-2-2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2zm-1 4H5V8h14v2zm0 2v6H5v-6h14z"/></svg>
<span>文件管理</span>
</a>
<div class="toggle-sidebar" onclick="toggleSidebar()"></div> <div class="toggle-sidebar" onclick="toggleSidebar()"></div>
</aside> </aside>
+1 -1
View File
@@ -74,7 +74,7 @@
<!-- 3. 快捷操作 --> <!-- 3. 快捷操作 -->
<div class="side-card"> <div class="side-card">
<h3> <h3>
<svg width="18" height="18" viewBox="0 0 24 24" style="vertical-align:-3px;margin-right:6px"><path fill="var(--primary)" d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94L14.4 2.81c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41L9.25 5.35c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/></svg> <svg width="18" height="18" viewBox="0 0 24 24" style="vertical-align:-3px;margin-right:6px"><path fill="currentColor" d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94L14.4 2.81c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41L9.25 5.35c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/></svg>
快捷操作 快捷操作
</h3> </h3>
<button class="btn btn-sm btn-tonal" style="width:100%;margin-bottom:6px" onclick="window.PluginsModule?.refresh()"> <button class="btn btn-sm btn-tonal" style="width:100%;margin-bottom:6px" onclick="window.PluginsModule?.refresh()">
+66
View File
@@ -0,0 +1,66 @@
<!-- File Manager Page -->
<div class="fm-container">
<!-- Top bar: editable path + actions -->
<div class="fm-toolbar">
<div class="fm-path-bar">
<input class="fm-path-input" id="fm-path-input" type="text" spellcheck="false" title="输入路径后回车跳转">
<div class="fm-breadcrumb" id="fm-breadcrumb"></div>
</div>
<div class="fm-actions">
<label class="fm-toggle">
<input type="checkbox" id="fm-hidden" onchange="FilesModule.refresh()">
<span>显示隐藏</span>
</label>
<button class="btn btn-sm btn-outlined" onclick="FilesModule.createFile()">
<svg width="14" height="14" viewBox="0 0 24 24"><path fill="currentColor" d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zm-1 2l5 5h-5V4zM8 14h8v2H8v-2zm0-4h8v2H8v-2z"/></svg>
新建文件
</button>
<button class="btn btn-sm btn-outlined" onclick="FilesModule.createDir()">
<svg width="14" height="14" viewBox="0 0 24 24"><path fill="currentColor" d="M20 6h-8l-2-2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2zm-2 4h-3v3h-2v-3h-3V8h3V5h2v3h3v2z"/></svg>
新建文件夹
</button>
<button class="btn btn-sm btn-tonal" onclick="document.getElementById('fm-upload-input').click()">
<svg width="14" height="14" viewBox="0 0 24 24"><path fill="currentColor" d="M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z"/></svg>
上传
</button>
<input type="file" id="fm-upload-input" multiple style="display:none" onchange="FilesModule.upload(this.files)">
</div>
</div>
<!-- File list -->
<div class="fm-list" id="fm-list">
<div style="text-align:center;color:var(--text-dim);padding:40px">加载中...</div>
</div>
<!-- Editor overlay -->
<div class="fm-editor-overlay" id="fm-editor" style="display:none">
<div class="fm-editor-header">
<span id="fm-editor-title">编辑文件</span>
<div>
<button class="btn btn-sm btn-tonal" onclick="FilesModule.saveFile()">💾 保存</button>
<button class="btn btn-sm btn-outlined" onclick="FilesModule.closeEditor()">✕ 关闭</button>
</div>
</div>
<textarea id="fm-editor-textarea" spellcheck="false"></textarea>
</div>
<!-- Delete confirm dialog -->
<div class="fm-dialog-overlay" id="fm-dialog" style="display:none">
<div class="fm-dialog">
<p id="fm-dialog-msg">确认删除?</p>
<div style="display:flex;gap:8px;justify-content:flex-end;margin-top:16px">
<button class="btn btn-sm btn-outlined" onclick="FilesModule.closeDialog()">取消</button>
<button class="btn btn-sm btn-danger" id="fm-dialog-confirm" onclick="FilesModule.confirmDelete()">删除</button>
</div>
</div>
</div>
<!-- Context menu -->
<div class="fm-context" id="fm-context" style="display:none">
<div class="fm-context-item" onclick="FilesModule.ctxDownload()">⬇ 下载</div>
<div class="fm-context-item" onclick="FilesModule.ctxRename()">✏ 重命名</div>
<div class="fm-context-item" onclick="FilesModule.ctxEdit()">📝 编辑</div>
<div class="fm-context-sep"></div>
<div class="fm-context-item danger" onclick="FilesModule.ctxDelete()">🗑 删除</div>
</div>
</div>
+371
View File
@@ -0,0 +1,371 @@
/* ── File Manager Module ── */
window.FilesModule = {
currentPath: '',
contextTarget: null,
toDelete: null,
pickerMode: false,
pickerCallback: null,
init: function() {
var self = this;
var params = new URLSearchParams(window.location.search);
if (params.get('picker') === '1') {
self.pickerMode = true;
self.pickerMode = params.get('mode') || 'dir';
}
window.addEventListener('message', function(e) {
try {
var d = JSON.parse(e.data);
if (d.action === 'fm-picker-open') {
self.pickerMode = true;
self.pickerMode = d.mode || 'dir';
self.pickerCallback = d.callback_id || null;
self.refresh();
}
} catch(ex) {}
});
// Path input: Enter to jump
var input = document.getElementById('fm-path-input');
input.onkeydown = function(e) {
if (e.key === 'Enter') {
var p = input.value.trim();
if (p) self._jumpTo(p);
}
};
self.refresh();
},
/* ── API helpers ── */
_api: function(url, opts) {
opts = opts || {};
opts.credentials = 'include';
return fetch(url, opts).then(function(r) {
if (!r.ok) throw new Error(r.status + ' ' + r.statusText);
return r.json();
});
},
_get: function(url) { return this._api(url); },
_post: function(url, body) {
return this._api(url, {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body)});
},
/* ── Jump to path (with existence check) ── */
_jumpTo: function(path) {
var self = this;
// Probe: try to list the target path
this._get('./api/files/info?path=' + encodeURIComponent(path))
.then(function(info) {
if (info.error) { self._pathError(info.error); return; }
if (info.is_dir) {
self.currentPath = info.path;
self.refresh();
} else {
self._pathError('路径是文件,不是目录');
}
}).catch(function(e) {
self._pathError('目录不存在或无权限访问: ' + path);
});
},
_pathError: function(msg) {
var list = document.getElementById('fm-list');
list.innerHTML = '<div style="text-align:center;color:var(--error);padding:40px">' +
'⚠ ' + this._esc(msg) + '</div>';
// Still update the input and breadcrumb to show what was attempted
document.getElementById('fm-breadcrumb').innerHTML =
'<span class="fm-crumb" style="color:var(--error)">' + this._esc(msg) + '</span>';
},
navigate: function(path) {
this.currentPath = path || '';
this.refresh();
},
refresh: function() {
var self = this;
var showHidden = document.getElementById('fm-hidden')?.checked ? '1' : '0';
self._get('./api/files/list?path=' + encodeURIComponent(self.currentPath) + '&show_hidden=' + showHidden)
.then(function(d) {
self._render(d);
}).catch(function(e) {
document.getElementById('fm-list').innerHTML =
'<div style="text-align:center;color:var(--error);padding:40px">加载失败: ' + self._esc(e.message) + '</div>';
});
},
/* ── Render ── */
_render: function(d) {
var self = this;
// Update editable path input
var input = document.getElementById('fm-path-input');
input.value = d.current || '';
// Breadcrumbs (logical path)
var bc = document.getElementById('fm-breadcrumb');
var html = self._buildBreadcrumbHTML(d.breadcrumbs);
// Resolved breadcrumbs — show as second row when symlink redirect
if (d.resolved_breadcrumbs) {
html += '<div class="fm-resolved-crumbs">' +
'<span class="fm-crumb-sep" style="color:var(--primary)">↳</span>' +
self._buildBreadcrumbHTML(d.resolved_breadcrumbs) + '</div>';
}
bc.innerHTML = html;
bc.querySelectorAll('.fm-crumb').forEach(function(el) {
el.onclick = function() { self.navigate(this.dataset.path); };
});
// File list
var list = document.getElementById('fm-list');
var rows = '';
// ".." row for parent (unless at filesystem root)
if (d.parent && d.parent !== d.current) {
rows += '<div class="fm-row fm-dir fm-parent" data-path="' + self._escAttr(d.parent) + '" data-is-dir="1">' +
'<span class="fm-icon">📂</span>' +
'<span class="fm-name" style="font-weight:600">..</span>' +
'<span class="fm-size"></span>' +
'<span class="fm-date"></span>' +
'</div>';
}
if (d.items && d.items.length) {
d.items.forEach(function(item) {
if (item.broken) {
// Broken symlink or unreadable entry — show as disabled
rows += '<div class="fm-row fm-broken" data-path="">' +
'<span class="fm-icon">❓</span>' +
'<span class="fm-name" style="color:var(--error)" title="损坏的链接或无法访问">' + self._esc(item.name) + '</span>' +
'<span class="fm-size">—</span>' +
'<span class="fm-date">—</span></div>';
return;
}
var icon = item.is_dir ? '📁' : FilesModule._fileIcon(item.ext);
var sizeStr = item.is_dir ? '—' : FilesModule._fmtSize(item.size);
rows += '<div class="fm-row' + (item.is_dir ? ' fm-dir' : '') +
'" data-path="' + self._escAttr(item.path) +
'" data-is-dir="' + (item.is_dir?'1':'0') + '">' +
'<span class="fm-icon">' + icon + '</span>' +
'<span class="fm-name">' + self._esc(item.name) + '</span>' +
'<span class="fm-size">' + sizeStr + '</span>' +
'<span class="fm-date">' + (item.mtime_str || '') + '</span>' +
'</div>';
});
}
if (!rows) {
list.innerHTML = '<div style="text-align:center;color:var(--text-dim);padding:40px">空目录</div>';
} else {
list.innerHTML = rows;
}
// Row click handlers
list.querySelectorAll('.fm-row').forEach(function(row) {
row.onclick = function(e) {
var p = this.dataset.path;
var isDir = this.dataset.isDir === '1';
if (isDir) {
self.navigate(p);
} else if (self.pickerMode) {
self._pickResult(p);
}
};
row.oncontextmenu = function(e) {
e.preventDefault();
self._showContext(e, {
path: this.dataset.path,
isDir: this.dataset.isDir === '1',
name: this.querySelector('.fm-name').textContent
});
};
});
// Close context on outside click
document.onclick = function() {
document.getElementById('fm-context').style.display = 'none';
};
// Picker banner
if (self.pickerMode) {
var banner = document.getElementById('fm-picker-banner');
if (!banner) {
banner = document.createElement('div');
banner.id = 'fm-picker-banner';
banner.innerHTML = '<div style="display:flex;align-items:center;gap:8px;padding:8px 16px;background:var(--primary-container);color:var(--md-sys-color-on-primary-container);border-radius:var(--shape-xs);margin-bottom:8px;font-size:.85rem">' +
'<span>📂 选择' + (self.pickerMode === 'file' ? '文件' : '目录') + '模式</span>' +
'<button class="btn btn-sm btn-filled" onclick="FilesModule._pickResult(FilesModule.currentPath)" style="margin-left:auto">选择当前目录</button>' +
'<button class="btn btn-sm btn-outlined" onclick="FilesModule._cancelPicker()">取消</button>' +
'</div>';
list.parentNode.insertBefore(banner, list);
}
}
},
/* ── Picker ── */
_pickResult: function(path) {
if (window.opener) {
window.opener.postMessage(JSON.stringify({action:'fm-picked', path: path, callback_id: this.pickerCallback}), '*');
window.close();
} else if (window.parent !== window) {
window.parent.postMessage(JSON.stringify({action:'fm-picked', path: path, callback_id: this.pickerCallback}), '*');
} else {
navigator.clipboard?.writeText(path);
alert('已选择: ' + path + '\n(路径已复制到剪贴板)');
this.pickerMode = false;
this.refresh();
}
},
_cancelPicker: function() {
this.pickerMode = false;
this.refresh();
},
/* ── Create ── */
createFile: function() {
var self = this;
var name = prompt('新建文件名:');
if (!name) return;
this._post('./api/files/touch', {path: this.currentPath, name: name})
.then(function() { self.refresh(); })
.catch(function(e) { alert('创建失败: ' + e.message); });
},
createDir: function() {
var self = this;
var name = prompt('新建文件夹名:');
if (!name) return;
this._post('./api/files/mkdir', {path: this.currentPath, name: name})
.then(function() { self.refresh(); })
.catch(function(e) { alert('创建失败: ' + e.message); });
},
upload: function(files) {
var self = this;
if (!files || !files.length) return;
var form = new FormData();
for (var i = 0; i < files.length; i++) form.append('file', files[i]);
fetch('./api/files/upload?path=' + encodeURIComponent(self.currentPath), {method:'POST', credentials:'include', body:form})
.then(function(r) { return r.json(); })
.then(function(d) {
if (d.ok) self.refresh();
else alert('上传失败: ' + (d.error || 'unknown'));
}).catch(function(e) { alert('上传失败: ' + e.message); });
},
/* ── Editor ── */
openEditor: function(path, name) {
var self = this;
this._get('./api/files/read?path=' + encodeURIComponent(path))
.then(function(d) {
document.getElementById('fm-editor-title').textContent = '📝 ' + (name || d.name);
document.getElementById('fm-editor-textarea').value = d.content;
document.getElementById('fm-editor-textarea').dataset.path = path;
document.getElementById('fm-editor').style.display = 'flex';
}).catch(function(e) { alert('无法读取: ' + e.message); });
},
closeEditor: function() {
document.getElementById('fm-editor').style.display = 'none';
},
saveFile: function() {
var self = this;
var ta = document.getElementById('fm-editor-textarea');
this._post('./api/files/write', {path: ta.dataset.path, content: ta.value})
.then(function() { self.closeEditor(); self.refresh(); })
.catch(function(e) { alert('保存失败: ' + e.message); });
},
/* ── Context menu ── */
_showContext: function(e, item) {
this.contextTarget = item;
var ctx = document.getElementById('fm-context');
ctx.style.display = 'block';
ctx.style.left = e.pageX + 'px';
ctx.style.top = e.pageY + 'px';
var items = ctx.querySelectorAll('.fm-context-item');
items[2].style.display = item.isDir ? 'none' : 'block';
},
ctxDownload: function() {
var t = this.contextTarget;
if (t) window.open('./api/files/download?path=' + encodeURIComponent(t.path), '_blank');
document.getElementById('fm-context').style.display = 'none';
},
ctxRename: function() {
var self = this;
var t = this.contextTarget;
if (!t) return;
var nn = prompt('新名称:', t.name);
if (!nn || nn === t.name) return;
this._post('./api/files/rename', {path: t.path, new_name: nn})
.then(function() { self.refresh(); })
.catch(function(e) { alert('重命名失败: ' + e.message); });
document.getElementById('fm-context').style.display = 'none';
},
ctxEdit: function() {
var t = this.contextTarget;
if (t && !t.isDir) this.openEditor(t.path, t.name);
document.getElementById('fm-context').style.display = 'none';
},
ctxDelete: function() {
var t = this.contextTarget;
if (!t) return;
this.toDelete = t;
document.getElementById('fm-dialog-msg').textContent = '确认删除 "' + t.name + '"?此操作不可撤销。';
document.getElementById('fm-dialog').style.display = 'flex';
document.getElementById('fm-context').style.display = 'none';
},
confirmDelete: function() {
var self = this;
if (!this.toDelete) return;
this._post('./api/files/delete', {path: this.toDelete.path})
.then(function() { self.toDelete = null; self.closeDialog(); self.refresh(); })
.catch(function(e) { alert('删除失败: ' + e.message); });
},
closeDialog: function() {
document.getElementById('fm-dialog').style.display = 'none';
this.toDelete = null;
},
/* ── Helpers ── */
_buildBreadcrumbHTML: function(crumbs) {
if (!crumbs || !crumbs.length) return '';
var h = '';
for (var i = 0; i < crumbs.length; i++) {
var c = crumbs[i];
h += '<span class="fm-crumb" data-path="' + this._escAttr(c.path || '') + '">' + this._esc(c.label) + '</span>';
if (i < crumbs.length - 1) h += '<span class="fm-crumb-sep">▸</span>';
}
return h;
},
_esc: function(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
},
_escAttr: function(s) {
return String(s).replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
},
_fmtSize: function(bytes) {
if (bytes === null || bytes === undefined) return '—';
if (bytes < 1024) return bytes + ' B';
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
if (bytes < 1073741824) return (bytes / 1048576).toFixed(1) + ' MB';
return (bytes / 1073741824).toFixed(2) + ' GB';
},
_fileIcon: function(ext) {
var map = {
'.py':'🐍','.js':'📜','.ts':'📘','.html':'🌐','.css':'🎨',
'.json':'📋','.yaml':'⚙','.yml':'⚙','.md':'📝','.txt':'📄',
'.log':'📊','.sh':'💻','.bat':'💻','.xml':'📰','.toml':'⚙',
'.cfg':'⚙','.ini':'⚙','.conf':'⚙','.sql':'🗄','.csv':'📊',
'.zip':'📦','.tar':'📦','.gz':'📦','.7z':'📦',
'.png':'🖼','.jpg':'🖼','.jpeg':'🖼','.gif':'🖼','.svg':'🖼','.ico':'🖼',
'.mp3':'🎵','.wav':'🎵','.ogg':'🎵','.mp4':'🎬','.avi':'🎬',
'.pdf':'📕','.doc':'📃','.docx':'📃','.xls':'📊','.xlsx':'📊',
'.c':'⚡','.h':'⚡','.cpp':'⚡','.java':'☕','.rs':'🦀','.go':'🔵',
};
return map[ext] || '📄';
},
destroy: function() {
document.getElementById('fm-context').style.display = 'none';
document.getElementById('fm-dialog').style.display = 'none';
document.getElementById('fm-editor').style.display = 'none';
}
};
+1 -1
View File
@@ -1,6 +1,6 @@
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:1rem;"> <div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:1rem;">
<h2 style="color:var(--primary); display:flex; align-items:center; gap:8px"> <h2 style="color:var(--primary); display:flex; align-items:center; gap:8px">
<svg width="22" height="22" viewBox="0 0 24 24"><path fill="var(--primary)" d="M20.5 11H19V7c0-1.1-.9-2-2-2h-4V3.5C13 2.12 11.88 1 10.5 1S8 2.12 8 3.5V5H4c-1.1 0-1.99.9-1.99 2v3.8H3.5c1.49 0 2.7 1.21 2.7 2.7s-1.21 2.7-2.7 2.7H2V20c0 1.1.9 2 2 2h3.8v-1.5c0-1.49 1.21-2.7 2.7-2.7 1.49 0 2.7 1.21 2.7 2.7V22H17c1.1 0 2-.9 2-2v-4h1.5c1.38 0 2.5-1.12 2.5-2.5S21.88 11 20.5 11z"/></svg> <svg width="22" height="22" viewBox="0 0 24 24"><path fill="currentColor" d="M20.5 11H19V7c0-1.1-.9-2-2-2h-4V3.5C13 2.12 11.88 1 10.5 1S8 2.12 8 3.5V5H4c-1.1 0-1.99.9-1.99 2v3.8H3.5c1.49 0 2.7 1.21 2.7 2.7s-1.21 2.7-2.7 2.7H2V20c0 1.1.9 2 2 2h3.8v-1.5c0-1.49 1.21-2.7 2.7-2.7 1.49 0 2.7 1.21 2.7 2.7V22H17c1.1 0 2-.9 2-2v-4h1.5c1.38 0 2.5-1.12 2.5-2.5S21.88 11 20.5 11z"/></svg>
插件管理 插件管理
</h2> </h2>
<button class="btn btn-sm btn-tonal" onclick="window.PluginsModule.refresh()"> <button class="btn btn-sm btn-tonal" onclick="window.PluginsModule.refresh()">