Files
Cyrene_v2/frontend/web/src/components/Nav.tsx
T
Aska 1b0b5bf99c
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(web): 项目图标统一用昔涟头像——侧边栏/管理面板顶栏 logo 从旧占位 /icon.svg 改为昔涟
- Nav.tsx(用户端侧边栏)/AdminApp.tsx(管理面板顶栏) 的 <img src="/icon.svg"> 占位 → 昔涟头像(Cyrene-2F-N-Happy-1-192x192.png, 与 favicon/项目图标一致)
- 与角色视觉分离红线: 项目图标/favicon 用昔涟(全局), 聊天头像走 personaAvatar(按角色取, 绝不 fallback 昔涟)
- 修复: 之前定了项目图标用昔涟, 但侧边栏/顶栏仍硬编码旧占位 /icon.svg
- npm run build 绿
2026-09-07 15:04:43 +08:00

215 lines
7.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState } from "react";
import { applyTheme, readTheme, type ThemeMode } from "../lib/theme";
import { createSubSession, getAuthToken } from "../lib/api";
import { useAppStore } from "../store/useAppStore";
import { Icon, type IconName } from "./Icon";
import { Tag } from "./primitives";
/**
* Left rail — NOT a behavior-mode list (design/01 §3: mode switching is driven
* at runtime by the agent / user, and rendered per message). This is a small
* navigation rail: brand + connection status + theme + admin/settings entry.
*
* Two surface variants share the same NavContent:
* - Sidebar (desktop): fixed rail, `hidden md:flex`.
* - MobileNav(mobile): drawer overlay opened from TopBar's hamburger button
* (`md:hidden`), with a scrim + a spring slide-in panel. Content is shared
* so desktop and mobile never diverge.
*/
function NavContent({ onNavigate }: { onNavigate: () => void }): React.ReactNode {
const wsStatus = useAppStore((s) => s.wsStatus);
const mood = useAppStore((s) => s.mood);
const sessions = useAppStore((s) => s.sessions);
const activeSessionId = useAppStore((s) => s.activeSessionId);
const setSessions = useAppStore((s) => s.setSessions);
const setActiveSession = useAppStore((s) => s.setActiveSession);
const [theme, setTheme] = useState<ThemeMode>(readTheme);
const [creating, setCreating] = useState(false);
const connected = wsStatus === "connected";
const wsLabel = connected ? "实时连接" : wsStatus === "expired" ? "登录过期" : "未连接";
const hasToken = !!getAuthToken();
const toggleTheme = () => {
const next: ThemeMode = theme === "dark" ? "light" : "dark";
applyTheme(next);
setTheme(next);
};
// Main session pinned first; secondary sessions follow by creation time.
const sessionList = Object.values(sessions).sort((a, b) => {
if (a.type !== b.type) return a.type === "main" ? -1 : 1;
return a.createdAt - b.createdAt;
});
const selectSession = (id: string) => {
setActiveSession(id);
onNavigate();
};
const createSession = async () => {
if (creating) return;
setCreating(true);
try {
const meta = await createSubSession();
// Optimistically append so the new session shows immediately; the backend
// will return it on the next GET /sessions too.
const next = Object.values(useAppStore.getState().sessions);
setSessions([...next, ...(next.some((s) => s.id === meta.id) ? [] : [meta])]);
setActiveSession(meta.id);
onNavigate();
} catch {
// Backend not wired yet — leave the list unchanged.
} finally {
setCreating(false);
}
};
return (
<>
<div className="flex items-center gap-2 px-2 py-2">
<img src="/images/Cyrene_Avatar/2nd_Form/Cyrene-2F-N-Happy-1-192x192.png" alt="Cyrene" className="h-7 w-7 rounded-full object-cover" />
<div className="leading-tight">
<div className="text-sm font-semibold">Cyrene</div>
<div className="text-[11px] text-[var(--mu-text-lo)]">昔涟</div>
</div>
</div>
{/* connection status */}
<NavItem
active={connected}
icon="channel"
label={wsLabel}
onClick={onNavigate}
hint={wsStatus}
/>
{mood ? (
<div className="px-2 py-1 text-[11px] text-[var(--mu-text-lo)]">
心情 · {mood}
</div>
) : null}
{/* conversation sessions (design/16 §2) — main fixed, secondary created */}
{hasToken ? (
<div className="flex flex-col gap-1">
<div className="px-2 pt-1 text-[11px] text-[var(--mu-text-lo)]">
会话
</div>
{sessionList.map((s) => (
<NavItem
key={s.id}
active={s.id === activeSessionId}
icon={s.type === "main" ? "chat" : "folder"}
label={s.title ?? (s.type === "main" ? "主会话" : "次级会话")}
onClick={() => selectSession(s.id)}
/>
))}
<button
onClick={() => void createSession()}
disabled={creating}
className="flex items-center gap-2.5 rounded-[var(--mu-radius)] px-3 py-2 text-sm text-[var(--mu-text-mid)] transition-all duration-200 ease-[var(--mu-spring)] hover:bg-[var(--mu-surface-4)] active:scale-[0.98] disabled:opacity-60"
>
<Icon name="chat" size={18} />
<span>{creating ? "创建中…" : "+ 新建会话"}</span>
</button>
</div>
) : null}
<div className="mt-auto flex flex-col gap-1">
<NavItem
icon="settings"
label="设置"
onClick={() => {
window.location.hash = "#/admin/system";
onNavigate();
}}
/>
<NavItem
icon="shield"
label="管理面板"
onClick={() => {
window.location.hash = "#/admin";
onNavigate();
}}
/>
<NavItem
icon={theme === "dark" ? "sun" : "moon"}
label={theme === "dark" ? "浅色" : "深色"}
onClick={toggleTheme}
/>
{!hasToken ? <Tag tone="warn">未登录</Tag> : null}
</div>
</>
);
}
/** Desktop left rail. */
export function Sidebar(): React.ReactNode {
return (
<nav className="glass hidden h-full w-[var(--mu-sidebar-w)] shrink-0 flex-col gap-1 border-r border-[var(--mu-glass-border)] p-3 md:flex">
<NavContent onNavigate={() => {}} />
</nav>
);
}
/** Mobile drawer — overlay + scrim + spring slide-in panel. */
export function MobileNav({ open, onClose }: { open: boolean; onClose: () => void }): React.ReactNode {
if (!open) return null;
return (
<div className="fixed inset-0 z-40 md:hidden" role="dialog" aria-modal="true">
{/* scrim */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-[2px]"
onClick={onClose}
aria-label="关闭侧边栏"
/>
{/* panel */}
<nav className="glass absolute inset-y-0 left-0 flex w-[var(--mu-sidebar-w)] max-w-[80vw] flex-col gap-1 border-r border-[var(--mu-glass-border)] p-3"
style={{ animation: "mu-drawer-in 0.3s var(--mu-spring) both" }}
>
<div className="flex items-center justify-between px-2 pb-1">
<span className="text-xs font-medium text-[var(--mu-text-lo)]">菜单</span>
<button
onClick={onClose}
aria-label="关闭"
className="inline-flex h-8 w-8 items-center justify-center rounded-[var(--mu-radius)] text-[var(--mu-text-mid)] transition-all duration-200 hover:bg-[var(--mu-surface-4)] active:scale-95"
>
<Icon name="close" size={18} />
</button>
</div>
<NavContent onNavigate={onClose} />
</nav>
</div>
);
}
function NavItem({
icon,
label,
onClick,
active = false,
hint,
}: {
icon: IconName;
label: string;
onClick: () => void;
active?: boolean;
hint?: string;
}): React.ReactNode {
return (
<button
onClick={onClick}
className={`flex items-center gap-2.5 rounded-[var(--mu-radius)] px-3 py-2 text-sm transition-all duration-200 ease-[var(--mu-spring)] active:scale-[0.98] ${
active
? "bg-[var(--mu-accent)]/15 font-medium text-[var(--mu-accent-hi)]"
: "text-[var(--mu-text-mid)] hover:bg-[var(--mu-surface-4)]"
}`}
>
<Icon name={icon} size={18} />
<span>{label}</span>
{hint ? <span className="ml-auto text-[10px] text-[var(--mu-text-lo)]">{hint}</span> : null}
</button>
);
}