feat(learning): replace courses with project catalog
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user