- Nav.tsx(用户端侧边栏)/AdminApp.tsx(管理面板顶栏) 的 <img src="/icon.svg"> 占位 → 昔涟头像(Cyrene-2F-N-Happy-1-192x192.png, 与 favicon/项目图标一致) - 与角色视觉分离红线: 项目图标/favicon 用昔涟(全局), 聊天头像走 personaAvatar(按角色取, 绝不 fallback 昔涟) - 修复: 之前定了项目图标用昔涟, 但侧边栏/顶栏仍硬编码旧占位 /icon.svg - npm run build 绿
215 lines
7.5 KiB
TypeScript
215 lines
7.5 KiB
TypeScript
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>
|
||
);
|
||
}
|