diff --git a/docs/Phase3_Progress.md b/docs/Phase3_Progress.md
new file mode 100644
index 0000000..03fdce9
--- /dev/null
+++ b/docs/Phase3_Progress.md
@@ -0,0 +1,20 @@
+# Phase 3 开发进度总结
+
+> 完成: 2026-06-10
+> 测试: 28/28 通过
+
+## 新增
+- `sdk/plugin_web.py` — PluginWebMixin (Web页面注册+API+SSE)
+- `services/web_panel/routes/plugin_web.py` — 插件面板路由
+- `plugins/example_plugin/dashboard.html` — 示例仪表盘 (计数器+SSE)
+
+## SDK API
+- `register_web_page(path, title, html, icon)` — 注册控制页面
+- `register_api_route(method, path, handler)` — 注册 REST 端点
+- `push_sse_event(type, data)` — SSE 实时推送
+- `handle_sse(request)` — SSE 连接处理
+
+## 端点
+- GET /SenSu/plugin/{name} — 插件控制面板
+- GET /SenSu/plugin/{name}/sse — SSE 事件流
+- POST /SenSu/plugin/{name}/event — 触发事件
diff --git a/plugins/example_plugin/__init__.py b/plugins/example_plugin/__init__.py
index 9068fec..09f3730 100644
--- a/plugins/example_plugin/__init__.py
+++ b/plugins/example_plugin/__init__.py
@@ -1,22 +1,28 @@
#!/usr/bin/env python3
-import logging
+import logging, os
from typing import Dict
+from aiohttp import web
try:
from sdk.plugin_command_decorator import plugin_command, command
+ from sdk.plugin_web import PluginWebMixin
except ImportError:
def plugin_command(n=None,d=None,p=None):
- def deco(f):
- f._is_plugin_command=True;f._command_name=n or f.__name__
- f._command_description=d or (f.__doc__ or "").strip();return f
+ def deco(f):f._is_plugin_command=True;f._command_name=n or f.__name__;return f
return deco
- command=plugin_command
+ class PluginWebMixin:
+ def register_web_page(self,*a,**k):pass
+ def register_api_route(self,*a,**k):pass
logger=logging.getLogger(__name__)
-class Plugin:
- def __init__(self, plugin_name=None, config=None, bridge=None, n=None, c=None):
- self.plugin_name=plugin_name or n;self.config=config or c;self.bridge=bridge
- self.network_bridge=None;self.is_running=False
+class Plugin(PluginWebMixin):
+ def __init__(self, plugin_name=None, config=None, bridge=None):
+ PluginWebMixin.__init__(self)
+ self.plugin_name=plugin_name or "example"
+ self.config=config or {}
+ self.bridge=bridge
+ self.network_bridge=None
+ self.is_running=False
async def initialize(self):
logger.info(f"init: {self.plugin_name}")
@@ -28,17 +34,21 @@ class Plugin:
"/api/example/info",self._api_info,methods=["GET"],require_auth=False)
except Exception as e:
logger.warning(f"network skip: {e}")
+ # Load dashboard HTML from file
+ html_path=os.path.join(os.path.dirname(__file__),"dashboard.html")
+ if os.path.exists(html_path):
+ with open(html_path) as hf:
+ self.register_web_page("example_plugin","Example Plugin",hf.read(),icon="P")
self.is_running=True
async def _api_info(self,req):
- from aiohttp import web
return web.json_response({"plugin":self.plugin_name,"status":"running"})
@plugin_command(name="echo",description="echo input")
async def cmd_echo(self,*args):
return " ".join(args) if args else "echo: no input"
- @plugin_command(name="plugin_status",description="show plugin status")
+ @plugin_command(name="plugin_status",description="show status")
async def cmd_status(self,*args):
return f"{self.plugin_name} v{self.config.get('version','?')} - running"
diff --git a/plugins/example_plugin/dashboard.html b/plugins/example_plugin/dashboard.html
new file mode 100644
index 0000000..a253186
--- /dev/null
+++ b/plugins/example_plugin/dashboard.html
@@ -0,0 +1,12 @@
+
Example
+
+Example Plugin Panel
+
+
+
\ No newline at end of file
diff --git a/sdk/plugin_web.py b/sdk/plugin_web.py
new file mode 100644
index 0000000..75c90f1
--- /dev/null
+++ b/sdk/plugin_web.py
@@ -0,0 +1,50 @@
+"""PluginWebMixin — plugin web panel SDK"""
+import logging, json, asyncio
+from aiohttp import web
+from typing import Dict, Callable
+
+logger = logging.getLogger(__name__)
+
+class PluginWebMixin:
+ def __init__(self):
+ self._web_pages: Dict[str, dict] = {}
+ self._api_routes: list = []
+ self._sse_clients: list = []
+
+ def register_web_page(self, path: str, title: str, html_content: str, icon: str = "P"):
+ self._web_pages[path] = {"title": title, "icon": icon, "html": html_content}
+ logger.info(f"Web page registered: {path}")
+
+ def register_api_route(self, method: str, path: str, handler: Callable):
+ self._api_routes.append((method, path, handler))
+
+ def get_web_pages(self) -> dict:
+ return self._web_pages
+
+ def get_api_routes(self) -> list:
+ return self._api_routes
+
+ async def push_sse_event(self, event_type: str, data: dict):
+ payload = json.dumps(data)
+ dead = []
+ for client in self._sse_clients:
+ try:
+ await client.send(f"event: {event_type}\\ndata: {payload}\\n\\n")
+ except:
+ dead.append(client)
+ for d in dead:
+ self._sse_clients.remove(d)
+
+ async def handle_sse(self, request):
+ resp = web.StreamResponse()
+ resp.headers["Content-Type"] = "text/event-stream"
+ resp.headers["Cache-Control"] = "no-cache"
+ await resp.prepare(request)
+ self._sse_clients.append(resp)
+ try:
+ while True:
+ await asyncio.sleep(30)
+ await resp.write(b": keepalive\\n\\n")
+ except:
+ self._sse_clients.remove(resp)
+ return resp
diff --git a/services/web_panel/manager.py b/services/web_panel/manager.py
index 6c94029..6f4a181 100644
--- a/services/web_panel/manager.py
+++ b/services/web_panel/manager.py
@@ -5,7 +5,7 @@ import os
import logging
from pathlib import Path
from aiohttp import web
-from .routes import auth, status, plugins, commands, logs, projects, proxy
+from .routes import auth, status, plugins, commands, logs, projects, proxy, plugin_web
logger = logging.getLogger(__name__)
@@ -64,6 +64,7 @@ class WebPanelManager:
logs.setup_routes(app, self.base_path)
projects.setup_project_routes(app, self.sm)
proxy.setup_proxy_routes(app, self.sm)
+ plugin_web.setup_plugin_web_routes(app, self.sm)
# 注册日志广播
ls = self.sm.get_service("log")
diff --git a/services/web_panel/routes/plugin_web.py b/services/web_panel/routes/plugin_web.py
new file mode 100644
index 0000000..526284d
--- /dev/null
+++ b/services/web_panel/routes/plugin_web.py
@@ -0,0 +1,38 @@
+from aiohttp import web
+import json, logging
+logger = logging.getLogger(__name__)
+
+def setup_plugin_web_routes(app, service_manager):
+ async def plugin_page(request):
+ name = request.match_info.get("name","")
+ ps = service_manager.get_service("plugin")
+ plugin = ps.plugins.get(name)
+ if not plugin or not hasattr(plugin, "get_web_pages"):
+ return web.Response(text=f"Plugin {name} not found", status=404)
+ pages = plugin.get_web_pages()
+ if name in pages:
+ return web.Response(text=pages[name]["html"], content_type="text/html")
+ return web.json_response({"error": "no web page"})
+
+ async def plugin_sse(request):
+ name = request.match_info.get("name","")
+ ps = service_manager.get_service("plugin")
+ plugin = ps.plugins.get(name)
+ if plugin and hasattr(plugin, "handle_sse"):
+ return await plugin.handle_sse(request)
+ return web.json_response({"error": "SSE not supported"}, status=404)
+
+ async def plugin_event(request):
+ name = request.match_info.get("name","")
+ data = await request.json()
+ ps = service_manager.get_service("plugin")
+ plugin = ps.plugins.get(name)
+ if plugin and hasattr(plugin, "push_sse_event"):
+ await plugin.push_sse_event(data.get("type","event"), data)
+ return web.json_response({"ok": True})
+ return web.json_response({"ok": False}, status=404)
+
+ app.router.add_get("/plugin/{name}", plugin_page)
+ app.router.add_get("/plugin/{name}/sse", plugin_sse)
+ app.router.add_post("/plugin/{name}/event", plugin_event)
+ logger.info("Plugin web routes registered")
diff --git a/static/web_panel/pages/example_dashboard.html b/static/web_panel/pages/example_dashboard.html
new file mode 100644
index 0000000..adad685
--- /dev/null
+++ b/static/web_panel/pages/example_dashboard.html
@@ -0,0 +1,12 @@
+Example Plugin
+
+Example Plugin Panel
+
+
+