1245 lines
54 KiB
TypeScript
1245 lines
54 KiB
TypeScript
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,
|
||
Bot,
|
||
Cpu,
|
||
ChevronRight,
|
||
Crown,
|
||
ExternalLink,
|
||
FolderKanban,
|
||
LogOut,
|
||
Plus,
|
||
Puzzle,
|
||
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 { 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 { 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 { 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 { useCodingWorkspaceStore } from '@/stores/coding-workspace';
|
||
import { useProviderStore } from '@/stores/providers';
|
||
import { isCanonicalCodingProjectId } from '@/lib/coding-projects';
|
||
import type { CodingProjectSummary } from '@/types/coding-project';
|
||
import { useProjectConfigStore } from '@/stores/project-config';
|
||
import type { ProjectType } from '../../../shared/project-config';
|
||
import type { ProjectIdentityChoice } from '../../../shared/coding-project-contracts';
|
||
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: CodingProjectSummary;
|
||
message: string;
|
||
};
|
||
|
||
type ProjectDirectoryMode = 'use-selected-directory' | 'create-child-directory';
|
||
type ProjectIdentityKind = ProjectIdentityChoice['kind'];
|
||
|
||
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 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 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>('interactive_ai_app');
|
||
const [newProjectIdentityKind, setNewProjectIdentityKind] = useState<ProjectIdentityKind>('create');
|
||
const [newProjectIdentityProjectId, setNewProjectIdentityProjectId] = useState('');
|
||
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 tokenUsageRequestIdRef = 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 projectConfigPath = '/project-config';
|
||
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 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);
|
||
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]);
|
||
|
||
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('interactive_ai_app');
|
||
setNewProjectIdentityKind('create');
|
||
setNewProjectIdentityProjectId('');
|
||
setCreateProjectError(null);
|
||
setCreateDialogOpen(true);
|
||
};
|
||
|
||
const closeCreateProject = () => {
|
||
if (creatingProject) return;
|
||
setCreateDialogOpen(false);
|
||
setNewProjectType('interactive_ai_app');
|
||
setNewProjectIdentityKind('create');
|
||
setNewProjectIdentityProjectId('');
|
||
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: CodingProjectSummary, message: string) => {
|
||
setProjectEntryError({ project, message });
|
||
};
|
||
|
||
const enterProject = async (project: CodingProjectSummary) => {
|
||
const result = await loadProjectConfig(project.id);
|
||
if (result.status !== 'valid' || !result.config) {
|
||
showProjectEntryError(project, result.status === 'missing' ? '项目缺少必要的配置文件' : result.error ?? '项目配置无效');
|
||
return;
|
||
}
|
||
await setActiveProject(project.id);
|
||
navigate(result.config.initialized
|
||
? '/chat'
|
||
: projectConfigPath);
|
||
};
|
||
|
||
const readAndEnterProject = async (project: CodingProjectSummary) => {
|
||
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;
|
||
}
|
||
const boundProjectId = newProjectIdentityProjectId.trim();
|
||
if (newProjectIdentityKind === 'bind' && !isCanonicalCodingProjectId(boundProjectId)) {
|
||
setCreateProjectError('请输入有效的项目 ID(小写 UUID)。');
|
||
return;
|
||
}
|
||
|
||
setCreatingProject(true);
|
||
setCreateProjectError(null);
|
||
try {
|
||
const project = await createProject({
|
||
projectType: newProjectType,
|
||
identity: newProjectIdentityKind === 'create'
|
||
? { kind: 'create' }
|
||
: { kind: 'bind', projectId: boundProjectId },
|
||
...(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 = async (project: CodingProjectSummary) => {
|
||
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',
|
||
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 && !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 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>
|
||
<div className="mt-3 grid gap-1">
|
||
<button
|
||
type="button"
|
||
data-testid="sidebar-nav-plugins"
|
||
aria-current={location.pathname === '/plugins' ? 'page' : undefined}
|
||
aria-label="插件"
|
||
onClick={() => navigate(activeProject ? '/plugins?scope=project' : '/plugins?scope=all')}
|
||
className={cn(
|
||
'motion-press flex min-h-10 w-full items-center gap-2 rounded-lg px-2 py-2 text-left text-sm font-medium transition-colors duration-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/35',
|
||
location.pathname === '/plugins' ? 'bg-brand-selected text-foreground' : 'text-foreground hover:bg-surface-subtle',
|
||
sidebarCollapsed && 'justify-center px-0',
|
||
)}
|
||
>
|
||
<Puzzle className="h-4 w-4 shrink-0 text-brand" />
|
||
{!sidebarCollapsed ? <span>插件</span> : null}
|
||
</button>
|
||
</div>
|
||
<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-colors duration-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/35 focus-visible:ring-offset-1',
|
||
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="项目智能体">
|
||
{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>
|
||
</>
|
||
) : (
|
||
<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-xl border border-foreground/10 bg-white p-3 text-sm font-semibold',
|
||
newProjectIdentityKind === 'create' ? 'ring-2 ring-brand/35 shadow-float' : 'hover:shadow-float',
|
||
)}
|
||
>
|
||
<input
|
||
type="radio"
|
||
name="project-identity"
|
||
value="create"
|
||
aria-label="创建新的项目 ID"
|
||
checked={newProjectIdentityKind === 'create'}
|
||
onChange={() => {
|
||
setNewProjectIdentityKind('create');
|
||
setCreateProjectError(null);
|
||
}}
|
||
disabled={creatingProject}
|
||
/>
|
||
<span className="ml-2">创建新的项目 ID</span>
|
||
<span className="mt-2 block text-[11px] font-medium leading-5 text-muted-foreground">
|
||
由主进程生成一个新的小写 UUID,不会发起云端数据创建。
|
||
</span>
|
||
</label>
|
||
<label
|
||
className={cn(
|
||
'cursor-pointer rounded-xl border border-foreground/10 bg-white p-3 text-sm font-semibold',
|
||
newProjectIdentityKind === 'bind' ? 'ring-2 ring-brand/35 shadow-float' : 'hover:shadow-float',
|
||
)}
|
||
>
|
||
<input
|
||
type="radio"
|
||
name="project-identity"
|
||
value="bind"
|
||
aria-label="绑定已有项目 ID"
|
||
checked={newProjectIdentityKind === 'bind'}
|
||
onChange={() => {
|
||
setNewProjectIdentityKind('bind');
|
||
setCreateProjectError(null);
|
||
}}
|
||
disabled={creatingProject}
|
||
/>
|
||
<span className="ml-2">绑定已有项目 ID</span>
|
||
<span className="mt-2 block text-[11px] font-medium leading-5 text-muted-foreground">
|
||
仅接受规范的小写 UUID;绑定不会把项目 ID 当作登录凭据。
|
||
</span>
|
||
</label>
|
||
</div>
|
||
{newProjectIdentityKind === 'bind' ? (
|
||
<div className="space-y-2 rounded-xl border border-foreground/10 bg-surface-subtle p-3">
|
||
<label className="block text-xs font-semibold" htmlFor="new-project-identity-id">
|
||
已有项目 ID
|
||
</label>
|
||
<input
|
||
id="new-project-identity-id"
|
||
value={newProjectIdentityProjectId}
|
||
onChange={(event) => {
|
||
setNewProjectIdentityProjectId(event.target.value);
|
||
setCreateProjectError(null);
|
||
}}
|
||
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
|
||
autoComplete="off"
|
||
spellCheck={false}
|
||
className="w-full rounded-md border border-foreground/15 bg-white px-3 py-2 font-mono text-sm font-medium outline-none focus:ring-2 focus:ring-brand/20"
|
||
disabled={creatingProject}
|
||
/>
|
||
<p className="text-[11px] font-medium leading-5 text-muted-foreground">
|
||
同一账号绑定相同项目 ID 会共享开发数据;项目 ID 不是凭据。
|
||
</p>
|
||
</div>
|
||
) : null}
|
||
</fieldset>
|
||
|
||
<fieldset className="space-y-2">
|
||
<legend className="text-xs font-semibold">项目类型</legend>
|
||
<div className="grid gap-2 sm:grid-cols-2">
|
||
{([
|
||
['interactive_ai_app', '交互式 AI 应用', '创建可交互、可发布的 AI 应用项目;进入对话后可用项目初始化 Skill 生成工程骨架。'],
|
||
['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',
|
||
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 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="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', 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 duration-100 hover:bg-surface-subtle"
|
||
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', 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-colors duration-100 hover:bg-background"
|
||
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 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>
|
||
);
|
||
}
|