feat: remove Learning module
This commit is contained in:
@@ -42,8 +42,6 @@ const Settings = lazy(() => import('./pages/Settings').then(({ Settings: compone
|
||||
const Setup = lazy(() => import('./pages/Setup').then(({ Setup: component }) => ({ default: component })));
|
||||
const Login = lazy(() => import('./pages/Login').then(({ Login: component }) => ({ default: component })));
|
||||
const ModuleSelection = lazy(() => import('./pages/ModuleSelection').then(({ ModuleSelection: component }) => ({ default: component })));
|
||||
const Learning = lazy(() => import('./pages/Learning').then(({ Learning: component }) => ({ default: component })));
|
||||
const LearningProjectDetail = lazy(() => import('./pages/Learning/ProjectDetail').then(({ LearningProjectDetail: component }) => ({ default: component })));
|
||||
|
||||
/**
|
||||
* Error Boundary to catch and display React rendering errors
|
||||
@@ -129,7 +127,6 @@ function moduleForPath(pathname: string): DesktopActivityModule | null {
|
||||
const module = getGuardedAiModuleForPath(pathname);
|
||||
if (module === 'programming') return 'programming';
|
||||
if (module === 'painting') return 'painting';
|
||||
if (module === 'learning') return 'learning';
|
||||
if (module === 'robot') return 'robot';
|
||||
return null;
|
||||
}
|
||||
@@ -457,8 +454,6 @@ function App() {
|
||||
<Route path="/image-canvas" element={<ImageCanvas />} />
|
||||
<Route path="/image-prompts" element={<ImagePromptMuseum />} />
|
||||
<Route path="/ai-hardware" element={<AiHardware />} />
|
||||
<Route path="/learning" element={<Learning />} />
|
||||
<Route path="/learning/project/:projectId" element={<LearningProjectDetail />} />
|
||||
<Route path="/workbench/:projectId" element={<Workbench />} />
|
||||
<Route path="/models" element={<Models />} />
|
||||
<Route path="/settings/*" element={<Settings />} />
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 44 KiB |
@@ -1,37 +0,0 @@
|
||||
import { FolderKanban } from 'lucide-react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type LearningSidebarProps = {
|
||||
sidebarCollapsed: boolean;
|
||||
};
|
||||
|
||||
export function LearningSidebar({ sidebarCollapsed }: LearningSidebarProps) {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const active = location.pathname === '/learning' || location.pathname.startsWith('/learning/project/');
|
||||
|
||||
return (
|
||||
<nav data-testid="sidebar-learning-navigation" aria-label="AI 学习导航" className="px-1 py-1">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="学习项目"
|
||||
aria-current={active ? 'page' : undefined}
|
||||
onClick={() => navigate('/learning')}
|
||||
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-semibold transition-colors duration-100',
|
||||
active ? 'bg-brand-soft text-foreground' : 'text-foreground hover:bg-surface-subtle',
|
||||
sidebarCollapsed && 'justify-center px-0',
|
||||
)}
|
||||
>
|
||||
<FolderKanban className="h-4 w-4 shrink-0 text-brand" />
|
||||
{!sidebarCollapsed ? <span>学习项目</span> : null}
|
||||
</button>
|
||||
{!sidebarCollapsed ? (
|
||||
<p className="px-2 pt-3 text-xs leading-5 text-muted-foreground">
|
||||
阅读项目说明并下载到电脑实践。
|
||||
</p>
|
||||
) : null}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -40,7 +40,6 @@ import { useCurrentUserProfile } from '@/hooks/use-current-user-profile';
|
||||
import type { SidebarPeekSource } from './sidebar-peek';
|
||||
import { ModuleSwitcher } from './ModuleSwitcher';
|
||||
import { ImageWorkspaceSidebar } from './ImageWorkspaceSidebar';
|
||||
import { LearningSidebar } from './LearningSidebar';
|
||||
import { SidebarUpdateButton } from './SidebarUpdateButton';
|
||||
import { UserAvatar } from '@/components/profile/UserAvatar';
|
||||
import { getAccountInitial } from '@/components/profile/user-avatar-utils';
|
||||
@@ -185,7 +184,6 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi
|
||||
const activeModule = getAiModuleForPath(location.pathname);
|
||||
const isProgrammingModule = activeModule === 'programming';
|
||||
const isPaintingModule = activeModule === 'painting';
|
||||
const isLearningModule = activeModule === 'learning';
|
||||
const isRobotModule = activeModule === 'robot';
|
||||
const isPromptMuseum = location.pathname === '/image-prompts' || location.pathname.startsWith('/image-prompts/');
|
||||
const projectConfigPath = '/project-config';
|
||||
@@ -672,8 +670,6 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
) : isLearningModule ? (
|
||||
<LearningSidebar sidebarCollapsed={sidebarCollapsed} />
|
||||
) : (
|
||||
<div data-testid="sidebar-robot-navigation" className="px-2 py-3">
|
||||
<button
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { Bot, Code2, Paintbrush, Sigma } from 'lucide-react';
|
||||
import { Bot, Code2, Paintbrush } from 'lucide-react';
|
||||
import type { ModuleAccess, ModuleAccessKey } from '../../shared/module-access';
|
||||
|
||||
export const AI_MODULE_SELECTION_PATH = '/module-select';
|
||||
|
||||
export type AiModuleId = 'programming' | 'painting' | 'learning' | 'robot';
|
||||
export type AiModuleId = 'programming' | 'painting' | 'robot';
|
||||
|
||||
export type AiModuleDefinition = {
|
||||
id: AiModuleId;
|
||||
@@ -36,15 +36,6 @@ export const aiModules: readonly AiModuleDefinition[] = [
|
||||
enabled: true,
|
||||
Icon: Paintbrush,
|
||||
},
|
||||
{
|
||||
id: 'learning',
|
||||
title: 'Makelore Learning',
|
||||
subtitle: 'AI 学习',
|
||||
description: '用顶尖的方法解锁万物规律',
|
||||
route: '/learning',
|
||||
enabled: true,
|
||||
Icon: Sigma,
|
||||
},
|
||||
{
|
||||
id: 'robot',
|
||||
title: 'Makelore Robot',
|
||||
@@ -60,7 +51,6 @@ export const aiModules: readonly AiModuleDefinition[] = [
|
||||
const moduleAccessKeyById: Record<AiModuleId, ModuleAccessKey> = {
|
||||
programming: 'programming',
|
||||
painting: 'design',
|
||||
learning: 'learning',
|
||||
robot: 'robot',
|
||||
};
|
||||
|
||||
@@ -113,9 +103,6 @@ export function getGuardedAiModuleForPath(pathname: string): AiModuleId | null {
|
||||
if (pathname === '/ai-hardware' || pathname.startsWith('/ai-hardware/')) {
|
||||
return 'robot';
|
||||
}
|
||||
if (pathname === '/learning' || pathname.startsWith('/learning/')) {
|
||||
return 'learning';
|
||||
}
|
||||
if (PROGRAMMING_ROUTE_PREFIXES.some((route) => matchesRoute(pathname, route))) {
|
||||
return 'programming';
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ export async function ensureHostApiToken(): Promise<string> {
|
||||
return token;
|
||||
}
|
||||
|
||||
export type DesktopActivityModule = 'programming' | 'painting' | 'learning' | 'robot' | 'other';
|
||||
export type DesktopActivityModule = 'programming' | 'painting' | 'robot' | 'other';
|
||||
|
||||
export function reportDesktopActivity(input: {
|
||||
visible: boolean;
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
import { invokeIpc } from '@/lib/api-client';
|
||||
import { AppError } from '@/lib/error-model';
|
||||
import { hostApiFetch } from '@/lib/host-api';
|
||||
import {
|
||||
LEARNING_API_PATH,
|
||||
LEARNING_MEDIA_MAX_BYTES,
|
||||
type LearningProjectDetail,
|
||||
type LearningProjectDownloadResult,
|
||||
type LearningProjectListQuery,
|
||||
type LearningProjectPage,
|
||||
} from '../../shared/learning';
|
||||
|
||||
type LearningEnvelope<T> = {
|
||||
success?: boolean;
|
||||
status?: number;
|
||||
code?: string;
|
||||
error?: string;
|
||||
data?: T;
|
||||
};
|
||||
|
||||
type LearningProjectMedia = {
|
||||
dataBase64: string;
|
||||
mimeType: string;
|
||||
};
|
||||
|
||||
const PROJECT_MEDIA_URL_PATTERN = /^\/api\/learning\/projects\/[A-Za-z0-9][A-Za-z0-9._-]{0,127}\/media\/[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
||||
const PROJECT_MEDIA_MIME_TYPES = new Set([
|
||||
'image/avif',
|
||||
'image/gif',
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/webp',
|
||||
]);
|
||||
const MAX_MEDIA_BASE64_LENGTH = Math.ceil(LEARNING_MEDIA_MAX_BYTES / 3) * 4;
|
||||
|
||||
export class LearningApiError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly code: string,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'LearningApiError';
|
||||
}
|
||||
}
|
||||
|
||||
function errorStatus(error: unknown): number {
|
||||
return error instanceof AppError && typeof error.details?.status === 'number'
|
||||
? error.details.status
|
||||
: 502;
|
||||
}
|
||||
|
||||
function errorCode(error: unknown, status: number): string {
|
||||
if (error instanceof AppError && typeof error.details?.backendCode === 'string') {
|
||||
return error.details.backendCode;
|
||||
}
|
||||
if (status === 401) return 'LEARNING_AUTH_REQUIRED';
|
||||
if (status === 404) return 'LEARNING_PROJECT_NOT_FOUND';
|
||||
return 'LEARNING_REQUEST_FAILED';
|
||||
}
|
||||
|
||||
async function requestData<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
let response: LearningEnvelope<T>;
|
||||
try {
|
||||
response = await hostApiFetch<LearningEnvelope<T>>(path, init);
|
||||
} catch (error) {
|
||||
const status = errorStatus(error);
|
||||
throw new LearningApiError(
|
||||
status,
|
||||
errorCode(error, status),
|
||||
error instanceof Error ? error.message : '学习项目请求失败',
|
||||
);
|
||||
}
|
||||
|
||||
if (!response.success || response.data === undefined) {
|
||||
throw new LearningApiError(
|
||||
response.status ?? 502,
|
||||
response.code ?? 'LEARNING_REQUEST_FAILED',
|
||||
response.error ?? '学习项目请求失败',
|
||||
);
|
||||
}
|
||||
return response.data;
|
||||
}
|
||||
|
||||
function listQuery(query: LearningProjectListQuery): string {
|
||||
const params = new URLSearchParams();
|
||||
if (query.cursor?.trim()) params.set('cursor', query.cursor.trim());
|
||||
if (query.limit !== undefined) params.set('limit', String(query.limit));
|
||||
const encoded = params.toString();
|
||||
return encoded ? `?${encoded}` : '';
|
||||
}
|
||||
|
||||
export function fetchLearningProjects(
|
||||
query: LearningProjectListQuery = {},
|
||||
): Promise<LearningProjectPage> {
|
||||
return requestData(`${LEARNING_API_PATH}/projects${listQuery(query)}`);
|
||||
}
|
||||
|
||||
export function fetchLearningProject(projectId: string): Promise<LearningProjectDetail> {
|
||||
return requestData(`${LEARNING_API_PATH}/projects/${encodeURIComponent(projectId)}`);
|
||||
}
|
||||
|
||||
export function downloadLearningProject(
|
||||
projectId: string,
|
||||
): Promise<LearningProjectDownloadResult> {
|
||||
return requestData(`${LEARNING_API_PATH}/projects/${encodeURIComponent(projectId)}/download`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchLearningProjectMedia(mediaUrl: string): Promise<string> {
|
||||
if (!PROJECT_MEDIA_URL_PATTERN.test(mediaUrl)) {
|
||||
throw new LearningApiError(400, 'LEARNING_INVALID_MEDIA_URL', '项目图片地址无效');
|
||||
}
|
||||
const localPath = mediaUrl.replace('/api/learning/', `${LEARNING_API_PATH}/`);
|
||||
const payload = await hostApiFetch<LearningProjectMedia>(localPath);
|
||||
const mimeType = typeof payload?.mimeType === 'string' ? payload.mimeType.trim().toLowerCase() : '';
|
||||
const dataBase64 = typeof payload?.dataBase64 === 'string' ? payload.dataBase64 : '';
|
||||
if (
|
||||
!PROJECT_MEDIA_MIME_TYPES.has(mimeType)
|
||||
|| !dataBase64
|
||||
|| dataBase64.length > MAX_MEDIA_BASE64_LENGTH
|
||||
|| !/^[A-Za-z0-9+/]*={0,2}$/.test(dataBase64)
|
||||
|| dataBase64.length % 4 === 1
|
||||
) {
|
||||
throw new LearningApiError(502, 'LEARNING_INVALID_MEDIA_RESPONSE', '项目图片返回了无效数据');
|
||||
}
|
||||
return `data:${mimeType};base64,${dataBase64}`;
|
||||
}
|
||||
|
||||
export function isLearningProjectMediaUrl(value: string): boolean {
|
||||
return PROJECT_MEDIA_URL_PATTERN.test(value);
|
||||
}
|
||||
|
||||
export function isSafeLearningImageUrl(value: string): boolean {
|
||||
if (PROJECT_MEDIA_URL_PATTERN.test(value)) return true;
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.protocol === 'https:' && !url.username && !url.password;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function openLearningExternalLink(value: string): Promise<void> {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(value);
|
||||
} catch {
|
||||
throw new LearningApiError(400, 'LEARNING_INVALID_LINK', '项目链接无效');
|
||||
}
|
||||
if (url.protocol !== 'https:' || url.username || url.password) {
|
||||
throw new LearningApiError(400, 'LEARNING_INVALID_LINK', '项目链接无效');
|
||||
}
|
||||
await invokeIpc('shell:openExternal', url.toString());
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
import { useCallback, useEffect, useState, type MouseEvent } from 'react';
|
||||
import { ArrowLeft, Download, FileArchive, Loader2, RefreshCw } from 'lucide-react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
downloadLearningProject,
|
||||
fetchLearningProject,
|
||||
openLearningExternalLink,
|
||||
} from '@/lib/learning';
|
||||
import type { LearningProjectDetail as LearningProjectDetailRecord } from '../../../shared/learning';
|
||||
import { ProjectImage } from './ProjectImage';
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes >= 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
||||
return `${Math.max(1, Math.round(bytes / (1024 * 1024)))} MB`;
|
||||
}
|
||||
|
||||
function isHttpsLink(value: string | undefined): value is string {
|
||||
if (!value) return false;
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.protocol === 'https:' && !url.username && !url.password;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function LearningProjectDetail() {
|
||||
const navigate = useNavigate();
|
||||
const { projectId = '' } = useParams();
|
||||
const [project, setProject] = useState<LearningProjectDetailRecord | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadProject = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
setProject(await fetchLearningProject(projectId));
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : '项目介绍暂时无法加载');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadProject();
|
||||
}, [loadProject]);
|
||||
|
||||
const download = useCallback(async () => {
|
||||
if (!project || downloading) return;
|
||||
setDownloading(true);
|
||||
try {
|
||||
const result = await downloadLearningProject(project.id);
|
||||
if (result.status === 'saved') toast.success('项目已保存到电脑');
|
||||
} catch (cause) {
|
||||
toast.error(cause instanceof Error ? cause.message : '项目下载失败');
|
||||
} finally {
|
||||
setDownloading(false);
|
||||
}
|
||||
}, [downloading, project]);
|
||||
|
||||
const openLink = (href: string) => (event: MouseEvent<HTMLAnchorElement>) => {
|
||||
event.preventDefault();
|
||||
void openLearningExternalLink(href).catch(() => toast.error('链接无法打开'));
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div data-testid="learning-project-detail" role="status" className="flex min-h-72 items-center justify-center text-sm font-medium text-muted-foreground">
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin" />正在读取项目介绍
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !project) {
|
||||
return (
|
||||
<section data-testid="learning-project-detail" className="mx-auto flex min-h-72 max-w-xl items-center justify-center">
|
||||
<div role="alert" className="w-full rounded-2xl border border-destructive/20 bg-destructive/5 p-8 text-center">
|
||||
<p className="font-semibold text-foreground">项目介绍加载失败</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">{error || '项目不存在或已下架'}</p>
|
||||
<div className="mt-5 flex justify-center gap-3">
|
||||
<Button variant="outline" className="min-h-10" onClick={() => navigate('/learning')}><ArrowLeft className="mr-2 h-4 w-4" />返回列表</Button>
|
||||
<Button className="min-h-10" onClick={() => void loadProject()}><RefreshCw className="mr-2 h-4 w-4" />重试</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<article data-testid="learning-project-detail" className="mx-auto w-full max-w-5xl pb-12">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<Button variant="ghost" className="min-h-10" onClick={() => navigate('/learning')}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />返回项目列表
|
||||
</Button>
|
||||
<Button className="min-h-10" disabled={downloading} onClick={() => void download()}>
|
||||
{downloading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Download className="mr-2 h-4 w-4" />}
|
||||
{downloading ? '正在下载' : '下载项目'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<header className="surface-card overflow-hidden rounded-3xl border border-border/80 bg-background shadow-soft">
|
||||
<ProjectImage
|
||||
src={project.cover.url}
|
||||
alt={project.cover.alt}
|
||||
allowHttps
|
||||
className="aspect-[21/8] w-full border-b border-black/10 object-cover"
|
||||
/>
|
||||
<div className="p-6 sm:p-8">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{project.tags.map((tag) => (
|
||||
<span key={tag} className="rounded-full bg-brand-soft px-3 py-1 text-xs font-semibold text-brand">{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
<h1 className="mt-4 text-balance text-3xl font-semibold tracking-tight text-foreground sm:text-4xl">{project.name}</h1>
|
||||
<p className="mt-3 max-w-3xl text-sm leading-7 text-muted-foreground sm:text-base">{project.summary}</p>
|
||||
<div className="mt-6 flex flex-wrap items-center gap-x-5 gap-y-2 border-t border-border/70 pt-5 text-xs text-muted-foreground">
|
||||
<span className="inline-flex items-center"><FileArchive className="mr-1.5 h-4 w-4" />ZIP · {formatBytes(project.archiveBytes)}</span>
|
||||
<span>{project.version ? `版本 ${project.version}` : '最新版'}</span>
|
||||
<span>更新于 {new Date(project.updatedAt).toLocaleDateString('zh-CN')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section aria-labelledby="learning-project-readme" className="mt-6 rounded-3xl border border-border/80 bg-background px-6 py-7 shadow-soft sm:px-10 sm:py-9">
|
||||
<h2 id="learning-project-readme" className="sr-only">项目说明</h2>
|
||||
<div className="learning-readme prose max-w-none break-words text-[15px] leading-7 text-foreground">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
skipHtml
|
||||
components={{
|
||||
h1: ({ children }) => <h1 className="mb-5 mt-9 text-balance text-3xl font-semibold tracking-tight first:mt-0">{children}</h1>,
|
||||
h2: ({ children }) => <h2 className="mb-4 mt-9 border-b border-border pb-2 text-balance text-2xl font-semibold tracking-tight">{children}</h2>,
|
||||
h3: ({ children }) => <h3 className="mb-3 mt-7 text-balance text-xl font-semibold">{children}</h3>,
|
||||
p: ({ children }) => <p className="my-4 leading-7 text-foreground/90">{children}</p>,
|
||||
a: ({ href, children }) => isHttpsLink(href) ? (
|
||||
<a href={href} onClick={openLink(href)} className="font-medium text-brand underline decoration-brand/30 underline-offset-4 hover:decoration-brand">{children}</a>
|
||||
) : <span>{children}</span>,
|
||||
img: ({ src, alt }) => typeof src === 'string' ? (
|
||||
<ProjectImage
|
||||
src={src}
|
||||
alt={alt || '项目说明图片'}
|
||||
allowHttps
|
||||
className="my-6 max-h-[34rem] w-full rounded-2xl border border-black/10 object-contain"
|
||||
/>
|
||||
) : null,
|
||||
pre: ({ children }) => <pre className="my-5 overflow-x-auto rounded-2xl border border-border bg-surface-subtle p-4 text-sm leading-6">{children}</pre>,
|
||||
code: ({ children, className }) => <code className={className || 'rounded bg-surface-subtle px-1.5 py-0.5 text-[0.9em]'}>{children}</code>,
|
||||
blockquote: ({ children }) => <blockquote className="my-5 border-l-4 border-brand/40 bg-brand-soft/40 px-5 py-3 text-muted-foreground">{children}</blockquote>,
|
||||
table: ({ children }) => <div className="my-5 overflow-x-auto"><table className="w-full border-collapse text-sm">{children}</table></div>,
|
||||
}}
|
||||
>
|
||||
{project.readmeMarkdown}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</section>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ImageOff, Loader2 } from 'lucide-react';
|
||||
import {
|
||||
fetchLearningProjectMedia,
|
||||
isLearningProjectMediaUrl,
|
||||
isSafeLearningImageUrl,
|
||||
} from '@/lib/learning';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type ProjectImageProps = {
|
||||
src: string;
|
||||
alt: string;
|
||||
className?: string;
|
||||
allowHttps?: boolean;
|
||||
};
|
||||
|
||||
export function ProjectImage({ src, alt, className, allowHttps = false }: ProjectImageProps) {
|
||||
const controlledMedia = isLearningProjectMediaUrl(src);
|
||||
const directSource = allowHttps && isSafeLearningImageUrl(src) && !controlledMedia ? src : null;
|
||||
const [failedSource, setFailedSource] = useState<string | null>(null);
|
||||
const [state, setState] = useState<{ input: string; source: string; status: 'ready' | 'error' }>({
|
||||
input: '',
|
||||
source: '',
|
||||
status: 'error',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!controlledMedia) return undefined;
|
||||
let cancelled = false;
|
||||
void fetchLearningProjectMedia(src).then((source) => {
|
||||
if (!cancelled) setState({ input: src, source, status: 'ready' });
|
||||
}).catch(() => {
|
||||
if (!cancelled) setState({ input: src, source: '', status: 'error' });
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [controlledMedia, src]);
|
||||
|
||||
if (failedSource === src || (!controlledMedia && !directSource)) {
|
||||
return (
|
||||
<span role="img" aria-label={`${alt || '项目图片'}加载失败`} className={cn('flex items-center justify-center bg-surface-subtle text-muted-foreground', className)}>
|
||||
<ImageOff className="h-6 w-6" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (controlledMedia && state.input !== src) {
|
||||
return (
|
||||
<span role="status" aria-label="正在加载项目图片" className={cn('flex items-center justify-center bg-surface-subtle text-muted-foreground', className)}>
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (controlledMedia && state.status === 'error') {
|
||||
return (
|
||||
<span role="img" aria-label={`${alt || '项目图片'}加载失败`} className={cn('flex items-center justify-center bg-surface-subtle text-muted-foreground', className)}>
|
||||
<ImageOff className="h-6 w-6" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<img
|
||||
src={directSource ?? state.source}
|
||||
alt={alt}
|
||||
loading="lazy"
|
||||
className={className}
|
||||
onError={() => setFailedSource(src)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { ArrowRight, FolderArchive, Loader2, RefreshCw } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { fetchLearningProjects } from '@/lib/learning';
|
||||
import type { LearningProjectSummary } from '../../../shared/learning';
|
||||
import { ProjectImage } from './ProjectImage';
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes >= 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
||||
if (bytes >= 1024 * 1024) return `${Math.round(bytes / (1024 * 1024))} MB`;
|
||||
return `${Math.max(1, Math.round(bytes / 1024))} KB`;
|
||||
}
|
||||
|
||||
function mergeProjects(
|
||||
current: LearningProjectSummary[],
|
||||
incoming: LearningProjectSummary[],
|
||||
): LearningProjectSummary[] {
|
||||
const projects = new Map(current.map((project) => [project.id, project]));
|
||||
for (const project of incoming) projects.set(project.id, project);
|
||||
return [...projects.values()];
|
||||
}
|
||||
|
||||
export function Learning() {
|
||||
const navigate = useNavigate();
|
||||
const [projects, setProjects] = useState<LearningProjectSummary[]>([]);
|
||||
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadProjects = useCallback(async (cursor?: string) => {
|
||||
const append = Boolean(cursor);
|
||||
if (append) setLoadingMore(true);
|
||||
else setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const page = await fetchLearningProjects({ cursor, limit: 24 });
|
||||
setProjects((current) => append ? mergeProjects(current, page.items) : page.items);
|
||||
setNextCursor(page.nextCursor);
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : '学习项目暂时无法加载');
|
||||
} finally {
|
||||
if (append) setLoadingMore(false);
|
||||
else setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadProjects();
|
||||
}, [loadProjects]);
|
||||
|
||||
return (
|
||||
<section data-testid="learning-home" className="mx-auto min-h-full w-full max-w-7xl pb-10">
|
||||
<header className="flex flex-col gap-5 py-2 sm:flex-row sm:items-end sm:justify-between sm:py-4">
|
||||
<div className="max-w-2xl">
|
||||
<p className="text-xs font-semibold tracking-wide text-brand">动手学习</p>
|
||||
<h1 className="mt-1 text-balance text-3xl font-semibold tracking-tight text-foreground">学习项目</h1>
|
||||
<p className="mt-2 text-sm leading-6 text-muted-foreground">
|
||||
选择一个感兴趣的项目,先阅读完整介绍,再把源码压缩包保存到电脑继续实践。
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
aria-label="刷新学习项目"
|
||||
variant="outline"
|
||||
disabled={loading}
|
||||
onClick={() => void loadProjects()}
|
||||
className="min-h-10 self-start sm:self-auto"
|
||||
>
|
||||
<RefreshCw className={`mr-2 h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
刷新
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
{loading ? (
|
||||
<div role="status" className="mt-4 flex min-h-56 items-center justify-center rounded-2xl bg-surface-subtle text-sm font-medium text-muted-foreground">
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin" />正在读取项目
|
||||
</div>
|
||||
) : error && projects.length === 0 ? (
|
||||
<div role="alert" className="mt-4 rounded-2xl border border-destructive/20 bg-destructive/5 px-6 py-10 text-center">
|
||||
<p className="text-sm font-semibold text-foreground">项目列表加载失败</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">{error}</p>
|
||||
<Button className="mt-5 min-h-10" variant="outline" onClick={() => void loadProjects()}>重新加载</Button>
|
||||
</div>
|
||||
) : projects.length === 0 ? (
|
||||
<div className="mt-4 rounded-2xl border border-dashed border-border bg-surface-subtle px-6 py-12 text-center">
|
||||
<FolderArchive className="mx-auto h-9 w-9 text-muted-foreground" />
|
||||
<p className="mt-3 text-sm font-semibold text-foreground">暂时还没有已发布项目</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">运营发布后,项目会直接出现在这里。</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{error ? <p role="alert" className="mb-3 text-sm font-medium text-destructive">{error}</p> : null}
|
||||
<div className="grid gap-5 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{projects.map((project) => (
|
||||
<article
|
||||
key={project.id}
|
||||
data-testid={`learning-project-${project.id}`}
|
||||
className="surface-card group overflow-hidden rounded-2xl border border-border/80 bg-background shadow-soft transition-[border-color,box-shadow,transform] duration-200 hover:-translate-y-0.5 hover:border-brand/30 hover:shadow-float"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="motion-press flex h-full w-full flex-col text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-inset"
|
||||
onClick={() => navigate(`/learning/project/${encodeURIComponent(project.id)}`)}
|
||||
aria-label={`查看项目:${project.name}`}
|
||||
>
|
||||
<ProjectImage
|
||||
src={project.cover.url}
|
||||
alt={project.cover.alt}
|
||||
allowHttps
|
||||
className="aspect-[16/9] w-full border-b border-black/10 object-cover"
|
||||
/>
|
||||
<span className="flex w-full flex-1 flex-col p-5">
|
||||
<span className="flex flex-wrap gap-1.5">
|
||||
{project.tags.slice(0, 3).map((tag) => (
|
||||
<span key={tag} className="rounded-full bg-brand-soft px-2.5 py-1 text-[11px] font-semibold text-brand">{tag}</span>
|
||||
))}
|
||||
</span>
|
||||
<span className="mt-4 line-clamp-2 text-balance text-lg font-semibold text-foreground">{project.name}</span>
|
||||
<span className="mt-2 line-clamp-3 text-sm leading-6 text-muted-foreground">{project.summary}</span>
|
||||
<span className="mt-5 flex items-center justify-between gap-3 border-t border-border/70 pt-4 text-xs text-muted-foreground">
|
||||
<span>{project.version ? `v${project.version}` : '最新版'} · {formatBytes(project.archiveBytes)}</span>
|
||||
<span className="inline-flex items-center font-semibold text-brand">
|
||||
查看项目<ArrowRight className="ml-1 h-3.5 w-3.5 transition-transform duration-200 group-hover:translate-x-0.5" />
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
{nextCursor ? (
|
||||
<div className="mt-8 flex justify-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="min-h-10 min-w-32"
|
||||
disabled={loadingMore}
|
||||
onClick={() => void loadProjects(nextCursor)}
|
||||
>
|
||||
{loadingMore ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||
{loadingMore ? '加载中' : '加载更多'}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import moduleCanvasImage from '@/assets/module-canvas-neon.webp';
|
||||
import moduleGameImage from '@/assets/module-game.webp';
|
||||
import moduleLearningImage from '@/assets/module-learning.webp';
|
||||
import moduleRobotImage from '@/assets/module-robot.webp';
|
||||
import logoWordmarkSource from '@/assets/makelore-wordmark-source.png';
|
||||
import { UserProfileDialog } from '@/components/profile/UserProfileDialog';
|
||||
@@ -16,7 +15,6 @@ import { getUserProfileDisplayName } from '@/stores/user-profile';
|
||||
const moduleSelectionContent: Record<AiModuleId, { label: string; image: string; imageAlt: string }> = {
|
||||
programming: { label: 'Code 编程', image: moduleGameImage, imageAlt: '游戏创作工作台' },
|
||||
painting: { label: 'Canvas 设计', image: moduleCanvasImage, imageAlt: '数位板设计创作' },
|
||||
learning: { label: 'Learning 学习', image: moduleLearningImage, imageAlt: '数学科技宇宙' },
|
||||
robot: { label: 'Robot 机器', image: moduleRobotImage, imageAlt: '青少年管理机器人硬件与设备绑定' },
|
||||
};
|
||||
|
||||
|
||||
@@ -785,7 +785,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
}),
|
||||
{
|
||||
name: 'niancode-auth',
|
||||
version: 3,
|
||||
version: 4,
|
||||
migrate: (persistedState: unknown) => {
|
||||
const state = persistedState && typeof persistedState === 'object'
|
||||
? persistedState as Record<string, unknown>
|
||||
|
||||
Reference in New Issue
Block a user