diff --git a/backend/docker_client.py b/backend/docker_client.py index 5f6e48a..d5a70df 100644 --- a/backend/docker_client.py +++ b/backend/docker_client.py @@ -433,3 +433,98 @@ def remove_image(image_id: str, force: bool = False) -> dict: return {"success": False, "error": result.stderr} except Exception as e: return {"success": False, "error": str(e)} + + +# ========== 文件浏览 ========== + +def list_directory(path: str) -> dict: + """列出目录内容""" + import os + try: + entries = os.listdir(path) + items = [] + for name in entries: + full_path = os.path.join(path, name) + try: + stat = os.stat(full_path) + is_dir = os.path.isdir(full_path) + items.append({ + "name": name, + "path": full_path, + "is_dir": is_dir, + "size": stat.st_size if not is_dir else 0, + "modified": stat.st_mtime, + }) + except Exception: + continue + # 目录在前,文件在后,按名称排序 + items.sort(key=lambda x: (not x["is_dir"], x["name"].lower())) + return {"success": True, "path": path, "items": items} + except PermissionError: + return {"success": False, "error": "权限不足"} + except FileNotFoundError: + return {"success": False, "error": "目录不存在"} + except Exception as e: + return {"success": False, "error": str(e)} + + +def read_file(path: str, encoding: str = "utf-8") -> dict: + """读取文件内容""" + import aiofiles + import asyncio + async def _read(): + async with aiofiles.open(path, "r", encoding=encoding) as f: + return await f.read() + try: + content = asyncio.run(_read()) + return {"success": True, "content": content, "path": path} + except UnicodeDecodeError: + # 尝试二进制读取 + try: + with open(path, "rb") as f: + content = f.read() + return {"success": False, "error": "文件为二进制格式,无法文本预览", "binary": True, "path": path} + except Exception as e: + return {"success": False, "error": str(e)} + except FileNotFoundError: + return {"success": False, "error": "文件不存在"} + except Exception as e: + return {"success": False, "error": str(e)} + + +def write_file(path: str, content: str, encoding: str = "utf-8") -> dict: + """写入文件内容""" + import aiofiles + import asyncio + async def _write(): + async with aiofiles.open(path, "w", encoding=encoding) as f: + await f.write(content) + try: + asyncio.run(_write()) + return {"success": True, "path": path} + except PermissionError: + return {"success": False, "error": "权限不足"} + except Exception as e: + return {"success": False, "error": str(e)} + + +def get_container_mounts() -> list[dict]: + """获取容器的挂载点信息""" + containers = list_containers(all=True) + mounts = [] + for c in containers: + try: + info = _do_request("GET", f"/containers/{c['id']}/json") + mount_info = info.get("Mounts") or [] + for m in mount_info: + mounts.append({ + "container_id": c["id"], + "container_name": c["name"], + "source": m.get("Source", ""), + "destination": m.get("Destination", ""), + "mode": m.get("Mode", ""), + "rw": m.get("RW", False), + }) + except Exception: + continue + return mounts diff --git a/backend/main.py b/backend/main.py index b2ac2ee..0169ca1 100644 --- a/backend/main.py +++ b/backend/main.py @@ -14,6 +14,7 @@ from docker_client import ( compose_restart, read_compose_file, write_compose_file, discover_compose_files, get_system_info, list_images, search_images, pull_image, remove_image, DEFAULT_MIRRORS, + list_directory, read_file, write_file, get_container_mounts, ) app = FastAPI(title="Docker Web Manager", version="1.0.0") @@ -170,6 +171,31 @@ def api_get_mirrors(): return DEFAULT_MIRRORS +# ========== 文件浏览 ========== +@app.get("/api/browser/ls") +def api_list_dir(path: str): + """列出目录内容""" + return list_directory(path) + + +@app.get("/api/browser/cat") +def api_read_file(path: str, encoding: str = "utf-8"): + """读取文件内容""" + return read_file(path, encoding=encoding) + + +@app.post("/api/browser/save") +def api_write_file(path: str, content: str, encoding: str = "utf-8"): + """保存文件内容""" + return write_file(path, content, encoding=encoding) + + +@app.get("/api/browser/mounts") +def api_container_mounts(): + """获取容器挂载点""" + return get_container_mounts() + + # ========== 前端静态文件 ========== frontend_path = os.environ.get("FRONTEND_DIST", "/app/frontend/dist") diff --git a/frontend/dist/index.html b/frontend/dist/index.html index 1a1fa77..758bdc7 100644 --- a/frontend/dist/index.html +++ b/frontend/dist/index.html @@ -119,6 +119,28 @@ .sr-stats{font-size:11px;color:var(--text-dim);margin-top:4px} .official-badge{background:rgba(99,102,241,.15);color:var(--accent);padding:2px 6px;border-radius:4px;font-size:11px} .editor-toolbar{display:flex;justify-content:space-between;align-items:center;padding:12px 16px;background:var(--card);border-bottom:1px solid var(--border);flex-wrap:wrap;gap:10px} + /* Browser View */ + .browser-toolbar{display:flex;gap:10px;margin-bottom:16px;flex-wrap:wrap;align-items:center} + .browser-toolbar input{flex:1;min-width:200px;padding:10px 14px;background:var(--card);border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:14px;outline:none} + .browser-toolbar input:focus{border-color:var(--accent} + .path-bar{display:flex;align-items:center;gap:4px;padding:8px 12px;background:var(--card);border:1px solid var(--border);border-radius:8px;margin-bottom:16px;font-size:13px;overflow-x:auto;white-space:nowrap} + .path-bar .path-sep{color:var(--text-dim)} + .path-segment{padding:2px 6px;border-radius:4px;cursor:pointer;color:var(--text-dim)} + .path-segment:hover{background:var(--border);color:var(--text)} + .file-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px} + .file-item{display:flex;align-items:center;gap:12px;padding:12px 16px;background:var(--card);border:1px solid var(--border);border-radius:10px;cursor:pointer;transition:all .15s} + .file-item:hover{border-color:var(--accent)} + .file-item .fi-icon{font-size:24px} + .file-item .fi-info{flex:1;min-width:0} + .file-item .fi-name{font-size:14px;font-weight:500;word-break:break-all} + .file-item .fi-meta{font-size:11px;color:var(--text-dim);margin-top:2px} + .file-item.folder{background:rgba(99,102,241,.08);border-color:rgba(99,102,241,.2)} + .mounts-list{display:flex;flex-direction:column;gap:8px;margin-bottom:20px} + .mount-item{display:flex;justify-content:space-between;align-items:center;padding:10px 14px;background:var(--card);border:1px solid var(--border);border-radius:8px;font-size:13px} + .mount-item .mi-container{font-weight:500} + .mount-item .mi-path{color:var(--text-dim);font-family:monospace;font-size:12px;margin-top:2px} + .file-editor-modal .modal{height:80vh;display:flex;flex-direction:column} + .file-editor-modal textarea{flex:1;min-height:400px} @@ -131,6 +153,7 @@ @@ -263,6 +286,70 @@ + +
+ + + +
+

📦 容器挂载点

+
暂无挂载点
+
+
+
+
{{m.container_name}}
+
{{m.source}} → {{m.destination}} {{m.rw?'':'🔒'}}
+
+ +
+
+
+ + +
+ + +
+ + +
+ + / + {{seg||'根目录'}} + +
+ + +
加载中...
+
{{dirError}}
+
目录为空
+
+ +
+
⬆️
+
+
上级目录
+
+
+ +
+
📁
+
+
{{item.name}}
+
目录
+
+
+ +
+
{{getFileIcon(item.name)}}
+
+
{{item.name}}
+
{{fmtBytes(item.size)}}
+
+
+
+
+
@@ -349,6 +436,25 @@
+ + +