feat(learning): replace courses with project catalog

This commit is contained in:
2026-08-20 00:08:04 +08:00
parent 2cb8a7aef4
commit 38db1589e2
56 changed files with 1741 additions and 8725 deletions

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

View File

@@ -14,8 +14,6 @@ import { useProjectConfigStore } from '@/stores/project-config';
import { useSettingsStore } from '@/stores/settings';
import { cn } from '@/lib/utils';
import type { SidebarPeekSource } from './sidebar-peek';
import { UserProfileDialog } from '@/components/profile/UserProfileDialog';
import { useCurrentUserProfile } from '@/hooks/use-current-user-profile';
const SIDEBAR_PEEK_CLOSE_DELAY_MS = 180;
@@ -37,24 +35,9 @@ export function MainLayout() {
const activeModule = getAiModuleForPath(location.pathname);
const isProgrammingModule = activeModule === 'programming';
const isPaintingModule = activeModule === 'painting';
const isLearningPlayer = location.pathname.startsWith('/learning/course/');
const isPromptMuseum = location.pathname === '/image-prompts' || location.pathname.startsWith('/image-prompts/');
const isChatWorkspace = location.pathname === '/opencode-chat';
const isInitializationSafeRoute = location.pathname === '/project-config' || !isProgrammingModule;
const {
profileRequired,
profileSyncState,
profileSyncError,
syncProfileNow,
} = useCurrentUserProfile(isLearningPlayer);
const [profileDialogOpen, setProfileDialogOpen] = useState(false);
const profileDialogShouldBeOpen = profileDialogOpen
|| profileRequired;
const profileSyncFailure = isLearningPlayer
? profileSyncError || (profileSyncState?.status === 'error' ? profileSyncState.error : null)
: null;
const learningContentBlocked = isLearningPlayer
&& (profileSyncState?.status !== 'ready' || profileRequired);
const handleSidebarPeekChange = useCallback((open: boolean, source: SidebarPeekSource) => {
if (!sidebarCollapsed) return;
@@ -108,7 +91,7 @@ export function MainLayout() {
<div data-testid="main-layout" className="relative flex h-[100dvh] flex-col overflow-hidden bg-transparent font-sans">
{/* Title bar: drag region on macOS, icon + controls on Windows */}
<TitleBar
integrated={!isLearningPlayer}
integrated
workspaceLayout={isChatWorkspace}
overlay={isPaintingModule && !isPromptMuseum}
pageTitle={isPromptMuseum ? '获取灵感' : undefined}
@@ -118,43 +101,20 @@ export function MainLayout() {
{/* Below the title bar: sidebar + content */}
<div className="relative flex min-h-0 flex-1 overflow-hidden">
{!isLearningPlayer ? (
<Sidebar
workspaceLayout={isChatWorkspace}
sidebarPeekOpen={sidebarPeekOpen}
onSidebarPeekChange={handleSidebarPeekChange}
/>
) : null}
<Sidebar
workspaceLayout={isChatWorkspace}
sidebarPeekOpen={sidebarPeekOpen}
onSidebarPeekChange={handleSidebarPeekChange}
/>
<main
data-testid="main-content"
className={cn(
'relative min-h-0 min-w-0 flex-1 overflow-auto bg-background',
isPaintingModule || isLearningPlayer ? 'basis-0 h-full overflow-hidden p-0' : 'p-5 sm:p-6',
isPaintingModule ? 'basis-0 h-full overflow-hidden p-0' : 'p-5 sm:p-6',
isChatWorkspace && !isPaintingModule && 'basis-0 overflow-hidden p-0',
)}
>
{profileSyncFailure ? (
<div data-testid="learning-profile-sync-error-gate" className="flex h-full items-center justify-center p-6">
<div className="surface-card max-w-md rounded-2xl border border-border/80 bg-background p-8 text-center shadow-float">
<h1 className="text-2xl font-semibold"></h1>
<p role="alert" className="mt-3 text-sm font-medium text-red-700">{profileSyncFailure}</p>
<div className="mt-6 flex flex-wrap justify-center gap-3">
<Button onClick={() => void syncProfileNow().catch(() => undefined)} className="font-semibold">
</Button>
<Button variant="outline" onClick={() => navigate('/learning')} className="font-semibold">
</Button>
</div>
</div>
</div>
) : learningContentBlocked ? (
profileRequired ? null : (
<div data-testid="learning-profile-sync-loading-gate" className="flex h-full items-center justify-center p-6 text-sm font-medium text-muted-foreground">
</div>
)
) : <Outlet />}
<Outlet />
{initializationBlocked ? (
<div data-testid="project-initialization-gate" className="glass-surface absolute inset-0 z-[100] flex items-center justify-center bg-background/90 p-6">
<div className="surface-card max-w-md rounded-2xl border border-border/80 bg-background p-8 text-center shadow-float">
@@ -167,15 +127,6 @@ export function MainLayout() {
) : null}
</main>
</div>
{isLearningPlayer ? (
<UserProfileDialog
open={profileDialogShouldBeOpen}
required={profileRequired}
onOpenChange={(open) => {
setProfileDialogOpen(open);
}}
/>
) : null}
</div>
);
}