feat(webui): app shell with flat nav, activity spine, and theme init

This commit is contained in:
xiaoxixi 2026-07-24 10:01:07 +08:00
parent c64984b496
commit 14ca5dd7b4
5 changed files with 56 additions and 25 deletions

View File

@ -78,6 +78,13 @@ pub async fn webui_styles() -> Response {
)
}
pub async fn webui_theme_init() -> Response {
static_response(
"text/javascript; charset=utf-8",
include_str!(concat!(env!("OUT_DIR"), "/webui/theme-init.js")),
)
}
const EMBEDDED_FONTS: &[(&str, &[u8])] = &[
(
"space-grotesk-500.woff2",

View File

@ -593,6 +593,7 @@ fn build_router(state: Arc<GatewayState>) -> Router {
.route("/", routing::get(http::webui_index))
.route("/app.js", routing::get(http::webui_script))
.route("/styles.css", routing::get(http::webui_styles))
.route("/theme-init.js", routing::get(http::webui_theme_init))
.route("/fonts/{name}", routing::get(http::webui_font))
.route("/health", routing::get(http::health))
.route("/api/auth/status", routing::get(auth::status))

View File

@ -4,7 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light dark" />
<meta name="theme-color" content="#0d1117" />
<meta name="theme-color" content="#0b1017" />
<script src="/theme-init.js"></script>
<title>PicoBot Console</title>
</head>
<body>

View File

@ -0,0 +1,5 @@
try {
var t = localStorage.getItem("picobot-theme");
if (!t) t = matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
document.documentElement.dataset.theme = t;
} catch (e) {}

View File

@ -2,7 +2,10 @@
import { onMount } from "svelte";
import { Tooltip } from "bits-ui";
import { api } from "./lib/api.js";
import { chat } from "./lib/chat.svelte.js";
import { applyTheme, preferredTheme } from "./lib/theme.js";
import Toast from "./lib/Toast.svelte";
import ActivitySpine from "./lib/components/ActivitySpine.svelte";
import ChatPage from "./pages/ChatPage.svelte";
import TasksPage from "./pages/TasksPage.svelte";
import MemoryPage from "./pages/MemoryPage.svelte";
@ -11,11 +14,13 @@
import PairingPage from "./pages/PairingPage.svelte";
const pages = [
["chat", "◉", "在线聊天", "与你的 PicoBot 实时对话"],
["tasks", "⌁", "任务执行", "查看定时任务、运行记录与后台子任务"],
["memory", "◇", "记忆", "检索 Knowledge 与 Timeline"],
["logs", "≋", "运行日志", "查看 Gateway 最近的本地日志"],
["settings", "⚙", "配置", "管理运行配置与助手档案"]
["chat", "◫", "聊天"],
["overview", "◉", "概览"],
["tools", "🧰", "工具&Skills"],
["logs", "≋", "日志"],
["memory", "◇", "记忆"],
["tasks", "⌁", "任务"],
["settings", "⚙", "配置"]
];
let current = $state("chat");
let menuOpen = $state(false);
@ -42,12 +47,9 @@
menuOpen = false;
}
function applyTheme(next) {
theme = next;
document.documentElement.dataset.theme = next;
document.documentElement.style.colorScheme = next;
document.querySelector('meta[name="theme-color"]')?.setAttribute("content", next === "dark" ? "#0d1117" : "#f6f7f9");
localStorage.setItem("picobot-theme", next);
function toggleTheme() {
theme = theme === "dark" ? "light" : "dark";
applyTheme(theme);
}
async function checkAuth() {
@ -66,16 +68,26 @@
health();
}
$effect(() => {
if (authenticated) {
chat.connect();
} else {
chat.disconnect();
}
});
onMount(() => {
const saved = localStorage.getItem("picobot-theme");
applyTheme(saved === "light" || saved === "dark" ? saved : (matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"));
const preferred = preferredTheme();
applyTheme(preferred);
theme = preferred;
checkAuth().then(() => authenticated && health());
const authRequired = () => authenticated = false;
const authRequired = () => (authenticated = false);
window.addEventListener("picobot-auth-required", authRequired);
const timer = setInterval(() => authenticated && health(), 30_000);
return () => {
clearInterval(timer);
window.removeEventListener("picobot-auth-required", authRequired);
chat.disconnect();
};
});
</script>
@ -94,21 +106,26 @@
<button class:active={current === page[0]} onclick={() => selectPage(page[0])}><span>{page[1]}</span>{page[2]}</button>
{/each}
</nav>
<div class="gateway-status"><i class:online></i><div><b>{online ? "运行中" : "不可用"}</b><small>{version}</small></div></div>
</aside>
<main>
<header class="topbar">
<button class="menu" aria-label="菜单" onclick={() => (menuOpen = !menuOpen)}>☰</button>
<div><h1>{meta[2]}</h1><p>{meta[3]}</p></div>
<button class="theme-toggle" aria-label={theme === "dark" ? "切换到浅色主题" : "切换到深色主题"} onclick={() => applyTheme(theme === "dark" ? "light" : "dark")}>
<div class="gateway-status">
<i class:online></i>
<div><b>{online ? "运行中" : "不可用"}</b><small>{version}</small></div>
<button class="theme-toggle" aria-label={theme === "dark" ? "切换到浅色主题" : "切换到深色主题"} onclick={toggleTheme}>
<span aria-hidden="true">{theme === "dark" ? "☀" : "☾"}</span><span>{theme === "dark" ? "浅色" : "深色"}</span>
</button>
</div>
</aside>
<main>
<ActivitySpine {version} />
<header class="topbar">
<button class="menu" aria-label="菜单" onclick={() => (menuOpen = !menuOpen)}>☰</button>
<h1>{meta[2]}</h1>
</header>
{#if current === "chat"}<ChatPage notify={(text, error) => toast.show(text, error)} />
{:else if current === "tasks"}<TasksPage />
{:else if current === "memory"}<MemoryPage />
{:else if current === "logs"}<LogsPage />
{:else}<SettingsPage notify={(text, error) => toast.show(text, error)} />{/if}
{:else if current === "memory"}<MemoryPage />
{:else if current === "tasks"}<TasksPage />
{:else if current === "settings"}<SettingsPage notify={(text, error) => toast.show(text, error)} />
{:else}<div class="empty-card">即将上线</div>{/if}
</main>
</div>
<Toast bind:this={toast} />