feat(gui): Electron desktop with embedded core, tray, autostart, win/linux packaging

- cmd/gui: Electron shell (Clash-Verge style) embedding the full WebUI 1:1
  - embedded llmsproxy core (luajit) with auto-generated profile
  - key stored in keys[] (non-seed) so no replace-the-key warning
  - gw_key cookie injection: web UI works without login
  - side-rail toggles for autostart / silent start
  - system tray with status + controls, silent start (--silent)
  - win cross-build (mingw luajit exe + dll) / deb / AppImage via electron-builder
- Makefile: build / gui / gui-dist / gui-deb / gui-win targets
- README: desktop GUI section
- lua(adapter): opencode normalizes non-whitelisted roles to system
This commit is contained in:
2026-08-16 09:53:05 +08:00
parent 40e08b14d2
commit 47f3b44d92
16 changed files with 6102 additions and 2 deletions

198
cmd/gui/renderer/app.js Normal file
View File

@ -0,0 +1,198 @@
// ModelRouter Desktop GUI — renderer
// Clash-Verge-style: the content area is a full-screen iframe loading the
// real WebUI 1:1 (status / chat / keys / priority / sources / adapters).
// The shell only adds the titlebar, settings overlay and tray integration.
const state = {
core: {
running: false,
baseUrl: "",
port: 8787,
profileDir: "",
configFile: "",
coreExists: true,
},
settings: {
port: 8787,
autoStart: true,
silentStart: false,
minimizeToTray: true,
},
theme: localStorage.getItem("mr-theme") || "light",
loadedOnce: false,
};
const $ = (s) => document.querySelector(s);
function esc(s) {
return String(s == null ? "" : s).replace(
/[&<>"']/g,
(c) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[
c
],
);
}
function toast(msg, isErr, ms) {
const t = $("#toast");
t.textContent = msg;
t.className = "show" + (isErr ? " err" : "");
clearTimeout(t._tm);
t._tm = setTimeout(() => {
t.className = "";
}, ms || 2600);
}
const frame = () => $("#webui-frame");
// ===== core status =====
async function refreshCore() {
state.core = await window.modelrouter.core.state();
renderCoreStatus();
}
function renderCoreStatus() {
const core = state.core;
const dot = $("#conn-dot");
dot.className = "status-dot" + (core.running ? " ok" : " bad");
dot.title = core.running ? "内核运行中" : "内核未运行";
}
function showOffline() {
frame().style.display = "none";
$("#offline").style.display = "flex";
$("#console-loading").style.display = "none";
}
function showLoading() {
frame().style.display = "none";
$("#offline").style.display = "none";
$("#console-loading").style.display = "flex";
}
function loadFrame(url) {
const f = frame();
f.style.display = "block";
$("#offline").style.display = "none";
$("#console-loading").style.display = "none";
f.src = url;
state.loadedOnce = true;
}
async function ensureCore() {
await refreshCore();
if (state.core.running) {
loadFrame(state.core.baseUrl);
} else {
showOffline();
}
}
// ===== side rail toggles =====
function renderRail() {
const auto = $("#rail-autostart-dot");
const sil = $("#rail-silent-dot");
if (auto) auto.classList.toggle("on", !!state.settings.autoStart);
if (sil) sil.classList.toggle("on", !!state.settings.silentStart);
}
async function toggleAutoStart() {
const next = !state.settings.autoStart;
state.settings.autoStart = next;
await window.modelrouter.settings.set({ autoStart: next });
renderRail();
toast(next ? "开机自启已开启" : "开机自启已关闭");
}
async function toggleSilent() {
const next = !state.settings.silentStart;
state.settings.silentStart = next;
await window.modelrouter.settings.set({ silentStart: next });
renderRail();
toast(next ? "静默启动已开启" : "静默启动已关闭");
}
// ===== settings overlay =====
async function openSettings() {
state.settings = await window.modelrouter.settings.get();
$("#set-port").value = state.settings.port ?? 8787;
$("#set-autostart").checked = !!state.settings.autoStart;
$("#set-silent").checked = !!state.settings.silentStart;
$("#set-tray").checked = !!state.settings.minimizeToTray;
$("#settings-overlay").style.display = "flex";
renderRail();
}
function closeSettings() {
$("#settings-overlay").style.display = "none";
}
async function saveSettings() {
const port = parseInt($("#set-port").value, 10);
const autoStart = $("#set-autostart").checked;
const silentStart = $("#set-silent").checked;
const minimizeToTray = $("#set-tray").checked;
await window.modelrouter.settings.set({
port,
autoStart,
silentStart,
minimizeToTray,
});
closeSettings();
toast("设置已保存");
await refreshCore();
if (state.core.running) {
loadFrame(state.core.baseUrl);
}
}
// ===== init =====
function init() {
$("#tb-min").onclick = () => window.modelrouter.win.minimize();
$("#tb-max").onclick = () => window.modelrouter.win.toggleMaximize();
$("#tb-close").onclick = () => window.modelrouter.win.close();
$("#tb-settings").onclick = openSettings;
$("#rail-settings").onclick = openSettings;
$("#rail-autostart").onclick = toggleAutoStart;
$("#rail-silent").onclick = toggleSilent;
$("#rail-theme").onclick = () => {
state.theme = state.theme === "dark" ? "light" : "dark";
applyTheme();
};
$("#set-close").onclick = closeSettings;
$("#set-cancel").onclick = closeSettings;
$("#set-save").onclick = saveSettings;
$("#set-dir").onclick = () =>
window.modelrouter.shell.openPath(state.core.profileDir);
$("#set-log").onclick = async () => {
const p = await window.modelrouter.settings.logFile();
window.modelrouter.shell.openPath(p);
};
$("#off-start").onclick = async () => {
showLoading();
await window.modelrouter.core.start();
await refreshCore();
if (state.core.running) loadFrame(state.core.baseUrl);
else showOffline();
};
frame().onload = () => refreshCore();
window.modelrouter.core.onState((d) => {
state.core = Object.assign({}, state.core, d);
renderCoreStatus();
if (d.running && !state.loadedOnce) loadFrame(state.core.baseUrl);
if (!d.running) showOffline();
});
// Escape closes overlay
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && $("#settings-overlay").style.display === "flex")
closeSettings();
});
}
async function boot() {
init();
state.settings = await window.modelrouter.settings.get();
renderRail();
await ensureCore();
}
boot();

