Files
ModelRouter/cmd/gui/renderer/index.html
JianFeeeee ed9baae8e4 feat(gui): 插件面板补齐安装/编辑/删除,on_disk 报告页面
桌面版插件面板此前只有启用/禁用,装不了新插件、读不回源码、删不掉坏的
插件——而 WebUI 有完整能力。两边不一致,且缺失的那三个恰好是出问题时唯一
能用的三个操作。

补齐(复用后端已有契约,未新增端点):
- POST /api/plugins 安装并保存源码。保存走安装路径是有意的:它会校验
  名称、编译并热重载,而 PUT /{name} 只切 enabled 开关,指过去会静默
  什么都没存。
- GET /api/plugins/{name} 读源码(读的是磁盘上的源,与写回可往返)。
- DELETE /api/plugins/{name} 删除。内置插件不显示删除按钮。
- 内置插件的"编辑"改为"查看源码"并置只读:它的文件在二进制里,给一个
  保存后被丢弃的文本框比直接说明更糟。
- 三个弹窗复用既有的 overlay / overlay-card,不新造一套弹窗体系。

后端补 DiskEntry.Pages:GUI 读的是 on_disk 而非 List,pages 只在 List
里,所以面板里那行"页面:…"永远不显示。新增 pageNames() 复刻加载期的
合并规则(page + pages,去重保序)。判据覆盖 pages 与单页 ui.page 两种
写法,并断言 JSON 里带 pages——GUI 读的是 HTTP body,不是 Go 结构体。
变异去掉赋值后两条判据都失败。

CDP 实测 10 项全部通过:安装带页面的插件、源码往返一致、禁用/启用、
热重载后版本号变化、坏插件在列表可见并带错误信息、非法名拦截、语法错误
被后端拒绝而非静默写入、路径穿越被 IPC 代理拦截。

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
2026-10-03 08:48:46 +08:00

236 lines
9.0 KiB
HTML

<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; connect-src * data: blob:; img-src * data: blob:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; frame-src *;"
/>
<title>ModelRouter</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<!-- sakura/frost glassmorphism background -->
<div id="bgfx" aria-hidden="true">
<i class="blob b1"></i><i class="blob b2"></i><i class="blob b3"></i>
</div>
<!-- frameless draggable titlebar -->
<div class="titlebar">
<div class="titlebar-brand">
<span class="tb-logo">M</span>
<span class="tb-title">ModelRouter</span>
<span class="status-dot" id="conn-dot" title="内核状态"></span>
</div>
<div class="titlebar-right">
<button class="tb-btn" id="tb-settings" title="设置">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
width="15"
height="15"
>
<circle cx="12" cy="12" r="3" />
<path
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"
/>
</svg>
</button>
<button class="tb-btn" id="tb-min" title="最小化">
<svg
viewBox="0 0 24 24"
width="13"
height="13"
fill="none"
stroke="currentColor"
stroke-width="1.6"
stroke-linecap="round"
>
<line x1="4" y1="12" x2="20" y2="12" />
</svg>
</button>
<button class="tb-btn" id="tb-max" title="最大化">
<svg
viewBox="0 0 24 24"
width="12"
height="12"
fill="none"
stroke="currentColor"
stroke-width="1.6"
stroke-linecap="round"
>
<rect x="5" y="5" width="14" height="14" rx="1.5" />
</svg>
</button>
<button class="tb-btn tb-close" id="tb-close" title="关闭">
<svg
viewBox="0 0 24 24"
width="14"
height="14"
fill="none"
stroke="currentColor"
stroke-width="1.6"
stroke-linecap="round"
>
<path d="M6 6l12 12M18 6L6 18" />
</svg>
</button>
</div>
</div>
<!-- side rail: autostart / silent toggles (Clash-Verge style) -->
<aside class="rail" id="rail">
<div class="rail-gap"></div>
<button
class="rail-btn tgl"
id="rail-autostart"
title="开机自启(点击切换)"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M18 10h-1.26A8 8 0 1 0 9 20h9a5 5 0 0 0 0-10z" />
</svg>
<span class="rail-dot" id="rail-autostart-dot"></span>
</button>
<button
class="rail-btn tgl"
id="rail-silent"
title="静默启动(启动不弹窗仅驻托盘,点击切换)"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 3v10l4 4" />
<path d="M8 21h8" />
<path d="M12 8a8 8 0 1 0 8 8" />
</svg>
<span class="rail-dot" id="rail-silent-dot"></span>
</button>
<div class="rail-gap"></div>
<button class="rail-btn" id="rail-settings" title="设置">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="3" />
<path
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"
/>
</svg>
</button>
<button class="rail-btn tgl" id="rail-theme" title="切换亮色/暗色">
<span class="theme-ic"></span>
</button>
</aside>
<!-- main content: full-screen embedded WebUI (1:1 clone of the web UI) -->
<div id="app">
<div class="app-main">
<iframe
id="webui-frame"
class="webui-frame"
title="ModelRouter Web UI"
></iframe>
<div id="offline" class="offline" style="display: none">
<div class="off-card">
<div class="off-logo">M</div>
<div class="off-title">内核未运行</div>
<div class="off-sub">
内嵌 ModelRouter 网关尚未启动,启动后即可使用完整 WebUI
</div>
<button class="primary" id="off-start">启动内核</button>
</div>
</div>
<div id="console-loading" class="console-loading" style="display: none">
<div class="spinner"></div>
<div>正在启动内核…</div>
</div>
</div>
</div>
<!-- settings overlay -->
<div id="settings-overlay" class="overlay" style="display: none">
<div class="overlay-card">
<div class="overlay-head">
<h2>设置</h2>
<button class="tb-btn" id="set-close" title="关闭">
<svg
viewBox="0 0 24 24"
width="14"
height="14"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
>
<path d="M6 6l12 12M18 6L6 18" />
</svg>
</button>
</div>
<div class="form">
<div class="row">
<label>内核端口</label
><input id="set-port" type="number" min="1" max="65535" />
</div>
<div class="row">
<label class="toggle"
><input type="checkbox" id="set-autostart" /> 开机自启</label
><span class="hint">登录后自动启动内核</span>
</div>
<div class="row">
<label class="toggle"
><input type="checkbox" id="set-silent" /> 静默启动</label
><span class="hint">启动时不显示主窗口,仅驻留托盘</span>
</div>
<div class="row">
<label class="toggle"
><input type="checkbox" id="set-tray" /> 关闭时最小化到托盘</label
><span class="hint">点关闭按钮隐藏到系统托盘</span>
</div>
<div class="row">
<label>插件</label>
<span class="hint" id="set-plugins-hint">加载中…</span>
</div>
<div id="pl-list" class="pl-list"></div>
<div class="row actions">
<button class="ghost" id="pl-toggle-all">全部启用</button>
<button class="ghost" id="pl-install">安装插件</button>
<button class="ghost" id="pl-reload">刷新</button>
</div>
<div class="row actions">
<button class="ghost" id="set-dir">打开数据目录</button>
<button class="ghost" id="set-log">查看日志</button>
</div>
<div class="row actions right">
<button class="ghost" id="set-cancel">取消</button>
<button class="primary" id="set-save">保存</button>
</div>
</div>
</div>
</div>
<div id="toast"></div>
<script src="app.js"></script>
</body>
</html>