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

1397 lines
63 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,
Bot,
Cpu,
ChevronRight,
Crown,
ExternalLink,
FolderKanban,
LogOut,
Plus,
RadioTower,
Settings as SettingsIcon,
Ticket,
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 {
subscribeCodingProjectCreationRequest,
subscribeCodingProjectOpenRequest,
} from '@/lib/coding-project-entry';
import { AppError } from '@/lib/error-model';
import {
fetchWorksResetCards,
fetchWorksTokenPointBalance,
redeemWorksResetCard,
type WorksResetCard,
type WorksTokenPointBalance,
} from '@/lib/works-square';
import {
formatWorksTokenPointValue,
isWorksTokenPointBalanceExhausted,
} from '@/lib/works-square-token-points';
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 type { CodingProjectSummary } from '@/types/coding-project';
import { useProjectConfigStore } from '@/stores/project-config';
import { toast } from 'sonner';
type OpenDialogResult = {
canceled: boolean;
filePaths: string[];
};
type TokenPointState = {
status: 'idle' | 'loading' | 'loaded' | 'error';
balance: WorksTokenPointBalance | null;
};
type ResetCardState = {
status: 'idle' | 'loading' | 'loaded' | 'error';
cards: WorksResetCard[];
};
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;
}
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 formatResetCardTime(value: string | null | undefined): string | null {
if (!value) return null;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return null;
return `${date.getFullYear()}-${padTimePart(date.getMonth() + 1)}-${padTimePart(date.getDate())} ${padTimePart(date.getHours())}:${padTimePart(date.getMinutes())}`;
}
function getEffectiveResetCardStatus(card: WorksResetCard): WorksResetCard['status'] {
if (card.status === 'available' && Date.parse(card.expires_at) <= Date.now()) return 'expired';
return card.status;
}
function getResetCardErrorMessage(error: unknown): string {
const details = error instanceof AppError ? error.details : undefined;
const backendCode = typeof details?.backendCode === 'string' ? details.backendCode : null;
const status = typeof details?.status === 'number' ? details.status : null;
const messages: Record<string, string> = {
reset_card_expired: '这张重置卡已过期,卡包已刷新。',
token_point_wallet_owner_required: '当前使用家庭共享额度,请由家庭管理员使用自己的重置卡。',
billing_temporarily_paused: '计费服务正在切换,请稍后再试。',
token_point_transaction_conflict: '重置卡状态刚刚发生变化,请刷新后重试。',
};
if (backendCode && messages[backendCode]) return messages[backendCode];
if (status === 404) return '重置卡不存在或已不可用。';
if (status === 409) return '当前有待处理的计费操作,暂时不能使用重置卡。';
return '重置卡暂时无法使用,请稍后重试。';
}
function isTokenPointBalanceExhausted(state: TokenPointState): boolean {
if (state.status !== 'loaded' || !state.balance) return false;
return isWorksTokenPointBalanceExhausted(state.balance);
}
function getSubscriptionPlanLabel(user: AuthUser | null, state: TokenPointState): string {
if (!user) return '未登录';
if (state.balance && !state.balance.can_manage_membership) {
return state.balance.family_shared ? '共享会员' : '青少年账户';
}
const planName = state.balance?.plan_name?.trim();
if (planName) return planName;
const planCode = state.balance?.plan_code?.trim();
if (planCode) return planCode;
return '会员';
}
function isExperienceSubscriptionPlan(state: TokenPointState): boolean {
const planCode = state.balance?.plan_code?.trim().toLowerCase();
const planName = state.balance?.plan_name?.trim().toLowerCase();
return planCode === 'experience' || planName === '体验';
}
function getSubscriptionMembershipLabel(user: AuthUser | null, state: TokenPointState): string {
const levelLabel = getSubscriptionPlanLabel(user, state);
if (
!user
|| isExperienceSubscriptionPlan(state)
|| levelLabel === '会员'
|| levelLabel === '共享会员'
|| levelLabel === '青少年账户'
) return levelLabel;
return `${levelLabel} 会员`;
}
function getWeeklyPointLabel(balance: WorksTokenPointBalance | null): string {
const remaining = formatWorksTokenPointValue(balance?.weekly_remaining);
const allowance = formatWorksTokenPointValue(balance?.weekly_allowance);
if (remaining && allowance) return `${remaining} / ${allowance}`;
return remaining ? `${remaining}` : '—';
}
function getTotalPointLabel(balance: WorksTokenPointBalance | null): string {
const remaining = formatWorksTokenPointValue(balance?.total_remaining);
return remaining ? `${remaining}` : '—';
}
function getCoarsePointLabel(balance: WorksTokenPointBalance | null): string {
if (balance?.shared_available === true) return '可用';
if (balance?.shared_available === false) return '已用尽';
return balance?.family_shared ? '由家庭管理员管理' : '由家长管理';
}
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 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 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 [resetCardDrawerOpen, setResetCardDrawerOpen] = useState(false);
const [tokenPointState, setTokenPointState] = useState<TokenPointState>({ status: 'idle', balance: null });
const [resetCardState, setResetCardState] = useState<ResetCardState>({ status: 'idle', cards: [] });
const [redeemingResetCardId, setRedeemingResetCardId] = useState<string | null>(null);
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 tokenPointRequestIdRef = useRef(0);
const resetCardRequestIdRef = 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 memberLabel = getSubscriptionPlanLabel(authUser, tokenPointState);
const membershipLabel = getSubscriptionMembershipLabel(authUser, tokenPointState);
const tokenPointBalanceExhausted = isTokenPointBalanceExhausted(tokenPointState);
const tokenPointBalance = tokenPointState.balance;
const tokenPointsCoarse = Boolean(
tokenPointBalance && !tokenPointBalance.can_manage_membership,
);
const nextRefreshLabel = getRefreshTimeLabel(tokenPointBalance?.next_refresh_at);
const availableResetCardCount = resetCardState.cards.filter(
(card) => getEffectiveResetCardStatus(card) === 'available',
).length;
const resetCardRedemptionBlocked = tokenPointBalance?.entitlement_source === 'shared_group';
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);
setResetCardDrawerOpen(false);
}
}, [accountMenuOpen]);
const refreshTokenPoints = useCallback(() => {
if (!authUser) {
tokenPointRequestIdRef.current += 1;
setTokenPointState({ status: 'idle', balance: null });
return;
}
const requestId = tokenPointRequestIdRef.current + 1;
tokenPointRequestIdRef.current = requestId;
setTokenPointState((current) => ({ status: 'loading', balance: current.balance }));
void getValidAccessToken()
.then((accessToken) => {
if (!accessToken) {
throw new Error('请先登录后再查看用量');
}
return fetchWorksTokenPointBalance(accessToken);
})
.then((balance) => {
if (tokenPointRequestIdRef.current === requestId) {
setTokenPointState({ status: 'loaded', balance });
}
})
.catch(() => {
if (tokenPointRequestIdRef.current === requestId) {
setTokenPointState({ status: 'error', balance: null });
}
});
}, [authUser, getValidAccessToken]);
const refreshResetCards = useCallback(async (): Promise<void> => {
if (!authUser) {
resetCardRequestIdRef.current += 1;
setResetCardState({ status: 'idle', cards: [] });
return;
}
const requestId = resetCardRequestIdRef.current + 1;
resetCardRequestIdRef.current = requestId;
setResetCardState((current) => ({ status: 'loading', cards: current.cards }));
try {
const accessToken = await getValidAccessToken();
if (!accessToken) throw new Error('请先登录后再查看重置卡');
const cards = await fetchWorksResetCards(accessToken);
if (resetCardRequestIdRef.current === requestId) {
setResetCardState({ status: 'loaded', cards });
}
} catch {
if (resetCardRequestIdRef.current === requestId) {
setResetCardState((current) => ({ status: 'error', cards: current.cards }));
}
}
}, [authUser, getValidAccessToken]);
useEffect(() => {
refreshTokenPoints();
}, [refreshTokenPoints]);
useEffect(() => {
resetCardRequestIdRef.current += 1;
setResetCardState({ status: 'idle', cards: [] });
setRedeemingResetCardId(null);
}, [authUser?.userId]);
useEffect(() => {
if (!authUser) return undefined;
const handleFocus = () => {
refreshTokenPoints();
if (resetCardDrawerOpen) void refreshResetCards();
};
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
refreshTokenPoints();
if (resetCardDrawerOpen) void refreshResetCards();
}
};
window.addEventListener('focus', handleFocus);
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
window.removeEventListener('focus', handleFocus);
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, [authUser, refreshResetCards, refreshTokenPoints, resetCardDrawerOpen]);
useEffect(() => {
if (authUser && accountMenuOpen) {
refreshTokenPoints();
}
}, [accountMenuOpen, authUser, refreshTokenPoints]);
useEffect(() => {
if (authUser && resetCardDrawerOpen) {
void refreshResetCards();
}
}, [authUser, refreshResetCards, resetCardDrawerOpen]);
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 result = await loadProjectConfig(project.id);
if (result.status !== 'valid' || !result.config) {
showProjectEntryError(project, result.status === 'missing' ? '项目缺少必要的配置文件' : result.error ?? '项目配置无效');
return;
}
await setActiveProject(project.id);
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);
setUsageDrawerOpen(false);
setResetCardDrawerOpen(false);
navigate(path);
};
const openProfileDialog = () => {
setAccountMenuOpen(false);
setUsageDrawerOpen(false);
setResetCardDrawerOpen(false);
setProfileDialogOpen(true);
void syncProfileNow().catch(() => undefined);
};
const openSubscriptionUpgrade = () => {
void openWorksSquareSubscriptionUpgrade().catch(() => undefined);
};
const handleRedeemResetCard = async (card: WorksResetCard) => {
if (redeemingResetCardId) return;
if (getEffectiveResetCardStatus(card) !== 'available') {
toast.error('这张重置卡已过期。');
void refreshResetCards();
return;
}
if (resetCardRedemptionBlocked) {
toast.error('当前使用家庭共享额度,请由家庭管理员使用自己的重置卡。');
return;
}
setRedeemingResetCardId(card.id);
try {
const accessToken = await getValidAccessToken();
if (!accessToken) throw new Error('请先登录后再使用重置卡');
const redeemedCard = await redeemWorksResetCard(accessToken, card.id);
setResetCardState((current) => ({
status: 'loaded',
cards: current.cards.map((item) => item.id === redeemedCard.id ? redeemedCard : item),
}));
toast.success('重置卡已使用,本周额度已刷新。');
refreshTokenPoints();
void refreshResetCards();
} catch (error) {
toast.error(getResetCardErrorMessage(error));
void refreshResetCards();
} finally {
setRedeemingResetCardId((current) => current === card.id ? null : current);
}
};
const handleAccountLogout = async () => {
setAccountMenuOpen(false);
setUsageDrawerOpen(false);
setResetCardDrawerOpen(false);
resetCardRequestIdRef.current += 1;
setResetCardState({ status: 'idle', cards: [] });
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',
hideOnCompact && 'hidden lg:flex',
)}
>
<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">
<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>
</>
) : (
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="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);
setResetCardDrawerOpen(false);
}}
>
<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>
{tokenPointState.status === 'error' ? (
<div data-testid="sidebar-token-points-error" className="rounded-md bg-background px-1.5 py-1.5">
<p className="text-[11px] leading-4 text-muted-foreground"></p>
<button
type="button"
className="mt-1 text-[11px] font-semibold text-foreground underline underline-offset-2"
onClick={refreshTokenPoints}
>
</button>
</div>
) : null}
{(tokenPointState.status === 'idle' || tokenPointState.status === 'loading') && !tokenPointBalance ? (
<div className="px-1 py-1 text-[11px] text-muted-foreground">
{authUser ? '正在加载词元点数…' : '登录后查看词元点数'}
</div>
) : null}
{tokenPointBalance && tokenPointsCoarse ? (
<>
<div className="flex min-h-6 items-center justify-between gap-3 px-1">
<span>{tokenPointBalance.family_shared ? '共享额度' : '词元点数'}</span>
<span className="text-foreground">{getCoarsePointLabel(tokenPointBalance)}</span>
</div>
<div className="px-1 text-[10px] leading-4 text-muted-foreground">
{tokenPointBalance.family_shared
? '具体词元点数由家庭管理员管理'
: '具体词元点数由家长管理'}
</div>
</>
) : null}
{tokenPointBalance && !tokenPointsCoarse ? (
<>
<div className="flex min-h-6 items-center justify-between gap-3 px-1">
<span></span>
<span data-testid="sidebar-weekly-token-points" className="text-foreground">
{getWeeklyPointLabel(tokenPointBalance)}
</span>
</div>
<div className="flex min-h-6 items-center justify-between gap-3 px-1">
<span></span>
<span data-testid="sidebar-total-token-points" className="text-foreground">
{getTotalPointLabel(tokenPointBalance)}
</span>
</div>
</>
) : null}
{tokenPointBalance && !tokenPointsCoarse && nextRefreshLabel ? (
<div data-testid="sidebar-token-points-refresh-at" className="px-1 text-[10px] font-semibold text-muted-foreground">
{nextRefreshLabel}
</div>
) : null}
{tokenPointBalanceExhausted ? (
<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">
{tokenPointsCoarse
? tokenPointBalance?.family_shared
? '家庭共享额度已用尽,请联系家庭管理员。'
: '词元点数已用尽,请联系家长。'
: '当前词元点数已用尽,升级订阅后可继续使用。'}
</p>
{!tokenPointsCoarse && tokenPointBalance?.upgrade_action === 'self_service' ? (
<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>
) : null}
</div>
) : null}
</DisclosureContent>
<button
type="button"
role="menuitem"
data-testid="sidebar-reset-cards-menuitem"
aria-expanded={resetCardDrawerOpen}
aria-controls="sidebar-reset-cards-drawer"
className="motion-press flex min-h-10 w-full items-center gap-2 rounded-md px-1 py-1 text-left text-sm font-medium transition-[background-color,transform] duration-100 hover:bg-surface-subtle"
onClick={() => {
setResetCardDrawerOpen((open) => !open);
setUsageDrawerOpen(false);
}}
>
<Ticket className="h-3.5 w-3.5 shrink-0" />
<span className="min-w-0 flex-1 truncate"></span>
{availableResetCardCount > 0 ? (
<span
data-testid="sidebar-reset-card-available-count"
className="min-w-5 rounded-full bg-accent-soft px-1.5 py-0.5 text-center text-[10px] font-semibold tabular-nums text-accent-strong"
>
{availableResetCardCount}
</span>
) : null}
<ChevronRight className={cn('h-3.5 w-3.5 shrink-0 text-muted-foreground', resetCardDrawerOpen && 'rotate-90')} />
</button>
<DisclosureContent
open={resetCardDrawerOpen}
id="sidebar-reset-cards-drawer"
data-testid="sidebar-reset-cards-drawer"
className="ml-5 rounded-lg bg-surface-subtle/70 p-1 text-xs font-medium text-muted-foreground shadow-soft"
innerClassName="grid gap-1"
>
{resetCardState.status === 'loading' && resetCardState.cards.length === 0 ? (
<p className="px-1.5 py-2 text-pretty text-[11px]"></p>
) : null}
{resetCardState.status === 'error' ? (
<div data-testid="sidebar-reset-cards-error" className="rounded-md bg-background px-1.5 py-1.5 shadow-soft">
<p className="text-pretty text-[11px] leading-4"></p>
<button
type="button"
className="motion-press mt-1 min-h-10 text-[11px] font-semibold text-foreground underline underline-offset-2 transition-transform duration-100"
onClick={() => void refreshResetCards()}
>
</button>
</div>
) : null}
{resetCardState.status === 'loaded' && resetCardState.cards.length === 0 ? (
<p className="px-1.5 py-2 text-pretty text-[11px]"></p>
) : null}
{resetCardRedemptionBlocked && resetCardState.cards.length > 0 ? (
<p className="rounded-md bg-background px-1.5 py-1.5 text-pretty text-[10px] leading-4 shadow-soft">
使使
</p>
) : null}
{resetCardState.cards.length > 0 ? (
<div className="grid max-h-64 gap-1 overflow-y-auto pr-0.5">
{resetCardState.cards.map((card) => {
const status = getEffectiveResetCardStatus(card);
const expiresAt = formatResetCardTime(card.expires_at) ?? '时间不可用';
const redeemedAt = formatResetCardTime(card.redeemed_at);
const redeeming = redeemingResetCardId === card.id;
return (
<article
key={card.id}
data-testid={`sidebar-reset-card-${card.id}`}
className="rounded-lg bg-background p-2 shadow-soft"
>
<div className="flex items-center justify-between gap-2">
<span className="font-semibold text-foreground"></span>
<span className={cn(
'rounded-full px-1.5 py-0.5 text-[10px] font-semibold',
status === 'available' && 'bg-accent-soft text-accent-strong',
status === 'expired' && 'bg-destructive/10 text-destructive',
status === 'redeemed' && 'bg-surface-subtle text-muted-foreground',
)}>
{status === 'available' ? '可使用' : status === 'expired' ? '已过期' : '已使用'}
</span>
</div>
<p className="mt-1 text-pretty text-[10px] leading-4 tabular-nums">
{expiresAt}
</p>
{status === 'redeemed' && redeemedAt ? (
<p className="text-pretty text-[10px] leading-4 tabular-nums">使 {redeemedAt}</p>
) : null}
{status === 'available' ? (
<button
type="button"
data-testid={`sidebar-reset-card-redeem-${card.id}`}
className="motion-press mt-1.5 min-h-10 w-full rounded-md bg-accent-soft px-2 py-1.5 text-xs font-semibold text-foreground shadow-soft transition-[background-color,transform] duration-100 hover:bg-background disabled:cursor-not-allowed disabled:opacity-50"
disabled={Boolean(redeemingResetCardId) || resetCardRedemptionBlocked}
onClick={() => void handleRedeemResetCard(card)}
>
{redeeming ? '使用中…' : resetCardRedemptionBlocked ? '家庭共享中不可用' : '立即使用'}
</button>
) : null}
</article>
);
})}
</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>
);
}