feat: remove Learning module
This commit is contained in:
@@ -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: '青少年管理机器人硬件与设备绑定' },
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user