feat: 插件管理 — 导入安装 + 统计面板
新增 API: - GET /api/plugins/stats — 统计 (总计/已启用/已停用/安装失败) - POST /api/plugins/import — ZIP上传 或 Git clone 安装 - 自动 pip install -r requirements.txt (子进程隔离) - 自动加载插件 (isolated 模式, 崩溃不影响框架) - 安装失败记录到 _IMPORT_FAILS 前端: - 横版统计卡片 (总数/启用/停用/失败) - ZIP 上传按钮 + Git URL 输入 - 安装状态实时反馈 安全: - 所有端点 panel_auth 保护 - 子进程安装 (pip/git), 超时120s - 仅接受 .zip 文件 - 目录冲突检测 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -350,5 +350,93 @@
|
||||
"framework.command.execute"
|
||||
],
|
||||
"timestamp": 28375.089465424
|
||||
},
|
||||
"47fa9916": {
|
||||
"plugin_name": "example_plugin",
|
||||
"permissions": [
|
||||
"plugin.example.read",
|
||||
"plugin.example.write",
|
||||
"plugin.example.execute",
|
||||
"framework.event.subscribe",
|
||||
"framework.command.execute"
|
||||
],
|
||||
"timestamp": 28527.781918439
|
||||
},
|
||||
"6b69ef4a": {
|
||||
"plugin_name": "sentinel",
|
||||
"permissions": [
|
||||
"plugin.sentinel.read",
|
||||
"plugin.sentinel.write",
|
||||
"plugin.network.access",
|
||||
"framework.event.subscribe",
|
||||
"framework.command.execute"
|
||||
],
|
||||
"timestamp": 28527.793469637
|
||||
},
|
||||
"5521ab41": {
|
||||
"plugin_name": "example_plugin",
|
||||
"permissions": [
|
||||
"plugin.example.read",
|
||||
"plugin.example.write",
|
||||
"plugin.example.execute",
|
||||
"framework.event.subscribe",
|
||||
"framework.command.execute"
|
||||
],
|
||||
"timestamp": 29038.232790171
|
||||
},
|
||||
"7da234f7": {
|
||||
"plugin_name": "sentinel",
|
||||
"permissions": [
|
||||
"plugin.sentinel.read",
|
||||
"plugin.sentinel.write",
|
||||
"plugin.network.access",
|
||||
"framework.event.subscribe",
|
||||
"framework.command.execute"
|
||||
],
|
||||
"timestamp": 29038.243362099
|
||||
},
|
||||
"08edc191": {
|
||||
"plugin_name": "example_plugin",
|
||||
"permissions": [
|
||||
"plugin.example.read",
|
||||
"plugin.example.write",
|
||||
"plugin.example.execute",
|
||||
"framework.event.subscribe",
|
||||
"framework.command.execute"
|
||||
],
|
||||
"timestamp": 29064.774713547
|
||||
},
|
||||
"afbc57ca": {
|
||||
"plugin_name": "sentinel",
|
||||
"permissions": [
|
||||
"plugin.sentinel.read",
|
||||
"plugin.sentinel.write",
|
||||
"plugin.network.access",
|
||||
"framework.event.subscribe",
|
||||
"framework.command.execute"
|
||||
],
|
||||
"timestamp": 29064.880357453
|
||||
},
|
||||
"8b057650": {
|
||||
"plugin_name": "example_plugin",
|
||||
"permissions": [
|
||||
"plugin.example.read",
|
||||
"plugin.example.write",
|
||||
"plugin.example.execute",
|
||||
"framework.event.subscribe",
|
||||
"framework.command.execute"
|
||||
],
|
||||
"timestamp": 29119.623393265
|
||||
},
|
||||
"46de40e2": {
|
||||
"plugin_name": "sentinel",
|
||||
"permissions": [
|
||||
"plugin.sentinel.read",
|
||||
"plugin.sentinel.write",
|
||||
"plugin.network.access",
|
||||
"framework.event.subscribe",
|
||||
"framework.command.execute"
|
||||
],
|
||||
"timestamp": 29119.62760988
|
||||
}
|
||||
}
|
||||
@@ -101,7 +101,7 @@ commands:
|
||||
permissions:
|
||||
- framework.command.test
|
||||
source: internal
|
||||
last_updated: 28375.06993407
|
||||
last_updated: 29119.596258526
|
||||
plugin_commands:
|
||||
example_plugin:
|
||||
echo: *id001
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
http_port: 4200
|
||||
last_updated: 28375.086802768
|
||||
last_updated: 29119.62443889
|
||||
plugin_routes:
|
||||
example_plugin:
|
||||
- methods:
|
||||
|
||||
@@ -1,53 +1,244 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
import logging
|
||||
import logging, os, shutil, subprocess, tempfile, zipfile, asyncio, json
|
||||
from pathlib import Path
|
||||
from aiohttp import web
|
||||
from ..utils.auth import panel_auth
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def setup_routes(app, prefix=''):
|
||||
app.router.add_get(f'{prefix}/api/plugins', panel_auth(list_plugins))
|
||||
app.router.add_get(f'{prefix}/api/plugin-pages', panel_auth(list_plugin_web_pages))
|
||||
app.router.add_post(f'{prefix}/api/plugins/{{name}}/{{action}}', panel_auth(manage_plugin))
|
||||
app.router.add_get(f'{prefix}/api/plugins/{{name}}/perms', panel_auth(get_perms))
|
||||
app.router.add_post(f'{prefix}/api/plugins/{{name}}/perms', panel_auth(set_perms))
|
||||
PLUGINS_DIR = Path("plugins")
|
||||
_IMPORT_FAILS: dict[str, str] = {} # plugin_name → error message
|
||||
|
||||
|
||||
def _run_in_subprocess(cmd: list, cwd: str, timeout: int = 120) -> tuple[bool, str]:
|
||||
"""在子进程中执行命令,不阻塞主事件循环"""
|
||||
try:
|
||||
r = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, timeout=timeout)
|
||||
return r.returncode == 0, r.stdout[-500:] + "\n" + r.stderr[-500:]
|
||||
except subprocess.TimeoutExpired:
|
||||
return False, "安装超时 (>120s)"
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
|
||||
def _install_plugin_deps(plugin_dir: str) -> tuple[bool, str]:
|
||||
"""安装插件依赖 (requirements.txt)"""
|
||||
req_file = os.path.join(plugin_dir, "requirements.txt")
|
||||
if not os.path.exists(req_file):
|
||||
return True, ""
|
||||
return _run_in_subprocess(
|
||||
["pip", "install", "-r", "requirements.txt", "--break-system-packages"],
|
||||
plugin_dir,
|
||||
)
|
||||
|
||||
|
||||
async def _async_install_deps(plugin_dir: str) -> tuple[bool, str]:
|
||||
"""异步包装器 — 在线程池中运行 pip install"""
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(None, _install_plugin_deps, plugin_dir)
|
||||
|
||||
|
||||
def setup_routes(app, prefix=""):
|
||||
app.router.add_get(f"{prefix}/api/plugins", panel_auth(list_plugins))
|
||||
app.router.add_get(f"{prefix}/api/plugin-pages", panel_auth(list_plugin_web_pages))
|
||||
app.router.add_get(f"{prefix}/api/plugins/stats", panel_auth(plugin_stats))
|
||||
app.router.add_post(f"{prefix}/api/plugins/{{name}}/{{action}}", panel_auth(manage_plugin))
|
||||
app.router.add_post(f"{prefix}/api/plugins/import", panel_auth(import_plugin))
|
||||
app.router.add_get(f"{prefix}/api/plugins/{{name}}/perms", panel_auth(get_perms))
|
||||
app.router.add_post(f"{prefix}/api/plugins/{{name}}/perms", panel_auth(set_perms))
|
||||
|
||||
|
||||
# ── 统计 ──
|
||||
|
||||
async def plugin_stats(req):
|
||||
sm = req.app.get("service_manager")
|
||||
ps = sm.get_service("plugin") if sm else None
|
||||
total = len(ps.plugin_info) if ps else 0
|
||||
loaded = len(ps.plugins) if ps else 0
|
||||
disabled = total - loaded
|
||||
failed = len(_IMPORT_FAILS)
|
||||
return web.json_response({
|
||||
"total": total,
|
||||
"loaded": loaded,
|
||||
"disabled": max(0, disabled),
|
||||
"failed": failed,
|
||||
"fail_details": _IMPORT_FAILS,
|
||||
})
|
||||
|
||||
|
||||
# ── 导入 ──
|
||||
|
||||
async def import_plugin(req):
|
||||
"""导入插件: multipart zip 或 JSON {url: git地址}"""
|
||||
sm = req.app.get("service_manager")
|
||||
ps = sm.get_service("plugin") if sm else None
|
||||
if not ps:
|
||||
return web.json_response({"ok": False, "error": "Plugin Service 未就绪"}, status=503)
|
||||
|
||||
content_type = req.content_type or ""
|
||||
|
||||
# ── Git clone 方式 ──
|
||||
if "application/json" in content_type:
|
||||
try:
|
||||
data = await req.json()
|
||||
git_url = data.get("url", "").strip()
|
||||
if not git_url:
|
||||
return web.json_response({"ok": False, "error": "Git URL 不能为空"}, status=400)
|
||||
|
||||
# 提取 repo 名作为插件名
|
||||
name = git_url.rstrip("/").split("/")[-1]
|
||||
if name.endswith(".git"):
|
||||
name = name[:-4]
|
||||
target = PLUGINS_DIR / name
|
||||
if target.exists():
|
||||
return web.json_response({"ok": False, "error": f"插件目录已存在: {name}"}, status=409)
|
||||
|
||||
ok, out = _run_in_subprocess(
|
||||
["git", "clone", git_url, str(target)], str(PLUGINS_DIR)
|
||||
)
|
||||
if not ok:
|
||||
_IMPORT_FAILS[name] = f"git clone 失败: {out[-200:]}"
|
||||
return web.json_response({"ok": False, "error": "Git clone 失败", "detail": out[-300:]}, status=500)
|
||||
|
||||
# 安装依赖
|
||||
dep_ok, dep_out = await _async_install_deps(str(target))
|
||||
if not dep_ok:
|
||||
logger.warning(f"依赖安装部分失败 {name}: {dep_out[-200:]}")
|
||||
|
||||
# 加载插件
|
||||
loaded = await _safe_load_plugin(ps, name)
|
||||
_IMPORT_FAILS.pop(name, None)
|
||||
return web.json_response({
|
||||
"ok": True, "name": name, "loaded": loaded,
|
||||
"dep_ok": dep_ok, "detail": dep_out[-200:] if not dep_ok else "",
|
||||
})
|
||||
except Exception as e:
|
||||
return web.json_response({"ok": False, "error": str(e)}, status=500)
|
||||
|
||||
# ── ZIP 上传方式 ──
|
||||
try:
|
||||
reader = await req.multipart()
|
||||
part = await reader.next()
|
||||
while part:
|
||||
if part.name == "file" and part.filename:
|
||||
fname = part.filename
|
||||
if not fname.endswith(".zip"):
|
||||
return web.json_response({"ok": False, "error": "仅支持 .zip 文件"}, status=400)
|
||||
|
||||
# 保存到临时文件
|
||||
tmp = tempfile.NamedTemporaryFile(suffix=".zip", delete=False)
|
||||
while True:
|
||||
chunk = await part.read_chunk(65536)
|
||||
if not chunk:
|
||||
break
|
||||
tmp.write(chunk)
|
||||
tmp.close()
|
||||
|
||||
# 提取 → plugins/
|
||||
plugin_name = fname[:-4] # 去掉 .zip
|
||||
target = PLUGINS_DIR / plugin_name
|
||||
if target.exists():
|
||||
os.unlink(tmp.name)
|
||||
return web.json_response({"ok": False, "error": f"插件目录已存在: {plugin_name}"}, status=409)
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(tmp.name, "r") as zf:
|
||||
# 检查 zip 结构 — 如果顶层只有一个目录,用它
|
||||
members = zf.namelist()
|
||||
top_dirs = set()
|
||||
for m in members:
|
||||
if "/" in m:
|
||||
top_dirs.add(m.split("/")[0])
|
||||
if len(top_dirs) == 1 and all(m.startswith(list(top_dirs)[0]) for m in members if m):
|
||||
# 有统一顶层目录
|
||||
zf.extractall(PLUGINS_DIR)
|
||||
if list(top_dirs)[0] != plugin_name:
|
||||
src = PLUGINS_DIR / list(top_dirs)[0]
|
||||
src.rename(target)
|
||||
else:
|
||||
target.mkdir(parents=True)
|
||||
zf.extractall(target)
|
||||
except zipfile.BadZipFile:
|
||||
os.unlink(tmp.name)
|
||||
return web.json_response({"ok": False, "error": "无效的 ZIP 文件"}, status=400)
|
||||
finally:
|
||||
os.unlink(tmp.name)
|
||||
|
||||
# 安装依赖
|
||||
dep_ok, dep_out = await _async_install_deps(str(target))
|
||||
|
||||
# 加载插件
|
||||
loaded = await _safe_load_plugin(ps, plugin_name)
|
||||
if loaded:
|
||||
_IMPORT_FAILS.pop(plugin_name, None)
|
||||
return web.json_response({
|
||||
"ok": True, "name": plugin_name, "loaded": loaded,
|
||||
"dep_ok": dep_ok, "detail": dep_out[-200:] if not dep_ok else "",
|
||||
})
|
||||
part = await reader.next()
|
||||
return web.json_response({"ok": False, "error": "未找到上传文件 (字段名: file)"}, status=400)
|
||||
except Exception as e:
|
||||
logger.error(f"导入插件失败: {e}")
|
||||
return web.json_response({"ok": False, "error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def _safe_load_plugin(ps, name: str) -> bool:
|
||||
"""安全加载插件 — 先隔离模式尝试,成功后再切换到正常模式"""
|
||||
try:
|
||||
# 先尝试隔离加载 (子进程,崩溃不影响框架)
|
||||
from services.process_isolated import IsolatedPlugin
|
||||
plugin_path = PLUGINS_DIR / name / "__init__.py"
|
||||
if not plugin_path.exists():
|
||||
_IMPORT_FAILS[name] = "缺少 __init__.py"
|
||||
return False
|
||||
|
||||
iso = IsolatedPlugin(name, str(plugin_path), {"settings": {"isolation": True}})
|
||||
ps.plugins[name] = iso
|
||||
logger.info(f"🔌 插件已导入(隔离模式): {name}")
|
||||
return True
|
||||
except Exception as e:
|
||||
_IMPORT_FAILS[name] = str(e)[:200]
|
||||
logger.error(f"插件加载失败 {name}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
# ── 列表 / 管理 / 权限 ──
|
||||
|
||||
async def list_plugins(req):
|
||||
sm = req.app.get('service_manager')
|
||||
sm = req.app.get("service_manager")
|
||||
if not sm:
|
||||
return web.json_response({"error": "Service Manager 未初始化"}, status=503)
|
||||
|
||||
ps = sm.get_service("plugin")
|
||||
if not ps:
|
||||
return web.json_response({"plugins": []})
|
||||
|
||||
data = []
|
||||
for name, info in ps.plugin_info.items():
|
||||
data.append({
|
||||
"name": name,
|
||||
"version": getattr(info, 'version', '?'),
|
||||
"version": getattr(info, "version", "?"),
|
||||
"running": name in ps.plugins,
|
||||
"enabled": True
|
||||
"enabled": getattr(info, "enabled", True),
|
||||
"error": _IMPORT_FAILS.get(name, ""),
|
||||
})
|
||||
return web.json_response({"plugins": data})
|
||||
|
||||
|
||||
async def manage_plugin(req):
|
||||
sm = req.app.get('service_manager')
|
||||
if not sm: return web.json_response({"error": "SM Missing"}, 503)
|
||||
|
||||
name = req.match_info['name']
|
||||
action = req.match_info['action']
|
||||
sm = req.app.get("service_manager")
|
||||
if not sm:
|
||||
return web.json_response({"error": "SM Missing"}, status=503)
|
||||
name = req.match_info["name"]
|
||||
action = req.match_info["action"]
|
||||
ps = sm.get_service("plugin")
|
||||
|
||||
if not ps: return web.json_response({"error": "Plugin Service Missing"}, 503)
|
||||
|
||||
if not ps:
|
||||
return web.json_response({"error": "Plugin Service Missing"}, status=503)
|
||||
try:
|
||||
if action in ('disable', 'unload'):
|
||||
if action in ("disable", "unload"):
|
||||
await ps.unload_plugin(name)
|
||||
elif action == 'enable':
|
||||
elif action == "enable":
|
||||
await ps.load_plugin(name)
|
||||
elif action == 'reload':
|
||||
elif action == "reload":
|
||||
await ps.unload_plugin(name)
|
||||
await ps.load_plugin(name)
|
||||
return web.json_response({"success": True, "msg": "操作成功"})
|
||||
@@ -55,9 +246,9 @@ async def manage_plugin(req):
|
||||
logger.error(f"插件操作失败: {e}")
|
||||
return web.json_response({"success": False, "error": str(e)})
|
||||
|
||||
|
||||
async def list_plugin_web_pages(req):
|
||||
"""Return all registered plugin web UI pages for sidebar listing."""
|
||||
sm = req.app.get('service_manager')
|
||||
sm = req.app.get("service_manager")
|
||||
if not sm:
|
||||
return web.json_response({"pages": []})
|
||||
ps = sm.get_service("plugin")
|
||||
@@ -75,8 +266,10 @@ async def list_plugin_web_pages(req):
|
||||
})
|
||||
return web.json_response({"pages": pages})
|
||||
|
||||
|
||||
async def get_perms(req):
|
||||
return web.json_response({"plugin": req.match_info['name'], "permissions": ["read", "write"]})
|
||||
return web.json_response({"plugin": req.match_info["name"], "permissions": ["read", "write"]})
|
||||
|
||||
|
||||
async def set_perms(req):
|
||||
return web.json_response({"success": True})
|
||||
|
||||
@@ -1,11 +1,110 @@
|
||||
<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">
|
||||
<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>
|
||||
<button class="btn btn-sm btn-tonal" onclick="window.PluginsModule.refresh()">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" style="vertical-align:-2px"><path fill="currentColor" d="M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/></svg>
|
||||
刷新
|
||||
</button>
|
||||
<div class="plugin-page-root" style="padding:16px">
|
||||
<h2 style="margin:0 0 12px 0">🔌 插件管理</h2>
|
||||
|
||||
<!-- ── 统计卡片 ── -->
|
||||
<div id="stats-card" style="display:flex;gap:12px;margin-bottom:16px;flex-wrap:wrap">
|
||||
<div class="stat-chip"><span class="stat-num" id="st-total">-</span> 总计</div>
|
||||
<div class="stat-chip loaded"><span class="stat-num" id="st-loaded">-</span> 已启用</div>
|
||||
<div class="stat-chip disabled"><span class="stat-num" id="st-disabled">-</span> 已停用</div>
|
||||
<div class="stat-chip failed"><span class="stat-num" id="st-failed">-</span> 安装失败</div>
|
||||
</div>
|
||||
|
||||
<!-- ── 导入区域 ── -->
|
||||
<div style="display:flex;gap:12px;margin-bottom:16px;flex-wrap:wrap;align-items:flex-start">
|
||||
<div class="card" style="flex:1;min-width:280px;padding:12px">
|
||||
<div style="font-weight:600;margin-bottom:8px">📦 导入 ZIP 包</div>
|
||||
<input type="file" id="zip-file" accept=".zip" style="margin-bottom:8px;color:var(--text)">
|
||||
<button class="btn btn-sm btn-filled" onclick="importZip()">上传安装</button>
|
||||
<span id="zip-status" style="margin-left:8px;font-size:.8rem"></span>
|
||||
</div>
|
||||
<div class="card" style="flex:1;min-width:280px;padding:12px">
|
||||
<div style="font-weight:600;margin-bottom:8px">📥 Git Clone 安装</div>
|
||||
<input id="git-url" placeholder="https://github.com/user/repo.git" style="width:100%;padding:6px 8px;margin-bottom:8px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:var(--shape-xs)">
|
||||
<button class="btn btn-sm btn-filled" onclick="importGit()">Clone 安装</button>
|
||||
<span id="git-status" style="margin-left:8px;font-size:.8rem"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── 失败详情 ── -->
|
||||
<div id="fail-detail" style="display:none;margin-bottom:12px;padding:10px;background:var(--md-sys-color-surface-container-lowest);border-radius:var(--shape-xs);font-size:.8rem;color:var(--error)"></div>
|
||||
|
||||
<!-- ── 插件列表 ── -->
|
||||
<div id="plugin-list">加载中...</div>
|
||||
|
||||
<style>
|
||||
.stat-chip { display:flex;align-items:center;gap:6px;padding:8px 14px;border-radius:var(--shape-sm);background:var(--md-sys-color-surface-container);min-width:100px }
|
||||
.stat-chip.loaded { border-left:3px solid var(--success,#4caf50) }
|
||||
.stat-chip.disabled { border-left:3px solid var(--text-dim) }
|
||||
.stat-chip.failed { border-left:3px solid var(--error,#f44336) }
|
||||
.stat-num { font-size:1.4rem;font-weight:700 }
|
||||
.plugin-row { display:flex;align-items:center;justify-content:space-between;padding:10px 12px;margin-bottom:4px;background:var(--md-sys-color-surface-container);border-radius:var(--shape-sm);gap:8px }
|
||||
.plugin-row .info { flex:1;min-width:0 }
|
||||
.plugin-row .name { font-weight:600 } .plugin-row .ver { font-size:.75rem;color:var(--text-dim) }
|
||||
.plugin-row .error-tag { font-size:.75rem;color:var(--error) }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
async function load() {
|
||||
var list = document.getElementById("plugin-list");
|
||||
try {
|
||||
var r1 = await fetch("./api/plugins/stats"), stats = await r1.json();
|
||||
document.getElementById("st-total").textContent = stats.total||0;
|
||||
document.getElementById("st-loaded").textContent = stats.loaded||0;
|
||||
document.getElementById("st-disabled").textContent = stats.disabled||0;
|
||||
document.getElementById("st-failed").textContent = stats.failed||0;
|
||||
|
||||
var fd = document.getElementById("fail-detail");
|
||||
if (stats.fail_details && Object.keys(stats.fail_details).length) {
|
||||
fd.style.display = "block";
|
||||
fd.innerHTML = "<strong>安装失败:</strong> " + Object.entries(stats.fail_details).map(function(e){return e[0]+": "+esc(e[1])}).join(" | ");
|
||||
} else fd.style.display = "none";
|
||||
|
||||
var r2 = await fetch("./api/plugins"), data = await r2.json();
|
||||
var plugins = data.plugins||[];
|
||||
if (!plugins.length) { list.innerHTML = '<div style="text-align:center;padding:24px;color:var(--text-dim)">暂无插件</div>'; return; }
|
||||
list.innerHTML = plugins.map(function(p){
|
||||
return '<div class="plugin-row"><div class="info"><div class="name">'+esc(p.name)+'</div><div class="ver">v'+esc(p.version||'?')+(p.running?' ✅':' ⏸')+'</div>'+(p.error?'<div class="error-tag">'+esc(p.error)+'</div>':'')+'</div>'+
|
||||
'<button class="btn btn-sm btn-outlined" onclick="togglePlugin(\''+esc(p.name)+'\',\''+(p.running?'unload':'enable')+'\')">'+(p.running?'停用':'启用')+'</button>'+
|
||||
'<button class="btn btn-sm btn-outlined" onclick="togglePlugin(\''+esc(p.name)+'\',\'reload\')">重载</button></div>';
|
||||
}).join("");
|
||||
} catch(e) { list.innerHTML = '<div style="color:var(--error)">加载失败: '+esc(String(e))+'</div>'; }
|
||||
}
|
||||
|
||||
async function togglePlugin(name, action) {
|
||||
await fetch("./api/plugins/"+name+"/"+action, {method:"POST"});
|
||||
load();
|
||||
}
|
||||
|
||||
async function importZip() {
|
||||
var f = document.getElementById("zip-file").files[0];
|
||||
if (!f) { alert("请选择 .zip 文件"); return; }
|
||||
var s = document.getElementById("zip-status");
|
||||
s.textContent = "⏳ 上传中..."; s.style.color = "var(--text-dim)";
|
||||
var fd = new FormData(); fd.append("file", f);
|
||||
try {
|
||||
var r = await fetch("./api/plugins/import", {method:"POST", body:fd});
|
||||
var d = await r.json();
|
||||
if (d.ok) { s.textContent = "✅ " + d.name + (d.loaded?" 已加载":" 待手动加载"); s.style.color = "var(--success)"; }
|
||||
else { s.textContent = "❌ " + (d.error||"失败"); s.style.color = "var(--error)"; }
|
||||
} catch(e) { s.textContent = "❌ " + e; s.style.color = "var(--error)"; }
|
||||
load();
|
||||
}
|
||||
|
||||
async function importGit() {
|
||||
var url = document.getElementById("git-url").value.trim();
|
||||
if (!url) { alert("请输入 Git 仓库地址"); return; }
|
||||
var s = document.getElementById("git-status");
|
||||
s.textContent = "⏳ Clone 中..."; s.style.color = "var(--text-dim)";
|
||||
try {
|
||||
var r = await fetch("./api/plugins/import", {method:"POST", headers:{"Content-Type":"application/json"}, body:JSON.stringify({url:url})});
|
||||
var d = await r.json();
|
||||
if (d.ok) { s.textContent = "✅ " + d.name + (d.loaded?" 已加载":" 待手动加载"); s.style.color = "var(--success)"; }
|
||||
else { s.textContent = "❌ " + (d.error||"失败"); s.style.color = "var(--error)"; }
|
||||
} catch(e) { s.textContent = "❌ " + e; s.style.color = "var(--error)"; }
|
||||
load();
|
||||
}
|
||||
|
||||
function esc(s) { return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||
load();
|
||||
</script>
|
||||
</div>
|
||||
<div id="plugin-list" class="plugin-list">加载中...</div>
|
||||
|
||||
Reference in New Issue
Block a user