feat(learning): replace courses with project catalog

This commit is contained in:
brother7 committed 2026-08-20 00:08:04 +08:00
1 parent 2cb8a7aef4
commit 38db1589e2
56 files changed
+1741 -8725

No files matched your search

+13 -149
View File
@@ -1,14 +1,6 @@
import { useCallback, useEffect, useState } from 'react';
import { BookOpen, Download, Loader2, Plus, Sparkles } from 'lucide-react';
import { FolderKanban } from 'lucide-react';
import { useLocation, useNavigate } from 'react-router-dom';
import {
downloadLearningCourse,
fetchMyLearningCourses,
LEARNING_LIBRARY_CHANGED_EVENT,
listInstalledLearningCourses,
} from '@/lib/learning';
import { cn } from '@/lib/utils';
import type { InstalledLearningCourse, LearningCourse } from '../../../shared/learning';
type LearningSidebarProps = {
sidebarCollapsed: boolean;
@@ -17,157 +9,29 @@ type LearningSidebarProps = {
export function LearningSidebar({ sidebarCollapsed }: LearningSidebarProps) {
const navigate = useNavigate();
const location = useLocation();
const [installed, setInstalled] = useState<Record<string, InstalledLearningCourse>>({});
const [ownedCourses, setOwnedCourses] = useState<LearningCourse[]>([]);
const [loading, setLoading] = useState(true);
const [downloading, setDownloading] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const loadCourses = useCallback(async () => {
setLoading(true);
setError(null);
try {
const [localCourses, personalCourses] = await Promise.all([
listInstalledLearningCourses(),
fetchMyLearningCourses().catch(() => []),
]);
setInstalled(Object.fromEntries(localCourses.map((record) => [record.course.id, record])));
setOwnedCourses(personalCourses);
} catch (cause) {
setError(cause instanceof Error ? cause.message : '个人课程暂时无法读取');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void loadCourses();
const refresh = () => void loadCourses();
window.addEventListener(LEARNING_LIBRARY_CHANGED_EVENT, refresh);
return () => window.removeEventListener(LEARNING_LIBRARY_CHANGED_EVENT, refresh);
}, [loadCourses]);
const personalCourses = (() => {
const courses = new Map<string, LearningCourse>();
for (const record of Object.values(installed)) courses.set(record.course.id, record.course);
for (const course of ownedCourses) courses.set(course.id, course);
return [...courses.values()];
})();
const openCourse = useCallback(async (course: LearningCourse) => {
if (installed[course.id]) {
navigate(`/learning/course/${encodeURIComponent(course.id)}`);
return;
}
setDownloading(course.id);
setError(null);
try {
const record = await downloadLearningCourse(course.id);
setInstalled((current) => ({ ...current, [course.id]: record }));
navigate(`/learning/course/${encodeURIComponent(course.id)}`);
} catch (cause) {
setError(cause instanceof Error ? cause.message : '课程下载失败');
} finally {
setDownloading(null);
}
}, [installed, navigate]);
const catalogActive = location.pathname === '/learning';
const active = location.pathname === '/learning' || location.pathname.startsWith('/learning/project/');
return (
<div data-testid="sidebar-learning-navigation" className="px-1 py-1">
<nav data-testid="sidebar-learning-navigation" aria-label="AI 学习导航" className="px-1 py-1">
<button
type="button"
aria-label="课程广场"
aria-current={catalogActive ? 'page' : undefined}
aria-label="学习项目"
aria-current={active ? 'page' : undefined}
onClick={() => navigate('/learning')}
className={cn(
'motion-press flex min-h-9 w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm font-semibold transition-colors duration-100',
catalogActive ? 'bg-brand-soft text-foreground' : 'text-foreground hover:bg-surface-subtle',
'motion-press flex min-h-10 w-full items-center gap-2 rounded-lg px-2 py-2 text-left text-sm font-semibold transition-colors duration-100',
active ? 'bg-brand-soft text-foreground' : 'text-foreground hover:bg-surface-subtle',
sidebarCollapsed && 'justify-center px-0',
)}
>
<BookOpen className="h-4 w-4 shrink-0 text-brand" />
{!sidebarCollapsed ? <span>课程广场</span> : null}
<FolderKanban className="h-4 w-4 shrink-0 text-brand" />
{!sidebarCollapsed ? <span>学习项目</span> : null}
</button>
<button
type="button"
aria-label="生成课程"
onClick={() => navigate('/learning?generate=1')}
className={cn(
'motion-press mt-1 flex min-h-9 w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm font-medium text-foreground transition-colors duration-100 hover:bg-surface-subtle',
sidebarCollapsed && 'justify-center px-0',
)}
>
<Plus className="h-4 w-4 shrink-0 text-brand" />
{!sidebarCollapsed ? <span>生成课程</span> : null}
</button>
{!sidebarCollapsed ? (
<div className="mt-4">
<div className="flex min-h-8 items-center gap-2 px-2 text-xs font-semibold text-muted-foreground">
<Sparkles className="h-4 w-4 shrink-0" />
<span className="min-w-0 flex-1 truncate">我的课程</span>
{!loading ? <span className="tabular-nums">{personalCourses.length}</span> : null}
</div>
{loading ? (
<div role="status" className="flex items-center px-2 py-3 text-xs font-medium text-muted-foreground">
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />正在读取课程
</div>
) : error ? (
<button
type="button"
className="w-full rounded-lg px-2 py-3 text-left text-xs font-medium leading-5 text-destructive hover:bg-destructive/5"
onClick={() => void loadCourses()}
>
{error},点击重试
</button>
) : personalCourses.length === 0 ? (
<p className="px-2 py-3 text-xs font-medium leading-5 text-muted-foreground">
下载或生成课程后,会显示在这里。
</p>
) : (
<div className="mt-1 space-y-1">
{personalCourses.map((course) => {
const isInstalled = Boolean(installed[course.id]);
const isActive = location.pathname === `/learning/course/${encodeURIComponent(course.id)}`;
const courseMeta = course.origin === 'user_single'
? isInstalled ? '我生成的,已下载' : '我生成的,待下载'
: '已下载到本机';
return (
<button
type="button"
key={course.id}
aria-label={`${isInstalled ? '打开' : '下载'}课程 ${course.title}`}
aria-current={isActive ? 'page' : undefined}
disabled={downloading === course.id}
onClick={() => void openCourse(course)}
className={cn(
'motion-press group/course flex w-full items-center gap-2 rounded-xl px-2.5 py-2 text-left transition-colors duration-100',
isActive ? 'bg-brand-selected text-foreground ring-1 ring-brand/20' : 'hover:bg-surface-tertiary',
)}
>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-semibold text-foreground">{course.title}</span>
<span className="mt-0.5 block truncate text-[11px] font-medium text-muted-foreground">{courseMeta}</span>
</span>
{downloading === course.id ? (
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-brand" />
) : isInstalled ? (
<BookOpen className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
) : (
<Download className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
)}
</button>
);
})}
</div>
)}
</div>
<p className="px-2 pt-3 text-xs leading-5 text-muted-foreground">
阅读项目说明并下载到电脑实践。
</p>
) : null}
</div>
</nav>
);
}