907 lines
41 KiB
TypeScript
907 lines
41 KiB
TypeScript
import classroomMark from '@/assets/consultations/mark.svg';
|
|
import { CodingProjectConversations } from '@/pages/Chat/CodingProjectConversations';
|
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { useLocation, useNavigate } from 'react-router-dom';
|
|
import {
|
|
Bot,
|
|
Cpu,
|
|
ChevronRight,
|
|
FolderKanban,
|
|
Gamepad2,
|
|
LogOut,
|
|
Plus,
|
|
RadioTower,
|
|
Settings as SettingsIcon,
|
|
UserCircle2,
|
|
} from 'lucide-react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
|
import { DisclosureContent } from '@/components/ui/disclosure';
|
|
import { getAiHardwareOverview, type AiHardwareOverview } from '@/lib/ai-hardware';
|
|
import {
|
|
AI_HARDWARE_NAVIGATION_EVENT,
|
|
AI_HARDWARE_OVERVIEW_EVENT,
|
|
announceAiHardwareNavigation,
|
|
readAiHardwareAgentId,
|
|
} from '@/lib/ai-hardware-navigation';
|
|
import { getAiModuleForPath } from '@/lib/ai-modules';
|
|
import { invokeIpc } from '@/lib/api-client';
|
|
import { getAuthUserDisplayName } from '@/lib/auth-user-display';
|
|
import {
|
|
subscribeCodingProjectCreationRequest,
|
|
subscribeCodingProjectOpenRequest,
|
|
} from '@/lib/coding-project-entry';
|
|
import { PointWallet } from '@/components/account/PointWallet';
|
|
import { cn } from '@/lib/utils';
|
|
import { useCurrentUserProfile } from '@/hooks/use-current-user-profile';
|
|
import type { SidebarPeekSource } from './sidebar-peek';
|
|
import { ModuleSwitcher } from './ModuleSwitcher';
|
|
import { ImageWorkspaceSidebar } from './ImageWorkspaceSidebar';
|
|
import { SidebarUpdateButton } from './SidebarUpdateButton';
|
|
import { ClassroomSidebarResizer } from './ClassroomSidebarResizer';
|
|
import { normalizeClassroomSidebarWidth } from '@/lib/classroom-layout';
|
|
import { UserAvatar } from '@/components/profile/UserAvatar';
|
|
import { getAccountInitial } from '@/components/profile/user-avatar-utils';
|
|
import { UserProfileDialog } from '@/components/profile/UserProfileDialog';
|
|
import { useSettingsStore } from '@/stores/settings';
|
|
import { useAuthStore } from '@/stores/auth';
|
|
import { useCodingWorkspaceStore } from '@/stores/coding-workspace';
|
|
import { useProviderStore } from '@/stores/providers';
|
|
import type { CodingProjectSummary } from '@/types/coding-project';
|
|
import { useProjectConfigStore } from '@/stores/project-config';
|
|
import { toast } from 'sonner';
|
|
import { CommunityLink } from '@/components/community/CommunityLink';
|
|
|
|
type OpenDialogResult = {
|
|
canceled: boolean;
|
|
filePaths: string[];
|
|
};
|
|
|
|
type ProjectEntryError = {
|
|
project: CodingProjectSummary;
|
|
message: string;
|
|
};
|
|
|
|
type ProjectDirectoryMode = 'use-selected-directory' | 'create-child-directory';
|
|
|
|
function getFolderName(pathValue: string): string {
|
|
const trimmed = pathValue.trim();
|
|
const withoutTrailingSeparators = trimmed.replace(/[\\/]+$/u, '');
|
|
return withoutTrailingSeparators.split(/[\\/]/u).filter(Boolean).at(-1) ?? trimmed;
|
|
}
|
|
|
|
type SidebarProps = {
|
|
workspaceLayout?: boolean;
|
|
hideOnCompact?: boolean;
|
|
forceExpanded?: boolean;
|
|
sidebarPeekOpen?: boolean;
|
|
onSidebarPeekChange?: (open: boolean, source: SidebarPeekSource) => void;
|
|
};
|
|
|
|
export function Sidebar({
|
|
workspaceLayout = false,
|
|
hideOnCompact = false,
|
|
forceExpanded = false,
|
|
sidebarPeekOpen = false,
|
|
onSidebarPeekChange,
|
|
}: SidebarProps = {}) {
|
|
const { t } = useTranslation('common');
|
|
const storedSidebarCollapsed = useSettingsStore((state) => state.sidebarCollapsed);
|
|
const sidebarWidth = useSettingsStore((state) => normalizeClassroomSidebarWidth(state.classroomSidebarWidth));
|
|
const sidebarPinnedCollapsed = !forceExpanded && storedSidebarCollapsed;
|
|
const sidebarCollapsed = sidebarPinnedCollapsed && !sidebarPeekOpen;
|
|
const projects = useCodingWorkspaceStore((state) => state.projects);
|
|
const activeProject = useCodingWorkspaceStore((state) => state.activeProject);
|
|
const loadProjectList = useCodingWorkspaceStore((state) => state.load);
|
|
const setActiveProject = useCodingWorkspaceStore((state) => state.setActiveProject);
|
|
const removeProject = useCodingWorkspaceStore((state) => state.removeProject);
|
|
const createProject = useCodingWorkspaceStore((state) => state.createProject);
|
|
const loadProjectConfig = useProjectConfigStore((state) => state.load);
|
|
const removeProjectConfig = useProjectConfigStore((state) => state.remove);
|
|
const authUser = useAuthStore((state) => state.user);
|
|
const logout = useAuthStore((state) => state.logout);
|
|
const {
|
|
userProfile,
|
|
profileRequired,
|
|
profileSyncError,
|
|
syncProfileNow,
|
|
} = useCurrentUserProfile();
|
|
const refreshProviderSnapshot = useProviderStore((state) => state.refreshProviderSnapshot);
|
|
const [projectsOpen, setProjectsOpen] = useState(true);
|
|
const [accountMenuOpen, setAccountMenuOpen] = useState(false);
|
|
const [profileDialogOpen, setProfileDialogOpen] = useState(false);
|
|
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
|
const [newProjectName, setNewProjectName] = useState('');
|
|
const [newProjectSelectedPath, setNewProjectSelectedPath] = useState('');
|
|
const [newProjectDirectoryMode, setNewProjectDirectoryMode] = useState<ProjectDirectoryMode>('use-selected-directory');
|
|
const [createProjectError, setCreateProjectError] = useState<string | null>(null);
|
|
const [creatingProject, setCreatingProject] = useState(false);
|
|
const [projectEntryError, setProjectEntryError] = useState<ProjectEntryError | null>(null);
|
|
const [removeProjectCandidate, setRemoveProjectCandidate] = useState<CodingProjectSummary | null>(null);
|
|
const [robotOverview, setRobotOverview] = useState<AiHardwareOverview | null>(null);
|
|
const [robotOverviewLoading, setRobotOverviewLoading] = useState(false);
|
|
const [robotSelectedAgentId, setRobotSelectedAgentId] = useState<string | null>(() => readAiHardwareAgentId());
|
|
const accountButtonRef = useRef<HTMLButtonElement | null>(null);
|
|
const accountMenuRef = useRef<HTMLDivElement | null>(null);
|
|
const projectEntryIntentRef = useRef(0);
|
|
const navigate = useNavigate();
|
|
const location = useLocation();
|
|
const activeModule = getAiModuleForPath(location.pathname);
|
|
const isProgrammingModule = activeModule === 'programming';
|
|
const isPaintingModule = activeModule === 'painting';
|
|
const isRobotModule = activeModule === 'robot';
|
|
const visibleProjects = projects;
|
|
const selectedProjectFolderName = getFolderName(newProjectSelectedPath);
|
|
const accountName = userProfile?.displayName?.trim() || getAuthUserDisplayName(authUser) || '未登录用户';
|
|
const accountFullName = accountName;
|
|
const accountInitial = getAccountInitial(accountName);
|
|
const accountAvatarUrl = userProfile?.avatarUrl ?? null;
|
|
const settingsLabel = t('sidebar.settings', '设置');
|
|
const activeRobotAgentId = isRobotModule ? robotSelectedAgentId : null;
|
|
|
|
const openRobotRoute = useCallback((agentId: string | null = null, openCreate = false) => {
|
|
const params = new URLSearchParams();
|
|
if (agentId) params.set('agent', agentId);
|
|
if (openCreate) params.set('create', '1');
|
|
const search = params.toString();
|
|
navigate({ pathname: '/ai-hardware', search: search ? `?${search}` : '' });
|
|
announceAiHardwareNavigation({ agentId, openCreate });
|
|
}, [navigate]);
|
|
|
|
useEffect(() => {
|
|
if (!isProgrammingModule) return;
|
|
void loadProjectList().catch(() => undefined);
|
|
}, [isProgrammingModule, loadProjectList]);
|
|
|
|
useEffect(() => {
|
|
if (!isProgrammingModule) return;
|
|
for (const project of visibleProjects) {
|
|
void loadProjectConfig(project.id).catch(() => undefined);
|
|
}
|
|
}, [isProgrammingModule, loadProjectConfig, visibleProjects]);
|
|
|
|
useEffect(() => {
|
|
if (!isProgrammingModule) return;
|
|
void refreshProviderSnapshot().catch(() => undefined);
|
|
}, [isProgrammingModule, refreshProviderSnapshot]);
|
|
|
|
useEffect(() => {
|
|
if (!isRobotModule) {
|
|
setRobotOverview(null);
|
|
setRobotOverviewLoading(false);
|
|
return undefined;
|
|
}
|
|
|
|
let active = true;
|
|
const handleOverview = (event: Event) => {
|
|
const next = (event as CustomEvent<AiHardwareOverview>).detail;
|
|
if (!next || !Array.isArray(next.agents) || !Array.isArray(next.devices)) return;
|
|
setRobotOverview(next);
|
|
setRobotOverviewLoading(false);
|
|
};
|
|
window.addEventListener(AI_HARDWARE_OVERVIEW_EVENT, handleOverview);
|
|
setRobotOverviewLoading(true);
|
|
void getAiHardwareOverview()
|
|
.then((next) => {
|
|
if (!active) return;
|
|
setRobotOverview(next);
|
|
setRobotOverviewLoading(false);
|
|
})
|
|
.catch(() => {
|
|
if (!active) return;
|
|
setRobotOverviewLoading(false);
|
|
});
|
|
|
|
return () => {
|
|
active = false;
|
|
window.removeEventListener(AI_HARDWARE_OVERVIEW_EVENT, handleOverview);
|
|
};
|
|
}, [isRobotModule]);
|
|
|
|
useEffect(() => {
|
|
if (!isRobotModule) {
|
|
setRobotSelectedAgentId(null);
|
|
return undefined;
|
|
}
|
|
|
|
setRobotSelectedAgentId(readAiHardwareAgentId(location.search));
|
|
const handleNavigation = (event: Event) => {
|
|
const detail = (event as CustomEvent<{ agentId?: string | null; openCreate?: boolean }>).detail;
|
|
if (!detail?.openCreate && detail?.agentId !== undefined) setRobotSelectedAgentId(detail.agentId);
|
|
};
|
|
window.addEventListener(AI_HARDWARE_NAVIGATION_EVENT, handleNavigation);
|
|
return () => window.removeEventListener(AI_HARDWARE_NAVIGATION_EVENT, handleNavigation);
|
|
}, [isRobotModule, location.search]);
|
|
|
|
useEffect(() => {
|
|
if (profileRequired || profileSyncError) setProfileDialogOpen(true);
|
|
}, [profileRequired, profileSyncError]);
|
|
|
|
useEffect(() => {
|
|
if (!accountMenuOpen) return undefined;
|
|
|
|
const handleMouseDown = (event: MouseEvent) => {
|
|
const target = event.target;
|
|
if (!(target instanceof Node)) return;
|
|
if (accountButtonRef.current?.contains(target)) return;
|
|
if (accountMenuRef.current?.contains(target)) return;
|
|
setAccountMenuOpen(false);
|
|
};
|
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
if (event.key === 'Escape') {
|
|
setAccountMenuOpen(false);
|
|
}
|
|
};
|
|
|
|
document.addEventListener('mousedown', handleMouseDown);
|
|
document.addEventListener('keydown', handleKeyDown);
|
|
return () => {
|
|
document.removeEventListener('mousedown', handleMouseDown);
|
|
document.removeEventListener('keydown', handleKeyDown);
|
|
};
|
|
}, [accountMenuOpen]);
|
|
|
|
const openCreateProject = useCallback(() => {
|
|
setNewProjectName('');
|
|
setNewProjectSelectedPath('');
|
|
setNewProjectDirectoryMode('use-selected-directory');
|
|
setCreateProjectError(null);
|
|
setCreateDialogOpen(true);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!isProgrammingModule) return undefined;
|
|
return subscribeCodingProjectCreationRequest(openCreateProject);
|
|
}, [isProgrammingModule, openCreateProject]);
|
|
|
|
const closeCreateProject = () => {
|
|
if (creatingProject) return;
|
|
setCreateDialogOpen(false);
|
|
setCreateProjectError(null);
|
|
};
|
|
|
|
const chooseProjectPath = async () => {
|
|
const result = await invokeIpc<OpenDialogResult>('dialog:open', {
|
|
properties: ['openDirectory'],
|
|
title: newProjectDirectoryMode === 'use-selected-directory'
|
|
? '选择要直接使用的项目文件夹'
|
|
: '选择项目保存位置',
|
|
});
|
|
if (result.canceled || !result.filePaths[0]) return;
|
|
setNewProjectSelectedPath(result.filePaths[0]);
|
|
setCreateProjectError(null);
|
|
};
|
|
|
|
const showProjectEntryError = useCallback((project: CodingProjectSummary, message: string) => {
|
|
setProjectEntryError({ project, message });
|
|
}, []);
|
|
|
|
const enterProject = useCallback(async (project: CodingProjectSummary) => {
|
|
const intent = ++projectEntryIntentRef.current;
|
|
const result = await loadProjectConfig(project.id);
|
|
if (intent !== projectEntryIntentRef.current) return;
|
|
if (result.status !== 'valid' || !result.config) {
|
|
showProjectEntryError(project, result.status === 'missing' ? '项目缺少必要的配置文件' : result.error ?? '项目配置无效');
|
|
return;
|
|
}
|
|
await setActiveProject(project.id);
|
|
if (intent === projectEntryIntentRef.current) navigate('/chat');
|
|
}, [loadProjectConfig, navigate, setActiveProject, showProjectEntryError]);
|
|
|
|
const readAndEnterProject = useCallback(async (project: CodingProjectSummary) => {
|
|
setProjectEntryError(null);
|
|
try {
|
|
await enterProject(project);
|
|
} catch (error) {
|
|
showProjectEntryError(project, error instanceof Error ? error.message : String(error));
|
|
}
|
|
}, [enterProject, showProjectEntryError]);
|
|
|
|
|
|
const confirmCreateProject = async () => {
|
|
const projectName = newProjectName.trim();
|
|
const selectedPath = newProjectSelectedPath.trim();
|
|
if (newProjectDirectoryMode === 'create-child-directory' && !projectName) {
|
|
setCreateProjectError('请输入项目名称');
|
|
return;
|
|
}
|
|
if (!selectedPath) {
|
|
setCreateProjectError('请选择项目路径');
|
|
return;
|
|
}
|
|
setCreatingProject(true);
|
|
setCreateProjectError(null);
|
|
try {
|
|
const project = await createProject({
|
|
projectType: 'interactive_ai_app',
|
|
identity: { kind: 'create' },
|
|
...(newProjectDirectoryMode === 'create-child-directory'
|
|
? { parentPath: selectedPath, projectName }
|
|
: { projectPath: selectedPath }),
|
|
});
|
|
setCreateDialogOpen(false);
|
|
await enterProject(project);
|
|
} catch (error) {
|
|
setCreateProjectError(error instanceof Error ? error.message : String(error));
|
|
} finally {
|
|
setCreatingProject(false);
|
|
}
|
|
};
|
|
|
|
const openProject = useCallback(async (project: CodingProjectSummary) => {
|
|
await readAndEnterProject(project);
|
|
}, [readAndEnterProject]);
|
|
|
|
useEffect(() => {
|
|
if (!isProgrammingModule) return undefined;
|
|
return subscribeCodingProjectOpenRequest((projectId) => {
|
|
const project = visibleProjects.find((candidate) => candidate.id === projectId);
|
|
if (project) void openProject(project);
|
|
});
|
|
}, [isProgrammingModule, openProject, visibleProjects]);
|
|
|
|
const requestRemoveUnavailableProject = () => {
|
|
if (!projectEntryError) return;
|
|
setProjectEntryError(null);
|
|
setRemoveProjectCandidate(projectEntryError.project);
|
|
};
|
|
|
|
const handleRemoveUnavailableProject = async () => {
|
|
if (!removeProjectCandidate) return;
|
|
const project = removeProjectCandidate;
|
|
await removeProject(project.id);
|
|
removeProjectConfig(project.id);
|
|
setRemoveProjectCandidate(null);
|
|
toast.success(`已移除项目 ${project.name}`);
|
|
};
|
|
|
|
const navigateFromAccountMenu = (path: string) => {
|
|
setAccountMenuOpen(false);
|
|
navigate(path);
|
|
};
|
|
|
|
const openProfileDialog = () => {
|
|
setAccountMenuOpen(false);
|
|
setProfileDialogOpen(true);
|
|
void syncProfileNow().catch(() => undefined);
|
|
};
|
|
|
|
const handleAccountLogout = async () => {
|
|
setAccountMenuOpen(false);
|
|
await logout();
|
|
navigate('/login');
|
|
};
|
|
|
|
const sidebarPeekPreview = sidebarPinnedCollapsed && sidebarPeekOpen;
|
|
|
|
return (
|
|
<aside
|
|
id={workspaceLayout ? 'classroom-sidebar' : undefined}
|
|
data-testid="sidebar"
|
|
data-classroom={workspaceLayout}
|
|
aria-hidden={sidebarCollapsed}
|
|
onPointerEnter={() => onSidebarPeekChange?.(true, 'sidebar')}
|
|
onPointerLeave={() => onSidebarPeekChange?.(false, 'sidebar')}
|
|
style={workspaceLayout && !sidebarCollapsed ? { width: sidebarWidth, minWidth: sidebarWidth, flexBasis: sidebarWidth } : undefined}
|
|
className={cn(
|
|
'sidebar-glass-surface flex min-h-0 shrink-0 flex-col overflow-hidden border-r border-border/80 text-foreground',
|
|
sidebarCollapsed
|
|
? 'pointer-events-none w-0 border-r-0 opacity-0'
|
|
: workspaceLayout
|
|
? 'opacity-100'
|
|
: 'w-64 opacity-100',
|
|
// Keep the collapsed preview in the same fixed layer while it opens
|
|
// and closes. This makes the hover state cover the titlebar and the
|
|
// full window height without reflowing the page underneath it.
|
|
sidebarPinnedCollapsed && 'fixed inset-y-0 left-0 z-[350]',
|
|
workspaceLayout && !sidebarPinnedCollapsed && 'relative',
|
|
sidebarPeekPreview && 'sidebar-peek-surface pt-10 shadow-float',
|
|
hideOnCompact && 'hidden lg:flex',
|
|
)}
|
|
>
|
|
{workspaceLayout && !sidebarPinnedCollapsed && <ClassroomSidebarResizer />}
|
|
<div
|
|
className={cn(
|
|
'bg-transparent px-3 text-foreground',
|
|
isPaintingModule && !sidebarCollapsed ? 'pb-2 pt-10' : 'py-2',
|
|
sidebarCollapsed && 'px-2',
|
|
)}
|
|
>
|
|
<div className="min-w-0 w-full">
|
|
{workspaceLayout ? <button type="button" aria-label="返回首页" title="返回首页" onClick={() => navigate('/module-select')} className="classroom-brand flex w-full items-center gap-2 rounded-lg text-left">
|
|
<span className="classroom-brand-mark"><img src={classroomMark} alt="" className="h-7 w-7" /></span>
|
|
<span className="min-w-0"><span className="block text-base font-semibold tracking-tight">麦洛</span><span className="classroom-brand-caption">把脑洞变成作品</span></span>
|
|
</button> : <ModuleSwitcher sidebarCollapsed={sidebarCollapsed} compact={!sidebarCollapsed} />}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="classroom-sidebar-content min-h-0 flex-1 overflow-auto px-2 py-2">
|
|
{isPaintingModule ? (
|
|
<ImageWorkspaceSidebar sidebarCollapsed={sidebarCollapsed} />
|
|
) : isProgrammingModule ? (
|
|
<>
|
|
<button
|
|
type="button"
|
|
data-testid="sidebar-create-project"
|
|
className={cn(
|
|
'motion-press mb-1 flex min-h-9 w-full items-center gap-2 rounded-lg border-0 bg-transparent px-2 py-1.5 text-left text-sm font-medium text-foreground shadow-none transition-colors duration-100 hover:bg-surface-subtle',
|
|
sidebarCollapsed ? 'justify-center px-0' : '',
|
|
)}
|
|
aria-label="新建项目"
|
|
onClick={openCreateProject}
|
|
>
|
|
<Plus className="h-4 w-4 shrink-0 text-brand" />
|
|
{!sidebarCollapsed && '新建项目'}
|
|
</button>
|
|
<CommunityLink variant="sidebar" collapsed={sidebarCollapsed} />
|
|
<div className="classroom-projects mt-3">
|
|
<button
|
|
type="button"
|
|
onClick={() => setProjectsOpen(!projectsOpen)}
|
|
className={cn(
|
|
'classroom-projects-heading motion-press flex min-h-8 w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-xs font-semibold text-muted-foreground hover:bg-surface-subtle hover:text-foreground',
|
|
sidebarCollapsed ? 'justify-center px-0' : '',
|
|
)}
|
|
>
|
|
<FolderKanban className="h-4 w-4 shrink-0" />
|
|
{!sidebarCollapsed && (
|
|
<>
|
|
<span className="min-w-0 flex-1 truncate">{workspaceLayout ? '我的项目' : '项目'}</span>
|
|
<span className="text-xs">{projectsOpen ? '收起' : '展开'}</span>
|
|
</>
|
|
)}
|
|
</button>
|
|
|
|
{!sidebarCollapsed ? (
|
|
<DisclosureContent open={projectsOpen} className="mt-1" innerClassName="space-y-1.5 px-1 py-1">
|
|
{visibleProjects.length === 0 ? (
|
|
<div className="px-2 py-2 text-xs text-muted-foreground">
|
|
暂无真实项目。添加本地项目后即可开始配置。
|
|
</div>
|
|
) : null}
|
|
|
|
{visibleProjects.map((project) => {
|
|
const active = activeProject?.id === project.id;
|
|
return (
|
|
<div key={project.id}><button
|
|
type="button"
|
|
data-testid={`sidebar-course-project-${project.id}`}
|
|
aria-label={`进入项目 ${project.name}`}
|
|
title={project.name}
|
|
aria-current={active ? 'page' : undefined}
|
|
onClick={() => void openProject(project)}
|
|
className={cn(
|
|
'classroom-project-card motion-press group/project flex w-full items-center gap-2 rounded-xl px-3 py-2 text-left transition-colors duration-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/35 focus-visible:ring-offset-1',
|
|
active
|
|
? 'bg-transparent text-foreground'
|
|
: 'bg-transparent text-foreground hover:bg-surface-tertiary',
|
|
)}
|
|
>
|
|
{workspaceLayout ? <span className="classroom-project-icon" data-tone={['orange', 'green', 'blue'][Array.from(project.id).reduce((sum, char) => sum + char.charCodeAt(0), 0) % 3]}><Gamepad2 aria-hidden="true" /></span> : <Gamepad2 className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />}
|
|
<span className="min-w-0 flex-1 truncate text-[13px] font-medium">{project.name}</span>
|
|
<ChevronRight aria-hidden="true" className="h-3.5 w-3.5 shrink-0 -translate-x-0.5 text-muted-foreground opacity-50 transition-[opacity,transform,color] duration-200 ease-out group-hover/project:translate-x-0 group-hover/project:text-foreground group-hover/project:opacity-100 group-focus-visible/project:translate-x-0 group-focus-visible/project:text-foreground group-focus-visible/project:opacity-100" />
|
|
</button>
|
|
{active && <CodingProjectConversations key={project.id} projectId={project.id} compact={workspaceLayout} />}
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
</DisclosureContent>
|
|
) : null}
|
|
</div>
|
|
</>
|
|
) : (
|
|
activeModule === 'cloud_agents' ? <div data-testid="sidebar-cloud-agents-navigation" className="px-2 py-3">
|
|
<button type="button" aria-label="我的智能体" onClick={() => navigate('/cloud-agents')}
|
|
aria-current={location.pathname === '/cloud-agents' ? 'page' : undefined}
|
|
className={cn('flex min-h-10 w-full items-center gap-2 rounded-lg px-2 py-2 text-left text-sm font-semibold hover:bg-surface-tertiary', location.pathname === '/cloud-agents' && 'bg-brand-soft', sidebarCollapsed && 'justify-center px-0')}>
|
|
<Bot className="h-4 w-4 shrink-0 text-brand" />
|
|
{!sidebarCollapsed && <span>我的智能体</span>}
|
|
</button>
|
|
<button type="button" aria-label="渠道" onClick={() => navigate('/cloud-agents/channels')}
|
|
aria-current={location.pathname.startsWith('/cloud-agents/channels') ? 'page' : undefined}
|
|
className={cn('mt-1 flex min-h-10 w-full items-center gap-2 rounded-lg px-2 py-2 text-left text-sm font-semibold hover:bg-surface-tertiary', location.pathname.startsWith('/cloud-agents/channels') && 'bg-brand-soft', sidebarCollapsed && 'justify-center px-0')}>
|
|
<RadioTower className="h-4 w-4 shrink-0 text-brand" />
|
|
{!sidebarCollapsed && <span>渠道</span>}
|
|
</button>
|
|
</div> : <div data-testid="sidebar-robot-navigation" className="px-2 py-3">
|
|
<button
|
|
type="button"
|
|
aria-label="机器人工作台"
|
|
aria-current={location.pathname === '/ai-hardware' ? 'page' : undefined}
|
|
onClick={() => openRobotRoute()}
|
|
className={cn(
|
|
'flex min-h-10 w-full items-center gap-2 rounded-lg bg-brand-soft px-2 py-2 text-left text-sm font-semibold text-foreground',
|
|
sidebarCollapsed && 'justify-center px-0',
|
|
)}
|
|
>
|
|
<Cpu className="h-4 w-4 shrink-0 text-brand" />
|
|
{!sidebarCollapsed ? <span>机器人工作台</span> : null}
|
|
</button>
|
|
{!sidebarCollapsed ? (
|
|
<div className="mt-4 border-t border-border/60 pt-4">
|
|
<div className="flex items-center justify-between gap-2 px-2">
|
|
<span className="text-xs font-semibold tracking-wide text-muted-foreground">智能体</span>
|
|
<button
|
|
type="button"
|
|
data-testid="sidebar-robot-create-agent"
|
|
aria-label="创建智能体"
|
|
title="创建智能体"
|
|
onClick={() => openRobotRoute(null, true)}
|
|
className="motion-press flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors duration-100 hover:bg-surface-subtle hover:text-brand focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/35"
|
|
>
|
|
<Plus className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
<div data-testid="sidebar-robot-agents" className="mt-2 space-y-1">
|
|
{robotOverviewLoading ? (
|
|
<p role="status" className="px-2 py-2 text-xs text-muted-foreground">正在读取智能体…</p>
|
|
) : robotOverview ? (
|
|
robotOverview.agents.length > 0 ? robotOverview.agents.map((agent) => {
|
|
const active = activeRobotAgentId === agent.id;
|
|
const deviceCount = robotOverview.devices.filter((device) => device.agent_id === agent.id).length;
|
|
return (
|
|
<button
|
|
key={agent.id}
|
|
type="button"
|
|
data-testid={`sidebar-robot-agent-${agent.id}`}
|
|
aria-label={`进入智能体 ${agent.name}`}
|
|
aria-current={active ? 'page' : undefined}
|
|
onClick={() => openRobotRoute(agent.id)}
|
|
className={cn(
|
|
'motion-press flex min-h-10 w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left transition-colors duration-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/35',
|
|
active ? 'bg-brand-selected text-foreground' : 'text-foreground hover:bg-surface-tertiary',
|
|
)}
|
|
>
|
|
<Bot className={cn('h-4 w-4 shrink-0', active ? 'text-brand' : 'text-muted-foreground')} />
|
|
<span className="min-w-0 flex-1">
|
|
<span className="block truncate text-sm font-medium">{agent.name}</span>
|
|
<span className="block text-[11px] tabular-nums text-muted-foreground">{deviceCount} 台设备</span>
|
|
</span>
|
|
</button>
|
|
);
|
|
}) : (
|
|
<p className="px-2 py-2 text-xs leading-5 text-muted-foreground">还没有智能体,点击右上角 + 创建。</p>
|
|
)
|
|
) : (
|
|
<p className="px-2 py-2 text-xs leading-5 text-muted-foreground">暂时无法加载智能体列表。</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{isProgrammingModule && createDialogOpen ? (
|
|
<Dialog
|
|
open={createDialogOpen}
|
|
onOpenChange={(open) => {
|
|
if (!open) closeCreateProject();
|
|
}}
|
|
>
|
|
<DialogContent
|
|
className="max-h-[calc(100vh-2rem)] max-w-xl overflow-y-auto p-5"
|
|
onInteractOutside={(event) => {
|
|
if (creatingProject) event.preventDefault();
|
|
}}
|
|
>
|
|
<DialogHeader className="flex-row items-start justify-between gap-3 space-y-0">
|
|
<div>
|
|
<DialogTitle className="text-xl">新建项目</DialogTitle>
|
|
<DialogDescription className="mt-1 text-xs">只需选择项目文件夹,其他设置会自动准备好。</DialogDescription>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className="rounded-md border border-foreground/15 px-2 py-1 text-xs font-semibold hover:bg-white"
|
|
onClick={closeCreateProject}
|
|
disabled={creatingProject}
|
|
>
|
|
取消
|
|
</button>
|
|
</DialogHeader>
|
|
|
|
<form
|
|
className="mt-4 space-y-3"
|
|
onSubmit={(event) => {
|
|
event.preventDefault();
|
|
void confirmCreateProject();
|
|
}}
|
|
>
|
|
<fieldset className="space-y-2">
|
|
<legend className="text-xs font-semibold">项目目录方式</legend>
|
|
<div className="grid gap-2 sm:grid-cols-2">
|
|
<label
|
|
className={cn(
|
|
'cursor-pointer rounded-md border border-foreground/15 px-3 py-2 text-xs font-semibold',
|
|
newProjectDirectoryMode === 'use-selected-directory' ? 'bg-brand-soft' : 'bg-white',
|
|
)}
|
|
>
|
|
<input
|
|
type="radio"
|
|
name="project-directory-mode"
|
|
value="use-selected-directory"
|
|
checked={newProjectDirectoryMode === 'use-selected-directory'}
|
|
onChange={() => {
|
|
setNewProjectDirectoryMode('use-selected-directory');
|
|
setCreateProjectError(null);
|
|
}}
|
|
className="mr-2 align-middle"
|
|
disabled={creatingProject}
|
|
/>
|
|
直接使用所选文件夹(默认)
|
|
</label>
|
|
<label
|
|
className={cn(
|
|
'cursor-pointer rounded-md border border-foreground/15 px-3 py-2 text-xs font-semibold',
|
|
newProjectDirectoryMode === 'create-child-directory' ? 'bg-brand-soft' : 'bg-white',
|
|
)}
|
|
>
|
|
<input
|
|
type="radio"
|
|
name="project-directory-mode"
|
|
value="create-child-directory"
|
|
checked={newProjectDirectoryMode === 'create-child-directory'}
|
|
onChange={() => {
|
|
setNewProjectDirectoryMode('create-child-directory');
|
|
setCreateProjectError(null);
|
|
}}
|
|
className="mr-2 align-middle"
|
|
disabled={creatingProject}
|
|
/>
|
|
在所选位置新建下级文件夹
|
|
</label>
|
|
</div>
|
|
</fieldset>
|
|
|
|
{newProjectDirectoryMode === 'create-child-directory' ? (
|
|
<div className="space-y-2">
|
|
<label className="block text-xs font-semibold" htmlFor="new-project-name">
|
|
项目名称
|
|
</label>
|
|
<input
|
|
id="new-project-name"
|
|
value={newProjectName}
|
|
onChange={(event) => {
|
|
setNewProjectName(event.target.value);
|
|
setCreateProjectError(null);
|
|
}}
|
|
className="w-full rounded-md border border-foreground/15 bg-white px-3 py-2 text-sm font-medium outline-none focus:ring-2 focus:ring-brand/20"
|
|
autoFocus
|
|
disabled={creatingProject}
|
|
/>
|
|
</div>
|
|
) : null}
|
|
|
|
<label className="block text-xs font-semibold" htmlFor="new-project-path">
|
|
项目路径
|
|
</label>
|
|
<div className="flex gap-2">
|
|
<input
|
|
id="new-project-path"
|
|
value={newProjectSelectedPath}
|
|
readOnly
|
|
className="min-w-0 flex-1 rounded-md border border-foreground/15 bg-white px-3 py-2 text-sm font-medium outline-none"
|
|
/>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
className="shrink-0 border border-foreground/15 bg-white text-foreground"
|
|
onClick={() => void chooseProjectPath()}
|
|
disabled={creatingProject}
|
|
>
|
|
选择路径
|
|
</Button>
|
|
</div>
|
|
<p className="text-[11px] font-medium text-muted-foreground">
|
|
{newProjectDirectoryMode === 'use-selected-directory'
|
|
? selectedProjectFolderName
|
|
? `将直接接入此文件夹,项目名称使用“${selectedProjectFolderName}”,不会重命名目录。`
|
|
: '将直接接入所选文件夹,不会创建下级目录。'
|
|
: '确认后会在所选位置下创建与项目名称相同的文件夹。'}
|
|
</p>
|
|
|
|
{createProjectError ? (
|
|
<div className="rounded-md border border-foreground/15 bg-accent-soft px-3 py-2 text-xs font-semibold">
|
|
{createProjectError}
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="flex justify-end gap-2 pt-2">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
className="border border-foreground/15 bg-white text-foreground"
|
|
onClick={closeCreateProject}
|
|
disabled={creatingProject}
|
|
>
|
|
取消
|
|
</Button>
|
|
<Button
|
|
type="submit"
|
|
className="border border-foreground/15 bg-brand-soft text-foreground"
|
|
disabled={creatingProject
|
|
|| !newProjectSelectedPath.trim()
|
|
|| (newProjectDirectoryMode === 'create-child-directory' && !newProjectName.trim())}
|
|
>
|
|
确认创建
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
) : null}
|
|
|
|
{isProgrammingModule && projectEntryError && typeof document !== 'undefined'
|
|
? createPortal(
|
|
<div
|
|
data-testid="project-config-error-overlay"
|
|
className="fixed inset-0 z-[1000] flex min-h-screen items-center justify-center bg-foreground/15 p-4 sm:p-6"
|
|
>
|
|
<div
|
|
role="alertdialog"
|
|
aria-modal="true"
|
|
data-testid="project-config-error"
|
|
className="glass-surface w-full max-w-md rounded-2xl border border-border/80 bg-background p-5 text-foreground shadow-float sm:p-6"
|
|
>
|
|
<h2 className="text-xl font-semibold">项目配置不可用</h2>
|
|
<p className="mt-2 text-sm font-medium leading-6 text-muted-foreground">
|
|
无法进入项目,请检查项目目录中的配置文件是否完整。
|
|
</p>
|
|
<pre className="mt-3 max-h-32 overflow-auto rounded-md border border-foreground/15 bg-white p-2 text-xs font-medium">
|
|
{projectEntryError.message}
|
|
</pre>
|
|
<div className="mt-4 flex justify-end gap-2">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
className="border border-foreground/15 bg-accent-soft text-foreground"
|
|
onClick={requestRemoveUnavailableProject}
|
|
>
|
|
移除项目卡片
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
className="border border-foreground/15 bg-brand-soft text-foreground"
|
|
onClick={() => setProjectEntryError(null)}
|
|
>
|
|
知道了
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>,
|
|
document.body,
|
|
)
|
|
: null}
|
|
|
|
{isProgrammingModule ? (
|
|
<ConfirmDialog
|
|
open={Boolean(removeProjectCandidate)}
|
|
title={`移除项目“${removeProjectCandidate?.name ?? ''}”?`}
|
|
message="这只会从 Makelore 的项目列表中移除登记,不会删除磁盘中的项目文件。"
|
|
confirmLabel="确认移除"
|
|
cancelLabel="取消"
|
|
variant="destructive"
|
|
onCancel={() => setRemoveProjectCandidate(null)}
|
|
onConfirm={handleRemoveUnavailableProject}
|
|
onError={(error) => toast.error(error instanceof Error ? error.message : String(error))}
|
|
/>
|
|
) : null}
|
|
|
|
<UserProfileDialog
|
|
open={profileDialogOpen}
|
|
required={profileRequired}
|
|
syncError={profileSyncError}
|
|
onOpenChange={setProfileDialogOpen}
|
|
/>
|
|
|
|
<div className="classroom-account-footer mt-auto shrink-0 px-2 pb-2 pt-1">
|
|
<div className="relative">
|
|
<div className={cn(
|
|
'flex items-center gap-2',
|
|
sidebarCollapsed && 'flex-col-reverse',
|
|
)}>
|
|
<button
|
|
ref={accountButtonRef}
|
|
type="button"
|
|
data-testid="sidebar-member-menu-trigger"
|
|
aria-label={`打开账户菜单:${accountName}`}
|
|
aria-haspopup="menu"
|
|
aria-expanded={accountMenuOpen}
|
|
onClick={() => setAccountMenuOpen((open) => !open)}
|
|
className={cn(
|
|
'motion-press flex min-h-9 min-w-0 flex-1 items-center gap-2 rounded-lg border-0 bg-transparent px-2 py-1 text-left text-sm font-medium text-foreground shadow-none transition-[background-color,color,transform] hover:bg-surface-subtle',
|
|
accountMenuOpen && 'bg-background text-foreground',
|
|
sidebarCollapsed && 'min-h-9 w-full justify-center px-0',
|
|
)}
|
|
>
|
|
<UserAvatar
|
|
src={accountAvatarUrl}
|
|
initial={accountInitial}
|
|
testId="sidebar-account-avatar"
|
|
className="h-7 w-7 shrink-0"
|
|
/>
|
|
{!sidebarCollapsed && (
|
|
<>
|
|
<span className="min-w-0 flex-1">
|
|
<span className="block truncate">{accountName}</span>
|
|
</span>
|
|
<ChevronRight className={cn('h-3.5 w-3.5 shrink-0', accountMenuOpen && 'rotate-90')} />
|
|
</>
|
|
)}
|
|
</button>
|
|
<SidebarUpdateButton collapsed={sidebarCollapsed} />
|
|
</div>
|
|
|
|
<DisclosureContent
|
|
ref={accountMenuRef}
|
|
open={accountMenuOpen}
|
|
role="menu"
|
|
data-testid="sidebar-account-menu"
|
|
className={cn(
|
|
'glass-surface z-50 max-w-[calc(100vw-1rem)] rounded-2xl border border-border/80 p-1 text-foreground shadow-float',
|
|
sidebarCollapsed
|
|
? 'fixed bottom-[72px] left-2 w-64'
|
|
: 'absolute bottom-[calc(100%+0.5rem)] left-0 w-full',
|
|
)}
|
|
innerClassName="max-h-[calc(100vh-7rem)] overflow-y-auto"
|
|
>
|
|
<div className="px-1 py-1">
|
|
<div className="flex items-center gap-2">
|
|
<UserAvatar
|
|
src={accountAvatarUrl}
|
|
initial={accountInitial}
|
|
testId="sidebar-account-menu-avatar"
|
|
className="h-7 w-7 shrink-0"
|
|
/>
|
|
<div className="min-w-0">
|
|
<p className="truncate text-sm font-semibold">{accountFullName}</p>
|
|
<p className="mt-0.5 text-[10px] font-medium text-muted-foreground">词元点数账户</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-1 grid gap-0.5">
|
|
{authUser && <PointWallet key={authUser.userId ?? authUser.username} active={accountMenuOpen} />}
|
|
<button
|
|
type="button"
|
|
role="menuitem"
|
|
className="flex min-h-8 w-full items-center gap-2 rounded-md px-1 py-1 text-left text-sm font-medium transition-colors duration-100 hover:bg-surface-subtle"
|
|
onClick={openProfileDialog}
|
|
>
|
|
<UserCircle2 className="h-3.5 w-3.5 shrink-0" />
|
|
<span className="min-w-0 flex-1 truncate">个人资料</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
role="menuitem"
|
|
data-testid="sidebar-nav-settings"
|
|
className="flex min-h-8 w-full items-center gap-2 rounded-md px-1 py-1 text-left text-sm font-medium transition-colors duration-100 hover:bg-surface-subtle"
|
|
onClick={() => navigateFromAccountMenu('/settings')}
|
|
>
|
|
<SettingsIcon className="h-3.5 w-3.5 shrink-0" />
|
|
<span className="min-w-0 flex-1 truncate">{settingsLabel}</span>
|
|
</button>
|
|
</div>
|
|
|
|
<div className="mt-1 pt-1">
|
|
<button
|
|
type="button"
|
|
role="menuitem"
|
|
className="flex min-h-8 w-full items-center gap-2 rounded-md px-1 py-1 text-left text-sm font-medium text-accent-strong transition-colors duration-100 hover:bg-accent-soft hover:text-foreground"
|
|
onClick={() => void handleAccountLogout()}
|
|
>
|
|
<LogOut className="h-3.5 w-3.5 shrink-0" />
|
|
<span className="min-w-0 flex-1 truncate">退出登录</span>
|
|
</button>
|
|
</div>
|
|
</DisclosureContent>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
);
|
|
}
|