| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916 |
- 'use client';
- import { useEffect, useState, type MouseEvent, type ReactNode } from 'react';
- import {
- Terminal,
- ArrowUpRight,
- ArrowRight,
- ArrowLeft,
- Code2,
- Database,
- Layers3,
- ListTodo,
- MemoryStick,
- Container,
- Server,
- Activity,
- BookOpen,
- Download,
- Monitor,
- X,
- Check,
- Copy,
- Clock3,
- Video as VideoIcon,
- ChevronRight,
- FileCode2,
- ExternalLink,
- } from 'lucide-react';
- import {
- Sidebar,
- SidebarProvider,
- SidebarContent,
- SidebarHeader,
- SidebarFooter,
- SidebarTrigger,
- useSidebar,
- } from '@/components/ui/sidebar';
- import { Button } from '@/components/ui/button';
- import {
- Table,
- TableHeader,
- TableBody,
- TableRow,
- TableHead,
- TableCell,
- } from '@/components/ui/table';
- import { ProtocolDemo, HttpDemo, AsyncDemo } from './demos';
- import type { Lesson, Video } from './lesson-types';
- import lessonData from './lessons.json';
- import videoData from './videos.json';
- const lessons = lessonData as Lesson[];
- const videos = videoData as Record<string, Video>;
- const modules = [
- { name: 'API 服务开发', slug: 'api', icon: Code2 },
- { name: '数据库开发', slug: 'database', icon: Database },
- { name: 'API + 数据库', slug: 'api-database', icon: Layers3 },
- { name: '异步任务开发', slug: 'tasks', icon: ListTodo },
- { name: 'Redis 缓存', slug: 'redis', icon: MemoryStick },
- { name: 'Docker 容器化', slug: 'docker', icon: Container },
- { name: 'Linux 部署', slug: 'linux', icon: Server },
- { name: '线上问题排查', slug: 'troubleshooting', icon: Activity },
- ];
- const phases = [
- {
- name: '起步与 HTTP',
- range: '01—04',
- description: '让服务运行,读懂请求,约束输入与输出。',
- from: 0,
- to: 4,
- },
- {
- name: 'REST 接口',
- range: '05—08',
- description: '围绕资源编写增删改查,处理错误与公共依赖。',
- from: 4,
- to: 8,
- },
- {
- name: '异步与调用',
- range: '09—10',
- description: '理解 I/O 等待,完成服务调用与浏览器联调。',
- from: 8,
- to: 10,
- },
- {
- name: '流式与实时通信',
- range: '11—16',
- description: '逐段读取响应,建立双向连接,完成接口验收。',
- from: 10,
- to: 16,
- },
- ];
- const lessonSections = [
- ['objectives', '本节目标'],
- ['video', '课程视频'],
- ['concepts', '必要概念'],
- ['steps', '动手实现'],
- ['checks', '验收结果'],
- ['pitfalls', '常见错误'],
- ['exercise', '课后练习'],
- ['references', '技术文档'],
- ];
- const overviewSections = [
- ['path', '学习路径'],
- ['communication', '通信方式演示'],
- ['project', '贯穿项目'],
- ['ready', '完成标准'],
- ];
- type Navigate = (module: string, lesson?: string) => void;
- function routeLink(module: string, lesson?: string) {
- return lesson
- ? `?lesson=${lesson}`
- : module === 'api'
- ? '?module=api'
- : `?module=${module}`;
- }
- function LocalLink({
- module = 'api',
- lesson,
- navigate,
- children,
- className,
- ...props
- }: {
- module?: string;
- lesson?: string;
- navigate: Navigate;
- children: ReactNode;
- className?: string;
- 'aria-current'?: 'page';
- }) {
- return (
- <a
- href={routeLink(module, lesson)}
- className={className}
- {...props}
- onClick={(e: MouseEvent<HTMLAnchorElement>) => {
- if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey)
- return;
- e.preventDefault();
- navigate(module, lesson);
- }}
- >
- {children}
- </a>
- );
- }
- function Navigation({
- active,
- lesson,
- navigate,
- }: {
- active: string;
- lesson?: string;
- navigate: Navigate;
- }) {
- const { setOpenMobile } = useSidebar();
- const go: Navigate = (m, l) => {
- navigate(m, l);
- setOpenMobile(false);
- };
- return (
- <Sidebar>
- <SidebarHeader>
- <LocalLink navigate={go} className="brand">
- <span className="brand-symbol">
- <Terminal size={21} />
- </span>
- <span>
- Agent 开发<span className="brand-sub">后端基础 · 补修课</span>
- </span>
- </LocalLink>
- </SidebarHeader>
- <SidebarContent>
- <div className="nav-caption">
- 课程目录 <span>08 MODULES</span>
- </div>
- <nav className="module-nav" aria-label="课程模块">
- {modules.map((m) => (
- <LocalLink
- key={m.slug}
- module={m.slug}
- navigate={go}
- className={`module-link ${active === m.slug ? 'selected' : ''}`}
- aria-current={active === m.slug && !lesson ? 'page' : undefined}
- >
- <m.icon size={18} />
- <span>{m.name}</span>
- {m.slug === 'api' ? (
- <span className="nav-dot" />
- ) : (
- <span className="soon">待编写</span>
- )}
- </LocalLink>
- ))}
- </nav>
- {active === 'api' && lesson && (
- <nav className="lesson-nav" aria-label="API 课时">
- <div className="nav-caption">
- 本模块课时 <span>16 LESSONS</span>
- </div>
- {phases.map((p) => (
- <div key={p.name}>
- <p className="lesson-nav-group">{p.name}</p>
- {lessons.slice(p.from, p.to).map((l) => (
- <LocalLink
- key={l.id}
- lesson={l.id}
- navigate={go}
- className={`lesson-nav-link ${lesson === l.id ? 'current' : ''}`}
- aria-current={lesson === l.id ? 'page' : undefined}
- >
- <span>{l.id}</span>
- {l.title}
- </LocalLink>
- ))}
- </div>
- ))}
- </nav>
- )}
- </SidebarContent>
- <SidebarFooter>
- <div className="sidebar-note">
- <BookOpen size={17} />
- <div>
- 从 Python 到后端服务<small>先运行,再理解,再修改。</small>
- </div>
- </div>
- </SidebarFooter>
- </Sidebar>
- );
- }
- export default function Course() {
- const [module, setModule] = useState('api');
- const [lessonId, setLessonId] = useState<string>();
- const [presentation, setPresentation] = useState(false);
- const [notFound, setNotFound] = useState(false);
- const lesson = lessons.find((l) => l.id === lessonId);
- const current = modules.find((m) => m.slug === module)!;
- useEffect(() => {
- const read = () => {
- const q = new URLSearchParams(location.search);
- const id = q.get('lesson');
- const mod = q.get('module') || 'api';
- const validLesson = id ? lessons.some((l) => l.id === id) : true;
- const validModule = modules.some((m) => m.slug === mod);
- setNotFound(!validLesson || !validModule);
- setLessonId(validLesson && id ? id : undefined);
- setModule(validModule ? mod : 'api');
- if (id && validLesson) setModule('api');
- };
- read();
- window.addEventListener('popstate', read);
- return () => window.removeEventListener('popstate', read);
- }, []);
- useEffect(() => {
- document.title = `${lesson?.title || current.name} · Agent 开发补修课`;
- }, [lesson, current.name]);
- const navigate: Navigate = (mod, id) => {
- setModule(mod);
- setLessonId(id);
- setNotFound(false);
- history.pushState({}, '', routeLink(mod, id));
- window.scrollTo({ top: 0, behavior: 'instant' });
- requestAnimationFrame(() =>
- document.querySelector<HTMLElement>('h1')?.focus({ preventScroll: true }),
- );
- };
- return (
- <SidebarProvider
- className={presentation ? 'presentation' : ''}
- style={{ '--sidebar-width': '258px' } as React.CSSProperties}
- >
- <a className="skip-link" href="#main-content">
- 跳到课件正文
- </a>
- <Navigation active={module} lesson={lessonId} navigate={navigate} />
- <div className="workspace">
- <header className="topbar">
- <div className="breadcrumbs">
- <SidebarTrigger aria-label="展开或收起课程目录" />
- <LocalLink navigate={navigate}>后端基础</LocalLink>
- <span>/</span>
- <strong>{current.name}</strong>
- {lesson && (
- <>
- <span>/</span>
- <span className="crumb-lesson">{lesson.id}</span>
- </>
- )}
- </div>
- <div className="topbar-actions">
- <a
- className="quiet-link docs-link"
- href="https://fastapi.tiangolo.com/"
- target="_blank"
- rel="noreferrer"
- >
- FastAPI 文档 <ArrowUpRight size={15} />
- </a>
- <Button
- variant="ghost"
- onClick={() => setPresentation((v) => !v)}
- aria-pressed={presentation}
- >
- {presentation ? <X /> : <Monitor />}
- <span>{presentation ? '退出授课' : '授课视图'}</span>
- </Button>
- </div>
- </header>
- <div className="document-grid">
- <main id="main-content">
- {notFound ? (
- <div className="pending-module">
- <h1 tabIndex={-1}>未找到该课件</h1>
- <p>该地址中的模块或课时不存在,请从课程目录重新选择。</p>
- <LocalLink navigate={navigate} className="primary-link">
- 返回 API 服务开发 <ArrowRight size={16} />
- </LocalLink>
- </div>
- ) : module !== 'api' ? (
- <PendingModule
- name={current.name}
- index={modules.findIndex((m) => m.slug === module) + 1}
- navigate={navigate}
- />
- ) : lesson ? (
- <LessonPage lesson={lesson} navigate={navigate} />
- ) : (
- <Overview navigate={navigate} />
- )}
- </main>
- <aside className="toc" aria-label="本页导航">
- <p>{lesson ? '本节导航' : '本模块导航'}</p>
- {(module === 'api'
- ? lesson
- ? lessonSections
- : overviewSections
- : [['reserved', '内容状态']]
- ).map(([id, label]) => (
- <a href={`#${id}`} key={id}>
- {label}
- </a>
- ))}
- <div className="toc-note">
- <span className="tiny-label">
- {lesson ? '本节实战' : '学习准备'}
- </span>
- <p>
- {lesson ? (
- <>
- {lesson.minutes} 分钟建议录制时长
- <br />
- 完成代码后检查验收结果
- <br />
- 遇到错误先看响应与日志
- </>
- ) : (
- <>
- 会写 Python 函数
- <br />
- 了解列表与字典
- <br />
- 能在终端执行命令
- </>
- )}
- </p>
- </div>
- {module === 'api' && (
- <a
- href="/downloads/api-lab.zip"
- download
- className="toc-download"
- >
- <Download size={14} />
- 示例项目
- </a>
- )}
- </aside>
- </div>
- <footer className="page-footer">
- <span>Agent 开发 · 后端基础补修课</span>
- <span>API 模块 / 课件版本 01</span>
- </footer>
- </div>
- </SidebarProvider>
- );
- }
- function Overview({ navigate }: { navigate: Navigate }) {
- const minutes = lessons.reduce((a, l) => a + l.minutes, 0);
- return (
- <>
- <div className="eyebrow">
- MODULE 01 <span /> 服务与通信
- </div>
- <h1 tabIndex={-1}>
- API 服务开发<span className="title-dot">.</span>
- </h1>
- <p className="lead">
- 把 Python 函数变成可以被调用的服务。
- <br />
- 从第一个请求开始,完成 REST 接口、流式输出与双向通信。
- </p>
- <div className="meta-row">
- <span>Python 3.11+</span>
- <span>FastAPI / Pydantic 2</span>
- <span>{lessons.length} 节实战课</span>
- <span>
- 建议录制约 {Math.floor(minutes / 60)} 小时 {minutes % 60} 分
- </span>
- </div>
- <div className="overview-actions">
- <LocalLink lesson="01" navigate={navigate} className="primary-link">
- 开始第一节 <ArrowRight size={16} />
- </LocalLink>
- <a href="/downloads/api-lab.zip" download className="secondary-link">
- <Download size={16} />
- 下载示例项目
- </a>
- <span className="material-state">课件可读 · 视频待录制</span>
- </div>
- <div className="module-intro">
- <span className="section-index">BUILD 01</span>
- <div>
- <h3>贯穿项目:笔记与消息服务</h3>
- <p>
- 创建与查询笔记,校验请求,逐段输出消息,建立实时连接。数据暂存在内存中,重启后清空。
- </p>
- </div>
- </div>
- <section id="path">
- <div className="section-heading">
- <h2>学习路径</h2>
- <span>每节:概念 → 实战 → 验收</span>
- </div>
- <div className="curriculum">
- {phases.map((p, i) => (
- <div className="phase" key={p.name}>
- <div className="phase-heading">
- <span className="phase-marker">
- {String(i + 1).padStart(2, '0')}
- </span>
- <div>
- <h3>{p.name}</h3>
- <p>{p.description}</p>
- </div>
- <span className="phase-range">{p.range}</span>
- </div>
- <div className="lesson-rows">
- {lessons.slice(p.from, p.to).map((l) => (
- <LocalLink
- key={l.id}
- lesson={l.id}
- navigate={navigate}
- className="lesson-row"
- >
- <span className="lesson-number">{l.id}</span>
- <div>
- <strong>{l.title}</strong>
- <p>{l.summary}</p>
- </div>
- {l.demo && <span className="interactive-badge">交互</span>}
- <span className="lesson-duration">{l.minutes} 分</span>
- <ChevronRight size={15} />
- </LocalLink>
- ))}
- </div>
- </div>
- ))}
- </div>
- </section>
- <section id="communication">
- <div className="section-heading">
- <h2>HTTP、SSE 与 WebSocket 通信过程</h2>
- <LocalLink lesson="11" navigate={navigate} className="quiet-link">
- 进入第 11 节 <ArrowUpRight size={14} />
- </LocalLink>
- </div>
- <ProtocolDemo />
- <p className="section-caption">
- REST 描述接口设计约定;SSE 和 WebSocket
- 描述不同的通信方式。流式响应本身仍然可以基于 HTTP。
- </p>
- </section>
- <section id="project">
- <div className="section-heading">
- <h2>示例项目接口</h2>
- <span>api-lab / 本地运行</span>
- </div>
- <p>
- 一个可以通过浏览器、命令行和测试代码调用的 FastAPI
- 服务。消息由固定逻辑生成,运行示例不需要模型 API Key。
- </p>
- <Table className="api-table">
- <TableHeader>
- <TableRow>
- <TableHead>入口</TableHead>
- <TableHead>可观察的结果</TableHead>
- <TableHead>对应课时</TableHead>
- </TableRow>
- </TableHeader>
- <TableBody>
- {[
- ['GET /health', '返回服务状态', '01'],
- ['GET / POST /notes', '查询与创建笔记', '05—06'],
- [
- 'GET / PUT / DELETE /notes/{id}',
- '读取、完整替换与删除',
- '06—07',
- ],
- ['GET /private', '校验 X-API-Key 请求头', '08'],
- ['GET /slow', '并发等待 I/O', '09'],
- ['GET /upstream-demo', '调用上游接口并处理超时', '10'],
- ['POST /generate', '一次返回完整 JSON', '11'],
- ['GET /stream', '连续收到 token,最后收到 done', '12—13'],
- ['WS /ws', '收发 echo 与 ping / pong 消息', '14—15'],
- ].map(([path, result, id]) => (
- <TableRow key={path}>
- <TableCell>
- <code>{path}</code>
- </TableCell>
- <TableCell>{result}</TableCell>
- <TableCell className="mono">{id}</TableCell>
- </TableRow>
- ))}
- </TableBody>
- </Table>
- <div className="resource-strip">
- <FileCode2 size={25} />
- <div>
- <h3>配套代码与浏览器客户端</h3>
- <p>包含 README、版本依赖、接口测试与 web-client.html。</p>
- </div>
- <a href="/downloads/api-lab.zip" download className="secondary-link">
- 下载 ZIP <Download size={15} />
- </a>
- </div>
- </section>
- <section id="ready">
- <div className="section-heading">
- <h2>模块完成标准</h2>
- <span>用实际结果判断是否掌握</span>
- </div>
- <ul className="check-list">
- {[
- '能从空目录启动服务,并用 /docs 或 curl 发出请求。',
- '能完成笔记的创建、查询、修改与删除,区分 201、204、401、404、422。',
- '能解释 await 等待期间另一个请求为什么可以被处理。',
- '能逐段显示 SSE 数据,在完成或取消时释放连接。',
- '能建立 WebSocket 连接,收发消息并处理断开。',
- '能运行测试,独立定位参数、方法、跨域与连接问题。',
- ].map((s) => (
- <li key={s}>
- <Check size={16} />
- {s}
- </li>
- ))}
- </ul>
- <div className="next-module">
- <span>后续模块</span>
- <strong>数据库开发</strong>
- <span className="soon">待本模块完善后编写</span>
- </div>
- </section>
- </>
- );
- }
- function PendingModule({
- name,
- index,
- navigate,
- }: {
- name: string;
- index: number;
- navigate: Navigate;
- }) {
- return (
- <div className="pending-module" id="reserved">
- <div className="eyebrow">
- MODULE {String(index).padStart(2, '0')} <span /> 课程目录
- </div>
- <h1 tabIndex={-1}>
- {name}
- <span className="title-dot">.</span>
- </h1>
- <span className="status-label">待编写</span>
- <p className="lead">
- 该模块已保留课程入口。
- <br />
- API 服务开发模块完善后,再补充本模块的课件、实战与视频。
- </p>
- <LocalLink navigate={navigate} className="primary-link">
- 返回 API 服务开发 <ArrowRight size={16} />
- </LocalLink>
- </div>
- );
- }
- function CodeBlock({
- code,
- lang = 'text',
- filename,
- }: {
- code: string;
- lang?: string;
- filename?: string;
- }) {
- const [copied, setCopied] = useState(false);
- const [error, setError] = useState(false);
- useEffect(() => {
- if (!copied && !error) return;
- const t = setTimeout(() => {
- setCopied(false);
- setError(false);
- }, 2200);
- return () => clearTimeout(t);
- }, [copied, error]);
- const copy = async () => {
- try {
- await navigator.clipboard.writeText(code);
- setCopied(true);
- } catch {
- setError(true);
- }
- };
- const highlight = (line: string) =>
- line
- .split(
- /("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|#[^\n]*|\b(?:from|import|async|await|def|return|class|if|else|elif|for|in|while|try|except|finally|raise|with|as|yield|True|False|None|const|let|new|function|throw)\b)/g,
- )
- .map((part, i) => (
- <span
- key={i}
- className={
- part.startsWith('#')
- ? 'syntax-comment'
- : /^['"]/.test(part)
- ? 'syntax-string'
- : /^(from|import|async|await|def|return|class|if|else|elif|for|in|while|try|except|finally|raise|with|as|yield|True|False|None|const|let|new|function|throw)$/.test(
- part,
- )
- ? 'syntax-keyword'
- : undefined
- }
- >
- {part}
- </span>
- ));
- return (
- <div className="code-block">
- <div className="code-toolbar">
- <span>
- <FileCode2 size={14} />
- {filename || lang}
- </span>
- <button onClick={copy} className="copy-button" aria-label="复制代码">
- {copied ? <Check size={13} /> : <Copy size={13} />}
- <span aria-live="polite">
- {copied ? '已复制' : error ? '请选择代码手动复制' : '复制'}
- </span>
- </button>
- </div>
- <pre>
- <code>
- {code.split('\n').map((line, i) => (
- <span className="code-line" key={i}>
- <span className="line-number" aria-hidden="true">
- {i + 1}
- </span>
- <span>{highlight(line) || ' '}</span>
- {'\n'}
- </span>
- ))}
- </code>
- </pre>
- </div>
- );
- }
- function VideoBlock({ id }: { id: string }) {
- const video = videos[id];
- const valid = video && /^(https?:\/\/|\/(?!\/))/.test(video.url);
- return (
- <section id="video" className="video-section">
- {valid ? (
- video.type === 'file' ? (
- <div className="video-player">
- <video controls preload="metadata" src={video.url} playsInline>
- <a href={video.url}>在浏览器打开课程视频</a>
- </video>
- <a href={video.url} target="_blank" rel="noreferrer">
- 无法播放时,直接打开视频 <ExternalLink size={14} />
- </a>
- </div>
- ) : (
- <a
- href={video.url}
- target="_blank"
- rel="noreferrer"
- className="video-pending"
- >
- <VideoIcon size={23} />
- <div>
- <strong>{video.title || '观看本节课程视频'}</strong>
- <span>在课程视频平台打开</span>
- </div>
- <ArrowUpRight size={20} />
- </a>
- )
- ) : (
- <div className="video-pending">
- <VideoIcon size={23} />
- <div>
- <strong>本节视频待录制</strong>
- <span>可先按下方课件完成实战步骤。</span>
- </div>
- <span className="status-label">课件已就绪</span>
- </div>
- )}
- </section>
- );
- }
- function LessonPage({
- lesson: l,
- navigate,
- }: {
- lesson: Lesson;
- navigate: Navigate;
- }) {
- const index = lessons.findIndex((x) => x.id === l.id);
- return (
- <article key={l.id} className="lesson-article">
- <LocalLink navigate={navigate} className="back-link">
- <ArrowLeft size={14} />
- API 服务开发 · 全部课时
- </LocalLink>
- <div className="eyebrow">
- LESSON {l.id} / 16 <span />
- {l.group}
- </div>
- <h1 className="lesson-title" tabIndex={-1}>
- {l.title}
- </h1>
- <p className="lead">{l.summary}</p>
- <div className="meta-row">
- <span>
- <Clock3 size={13} />
- {l.minutes} 分钟建议录制
- </span>
- <span>实战课件</span>
- <span>{l.demo ? '含交互演示' : '含代码与练习'}</span>
- </div>
- <section id="objectives" className="objectives">
- <h2>本节目标</h2>
- <ul>
- {l.objectives.map((o, i) => (
- <li key={o}>
- <span>{String(i + 1).padStart(2, '0')}</span>
- {o}
- </li>
- ))}
- </ul>
- </section>
- <VideoBlock id={l.id} />
- <section id="concepts">
- <div className="section-heading">
- <h2>必要概念</h2>
- <span>先明确代码在做什么</span>
- </div>
- {l.concepts.map((c, i) => (
- <div className="concept" key={c.title}>
- <h3>
- <span>{i + 1}.</span>
- {c.title}
- </h3>
- <p>{c.body}</p>
- </div>
- ))}
- {l.demo === 'http' ? (
- <HttpDemo />
- ) : l.demo === 'async' ? (
- <AsyncDemo />
- ) : l.demo === 'protocol' ? (
- <ProtocolDemo />
- ) : null}
- </section>
- <section id="steps">
- <div className="section-heading">
- <h2>动手实现</h2>
- <span>{l.steps.length} 个步骤</span>
- </div>
- {l.steps.map((s, i) => (
- <div className="practice-step" key={s.title}>
- <div className="practice-title">
- <span>{String(i + 1).padStart(2, '0')}</span>
- <h3>{s.title}</h3>
- </div>
- <p>{s.body}</p>
- {s.code && (
- <CodeBlock code={s.code} lang={s.lang} filename={s.filename} />
- )}
- </div>
- ))}
- </section>
- <section id="checks">
- <div className="section-heading">
- <h2>验收结果</h2>
- <span>检查响应与运行行为</span>
- </div>
- <ul className="check-list">
- {l.checks.map((c) => (
- <li key={c}>
- <Check size={16} />
- {c}
- </li>
- ))}
- </ul>
- </section>
- <section id="pitfalls">
- <div className="section-heading">
- <h2>常见错误</h2>
- <span>现象 → 原因 → 处理</span>
- </div>
- <div className="pitfall-list">
- {l.pitfalls.map((p) => (
- <div className="pitfall" key={p.symptom}>
- <h3>{p.symptom}</h3>
- <p>
- <strong>原因</strong>
- {p.cause}
- </p>
- <p>
- <strong>处理</strong>
- {p.fix}
- </p>
- </div>
- ))}
- </div>
- </section>
- <section id="exercise" className="exercise">
- <div className="section-heading">
- <h2>扩展练习</h2>
- <span>课后练习</span>
- </div>
- <p>{l.exercise.task}</p>
- <details>
- <summary>
- 查看提示 <ChevronRight size={15} />
- </summary>
- <p>{l.exercise.hint}</p>
- </details>
- <details>
- <summary>
- 查看参考答案 <ChevronRight size={15} />
- </summary>
- <div className="answer-text">{l.exercise.answer}</div>
- </details>
- </section>
- <section id="references">
- <div className="section-heading">
- <h2>技术文档</h2>
- <span>对照原始定义与框架用法</span>
- </div>
- <div className="reference-links">
- {l.refs.map((r) => (
- <a key={r.url} href={r.url} target="_blank" rel="noreferrer">
- {r.label}
- <ArrowUpRight size={15} />
- </a>
- ))}
- </div>
- </section>
- <div className="lesson-pagination">
- {index > 0 ? (
- <LocalLink navigate={navigate} lesson={lessons[index - 1].id}>
- <span>
- <ArrowLeft size={13} />
- 上一节
- </span>
- <strong>{lessons[index - 1].title}</strong>
- </LocalLink>
- ) : (
- <LocalLink navigate={navigate}>
- <span>
- <ArrowLeft size={13} />
- 模块概览
- </span>
- <strong>查看完整学习路径</strong>
- </LocalLink>
- )}
- {index < lessons.length - 1 ? (
- <LocalLink
- navigate={navigate}
- lesson={lessons[index + 1].id}
- className="next"
- >
- <span>
- 下一节
- <ArrowRight size={13} />
- </span>
- <strong>{lessons[index + 1].title}</strong>
- </LocalLink>
- ) : (
- <LocalLink navigate={navigate} className="next">
- <span>
- 本模块课件结束
- <Check size={13} />
- </span>
- <strong>返回模块完成标准</strong>
- </LocalLink>
- )}
- </div>
- </article>
- );
- }
|