Files
Jyotisha/frontend/src/components/app-sidebar.tsx
T
Jesse_ChenandCursor 92558ee61d
Independent Staging Quality Gate / validate (push) Successful in 9m27s
Independent Staging Quality Gate / publish (push) Successful in 7m40s
fix(web): stack mobile audit tables and restore sidebar hierarchy
Narrow screens were wrapping 已执行 into a glyph column and painting every sidebar row the same gray, so history looked like top-level chrome.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-22 09:57:06 +08:00

321 lines
12 KiB
TypeScript

"use client";
import { Menu } from "@base-ui/react/menu";
import {
ChevronDown,
ChevronRight,
Clock3,
FileText,
Gift,
LogOut,
MessageSquareText,
SquarePen,
Star,
UserPlus,
UserRound,
Users,
} from "lucide-react";
import { useEffect, useRef } from "react";
import type { Ref } from "react";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarRail,
useSidebar,
} from "@/components/ui/sidebar";
import {
SidebarSessionRow,
type SidebarSession,
type SidebarSessionControls,
} from "@/components/sidebar-session-row";
import { UserAvatar } from "@/components/user-avatar";
import type { BeamAvatar } from "@/lib/beam-avatar";
export type SidebarAccount = {
name: string;
email: string;
credits: number;
initial: string;
avatar: BeamAvatar | null;
};
export type SidebarChart = {
readonly id: string;
readonly name: string;
readonly role: "self" | "other";
};
export type AppSidebarProps = {
sessions: readonly SidebarSession[];
charts: readonly SidebarChart[];
activeSessionId: string | null;
account: SidebarAccount;
accountMenuOpen: boolean;
accountTriggerRef: Ref<HTMLButtonElement>;
newChatDisabled: boolean;
creatingSession: boolean;
sessionControls: SidebarSessionControls;
onAccountMenuOpenChange: (open: boolean) => void;
onNewChat: () => void;
onOpenReports: () => void;
onSelectSession: (sessionId: string) => void;
onSelectChart: (chartId: string) => void;
onAddChart: () => void;
onOpenProfile: () => void;
onOpenRedeem: () => void;
onOpenLogout: () => void;
};
export function AppSidebar({
sessions,
charts,
activeSessionId,
account,
accountMenuOpen,
accountTriggerRef,
newChatDisabled,
creatingSession,
sessionControls,
onAccountMenuOpenChange,
onNewChat,
onOpenReports,
onSelectSession,
onSelectChart,
onAddChart,
onOpenProfile,
onOpenRedeem,
onOpenLogout,
}: AppSidebarProps) {
const { isMobile, setOpen, setOpenMobile, state, viewport } = useSidebar();
const firstSessionRef = useRef<HTMLButtonElement>(null);
const historyHeadingRef = useRef<HTMLElement>(null);
const isCollapsedDesktop = state === "collapsed" && !isMobile;
const showExpandedContent = !isCollapsedDesktop;
const menuPlacement = `${viewport}:${state}`;
const previousMenuPlacement = useRef(menuPlacement);
const favoriteSessions = sessions.filter((session) => session.pinned);
const historySessions = sessions.filter((session) => !session.pinned);
useEffect(() => {
if (previousMenuPlacement.current !== menuPlacement && accountMenuOpen) {
onAccountMenuOpenChange(false);
}
previousMenuPlacement.current = menuPlacement;
}, [accountMenuOpen, menuPlacement, onAccountMenuOpenChange]);
function handleNewChat() {
onNewChat();
if (isMobile) setOpenMobile(false);
}
function handleOpenReports() {
onOpenReports();
if (isMobile) setOpenMobile(false);
}
function handleExpandHistory() {
setOpen(true);
window.requestAnimationFrame(() => {
(firstSessionRef.current ?? historyHeadingRef.current)?.focus();
});
}
function renderSession(session: SidebarSession, index: number) {
return (
<SidebarMenuItem key={session.id}>
<SidebarSessionRow
ref={index === 0 ? firstSessionRef : undefined}
session={session}
active={session.id === activeSessionId}
disabled={sessionControls.disabled}
menuOpen={sessionControls.menuSessionId === session.id}
onMenuOpenChange={(open) => sessionControls.onMenuSessionChange(open ? session.id : null)}
onSelect={() => {
onSelectSession(session.id);
if (isMobile) setOpenMobile(false);
}}
onTogglePinned={() => sessionControls.onTogglePinned(session.id)}
onRename={() => sessionControls.onRename(session.id)}
onShare={() => sessionControls.onShare(session.id)}
onToggleArchived={() => sessionControls.onToggleArchived(session.id)}
onDelete={() => sessionControls.onDelete(session.id)}
/>
</SidebarMenuItem>
);
}
return (
<Sidebar className="sidebar" aria-label="对话导航">
<SidebarHeader className="sidebar-header">
<div className="brand-row">
<span className="brand-mark" aria-hidden="true" />
{showExpandedContent ? <strong>Jyotisha</strong> : null}
</div>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
className="new-chat"
type="button"
tooltip="新对话"
disabled={newChatDisabled}
onClick={handleNewChat}
>
<SquarePen size={18} strokeWidth={1.75} aria-hidden="true" />
{showExpandedContent ? <span>{creatingSession ? "正在创建" : "新建对话"}</span> : null}
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
className="report-nav-button"
type="button"
tooltip="我的报告"
onClick={handleOpenReports}
>
<FileText size={18} strokeWidth={1.75} aria-hidden="true" />
{showExpandedContent ? <span>我的报告</span> : null}
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
{showExpandedContent ? (
<>
<SidebarGroup className="chart-nav" aria-label="星盘列表">
<div className="session-nav-header">
<SidebarGroupLabel className="sidebar-label">
<Users size={18} strokeWidth={1.75} aria-hidden="true" />
星盘列表
</SidebarGroupLabel>
<button
className="session-nav-toggle"
type="button"
aria-label="添加星盘"
onClick={onAddChart}
>
<UserPlus size={18} strokeWidth={1.75} aria-hidden="true" />
</button>
</div>
<SidebarGroupContent className="sidebar-nested">
{charts.length === 0 ? <p className="sidebar-empty">还没有星盘</p> : (
<div className="chart-nav-list">
{charts.map((chart) => (
<button
className="chart-nav-chip"
key={chart.id}
type="button"
onClick={() => onSelectChart(chart.id)}
>
{chart.name}
</button>
))}
</div>
)}
</SidebarGroupContent>
</SidebarGroup>
<SidebarGroup className="session-nav" aria-label="收藏对话">
<details className="sidebar-section" open>
<summary className="sidebar-section-summary">
<Star size={18} strokeWidth={1.75} aria-hidden="true" />
<span>收藏对话</span>
<ChevronDown className="sidebar-section-chevron" size={16} strokeWidth={1.75} aria-hidden="true" />
</summary>
<SidebarGroupContent className="sidebar-nested">
{favoriteSessions.length === 0 ? <p className="sidebar-empty">还没有收藏</p> : (
<SidebarMenu className="session-list">
{favoriteSessions.map((session, index) => renderSession(session, index))}
</SidebarMenu>
)}
</SidebarGroupContent>
</details>
</SidebarGroup>
<SidebarGroup className="session-nav" aria-label={sessionControls.showingArchived ? "归档记录" : "历史对话"}>
<details className="sidebar-section" open>
<summary className="sidebar-section-summary" ref={historyHeadingRef} tabIndex={-1}>
<Clock3 size={18} strokeWidth={1.75} aria-hidden="true" />
<span>{sessionControls.showingArchived ? "归档记录" : "历史对话"}</span>
<ChevronDown className="sidebar-section-chevron" size={16} strokeWidth={1.75} aria-hidden="true" />
</summary>
<SidebarGroupContent className="sidebar-nested">
{historySessions.length === 0 ? <p className="sidebar-empty">暂无对话</p> : (
<SidebarMenu className="session-list">
{historySessions.map((session, index) => renderSession(session, favoriteSessions.length + index))}
</SidebarMenu>
)}
</SidebarGroupContent>
</details>
</SidebarGroup>
</>
) : (
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
type="button"
tooltip="聊天记录"
aria-label="聊天记录"
onClick={handleExpandHistory}
>
<MessageSquareText size={18} strokeWidth={1.75} aria-hidden="true" />
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
)}
</SidebarContent>
<SidebarFooter className="sidebar-footer">
<Menu.Root open={accountMenuOpen} onOpenChange={onAccountMenuOpenChange} modal={false}>
<Menu.Trigger
className="profile-trigger"
ref={accountTriggerRef}
type="button"
>
{account.avatar
? <UserAvatar avatar={account.avatar} size={32} className="profile-avatar" />
: <span className="profile-initial" aria-hidden="true">{account.initial}</span>}
{showExpandedContent ? <span><b>{account.name}</b></span> : null}
{showExpandedContent ? <ChevronRight className={accountMenuOpen ? "chevron is-open" : "chevron"} aria-hidden="true" /> : null}
</Menu.Trigger>
<Menu.Portal>
<Menu.Positioner
side={isMobile || state === "expanded" ? "top" : "right"}
align={isMobile || state === "expanded" ? "end" : "center"}
sideOffset={8}
collisionPadding={12}
>
<Menu.Popup className="account-menu-popup" aria-label="账户菜单">
<div className="account-menu-identity">
{account.avatar
? <UserAvatar avatar={account.avatar} size={40} className="account-menu-avatar" />
: <span className="account-menu-avatar" aria-hidden="true">{account.initial}</span>}
<span><b>{account.name}</b><small>{account.email}</small></span>
</div>
<Menu.Item className="account-menu-item" onClick={onOpenProfile}>
<UserRound aria-hidden="true" /><span>个人资料</span><ChevronRight aria-hidden="true" />
</Menu.Item>
<Menu.Item className="account-menu-item" onClick={onOpenRedeem}>
<Gift aria-hidden="true" /><span>兑换点数</span><small>{account.credits} </small>
</Menu.Item>
<Menu.Separator className="account-menu-separator" />
<Menu.Item className="account-menu-item account-menu-danger" onClick={onOpenLogout}>
<LogOut aria-hidden="true" /><span>退出登录</span>
</Menu.Item>
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
</Menu.Root>
</SidebarFooter>
<SidebarRail />
</Sidebar>
);
}