Files
makelore/src/components/layout/Sidebar.tsx
T

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>
);
}