Files
makelore/src/components/layout/Sidebar.tsx
inman 01bee3188b
Some checks failed
Electron E2E / Electron E2E (macos-latest) (push) Has been cancelled
Electron E2E / Electron E2E (ubuntu-latest) (push) Has been cancelled
Electron E2E / Electron E2E (windows-latest) (push) Has been cancelled
feat: integrate learning module
2026-08-16 20:52:16 +08:00

1025 lines
44 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 { useCallback, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { useLocation, useNavigate } from 'react-router-dom';
import {
BarChart3,
Cpu,
ChevronRight,
Crown,
ExternalLink,
FolderKanban,
LogOut,
Plus,
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 { getAgentAvatarSrc } from '@/lib/agent-avatars';
import { getAiModuleForPath } from '@/lib/ai-modules';
import { invokeIpc } from '@/lib/api-client';
import { getAuthUserDisplayName } from '@/lib/auth-user-display';
import { WORKS_SQUARE_TOKEN_USAGE_STALE_EVENT } from '@/lib/works-square-usage-events';
import { fetchWorksTokenUsage, type WorksTokenUsage } from '@/lib/works-square';
import { isWorksTokenUsageExhausted } from '@/lib/works-square-token-usage';
import { openWorksSquareSubscriptionUpgrade } from '@/lib/subscription-upgrade';
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 { LearningSidebar } from './LearningSidebar';
import { SidebarUpdateButton } from './SidebarUpdateButton';
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, type AuthUser } from '@/stores/auth';
import { useOpencodeStore, type ProjectDirectoryMode } from '@/stores/opencode';
import { useProviderStore } from '@/stores/providers';
import type { OpencodeProject } from '@/types/opencode';
import { useProjectConfigStore } from '@/stores/project-config';
import type { ProjectType } from '../../../shared/project-config';
import { toast } from 'sonner';
type OpenDialogResult = {
canceled: boolean;
filePaths: string[];
};
type TokenUsageState = {
status: 'idle' | 'loading' | 'loaded' | 'error';
usage: WorksTokenUsage | null;
error?: string;
};
type ProjectEntryError = {
project: OpencodeProject;
message: string;
};
function getFolderName(pathValue: string): string {
const trimmed = pathValue.trim();
const withoutTrailingSeparators = trimmed.replace(/[\\/]+$/u, '');
return withoutTrailingSeparators.split(/[\\/]/u).filter(Boolean).at(-1) ?? trimmed;
}
function formatRemainingPercent(value: number | null | undefined): string {
if (typeof value !== 'number' || !Number.isFinite(value)) return '不限';
const clamped = Math.min(100, Math.max(0, value));
return `${Math.round(clamped)}%`;
}
function padTimePart(value: number): string {
return String(value).padStart(2, '0');
}
function formatRefreshTime(value: string | null | undefined): string | null {
if (!value) return null;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return null;
return `${padTimePart(date.getMonth() + 1)}-${padTimePart(date.getDate())} ${padTimePart(date.getHours())}:${padTimePart(date.getMinutes())}`;
}
function getRefreshTimeLabel(value: string | null | undefined): string | null {
const formatted = formatRefreshTime(value);
return formatted ? `刷新时间 ${formatted}` : null;
}
function isTokenUsageExhausted(state: TokenUsageState): boolean {
if (state.status !== 'loaded' || !state.usage) return false;
return isWorksTokenUsageExhausted(state.usage);
}
function getSubscriptionPlanLabel(user: AuthUser | null, state: TokenUsageState): string {
if (!user) return '未登录';
const planName = state.usage?.plan_name?.trim();
if (planName) return planName;
const planCode = state.usage?.plan_code?.trim();
if (planCode) return planCode;
return '会员';
}
function isFreeSubscriptionPlan(state: TokenUsageState): boolean {
const planCode = state.usage?.plan_code?.trim().toLowerCase();
const planName = state.usage?.plan_name?.trim().toLowerCase();
return planCode === 'free' || planName === 'free';
}
function getSubscriptionMembershipLabel(user: AuthUser | null, state: TokenUsageState): string {
const levelLabel = getSubscriptionPlanLabel(user, state);
if (!user || isFreeSubscriptionPlan(state) || levelLabel === '会员') return levelLabel;
return `${levelLabel} 会员`;
}
type SidebarProps = {
workspaceLayout?: boolean;
sidebarPeekOpen?: boolean;
onSidebarPeekChange?: (open: boolean, source: SidebarPeekSource) => void;
};
export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSidebarPeekChange }: SidebarProps = {}) {
const { t } = useTranslation('common');
const sidebarPinnedCollapsed = useSettingsStore((state) => state.sidebarCollapsed);
const sidebarCollapsed = sidebarPinnedCollapsed && !sidebarPeekOpen;
const opencodeProjects = useOpencodeStore((state) => state.projects);
const activeProject = useOpencodeStore((state) => state.activeProject);
const loadProjectList = useOpencodeStore((state) => state.loadProjectList);
const activateProjectLocally = useOpencodeStore((state) => state.activateProjectLocally);
const setActiveProject = useOpencodeStore((state) => state.setActiveProject);
const removeProject = useOpencodeStore((state) => state.removeProject);
const createProject = useOpencodeStore((state) => state.createProject);
const projectConfigs = useProjectConfigStore((state) => state.configsByProjectId);
const loadProjectConfig = useProjectConfigStore((state) => state.load);
const removeProjectConfig = useProjectConfigStore((state) => state.remove);
const authUser = useAuthStore((state) => state.user);
const getValidAccessToken = useAuthStore((state) => state.getValidAccessToken);
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 [usageDrawerOpen, setUsageDrawerOpen] = useState(false);
const [tokenUsageState, setTokenUsageState] = useState<TokenUsageState>({ status: 'idle', usage: null });
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [newProjectName, setNewProjectName] = useState('');
const [newProjectSelectedPath, setNewProjectSelectedPath] = useState('');
const [newProjectDirectoryMode, setNewProjectDirectoryMode] = useState<ProjectDirectoryMode>('use-selected-directory');
const [newProjectType, setNewProjectType] = useState<ProjectType>('mini_game');
const [createProjectError, setCreateProjectError] = useState<string | null>(null);
const [creatingProject, setCreatingProject] = useState(false);
const [projectEntryError, setProjectEntryError] = useState<ProjectEntryError | null>(null);
const [removeProjectCandidate, setRemoveProjectCandidate] = useState<OpencodeProject | null>(null);
const accountButtonRef = useRef<HTMLButtonElement | null>(null);
const accountMenuRef = useRef<HTMLDivElement | null>(null);
const tokenUsageRequestIdRef = useRef(0);
const navigate = useNavigate();
const location = useLocation();
const activeModule = getAiModuleForPath(location.pathname);
const isProgrammingModule = activeModule === 'programming';
const isPaintingModule = activeModule === 'painting';
const isLearningModule = activeModule === 'learning';
const isPromptMuseum = location.pathname === '/image-prompts' || location.pathname.startsWith('/image-prompts/');
const projectConfigPath = '/project-config';
const visibleProjects = opencodeProjects;
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 memberLabel = getSubscriptionPlanLabel(authUser, tokenUsageState);
const membershipLabel = getSubscriptionMembershipLabel(authUser, tokenUsageState);
const tokenUsageExhausted = isTokenUsageExhausted(tokenUsageState);
const fiveHourRefreshLabel = getRefreshTimeLabel(tokenUsageState.usage?.five_hour_refresh_at);
const weeklyRefreshLabel = getRefreshTimeLabel(tokenUsageState.usage?.weekly_refresh_at);
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 (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]);
useEffect(() => {
if (!accountMenuOpen) {
setUsageDrawerOpen(false);
}
}, [accountMenuOpen]);
const refreshTokenUsage = useCallback(() => {
if (!authUser) {
tokenUsageRequestIdRef.current += 1;
setTokenUsageState({ status: 'idle', usage: null });
return;
}
const requestId = tokenUsageRequestIdRef.current + 1;
tokenUsageRequestIdRef.current = requestId;
setTokenUsageState((current) => ({ status: 'loading', usage: current.usage }));
void getValidAccessToken()
.then((accessToken) => {
if (!accessToken) {
throw new Error('请先登录后再查看用量');
}
return fetchWorksTokenUsage(accessToken);
})
.then((usage) => {
if (tokenUsageRequestIdRef.current === requestId) {
setTokenUsageState({ status: 'loaded', usage });
}
})
.catch((error) => {
if (tokenUsageRequestIdRef.current === requestId) {
setTokenUsageState({
status: 'error',
usage: null,
error: error instanceof Error ? error.message : '暂时无法获取套餐额度',
});
}
});
}, [authUser, getValidAccessToken]);
useEffect(() => {
refreshTokenUsage();
}, [refreshTokenUsage]);
useEffect(() => {
if (!authUser) return undefined;
const handleFocus = () => {
refreshTokenUsage();
};
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
refreshTokenUsage();
}
};
window.addEventListener('focus', handleFocus);
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
window.removeEventListener('focus', handleFocus);
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, [authUser, refreshTokenUsage]);
useEffect(() => {
if (authUser && accountMenuOpen) {
refreshTokenUsage();
}
}, [accountMenuOpen, authUser, refreshTokenUsage]);
useEffect(() => {
if (!authUser) return undefined;
const retryTimers = new Set<number>();
const handleTokenUsageStale = () => {
refreshTokenUsage();
const retryTimer = window.setTimeout(() => {
retryTimers.delete(retryTimer);
refreshTokenUsage();
}, 1500);
retryTimers.add(retryTimer);
};
window.addEventListener(WORKS_SQUARE_TOKEN_USAGE_STALE_EVENT, handleTokenUsageStale);
return () => {
window.removeEventListener(WORKS_SQUARE_TOKEN_USAGE_STALE_EVENT, handleTokenUsageStale);
for (const retryTimer of retryTimers) {
window.clearTimeout(retryTimer);
}
};
}, [authUser, refreshTokenUsage]);
const openCreateProject = () => {
setNewProjectName('');
setNewProjectSelectedPath('');
setNewProjectDirectoryMode('use-selected-directory');
setNewProjectType('mini_game');
setCreateProjectError(null);
setCreateDialogOpen(true);
};
const closeCreateProject = () => {
if (creatingProject) return;
setCreateDialogOpen(false);
setNewProjectType('mini_game');
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 = (project: OpencodeProject, message: string) => {
setProjectEntryError({ project, message });
};
const enterProject = async (project: OpencodeProject) => {
const result = await loadProjectConfig(project.id);
if (result.status !== 'valid' || !result.config) {
showProjectEntryError(project, result.status === 'missing' ? '项目缺少 .niancode/project.json' : result.error ?? '项目配置无效');
return;
}
activateProjectLocally(project);
navigate(result.config.initialized
? '/opencode-chat'
: projectConfigPath);
void setActiveProject(project.id).catch(() => undefined);
};
const readAndEnterProject = async (project: OpencodeProject) => {
setProjectEntryError(null);
try {
await enterProject(project);
} catch (error) {
showProjectEntryError(project, error instanceof Error ? error.message : String(error));
}
};
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({
directoryMode: newProjectDirectoryMode,
selectedPath,
projectType: newProjectType,
...(newProjectDirectoryMode === 'create-child-directory' ? { projectName } : {}),
});
setCreateDialogOpen(false);
await enterProject(project);
} catch (error) {
setCreateProjectError(error instanceof Error ? error.message : String(error));
} finally {
setCreatingProject(false);
}
};
const openProject = async (project: OpencodeProject) => {
await readAndEnterProject(project);
};
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);
setUsageDrawerOpen(false);
navigate(path);
};
const openProfileDialog = () => {
setAccountMenuOpen(false);
setUsageDrawerOpen(false);
setProfileDialogOpen(true);
void syncProfileNow().catch(() => undefined);
};
const openSubscriptionUpgrade = () => {
void openWorksSquareSubscriptionUpgrade().catch(() => undefined);
};
const handleAccountLogout = async () => {
setAccountMenuOpen(false);
setUsageDrawerOpen(false);
await logout();
navigate('/login');
};
const sidebarPeekPreview = sidebarPinnedCollapsed && sidebarPeekOpen;
return (
<aside
data-testid="sidebar"
aria-hidden={sidebarCollapsed}
onPointerEnter={() => onSidebarPeekChange?.(true, 'sidebar')}
onPointerLeave={() => onSidebarPeekChange?.(false, 'sidebar')}
className={cn(
'sidebar-glass-surface flex min-h-0 shrink-0 flex-col overflow-hidden border-r border-border/80 text-foreground transition-[width,opacity,box-shadow] duration-300 ease-spring motion-reduce:transition-none',
sidebarCollapsed
? 'pointer-events-none w-0 border-r-0 opacity-0'
: workspaceLayout
? 'w-[256px] min-w-[256px] basis-[256px] 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]',
sidebarPeekPreview && 'sidebar-peek-surface pt-10 shadow-float',
)}
>
<div
className={cn(
'bg-transparent px-3 text-foreground',
isPaintingModule && !isPromptMuseum && !sidebarCollapsed ? 'pb-2 pt-10' : 'py-2',
sidebarCollapsed && 'px-2',
)}
>
<div className="min-w-0 w-full">
<ModuleSwitcher sidebarCollapsed={sidebarCollapsed} compact={!sidebarCollapsed} />
</div>
</div>
<div className="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 hover:bg-surface-subtle active:scale-[0.985]',
sidebarCollapsed ? 'justify-center px-0' : '',
)}
aria-label="新建项目"
onClick={openCreateProject}
>
<Plus className="h-4 w-4 shrink-0 text-brand" />
{!sidebarCollapsed && '新建项目'}
</button>
<div className="mt-3">
<button
type="button"
onClick={() => setProjectsOpen(!projectsOpen)}
className={cn(
'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">项目列表</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 projectConfig = projectConfigs[project.id];
const active = activeProject?.id === project.id;
return (
<button
type="button"
key={project.id}
data-testid={`sidebar-course-project-${project.id}`}
aria-label={`进入项目 ${project.name}`}
aria-current={active ? 'page' : undefined}
onClick={() => void openProject(project)}
className={cn(
'motion-press group/project flex w-full items-center gap-2 rounded-xl px-3 py-2 text-left transition-[background-color,color,box-shadow,transform] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/35 focus-visible:ring-offset-1 active:scale-[0.985]',
active
? 'bg-brand-selected text-foreground shadow-soft ring-1 ring-brand/25'
: 'bg-transparent text-foreground hover:bg-surface-tertiary',
)}
>
<span className="min-w-0 flex-1 truncate text-sm font-semibold">{project.name}</span>
<div className="flex shrink-0 -space-x-1.5" aria-label="项目 Agent">
{projectConfig?.agents.map((agent) => (
<span
key={agent.id}
title={agent.name.trim() || agent.roleName}
className="h-5 w-5 overflow-hidden rounded-full border border-background bg-white"
>
<img
src={getAgentAvatarSrc(agent.avatarId, agent.avatarDataUrl)}
alt=""
className="h-full w-full object-cover [image-rendering:pixelated]"
/>
</span>
))}
</div>
<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>
);
})}
</DisclosureContent>
) : null}
</div>
</>
) : isLearningModule ? (
<LearningSidebar sidebarCollapsed={sidebarCollapsed} />
) : (
<div data-testid="sidebar-robot-navigation" className="px-2 py-3">
<button
type="button"
aria-label="机器人工作台"
aria-current="page"
onClick={() => navigate('/ai-hardware')}
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 ? (
<p className="mt-3 px-2 text-xs font-medium leading-5 text-muted-foreground">
管理机器人智能体、设备绑定与配置。
</p>
) : 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-3">
{([
['mini_game', '小游戏', '适合实时互动、Canvas 和关卡玩法;创建后可直接提交审核。'],
['mini_program', '小程序', '适合页面、表单和轻量工具;创建后可直接提交审核。'],
['custom', '自定义项目', '只创建项目空间,不配置默认发布方式。'],
] as const).map(([value, title, description]) => (
<label
key={value}
className={cn(
'min-h-28 cursor-pointer rounded-xl bg-white p-3 shadow-soft transition-[box-shadow,transform] active:scale-[0.96]',
newProjectType === value
? 'ring-2 ring-brand/35 shadow-float'
: 'ring-1 ring-foreground/10 hover:shadow-float',
)}
>
<span className="flex items-center gap-2 text-sm font-semibold">
<input
type="radio"
name="project-type"
value={value}
aria-label={title}
checked={newProjectType === value}
onChange={() => {
setNewProjectType(value);
setCreateProjectError(null);
}}
disabled={creatingProject}
/>
{title}
</span>
<span className="mt-2 block text-[11px] font-medium leading-5 text-muted-foreground [text-wrap:pretty]">
{description}
</span>
</label>
))}
</div>
</fieldset>
<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 backdrop-blur-md 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">
无法进入项目,请检查 .niancode/project.json。
</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="mt-auto 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 transition-transform duration-200 ease-out', 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">{membershipLabel}</p>
</div>
</div>
</div>
<div className="mt-1 grid gap-0.5">
<button
type="button"
role="menuitem"
data-testid="sidebar-account-usage-menuitem"
aria-expanded={usageDrawerOpen}
aria-controls="sidebar-account-usage-drawer"
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 hover:bg-surface-subtle active:scale-[0.985]"
onClick={() => setUsageDrawerOpen((open) => !open)}
>
<BarChart3 className="h-3.5 w-3.5 shrink-0" />
<span className="min-w-0 flex-1 truncate">剩余用量</span>
<ChevronRight className={cn('h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform duration-200 ease-out', usageDrawerOpen && 'rotate-90')} />
</button>
<DisclosureContent
open={usageDrawerOpen}
id="sidebar-account-usage-drawer"
data-testid="sidebar-account-usage-drawer"
className="ml-5 rounded-md bg-surface-subtle/70 px-1 py-0.5 text-xs font-medium text-muted-foreground"
innerClassName="grid gap-0.5"
>
<div className="flex min-h-6 items-center justify-between gap-3 px-1">
<span>订阅等级</span>
<span className="text-foreground">{memberLabel}</span>
</div>
<div className="flex min-h-6 items-center justify-between gap-3 px-1">
<span>5 小时</span>
<span className="text-foreground">
{formatRemainingPercent(tokenUsageState.usage?.five_hour_remaining_percent)}
</span>
</div>
<div className="flex min-h-6 items-center justify-between gap-3 px-1">
<span>1 周</span>
<span className="text-foreground">
{formatRemainingPercent(tokenUsageState.usage?.weekly_remaining_percent)}
</span>
</div>
{fiveHourRefreshLabel ? (
<div data-testid="sidebar-five-hour-refresh-at" className="px-1 text-[10px] font-semibold text-muted-foreground">
{fiveHourRefreshLabel}
</div>
) : null}
{weeklyRefreshLabel ? (
<div data-testid="sidebar-weekly-refresh-at" className="px-1 text-[10px] font-semibold text-muted-foreground">
{weeklyRefreshLabel}
</div>
) : null}
{tokenUsageExhausted ? (
<div className="mt-1 rounded-md border border-accent/50 bg-background p-1.5 text-foreground">
<p className="text-[11px] leading-4 text-muted-foreground">当前额度已用尽,升级订阅后可继续使用。</p>
<button
type="button"
data-testid="sidebar-account-upgrade-button"
className="mt-1.5 flex min-h-8 w-full items-center justify-center gap-1.5 rounded-md border border-foreground/15 bg-accent-soft px-2 py-1.5 text-xs font-semibold text-foreground shadow-soft transition-transform hover:bg-background active:scale-[0.985]"
onClick={openSubscriptionUpgrade}
>
<Crown className="h-3.5 w-3.5" />
<span>升级订阅</span>
<ExternalLink className="h-3.5 w-3.5" />
</button>
</div>
) : null}
</DisclosureContent>
<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 hover:bg-surface-subtle active:scale-[0.985]"
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 hover:bg-surface-subtle active:scale-[0.985]"
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 hover:bg-accent-soft hover:text-foreground active:scale-[0.985]"
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>
);
}