feat: 添加文件浏览和编辑功能,支持容器挂载点管理

This commit is contained in:
OpenClaw Bot
2026-03-23 18:00:06 +08:00
parent 862d86f9d0
commit 558783e773
3 changed files with 244 additions and 3 deletions

View File

@@ -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

View File

@@ -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")

View File

@@ -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}
</style>
</head>
<body>
@@ -131,6 +153,7 @@
<nav>
<div :class="['nav-item',{active:view==='containers'}]" @click="view='containers'"><span class="icon">📦</span><span>容器</span></div>
<div :class="['nav-item',{active:view==='images'}]" @click="view='images'"><span class="icon">💾</span><span>镜像</span></div>
<div :class="['nav-item',{active:view==='browser'}]" @click="view='browser'"><span class="icon">📂</span><span>文件</span></div>
<div :class="['nav-item',{active:view==='compose'}]" @click="view='compose'"><span class="icon">📁</span><span>Compose</span></div>
<div :class="['nav-item',{active:view==='logs'}]" @click="view='logs'"><span class="icon">📜</span><span>日志</span></div>
</nav>
@@ -263,6 +286,70 @@
</div>
</div>
<!-- Browser -->
<div v-if="view==='browser'">
<div class="page-header"><h1>文件浏览</h1><p>浏览和编辑服务器上的文件</p></div>
<!-- 快速路径:容器挂载点 -->
<div class="images-section">
<h2>📦 容器挂载点</h2>
<div v-if="mounts.length===0" class="empty">暂无挂载点</div>
<div v-else class="mounts-list">
<div v-for="m in mounts" :key="m.container_id+m.destination" class="mount-item">
<div>
<div class="mi-container">{{m.container_name}}</div>
<div class="mi-path">{{m.source}} → {{m.destination}} {{m.rw?'':'🔒'}}</div>
</div>
<button class="btn btn-primary btn-sm" @click="browsePath=m.source;loadDir()">📂 打开</button>
</div>
</div>
</div>
<!-- 手动输入路径 -->
<div class="browser-toolbar">
<input v-model="browsePath" @keyup.enter="loadDir" placeholder="输入完整路径,如 /data/compose">
<button class="btn btn-primary" @click="loadDir">📂 浏览</button>
</div>
<!-- 路径导航 -->
<div v-if="currentPath" class="path-bar">
<span v-for="(seg,i) in pathSegments" :key="i">
<span v-if="i>0" class="path-sep">/</span>
<span class="path-segment" @click="navigateTo(i)">{{seg||'根目录'}}</span>
</span>
</div>
<!-- 文件列表 -->
<div v-if="dirLoading" class="loading">加载中...</div>
<div v-else-if="dirError" class="empty" style="color:var(--red)">{{dirError}}</div>
<div v-else-if="dirItems.length===0" class="empty">目录为空</div>
<div v-else class="file-grid">
<!-- 返回上级目录 -->
<div v-if="currentPath && currentPath!=='/'" class="file-item folder" @click="goUp">
<div class="fi-icon">⬆️</div>
<div class="fi-info">
<div class="fi-name">上级目录</div>
</div>
</div>
<!-- 目录 -->
<div v-for="item in dirItems.filter(i=>i.is_dir)" :key="item.path" class="file-item folder" @click="browsePath=item.path;loadDir()">
<div class="fi-icon">📁</div>
<div class="fi-info">
<div class="fi-name">{{item.name}}</div>
<div class="fi-meta">目录</div>
</div>
</div>
<!-- 文件 -->
<div v-for="item in dirItems.filter(i=>!i.is_dir)" :key="item.path" class="file-item" @click="openFile(item)">
<div class="fi-icon">{{getFileIcon(item.name)}}</div>
<div class="fi-info">
<div class="fi-name">{{item.name}}</div>
<div class="fi-meta">{{fmtBytes(item.size)}}</div>
</div>
</div>
</div>
</div>
<!-- Logs -->
<div v-if="view==='logs'">
<div class="page-header"><h1>日志查看</h1><p>实时查看容器日志</p></div>
@@ -349,6 +436,25 @@
</div>
</div>
</div>
<!-- File Editor Modal -->
<div v-if="m.fileEditor" class="modal-overlay file-editor-modal" @click.self="m.fileEditor=false">
<div class="modal" style="max-width:90vw;">
<div class="modal-header">
<h2>✏️ {{m.fileName}} <span style="font-size:12px;color:var(--text-dim);font-weight:normal">{{m.filePath}}</span></h2>
<button class="close-btn" @click="m.fileEditor=false">×</button>
</div>
<div class="editor-toolbar">
<span style="font-size:13px;color:var(--text-dim);">{{m.filePath}}</span>
<div style="display:flex;gap:8px;">
<button class="btn btn-ghost btn-sm" @click="m.fileEditor=false">取消</button>
<button class="btn btn-primary btn-sm" @click="saveFile" :disabled="fileSaving">💾 保存</button>
</div>
</div>
<textarea v-if="!m.fileBinary" id="file-editor-content" v-model="m.fileContent" spellcheck="false" style="flex:1;min-height:400px;width:100%;background:#0d0f14;color:#e4e4e7;border:none;padding:16px;font-family:'Fira Code',monospace;font-size:14px;line-height:1.7;resize:vertical;outline:none;"></textarea>
<div v-else style="padding:20px;text-align:center;color:var(--text-dim);">{{m.fileBinaryMsg}}</div>
</div>
</div>
</div>
<script>
@@ -359,6 +465,10 @@ const logTail=ref(100);
const localImages=ref([]),imagesLoading=ref(false),mirrors=ref([]);
const searchQuery=ref(''),searchResults=ref([]),searching=ref(false);
const pullImageName=ref(''),pullMirror=ref(''),pulling=ref(false),pullStatus=ref(null);
// Browser
const browsePath=ref('/data/compose'),currentPath=ref(''),dirItems=ref([]),dirLoading=ref(false),dirError=ref('');
const mounts=ref([]);
const fileSaving=ref(false);
createApp({
setup(){
const view=ref('containers'),loading=ref(false),containers=ref([]);
@@ -367,7 +477,7 @@ createApp({
const composeLoading=ref(false),discoveredFiles=ref([]),discoverPath=ref('/data/compose');
const logContainerId=ref(''),logContent=ref(''),logAutoRefresh=ref(false),logContentEl=ref(null);
const toast=ref({show:false,type:'info',msg:''}),saving=ref(false),forceRemove=ref(false);
const m=ref({logs:false,logsName:'',logsId:'',logsContent:'',logTail:100,logTimestamps:false,stats:false,statsName:'',statsId:'',statsData:null,editor:false,editorPath:'',editorContent:'',confirmRemove:null});
const m=ref({logs:false,logsName:'',logsId:'',logsContent:'',logTail:100,logTimestamps:false,stats:false,statsName:'',statsId:'',statsData:null,editor:false,editorPath:'',editorContent:'',confirmRemove:null,fileEditor:false,filePath:'',fileName:'',fileContent:'',fileBinary:false,fileBinaryMsg:''});
let refreshTimer=null,logTimer=null;
const runningCount=computed(()=>containers.value.filter(c=>c.state==='running').length);
const logContainerName=computed(()=>{const c=containers.value.find(x=>x.id===logContainerId.value);return c?c.name:'日志';});
@@ -424,7 +534,17 @@ createApp({
function quickPull(name){pullImageName.value=name;doPullImage();}
async function doRemoveImage(id){if(!confirm('确定要删除这个镜像吗?'))return;try{const r=await api.delete(`/images/${id}`);if(r.data.success){showToast('删除成功','success');await loadImages();}else{showToast(r.data.error||'删除失败','error');}}catch(e){showToast('删除失败: '+e.message,'error');}}
watch(()=>view.value,(v)=>{if(v==='images'){loadImages();loadMirrors();}});
// Browser
async function loadMounts(){try{const r=await api.get('/browser/mounts');mounts.value=r.data;}catch(e){console.error(e);}}
async function loadDir(){if(!browsePath.value){showToast('请输入路径','error');return;}dirLoading.value=true;dirError.value='';dirItems.value=[];currentPath.value='';try{const r=await api.get('/browser/ls',{params:{path:browsePath.value}});if(r.data.success){currentPath.value=browsePath.value;dirItems.value=r.data.items||[];}else{dirError.value=r.data.error||'加载失败';}}catch(e){dirError.value='加载失败: '+e.message;}dirLoading.value=false;}
function navigateTo(idx){const parts=pathSegments.value;const newPath=parts.slice(0,idx+1).join('/');browsePath.value=newPath||'/';loadDir();}
function goUp(){if(!currentPath.value)return;const parts=currentPath.value.split('/').filter(p=>p);parts.pop();browsePath.value='/'+parts.join('/');loadDir();}
const pathSegments=computed(()=>currentPath.value?currentPath.value.split('/').filter(p=>p):[]);
function getFileIcon(name){const ext=name.split('.').pop().toLowerCase();const icons={'yml':'📄','yaml':'📄','json':'📋','txt':'📝','md':'📝','sh':'📜','py':'🐍','js':'📜','html':'🌐','css':'🎨','conf':'⚙️','cfg':'⚙️','ini':'⚙️','log':'📜','env':'🔐','gitignore':'🔐','dockerfile':'🐳','git':'📦'};return icons[ext]||'📄';}
async function openFile(item){m.value.filePath=item.path;m.value.fileName=item.name;m.value.fileContent='加载中...';m.value.fileBinary=false;showToast('正在加载文件...','info',1000);try{const r=await api.get('/browser/cat',{params:{path:item.path}});if(r.data.success){m.value.fileContent=r.data.content;m.value.fileBinary=false;}else{if(r.data.binary){m.value.fileBinary=true;m.value.fileBinaryMsg=r.data.error;}else{m.value.fileContent='# '+r.data.error;m.value.fileBinary=false;}}}catch(e){m.value.fileContent='# 加载失败: '+e.message;}m.value.fileEditor=true;}
async function saveFile(){fileSaving.value=true;try{const r=await api.post('/browser/save',null,{params:{path:m.value.filePath},data:m.value.fileContent,headers:{'Content-Type':'text/plain'}});if(r.data.success){showToast('保存成功','success');m.value.fileEditor=false;}else{showToast(r.data.error||'保存失败','error');}}catch(e){showToast('保存失败: '+e.message,'error');}fileSaving.value=false;}
watch(()=>view.value,(v)=>{if(v==='images'){loadImages();loadMirrors();}if(v==='browser'){loadMounts();if(!browsePath.value)browsePath.value='/data/compose';loadDir();}});
function fmtPorts(ports){if(!ports||Object.keys(ports).length===0)return'无端口映射';return Object.entries(ports).map(([k,v])=>{if(Array.isArray(v)&&v.length>0)return`${k}->${v.map(x=>x.HostPort).join(',')}`;return k;}).join(', ');}
function fmtBytes(b){if(!b)return'0 B';const u=['B','KB','MB','GB','TB'];let i=0;while(b>=1024&&i<u.length-1){b/=1024;i++;}return b.toFixed(1)+' '+u[i];}
@@ -432,7 +552,7 @@ createApp({
onMounted(()=>{loadAll();startRefresh();});
onUnmounted(()=>{clearInterval(refreshTimer);clearInterval(logTimer);});
return{view,loading,containers,sysInfo,actionLoading,autoRefresh,composeLoading,discoveredFiles,discoverPath,logContainerId,logContent,logAutoRefresh,logContentEl,toast,m,saving,forceRemove,runningCount,logContainerName,logTail,doAction,confirmRemove,doRemove,openLogs,loadLogs,loadModalLogs,openStats,discoverCompose,composeAction,editCompose,saveCompose,composeEditorAction,fmtPorts,fmtBytes,fmtMem,localImages,imagesLoading,mirrors,searchQuery,searchResults,searching,pullImageName,pullMirror,pulling,pullStatus,doSearchImages,doPullImage,quickPull,doRemoveImage};
return{view,loading,containers,sysInfo,actionLoading,autoRefresh,composeLoading,discoveredFiles,discoverPath,logContainerId,logContent,logAutoRefresh,logContentEl,toast,m,saving,forceRemove,runningCount,logContainerName,logTail,doAction,confirmRemove,doRemove,openLogs,loadLogs,loadModalLogs,openStats,discoverCompose,composeAction,editCompose,saveCompose,composeEditorAction,fmtPorts,fmtBytes,fmtMem,localImages,imagesLoading,mirrors,searchQuery,searchResults,searching,pullImageName,pullMirror,pulling,pullStatus,doSearchImages,doPullImage,quickPull,doRemoveImage,browsePath,currentPath,dirItems,dirLoading,dirError,mounts,pathSegments,navigateTo,goUp,getFileIcon,openFile,saveFile,loadDir,loadMounts};
}
}).mount('#app');
</script>