225
cmd/gui/renderer/index.html Normal file
View File

@ -0,0 +1,225 @@
<!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>
<!-- NapCat 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 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>

598
cmd/gui/renderer/style.css Normal file
View File

@ -0,0 +1,598 @@
/* =====================================================================
ModelRouter Desktop GUI — Clash-Verge-style shell
Content area = full-screen iframe embedding the real WebUI 1:1.
Palette mirrors the WebUI (NapCat sakura/frost/glassmorphism).
===================================================================== */
:root {
--primary: #ff7fac;
--primary-h: #f33b7c;
--primary-50: #fff0f5;
--primary-100: #ffe4e9;
--primary-200: #ffcdd9;
--primary-300: #ff9eb5;
--primary-400: #ff7fac;
--primary-500: #f33b7c;
--secondary: #88c0d0;
--secondary-h: #4c8dae;
--danger: #db3694;
--ok: #17a964;
--warn: #b7791f;
--bg-s1: #eef2ff;
--bg-s2: #ffffff;
--bg-s3: #ffe9f0;
--card: rgba(255, 255, 255, 0.62);
--card-solid: #fff;
--line: rgba(255, 127, 172, 0.18);
--line-strong: rgba(120, 90, 150, 0.22);
--fg: #3b3350;
--muted: #7c7a95;
--blob1: rgba(255, 127, 172, 0.45);
--blob2: rgba(136, 192, 208, 0.42);
--blob3: rgba(244, 114, 182, 0.3);
--sh-sm: 0 1px 2px rgba(70, 50, 110, 0.06);
--sh-md: 0 6px 20px rgba(120, 90, 160, 0.13);
--sh-lg: 0 18px 46px rgba(120, 90, 160, 0.22);
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
}
html[data-theme="dark"] {
--primary: #f54180;
--primary-h: #f871a0;
--primary-50: #310413;
--primary-100: #610726;
--primary-200: #920b3a;
--primary-300: #c20e4d;
--primary-400: #f31260;
--primary-500: #f54180;
--secondary: #5e9fbf;
--secondary-h: #88c0d0;
--danger: #db3694;
--ok: #4f7cff;
--warn: #e0b25c;
--bg-s1: #14121c;
--bg-s2: #1a1824;
--bg-s3: #221a28;
--card: rgba(34, 31, 50, 0.55);
--card-solid: #241f38;
--line: rgba(255, 127, 172, 0.16);
--line-strong: rgba(200, 180, 235, 0.18);
--fg: #ece6f5;
--muted: #a09db8;
--blob1: rgba(255, 79, 160, 0.32);
--blob2: rgba(100, 180, 215, 0.26);
--blob3: rgba(219, 54, 148, 0.22);
--sh-sm: 0 1px 2px rgba(0, 0, 0, 0.35);
--sh-md: 0 8px 26px rgba(0, 0, 0, 0.45);
--sh-lg: 0 20px 52px rgba(0, 0, 0, 0.55);
}
* {
box-sizing: border-box;
}
html,
body {
height: 100%;
}
body {
margin: 0;
color: var(--fg);
overflow: hidden;
font:
13px / 1.5 "Quicksand",
"Nunito",
"Inter",
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
Roboto,
"PingFang SC",
"Microsoft YaHei",
sans-serif;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
background: var(--bg-s1);
}
button {
font: inherit;
cursor: pointer;
border: none;
background: none;
color: inherit;
}
input {
font: inherit;
color: var(--fg);
}
::selection {
background: #ffcdba;
color: #fff;
}
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-thumb {
background: rgba(255, 182, 193, 0.45);
border-radius: 4px;
}
/* animated gradient-mesh background (NapCat PageBackground) */
#bgfx {
position: fixed;
inset: 0;
z-index: -10;
overflow: hidden;
background: linear-gradient(135deg, var(--bg-s1), var(--bg-s2), var(--bg-s3));
}
#bgfx i {
position: absolute;
border-radius: 50%;
filter: blur(64px);
will-change: transform;
opacity: 0.6;
}
#bgfx .b1 {
top: -10%;
left: -8%;
width: 560px;
height: 560px;
background: var(--blob1);
animation: drift1 30s ease-in-out infinite alternate;
}
#bgfx .b2 {
top: 16%;
right: -10%;
width: 440px;
height: 440px;
background: var(--blob2);
animation: drift2 36s ease-in-out infinite alternate;
}
#bgfx .b3 {
bottom: -12%;
left: 24%;
width: 620px;
height: 620px;
background: var(--blob3);
animation: drift3 42s ease-in-out infinite alternate;
}
@keyframes drift1 {
from {
transform: translate(0, 0) scale(1);
}
to {
transform: translate(30px, -20px) scale(1.05);
}
}
@keyframes drift2 {
from {
transform: translate(0, 0) scale(1);
}
to {
transform: translate(-25px, 18px) scale(1.03);
}
}
@keyframes drift3 {
from {
transform: translate(0, 0) scale(1);
}
to {
transform: translate(18px, 25px) scale(1.04);
}
}
/* ---- frameless titlebar ---- */
.titlebar {
height: 36px;
display: flex;
align-items: center;
padding: 0 8px 0 14px;
-webkit-app-region: drag;
user-select: none;
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.5),
rgba(255, 255, 255, 0.16)
);
backdrop-filter: blur(14px) saturate(1.4);
-webkit-backdrop-filter: blur(14px) saturate(1.4);
border-bottom: 1px solid var(--line);
position: relative;
z-index: 30;
}
html[data-theme="dark"] .titlebar {
background: linear-gradient(
180deg,
rgba(26, 23, 38, 0.7),
rgba(26, 23, 38, 0.42)
);
}
.titlebar-brand {
display: flex;
align-items: center;
gap: 9px;
}
.tb-logo {
width: 20px;
height: 20px;
border-radius: 6px;
background: linear-gradient(
135deg,
#3f6ef5,
#6a8ffb
); /* matches site favicon */
color: #fff;
font-size: 12px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
}
.tb-title {
font-size: 12.5px;
font-weight: 600;
letter-spacing: 0.03em;
color: var(--fg);
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #c8c6d2;
}
.status-dot.ok {
background: var(--ok);
box-shadow: 0 0 8px rgba(23, 169, 100, 0.55);
}
.status-dot.bad {
background: var(--danger);
}
.titlebar-right {
margin-left: auto;
display: flex;
align-items: center;
height: 36px;
-webkit-app-region: no-drag;
gap: 2px;
}
.tb-btn {
width: 34px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 7px;
color: var(--muted);
transition:
background 0.15s,
color 0.15s;
}
.tb-btn:hover {
background: var(--primary-100);
color: var(--fg);
}
.tb-btn.tb-close:hover {
background: var(--danger);
color: #fff;
}
/* ---- content area ---- */
#app {
position: absolute;
inset: 36px 0 0 0;
display: flex;
}
#rail {
flex: 0 0 56px;
width: 56px;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 10px 0;
background: var(--card2, rgba(255, 255, 255, 0.45));
border-right: 1px solid var(--line);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
.rail-gap {
flex: 1;
}
.rail-btn {
position: relative;
width: 38px;
height: 38px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 11px;
color: var(--muted);
transition: all 0.15s;
}
.rail-btn svg {
width: 18px;
height: 18px;
}
.rail-btn:hover {
color: var(--fg);
background: var(--primary-100);
}
.rail-btn.active {
color: var(--primary-h);
background: var(--primary-100);
}
/* toggle status dot (top-right corner of the button) */
.rail-dot {
position: absolute;
top: 5px;
right: 5px;
width: 7px;
height: 7px;
border-radius: 50%;
background: #c8c6d2;
border: 1.5px solid var(--card-solid, #fff);
box-sizing: content-box;
transition: background 0.15s;
}
.rail-dot.on {
background: var(--ok, #17a964);
box-shadow: 0 0 6px rgba(23, 169, 100, 0.6);
}
.theme-ic {
display: flex;
align-items: center;
justify-content: center;
}
.theme-ic svg {
width: 16px;
height: 16px;
}
/* content column: iframe sits to the right of the rail */
.app-main {
flex: 1;
min-width: 0;
position: relative;
}
.webui-frame {
width: 100%;
height: 100%;
border: none;
background: #fff;
}
html[data-theme="dark"] .webui-frame {
background: #1a1824;
}
/* ---- offline placeholder ---- */
.offline {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
z-index: 5;
}
.off-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 38px 46px;
background: var(--card);
border: 1px solid var(--line-strong);
border-radius: 20px;
box-shadow: var(--sh-lg);
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
}
.off-logo {
width: 52px;
height: 52px;
border-radius: 15px;
background: linear-gradient(
135deg,
#3f6ef5,
#6a8ffb
); /* matches site favicon */
color: #fff;
font-size: 26px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
box-shadow: var(--sh-md);
}
.off-title {
font-size: 17px;
font-weight: 700;
}
.off-sub {
font-size: 12.5px;
color: var(--muted);
max-width: 340px;
text-align: center;
}
.off-card button {
padding: 10px 26px;
border-radius: 10px;
font-weight: 600;
margin-top: 6px;
}
.off-card .primary {
background: var(--primary);
color: #fff;
}
.off-card .primary:hover {
background: var(--primary-h);
}
/* loading spinner */
.console-loading {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 14px;
color: var(--muted);
font-size: 13px;
z-index: 5;
}
.spinner {
width: 32px;
height: 32px;
border-radius: 50%;
border: 3px solid var(--primary-100);
border-top-color: var(--primary-h);
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* ---- settings overlay ---- */
.overlay {
position: absolute;
inset: 36px 0 0 0;
display: flex;
align-items: center;
justify-content: center;
z-index: 40;
background: rgba(30, 24, 48, 0.18);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
}
html[data-theme="dark"] .overlay {
background: rgba(10, 8, 18, 0.5);
}
.overlay-card {
width: 400px;
max-width: 92%;
background: var(--card-solid);
border: 1px solid var(--line-strong);
border-radius: 18px;
box-shadow: var(--sh-lg);
padding: 22px 24px;
animation: popIn 0.18s var(--ease-spring);
}
@keyframes popIn {
from {
opacity: 0;
transform: translateY(8px) scale(0.98);
}
to {
opacity: 1;
transform: none;
}
}
.overlay-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.overlay-head h2 {
margin: 0;
font-size: 17px;
}
.form .row {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 0;
border-bottom: 1px solid var(--line);
}
.form .row label {
flex: 0 0 110px;
font-size: 13px;
color: var(--muted);
}
.form .row .toggle {
flex: 1;
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
font-size: 13px;
color: var(--fg);
font-weight: 500;
}
.form .row .toggle input {
accent-color: var(--primary);
width: 15px;
height: 15px;
}
.form .row .hint {
font-size: 11px;
color: var(--muted);
}
.form input[type="number"] {
width: 120px;
padding: 7px 10px;
border: 1px solid var(--line-strong);
border-radius: 8px;
background: var(--bg-s2);
outline: none;
}
.form input[type="number"]:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px var(--primary-100);
}
.form .actions {
display: flex;
justify-content: flex-start;
gap: 9px;
border-bottom: none;
padding-top: 12px;
}
.form .actions.right {
justify-content: flex-end;
}
.form .actions button {
padding: 9px 18px;
border-radius: 9px;
font-weight: 600;
transition: all 0.15s;
}
.form .actions .primary {
background: var(--primary);
color: #fff;
}
.form .actions .primary:hover {
background: var(--primary-h);
}
.form .actions .ghost {
background: var(--bg-s2);
border: 1px solid var(--line-strong);
color: var(--muted);
}
.form .actions .ghost:hover {
background: var(--primary-50);
color: var(--fg);
}
/* ---- toast ---- */
#toast {
position: fixed;
right: 20px;
bottom: 20px;
z-index: 200;
padding: 11px 18px;
border-radius: 12px;
background: var(--card-solid);
color: var(--fg);
font-size: 13px;
box-shadow: var(--sh-lg);
border: 1px solid var(--line);
opacity: 0;
transform: translateY(10px);
pointer-events: none;
transition: all 0.25s var(--ease-spring);
}
#toast.show {
opacity: 1;
transform: none;
}
#toast.err {
border-color: var(--danger);
}