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:
@@ -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.*" # 完整文件管理权限
|
||||||
|
|||||||
@@ -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,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
@@ -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,清晰的文档,直观的配置
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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()">
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||||
|
},
|
||||||
|
_escAttr: function(s) {
|
||||||
|
return String(s).replace(/&/g,'&').replace(/"/g,'"').replace(/</g,'<').replace(/>/g,'>');
|
||||||
|
},
|
||||||
|
_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,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()">
|
||||||
|
|||||||
Reference in New Issue
Block a user