feat: hash路由 + 插件页面内联加载 + 侧边栏插件面板展开项
- Hash路由: #/dashboard #/files #/plugins/xxx 支持刷新保持/前进后退 - 登录过期保留hash,登录后自动跳回原页面 - 插件WebUI页面内联加载到page-container(不再弹新窗) - 插件页面HTML自动处理: style提取/body剥离/script执行/padding归零 - 侧边栏新增「插件面板」可折叠项,展开列出所有已注册插件页面 - 内存卡片显示 已用/总容量(百分比右侧) - 插件开发指南新增 2.6 WebUI页面开发章节 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -92,7 +92,7 @@ commands:
|
|||||||
permissions:
|
permissions:
|
||||||
- framework.command.test
|
- framework.command.test
|
||||||
source: internal
|
source: internal
|
||||||
last_updated: 291543.087298158
|
last_updated: 297792.734060618
|
||||||
plugin_commands:
|
plugin_commands:
|
||||||
example_plugin:
|
example_plugin:
|
||||||
echo: *id001
|
echo: *id001
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
http_port: 4200
|
http_port: 4200
|
||||||
last_updated: 291543.090646752
|
last_updated: 297792.741311191
|
||||||
plugin_routes:
|
plugin_routes:
|
||||||
example_plugin:
|
example_plugin:
|
||||||
- methods:
|
- methods:
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
- [SenSu 插件开发超详细指南](#sensu-插件开发超详细指南)
|
- [SenSu 插件开发超详细指南](#sensu-插件开发超详细指南)
|
||||||
- [一、插件系统架构深度解析](#一插件系统架构深度解析)
|
- [一、插件系统架构深度解析](#一插件系统架构深度解析)
|
||||||
- [二、插件开发完整方案](#二插件开发完整方案)
|
- [二、插件开发完整方案](#二插件开发完整方案)
|
||||||
|
- [2.6 插件 WebUI 页面](#26-插件-webui-页面-v060)
|
||||||
- [三、插件生命周期管理](#三插件生命周期管理)
|
- [三、插件生命周期管理](#三插件生命周期管理)
|
||||||
- [四、插件开发最佳实践](#四插件开发最佳实践)
|
- [四、插件开发最佳实践](#四插件开发最佳实践)
|
||||||
- [4.1 错误处理最佳实践](#41-错误处理最佳实践)
|
- [4.1 错误处理最佳实践](#41-错误处理最佳实践)
|
||||||
@@ -3400,6 +3401,73 @@ class PluginResourceManager:
|
|||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
|
### 2.6 插件 WebUI 页面 (v0.6.0)
|
||||||
|
|
||||||
|
插件可以注册 WebUI 页面,自动出现在 SenSu 管理面板侧边栏的「插件面板」折叠项中。
|
||||||
|
|
||||||
|
#### 2.6.1 注册页面
|
||||||
|
|
||||||
|
```python
|
||||||
|
from sdk.plugin_web import PluginWebMixin
|
||||||
|
|
||||||
|
class MyPlugin(PluginWebMixin):
|
||||||
|
async def initialize(self):
|
||||||
|
self.register_web_page(
|
||||||
|
path="mypanel", # 访问路径: /plugin/{plugin_name}
|
||||||
|
title="我的面板", # 侧边栏显示名称
|
||||||
|
html_content="<h1>Hello</h1>", # 完整 HTML 页面
|
||||||
|
icon="M" # 侧边栏图标 (单字符)
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2.6.2 页面渲染机制
|
||||||
|
|
||||||
|
插件页面在 SenSu 主面板的 `page-container` 区域内联加载(不弹新窗口):
|
||||||
|
|
||||||
|
1. 用户点击侧边栏「插件面板」→ 展开子项 → 点击插件页面
|
||||||
|
2. 前端 fetch `/plugin/{plugin_name}` 获取完整 HTML
|
||||||
|
3. 渲染管线自动处理:
|
||||||
|
- 提取 `<style>` 标签并注入页面
|
||||||
|
- 剥离 `<html>/<head>/<body>` 包装标签
|
||||||
|
- 提取 `<script>` 标签,通过动态 script 元素执行
|
||||||
|
- 页面 content 区 padding 归零,插件内容边到边铺满
|
||||||
|
|
||||||
|
#### 2.6.3 HTML 编写建议
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- 推荐:使用内联样式 + 容器类,不依赖 body 选择器 -->
|
||||||
|
<style>
|
||||||
|
/* body 选择器不会生效(body 标签已被剥离) */
|
||||||
|
/* 改用类选择器或直接用容器 div */
|
||||||
|
.plugin-root {
|
||||||
|
background: var(--bg); /* 继承 SenSu 主题背景 */
|
||||||
|
color: var(--text); /* 继承 SenSu 主题文字 */
|
||||||
|
font-family: system-ui, sans-serif;
|
||||||
|
padding: 16px;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
.plugin-root h2 { color: var(--primary); }
|
||||||
|
</style>
|
||||||
|
<div class="plugin-root">
|
||||||
|
<h2>插件面板标题</h2>
|
||||||
|
<button class="btn btn-sm btn-tonal" onclick="...">操作</button>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
// 脚本会被动态 script 元素执行,声明的函数可被 onclick 调用
|
||||||
|
function handleClick() { ... }
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2.6.4 注意事项
|
||||||
|
|
||||||
|
| 事项 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `body` 选择器 | CSS 中 `body { ... }` 不会生效,改用容器类 |
|
||||||
|
| 硬编码背景色 | 避免 `background: #000`,用 `var(--bg)` 自适应主题 |
|
||||||
|
| `onclick` 函数 | 函数需在 `<script>` 中声明,浏览器 innerHTML 不执行 script |
|
||||||
|
| `<html>/<head>` | 可省略,直接写 body 内容 |
|
||||||
|
| 文件管理器调用 | 可通过 postMessage API 唤出文件选择器 (见第八章) |
|
||||||
|
|
||||||
## 三、插件生命周期管理
|
## 三、插件生命周期管理
|
||||||
|
|
||||||
### 3.1 插件完整生命周期
|
### 3.1 插件完整生命周期
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
def setup_routes(app, prefix=''):
|
def setup_routes(app, prefix=''):
|
||||||
app.router.add_get(f'{prefix}/api/plugins', panel_auth(list_plugins))
|
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_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_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))
|
app.router.add_post(f'{prefix}/api/plugins/{{name}}/perms', panel_auth(set_perms))
|
||||||
@@ -54,6 +55,26 @@ async def manage_plugin(req):
|
|||||||
logger.error(f"插件操作失败: {e}")
|
logger.error(f"插件操作失败: {e}")
|
||||||
return web.json_response({"success": False, "error": str(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')
|
||||||
|
if not sm:
|
||||||
|
return web.json_response({"pages": []})
|
||||||
|
ps = sm.get_service("plugin")
|
||||||
|
if not ps:
|
||||||
|
return web.json_response({"pages": []})
|
||||||
|
pages = []
|
||||||
|
for name, plugin in ps.plugins.items():
|
||||||
|
if hasattr(plugin, "get_web_pages"):
|
||||||
|
for path, info in plugin.get_web_pages().items():
|
||||||
|
pages.append({
|
||||||
|
"plugin": name,
|
||||||
|
"path": "/plugin/" + name,
|
||||||
|
"title": info.get("title", name),
|
||||||
|
"icon": info.get("icon", "P"),
|
||||||
|
})
|
||||||
|
return web.json_response({"pages": pages})
|
||||||
|
|
||||||
async def get_perms(req):
|
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"]})
|
||||||
|
|
||||||
|
|||||||
@@ -199,6 +199,16 @@ h3{font-size:1rem;font-weight:500;letter-spacing:.15px}
|
|||||||
}
|
}
|
||||||
.toggle-sidebar:hover{color:var(--primary)}
|
.toggle-sidebar:hover{color:var(--primary)}
|
||||||
|
|
||||||
|
/* ── Expandable nav section (plugin pages) ── */
|
||||||
|
.nav-expandable{border-top:1px solid var(--outline);margin-top:4px;padding-top:4px}
|
||||||
|
.nav-expand-header{display:flex;align-items:center;gap:12px;padding:10px 16px;cursor:pointer;color:var(--text-dim);font-size:14px;font-weight:500;border-radius:var(--shape-full);margin:0 8px;transition:.15s}
|
||||||
|
.nav-expand-header:hover{background:rgba(208,188,255,.08);color:var(--text)}
|
||||||
|
.nav-expand-body{padding:2px 0;overflow:hidden}
|
||||||
|
.nav-sub-item{display:flex;align-items:center;gap:10px;padding:8px 16px 8px 36px;cursor:pointer;color:var(--text-dim);font-size:.82rem;text-decoration:none;transition:.12s;border-radius:var(--shape-full);margin:0 8px}
|
||||||
|
.nav-sub-item:hover{background:rgba(208,188,255,.08);color:var(--text)}
|
||||||
|
.nav-sub-item.active{background:var(--primary-container);color:var(--md-sys-color-on-primary-container)}
|
||||||
|
.nav-sub-item .nav-icon{width:18px;height:18px;display:flex;align-items:center;justify-content:center;font-size:.8rem;flex-shrink:0;border-radius:4px;background:rgba(208,188,255,.12)}
|
||||||
|
|
||||||
/* ── Main Content ── */
|
/* ── Main Content ── */
|
||||||
.content-area{overflow:hidden;display:flex;flex-direction:column;background:var(--bg)}
|
.content-area{overflow:hidden;display:flex;flex-direction:column;background:var(--bg)}
|
||||||
.progress-bar{
|
.progress-bar{
|
||||||
|
|||||||
@@ -46,6 +46,19 @@
|
|||||||
<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>
|
<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>
|
<span>文件管理</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
<!-- Plugin WebUI pages (collapsible) -->
|
||||||
|
<div class="nav-expandable" id="nav-plugins">
|
||||||
|
<div class="nav-expand-header" onclick="togglePluginPages()">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" style="flex-shrink:0"><path 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>
|
||||||
|
<span style="flex:1">插件面板</span>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" id="nav-plugins-arrow" style="transition:transform .2s"><path d="M7 10l5 5 5-5z"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="nav-expand-body" id="nav-plugins-body" style="display:none">
|
||||||
|
<div class="nav-expand-empty" style="padding:6px 16px;font-size:.75rem;color:var(--text-dim)">暂无插件面板</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="toggle-sidebar" onclick="toggleSidebar()">☰</div>
|
<div class="toggle-sidebar" onclick="toggleSidebar()">☰</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
@@ -59,6 +72,6 @@
|
|||||||
<script src="./static/js/chart.js?v=0603"></script>
|
<script src="./static/js/chart.js?v=0603"></script>
|
||||||
|
|
||||||
<!-- 🟢 2. 再加载主逻辑 -->
|
<!-- 🟢 2. 再加载主逻辑 -->
|
||||||
<script src="./static/js/app.js?v=0601"></script>
|
<script src="./static/js/app.js?v=0700"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -27,7 +27,11 @@
|
|||||||
credentials: 'include', body: JSON.stringify({username: u, password: p})
|
credentials: 'include', body: JSON.stringify({username: u, password: p})
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if(res.ok && data.success) window.location.href = './home.html';
|
if(res.ok && data.success) {
|
||||||
|
var hash = sessionStorage.getItem('sensu_redirect_hash') || '';
|
||||||
|
sessionStorage.removeItem('sensu_redirect_hash');
|
||||||
|
window.location.href = './home.html' + hash;
|
||||||
|
}
|
||||||
else err.textContent = data.msg || "凭证错误";
|
else err.textContent = data.msg || "凭证错误";
|
||||||
} catch(e) { err.textContent = "网络异常"; }
|
} catch(e) { err.textContent = "网络异常"; }
|
||||||
}
|
}
|
||||||
|
|||||||
+184
-57
@@ -1,4 +1,6 @@
|
|||||||
// 主题初始化 (必须在渲染前执行, 防止闪白)
|
/* ── SenSu WebUI — Theme + Routing + Page Loader ── */
|
||||||
|
|
||||||
|
// ═══ 主题初始化 (必须在渲染前, 防闪白) ═══
|
||||||
(function(){
|
(function(){
|
||||||
var s = localStorage.getItem("sensu-theme") || "dark";
|
var s = localStorage.getItem("sensu-theme") || "dark";
|
||||||
document.documentElement.setAttribute("data-theme", s);
|
document.documentElement.setAttribute("data-theme", s);
|
||||||
@@ -12,97 +14,222 @@ window.toggleTheme = function(){
|
|||||||
if(btn) btn.textContent = t === "light" ? "☀️" : "🌙";
|
if(btn) btn.textContent = t === "light" ? "☀️" : "🌙";
|
||||||
};
|
};
|
||||||
|
|
||||||
// 初始化检查
|
|
||||||
window.onload = async () => {
|
// ═══ 路由系统 ═══
|
||||||
// 设置按钮初始图标
|
// Hash format: #/dashboard | #/files | #/plugins/example_plugin
|
||||||
|
// Built-in pages map to ./static/pages/{name}.html
|
||||||
|
// Plugin pages load from /plugin/{name}
|
||||||
|
|
||||||
|
var BUILTIN_PAGES = ['dashboard','logs','console','plugins','projects','proxy','files'];
|
||||||
|
var _pluginPagesCache = null; // {plugin_name: {path, title, icon}}
|
||||||
|
|
||||||
|
function currentRoute() {
|
||||||
|
var h = location.hash.replace('#', '') || '/dashboard';
|
||||||
|
return h.replace(/^\/+/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigateTo(route) {
|
||||||
|
if (!route) route = 'dashboard';
|
||||||
|
location.hash = '#' + route; // triggers hashchange → routePage()
|
||||||
|
}
|
||||||
|
|
||||||
|
function routePage() {
|
||||||
|
var r = currentRoute();
|
||||||
|
if (r.startsWith('plugins/')) {
|
||||||
|
loadPluginPage(r.replace('plugins/', ''));
|
||||||
|
} else if (BUILTIN_PAGES.indexOf(r) >= 0) {
|
||||||
|
loadPage(r);
|
||||||
|
} else {
|
||||||
|
loadPage('dashboard');
|
||||||
|
location.hash = '#/dashboard';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══ onload ═══
|
||||||
|
window.onload = async function() {
|
||||||
var btn = document.querySelector(".theme-toggle");
|
var btn = document.querySelector(".theme-toggle");
|
||||||
if(btn) btn.textContent = document.documentElement.getAttribute("data-theme") === "light" ? "☀️" : "🌙";
|
if(btn) btn.textContent = document.documentElement.getAttribute("data-theme") === "light" ? "☀️" : "🌙";
|
||||||
try {
|
|
||||||
const res = await fetch('./api/auth/status', { credentials: 'include' });
|
|
||||||
if(res.status === 401) { window.location.href = './index.html'; return; }
|
|
||||||
const data = await res.json();
|
|
||||||
if(!data.authenticated) { window.location.href = './index.html'; return; }
|
|
||||||
|
|
||||||
|
try {
|
||||||
|
var res = await fetch('./api/auth/status', {credentials:'include'});
|
||||||
|
if(res.status === 401 || !res.ok) {
|
||||||
|
sessionStorage.setItem('sensu_redirect_hash', location.hash);
|
||||||
|
window.location.href = './index.html'; return;
|
||||||
|
}
|
||||||
|
var data = await res.json();
|
||||||
|
if(!data.authenticated) {
|
||||||
|
sessionStorage.setItem('sensu_redirect_hash', location.hash);
|
||||||
|
window.location.href = './index.html'; return;
|
||||||
|
}
|
||||||
document.getElementById('uname').textContent = data.username || 'Admin';
|
document.getElementById('uname').textContent = data.username || 'Admin';
|
||||||
loadPage('dashboard'); // 默认加载
|
|
||||||
} catch(e) { window.location.href = './index.html'; }
|
} catch(e) { window.location.href = './index.html'; }
|
||||||
|
|
||||||
|
window.addEventListener('hashchange', routePage);
|
||||||
|
routePage(); // load from current hash (or default to dashboard)
|
||||||
};
|
};
|
||||||
|
|
||||||
// 路由加载器
|
|
||||||
|
|
||||||
|
// ═══ Page loader (built-in pages) ═══
|
||||||
async function loadPage(pageName) {
|
async function loadPage(pageName) {
|
||||||
const content = document.getElementById('page-content');
|
var content = document.getElementById('page-content');
|
||||||
const bar = document.getElementById('progress');
|
var bar = document.getElementById('progress');
|
||||||
|
|
||||||
// 侧边栏高亮
|
// Sidebar highlight
|
||||||
document.querySelectorAll('.nav-item').forEach(el => el.classList.remove('active'));
|
document.querySelectorAll('.nav-item').forEach(function(el){ el.classList.remove('active'); });
|
||||||
document.querySelector(`.nav-item[data-page="${pageName}"]`)?.classList.add('active');
|
document.querySelectorAll('.nav-sub-item').forEach(function(el){ el.classList.remove('active'); });
|
||||||
|
var navItem = document.querySelector('.nav-item[data-page="'+pageName+'"]');
|
||||||
|
if(navItem) navItem.classList.add('active');
|
||||||
|
content.style.padding = '';
|
||||||
|
|
||||||
// 进度条动画
|
// Progress
|
||||||
bar.classList.add('active'); bar.style.width = '0%';
|
bar.classList.add('active'); bar.style.width = '0%';
|
||||||
await new Promise(r => requestAnimationFrame(() => { bar.style.width = '80%'; setTimeout(r, 100); }));
|
await new Promise(function(r){ requestAnimationFrame(function(){ bar.style.width='80%'; setTimeout(r,100); }); });
|
||||||
|
|
||||||
|
// Destroy previous module
|
||||||
|
var modName = pageName.charAt(0).toUpperCase() + pageName.slice(1) + 'Module';
|
||||||
|
if(window[modName] && window[modName].destroy) { try { window[modName].destroy(); } catch(e){} }
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`./static/pages/${pageName}.html`);
|
var resp = await fetch('./static/pages/'+pageName+'.html');
|
||||||
if(!resp.ok) throw new Error('404');
|
if(!resp.ok) throw new Error('404');
|
||||||
const html = await resp.text();
|
var html = await resp.text();
|
||||||
|
|
||||||
// Extract inline scripts before innerHTML (browsers skip them)
|
|
||||||
const scripts = [];
|
|
||||||
const cleanHtml = html.replace(/<script\b[^>]*>([\s\S]*?)<\/script>/gi, (m, code) => {
|
|
||||||
scripts.push(code.trim()); return '';
|
|
||||||
});
|
|
||||||
|
|
||||||
|
var scripts = [];
|
||||||
|
var cleanHtml = html.replace(/<script\b[^>]*>([\s\S]*?)<\/script>/gi, function(m,code){ scripts.push(code.trim()); return ''; });
|
||||||
content.innerHTML = cleanHtml;
|
content.innerHTML = cleanHtml;
|
||||||
|
|
||||||
// Execute inline scripts via dynamic <script> tag so function
|
scripts.forEach(function(code){
|
||||||
// declarations become globally accessible (onclick handlers need them)
|
try { var s=document.createElement('script'); s.textContent=code; document.head.appendChild(s); document.head.removeChild(s); }
|
||||||
for(const code of scripts) {
|
catch(ex){ console.error('Inline script error:', ex); }
|
||||||
try {
|
});
|
||||||
const s = document.createElement('script');
|
|
||||||
s.textContent = code;
|
|
||||||
document.head.appendChild(s);
|
|
||||||
document.head.removeChild(s);
|
|
||||||
} catch(e) { console.error('Inline script error:', e); }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load external JS module the same way (optional — skip if 404)
|
// External JS module
|
||||||
try {
|
try {
|
||||||
const jsResp = await fetch(`./static/pages/${pageName}.js?t=${Date.now()}`);
|
var jsResp = await fetch('./static/pages/'+pageName+'.js?t='+Date.now());
|
||||||
if(jsResp.ok) {
|
if(jsResp.ok) {
|
||||||
const jsCode = await jsResp.text();
|
var jsCode = await jsResp.text();
|
||||||
const s = document.createElement('script');
|
var s=document.createElement('script'); s.textContent=jsCode; document.head.appendChild(s); document.head.removeChild(s);
|
||||||
s.textContent = jsCode;
|
if(window[modName] && window[modName].init) window[modName].init();
|
||||||
document.head.appendChild(s);
|
|
||||||
document.head.removeChild(s);
|
|
||||||
const moduleName = pageName.charAt(0).toUpperCase() + pageName.slice(1) + 'Module';
|
|
||||||
if(window[moduleName]?.init) window[moduleName].init();
|
|
||||||
}
|
}
|
||||||
} catch(e) { console.error('Page JS error:', pageName, e); }
|
} catch(e){ console.error('Page JS error:', pageName, e); }
|
||||||
|
|
||||||
bar.style.width = '100%';
|
bar.style.width = '100%';
|
||||||
setTimeout(() => bar.classList.remove('active'), 200);
|
setTimeout(function(){ bar.classList.remove('active'); }, 200);
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
content.innerHTML = `<div style="color:var(--error); text-align:center; margin-top:20vh;">页面加载失败: ${e.message}</div>`;
|
content.innerHTML = '<div style="color:var(--error);text-align:center;margin-top:20vh">页面加载失败: '+e.message+'</div>';
|
||||||
bar.style.background = 'var(--error)';
|
bar.style.background='var(--error)';
|
||||||
setTimeout(() => { bar.style.width = '100%'; setTimeout(() => { bar.classList.remove('active'); bar.style.background = 'var(--primary)'; }, 200); }, 100);
|
setTimeout(function(){ bar.style.width='100%'; setTimeout(function(){ bar.classList.remove('active'); bar.style.background='var(--primary)'; },200); },100);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// 侧边栏切换
|
// ═══ Plugin page loader ═══
|
||||||
|
async function loadPluginPage(pluginName) {
|
||||||
|
var content = document.getElementById('page-content');
|
||||||
|
var bar = document.getElementById('progress');
|
||||||
|
|
||||||
|
// Highlight sub-item
|
||||||
|
document.querySelectorAll('.nav-item').forEach(function(el){ el.classList.remove('active'); });
|
||||||
|
document.querySelectorAll('.nav-sub-item').forEach(function(el){ el.classList.remove('active'); });
|
||||||
|
var sub = document.querySelector('.nav-sub-item[data-plugin="'+pluginName+'"]');
|
||||||
|
if(sub) sub.classList.add('active');
|
||||||
|
|
||||||
|
bar.classList.add('active'); bar.style.width='0%';
|
||||||
|
await new Promise(function(r){ requestAnimationFrame(function(){ bar.style.width='80%'; setTimeout(r,100); }); });
|
||||||
|
|
||||||
|
// Also load plugin pages list if not yet
|
||||||
|
if(!_pluginPagesCache) await fetchPluginPages();
|
||||||
|
|
||||||
|
try {
|
||||||
|
var resp = await fetch('/plugin/'+pluginName, {credentials:'include'});
|
||||||
|
if(!resp.ok) throw new Error(resp.status);
|
||||||
|
var html = await resp.text();
|
||||||
|
|
||||||
|
var styles = '';
|
||||||
|
html.replace(/<style\b[^>]*>([\s\S]*?)<\/style>/gi, function(m,code){ styles+=code; return ''; });
|
||||||
|
|
||||||
|
var bodyMatch = html.match(/<body\b[^>]*>([\s\S]*)<\/body>/i);
|
||||||
|
var clean = bodyMatch ? bodyMatch[1] : html.replace(/<html[^>]*>|<\/html>|<head[^>]*>[\s\S]*?<\/head>|<body[^>]*>|<\/body>/gi, '');
|
||||||
|
|
||||||
|
var scripts = [];
|
||||||
|
clean = clean.replace(/<script\b[^>]*>([\s\S]*?)<\/script>/gi, function(m,code){ scripts.push(code.trim()); return ''; });
|
||||||
|
|
||||||
|
content.style.padding = '0';
|
||||||
|
content.innerHTML = '<style>'+styles+'</style>'+clean;
|
||||||
|
|
||||||
|
scripts.forEach(function(code){
|
||||||
|
try { var s=document.createElement('script'); s.textContent=code; document.head.appendChild(s); document.head.removeChild(s); }
|
||||||
|
catch(ex){ console.error('Plugin script error:', ex); }
|
||||||
|
});
|
||||||
|
|
||||||
|
bar.style.width='100%';
|
||||||
|
setTimeout(function(){ bar.classList.remove('active'); }, 200);
|
||||||
|
} catch(ex) {
|
||||||
|
content.innerHTML = '<div style="color:var(--error);text-align:center;margin-top:20vh">插件页面加载失败: '+ex.message+'</div>';
|
||||||
|
bar.style.background='var(--error)';
|
||||||
|
setTimeout(function(){ bar.style.width='100%'; setTimeout(function(){ bar.classList.remove('active'); bar.style.background='var(--primary)'; },200); },100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ═══ Sidebar toggle ═══
|
||||||
function toggleSidebar() {
|
function toggleSidebar() {
|
||||||
document.getElementById('app').classList.toggle('collapsed');
|
document.getElementById('app').classList.toggle('collapsed');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 退出登录
|
// ═══ Logout ═══
|
||||||
async function doLogout() {
|
async function doLogout() {
|
||||||
await fetch('./api/logout', { method: 'POST', credentials: 'include' });
|
await fetch('./api/logout', {method:'POST', credentials:'include'});
|
||||||
window.location.href = './index.html';
|
window.location.href = './index.html';
|
||||||
}
|
}
|
||||||
|
|
||||||
// 点击侧边栏事件委托
|
|
||||||
document.addEventListener('click', (e) => {
|
// ═══ Plugin pages expand/collapse ═══
|
||||||
const nav = e.target.closest('.nav-item');
|
async function fetchPluginPages() {
|
||||||
if(nav) { loadPage(nav.dataset.page); e.preventDefault(); }
|
try {
|
||||||
|
var res = await fetch('./api/plugin-pages', {credentials:'include'});
|
||||||
|
var data = await res.json();
|
||||||
|
_pluginPagesCache = {};
|
||||||
|
(data.pages||[]).forEach(function(p){ _pluginPagesCache[p.plugin] = p; });
|
||||||
|
return _pluginPagesCache;
|
||||||
|
} catch(e) { return {}; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function togglePluginPages() {
|
||||||
|
var body = document.getElementById('nav-plugins-body');
|
||||||
|
var arrow = document.getElementById('nav-plugins-arrow');
|
||||||
|
var isOpen = body.style.display !== 'none';
|
||||||
|
if(isOpen){ body.style.display='none'; arrow.style.transform=''; return; }
|
||||||
|
body.style.display='block';
|
||||||
|
arrow.style.transform='rotate(180deg)';
|
||||||
|
|
||||||
|
if(!_pluginPagesCache) await fetchPluginPages();
|
||||||
|
var pages = _pluginPagesCache || {};
|
||||||
|
var names = Object.keys(pages);
|
||||||
|
if(names.length) {
|
||||||
|
var html = '';
|
||||||
|
names.forEach(function(name){
|
||||||
|
var p = pages[name];
|
||||||
|
html += '<div class="nav-sub-item" data-plugin="'+p.plugin+'" data-path="'+p.path+'">'+
|
||||||
|
'<span class="nav-icon">'+(p.icon||'P')+'</span><span>'+p.title+'</span></div>';
|
||||||
|
});
|
||||||
|
body.innerHTML = html;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══ Click delegation ═══
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
// Built-in nav
|
||||||
|
var nav = e.target.closest('.nav-item');
|
||||||
|
if(nav && nav.dataset.page) {
|
||||||
|
navigateTo(nav.dataset.page);
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Plugin sub-item
|
||||||
|
var sub = e.target.closest('.nav-sub-item');
|
||||||
|
if(sub && sub.dataset.plugin) {
|
||||||
|
navigateTo('plugins/' + sub.dataset.plugin);
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,7 +14,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<h3>🧠 内存使用</h3>
|
<h3>🧠 内存使用</h3>
|
||||||
|
<div style="display:flex;align-items:baseline;gap:8px">
|
||||||
<div class="stat-value" id="d-mem">--%</div>
|
<div class="stat-value" id="d-mem">--%</div>
|
||||||
|
<div class="stat-sub" id="d-mem-detail">-- / --</div>
|
||||||
|
</div>
|
||||||
<canvas id="chart-mem" class="mini-chart"></canvas>
|
<canvas id="chart-mem" class="mini-chart"></canvas>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
|
|||||||
@@ -74,6 +74,8 @@ window.DashboardModule = {
|
|||||||
if (sys.memory) {
|
if (sys.memory) {
|
||||||
var m = sys.memory.percent || 0;
|
var m = sys.memory.percent || 0;
|
||||||
var el = document.getElementById('d-mem'); if (el) el.textContent = m + '%';
|
var el = document.getElementById('d-mem'); if (el) el.textContent = m + '%';
|
||||||
|
el = document.getElementById('d-mem-detail');
|
||||||
|
if (el) el.textContent = (sys.memory.used_gb || 0).toFixed(1) + ' / ' + (sys.memory.total_gb || 0).toFixed(1) + ' GB';
|
||||||
self.charts.mem.update(m);
|
self.charts.mem.update(m);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user