diff --git a/config/framework/permission_rules.yaml b/config/framework/permission_rules.yaml index 69382c3..82dcfa3 100644 --- a/config/framework/permission_rules.yaml +++ b/config/framework/permission_rules.yaml @@ -13,3 +13,11 @@ admin_permissions: - "framework.*" - "plugin.*" - "service.*" + - "filemanager.*" + +# 文件管理权限 (v0.6.0 新增) +filemanager_permissions: + - "filemanager.access" # 浏览/读取文件 + - "filemanager.write" # 创建/删除/重命名/上传/写入 + - "filemanager.picker" # 插件调用文件选择器接口 + - "filemanager.*" # 完整文件管理权限 diff --git a/config/plugins/commands.yaml b/config/plugins/commands.yaml index 33ba9e0..7d5e557 100644 --- a/config/plugins/commands.yaml +++ b/config/plugins/commands.yaml @@ -92,7 +92,7 @@ commands: permissions: - framework.command.test source: internal -last_updated: 283744.334290456 +last_updated: 291543.087298158 plugin_commands: example_plugin: echo: *id001 diff --git a/config/services/network_routes.yaml b/config/services/network_routes.yaml index 6a9f5a0..4292fc5 100644 --- a/config/services/network_routes.yaml +++ b/config/services/network_routes.yaml @@ -1,5 +1,5 @@ http_port: 4200 -last_updated: 283744.338585717 +last_updated: 291543.090646752 plugin_routes: example_plugin: - methods: diff --git a/docs/SenSu 插件开发详细指南.md b/docs/SenSu 插件开发详细指南.md index ce5bc3e..7019466 100644 --- a/docs/SenSu 插件开发详细指南.md +++ b/docs/SenSu 插件开发详细指南.md @@ -26,7 +26,8 @@ - [7.2 开发中检查清单](#72-开发中检查清单) - [7.3 测试检查清单](#73-测试检查清单) - [7.4 发布检查清单](#74-发布检查清单) - - [八、总结](#八总结) + - [八、文件管理器集成 (v0.6.0)](#八文件管理器集成-v060-新增) + - [九、总结](#九总结) - [8.1 成功插件的特点](#81-成功插件的特点) - [8.2 持续改进](#82-持续改进) - [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. **可靠性**:稳定运行,正确处理各种异常情况 2. **易用性**:简洁的API,清晰的文档,直观的配置 diff --git a/services/web_panel/manager.py b/services/web_panel/manager.py index 3280a44..6091161 100644 --- a/services/web_panel/manager.py +++ b/services/web_panel/manager.py @@ -5,7 +5,7 @@ import os import logging from pathlib import Path 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__) @@ -66,6 +66,7 @@ class WebPanelManager: projects.setup_project_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) + files.setup_file_routes(app, self.sm, self.base_path) # 注册日志广播 ls = self.sm.get_service("log") diff --git a/services/web_panel/routes/files.py b/services/web_panel/routes/files.py new file mode 100644 index 0000000..0f12ede --- /dev/null +++ b/services/web_panel/routes/files.py @@ -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) diff --git a/static/web_panel/css/style.css b/static/web_panel/css/style.css index 68cbc8d..5ede621 100644 --- a/static/web_panel/css/style.css +++ b/static/web_panel/css/style.css @@ -64,6 +64,7 @@ /* ── Reset ── */ *,*::before,*::after{box-sizing:border-box;margin:0;padding:0} +svg{fill:currentColor} body{ background:var(--bg);color:var(--text); 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.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 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} .toggle-sidebar{ 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 ── */ .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} +} diff --git a/static/web_panel/home.html b/static/web_panel/home.html index 39e9244..0572fb4 100644 --- a/static/web_panel/home.html +++ b/static/web_panel/home.html @@ -42,6 +42,10 @@ 反向代理 + + + 文件管理 +
diff --git a/static/web_panel/pages/dashboard.html b/static/web_panel/pages/dashboard.html index 88263a4..7d1376b 100644 --- a/static/web_panel/pages/dashboard.html +++ b/static/web_panel/pages/dashboard.html @@ -74,7 +74,7 @@