Files
Cyrene_v2/frontend/web/src/components/TopBar.tsx
T
AskaEth 852c327360
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
refactor(web): 随路径式入口清理失效的管理链接(设计:用户端不提供管理入口)
- TopBar / Nav 的 hash 形式管理入口(#/admin、#/admin/system)**删除**(design/07 §0.2:用户端不提供任何
  管理入口、bundle 不含该路径串);面板只经 ADMIN_PATH 路径直入
- AdminApp 三处 window.location.hash = "" 改调 router 导出的 navigateUserShell()
- 复核:tsc 0 错 + vite build 成功;用户端源码 grep "#/admin" 0 命中(仅在 channels/useAdapterNav.ts
  尚有模块内闭环的 hash 字面量,属另一决策,仅报告未改)
- 局限:无 playwright/puppeteer 且不许加依赖 ⇒ 「布局未塌」为结构+静态样式依赖推断(无子元素数量相关样式)
2026-10-04 14:01:43 +08:00

101 lines
4.5 KiB
TypeScript

import { useState } from "react";
import { useAppStore } from "../store/useAppStore";
import { useCurrentMood, useCurrentPersona } from "../store/personaSelectors";
import { useModeStore } from "../store/useModeStore";
import { applyTheme, readTheme, type ThemeMode } from "../lib/theme";
import { personaAvatar } from "../lib/persona";
import { labelFor } from "../lib/enums";
import type { Mode, WsStatus } from "../lib/types";
import { UiModeSwitch } from "../design/UiModeSwitch";
import { Tag } from "./primitives";
import { Icon, type IconName } from "./Icon";
import { AwakenBadge } from "./status";
const MODE_LABEL: Record<Mode, string> = {
chat: "对话", code: "代码", home: "家居", work: "工作", docs: "知识库",
voice: "语音", vision: "视觉", agents: "子代理", settings: "设置",
};
const MODE_ICON: Record<Mode, IconName> = {
chat: "chat", code: "terminal", home: "home", work: "folder", docs: "docs",
voice: "mic", vision: "vision", agents: "agent", settings: "settings",
};
const wsMeta: Record<WsStatus, { label: string; tone: "ok" | "warn" | "neutral" | "err" }> = {
idle: { label: "未连接", tone: "neutral" },
connecting: { label: "连接中", tone: "warn" },
connected: { label: "实时", tone: "ok" },
reconnecting: { label: "重连中", tone: "warn" },
disconnected: { label: "已断开", tone: "err" },
expired: { label: "登录过期", tone: "err" },
};
export function TopBar({ onMenu }: { onMenu: () => void }): React.ReactNode {
const wsStatus = useAppStore((s) => s.wsStatus);
// Both follow the *active session's* role (design/22 §4.6): switching sessions
// switches the avatar/name, and the mood is the one the active role reported
// (mood frames are role-scoped).
const mood = useCurrentMood();
const currentPersona = useCurrentPersona();
const mode = useModeStore((s) => s.mode);
const [theme, setTheme] = useState<ThemeMode>(readTheme);
const toggleTheme = () => {
const next: ThemeMode = theme === "dark" ? "light" : "dark";
applyTheme(next);
setTheme(next);
};
const meta = wsMeta[wsStatus];
return (
<header className="glass sticky top-0 z-30 flex h-[var(--mu-topbar-h)] items-center gap-2 border-b border-[var(--mu-glass-border)] px-3 sm:px-4">
<button
onClick={onMenu}
aria-label="打开菜单"
title="菜单"
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-[var(--mu-radius)] text-[var(--mu-text-mid)] transition-all duration-200 ease-[var(--mu-spring)] hover:bg-[var(--mu-surface-4)] active:scale-95 md:hidden"
>
<Icon name="menu" size={20} />
</button>
<div className="flex min-w-0 items-center gap-2">
<img src={personaAvatar(currentPersona)} alt={currentPersona?.name ?? "Cyrene"} className="h-7 w-7 shrink-0 rounded-full object-cover" />
<div className="min-w-0 leading-tight">
<div className="truncate text-sm font-semibold">{currentPersona?.name ?? "昔涟"}</div>
<div className="truncate text-[11px] text-[var(--mu-text-lo)]">
<span className="inline-flex items-center gap-1">
<Icon name={MODE_ICON[mode]} size={12} />
{MODE_LABEL[mode]}
</span>
</div>
</div>
</div>
<div className="flex-1" />
{mood ? <Tag tone="accent" className="hidden sm:inline-flex">心情 · {labelFor("mood", mood)}</Tag> : null}
<AwakenBadge />
<Tag tone={meta.tone} className="hidden sm:inline-flex">
<span className={`inline-block h-1.5 w-1.5 rounded-full ${wsStatus === "connected" ? "bg-[var(--mu-ok)]" : wsStatus === "expired" ? "bg-[var(--mu-err)]" : "mu-blink"}`} />
{meta.label}
</Tag>
{/* 双档 UI 档位(裁决 16.4.4):胶囊形态,尺寸对齐本栏其余 h-8 按钮。
窄屏(<sm)本栏已被头像/状态位占满,再塞 88px 会把角色名挤没甚至横向溢出,
故窄屏收起——移动端改由侧栏抽屉里的分段开关切档(Nav.tsx)。 */}
<span className="hidden shrink-0 sm:inline-flex">
<UiModeSwitch variant="compact" />
</span>
<button
onClick={toggleTheme}
aria-label="切换主题"
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-[var(--mu-radius)] bg-[var(--mu-surface-4)] text-sm transition-all duration-200 ease-[var(--mu-spring)] hover:bg-[var(--mu-surface-5)] active:scale-95"
>
<Icon name={theme === "dark" ? "sun" : "moon"} size={18} />
</button>
</header>
);
}