course.tsx 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916
  1. 'use client';
  2. import { useEffect, useState, type MouseEvent, type ReactNode } from 'react';
  3. import {
  4. Terminal,
  5. ArrowUpRight,
  6. ArrowRight,
  7. ArrowLeft,
  8. Code2,
  9. Database,
  10. Layers3,
  11. ListTodo,
  12. MemoryStick,
  13. Container,
  14. Server,
  15. Activity,
  16. BookOpen,
  17. Download,
  18. Monitor,
  19. X,
  20. Check,
  21. Copy,
  22. Clock3,
  23. Video as VideoIcon,
  24. ChevronRight,
  25. FileCode2,
  26. ExternalLink,
  27. } from 'lucide-react';
  28. import {
  29. Sidebar,
  30. SidebarProvider,
  31. SidebarContent,
  32. SidebarHeader,
  33. SidebarFooter,
  34. SidebarTrigger,
  35. useSidebar,
  36. } from '@/components/ui/sidebar';
  37. import { Button } from '@/components/ui/button';
  38. import {
  39. Table,
  40. TableHeader,
  41. TableBody,
  42. TableRow,
  43. TableHead,
  44. TableCell,
  45. } from '@/components/ui/table';
  46. import { ProtocolDemo, HttpDemo, AsyncDemo } from './demos';
  47. import type { Lesson, Video } from './lesson-types';
  48. import lessonData from './lessons.json';
  49. import videoData from './videos.json';
  50. const lessons = lessonData as Lesson[];
  51. const videos = videoData as Record<string, Video>;
  52. const modules = [
  53. { name: 'API 服务开发', slug: 'api', icon: Code2 },
  54. { name: '数据库开发', slug: 'database', icon: Database },
  55. { name: 'API + 数据库', slug: 'api-database', icon: Layers3 },
  56. { name: '异步任务开发', slug: 'tasks', icon: ListTodo },
  57. { name: 'Redis 缓存', slug: 'redis', icon: MemoryStick },
  58. { name: 'Docker 容器化', slug: 'docker', icon: Container },
  59. { name: 'Linux 部署', slug: 'linux', icon: Server },
  60. { name: '线上问题排查', slug: 'troubleshooting', icon: Activity },
  61. ];
  62. const phases = [
  63. {
  64. name: '起步与 HTTP',
  65. range: '01—04',
  66. description: '让服务运行,读懂请求,约束输入与输出。',
  67. from: 0,
  68. to: 4,
  69. },
  70. {
  71. name: 'REST 接口',
  72. range: '05—08',
  73. description: '围绕资源编写增删改查,处理错误与公共依赖。',
  74. from: 4,
  75. to: 8,
  76. },
  77. {
  78. name: '异步与调用',
  79. range: '09—10',
  80. description: '理解 I/O 等待,完成服务调用与浏览器联调。',
  81. from: 8,
  82. to: 10,
  83. },
  84. {
  85. name: '流式与实时通信',
  86. range: '11—16',
  87. description: '逐段读取响应,建立双向连接,完成接口验收。',
  88. from: 10,
  89. to: 16,
  90. },
  91. ];
  92. const lessonSections = [
  93. ['objectives', '本节目标'],
  94. ['video', '课程视频'],
  95. ['concepts', '必要概念'],
  96. ['steps', '动手实现'],
  97. ['checks', '验收结果'],
  98. ['pitfalls', '常见错误'],
  99. ['exercise', '课后练习'],
  100. ['references', '技术文档'],
  101. ];
  102. const overviewSections = [
  103. ['path', '学习路径'],
  104. ['communication', '通信方式演示'],
  105. ['project', '贯穿项目'],
  106. ['ready', '完成标准'],
  107. ];
  108. type Navigate = (module: string, lesson?: string) => void;
  109. function routeLink(module: string, lesson?: string) {
  110. return lesson
  111. ? `?lesson=${lesson}`
  112. : module === 'api'
  113. ? '?module=api'
  114. : `?module=${module}`;
  115. }
  116. function LocalLink({
  117. module = 'api',
  118. lesson,
  119. navigate,
  120. children,
  121. className,
  122. ...props
  123. }: {
  124. module?: string;
  125. lesson?: string;
  126. navigate: Navigate;
  127. children: ReactNode;
  128. className?: string;
  129. 'aria-current'?: 'page';
  130. }) {
  131. return (
  132. <a
  133. href={routeLink(module, lesson)}
  134. className={className}
  135. {...props}
  136. onClick={(e: MouseEvent<HTMLAnchorElement>) => {
  137. if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey)
  138. return;
  139. e.preventDefault();
  140. navigate(module, lesson);
  141. }}
  142. >
  143. {children}
  144. </a>
  145. );
  146. }
  147. function Navigation({
  148. active,
  149. lesson,
  150. navigate,
  151. }: {
  152. active: string;
  153. lesson?: string;
  154. navigate: Navigate;
  155. }) {
  156. const { setOpenMobile } = useSidebar();
  157. const go: Navigate = (m, l) => {
  158. navigate(m, l);
  159. setOpenMobile(false);
  160. };
  161. return (
  162. <Sidebar>
  163. <SidebarHeader>
  164. <LocalLink navigate={go} className="brand">
  165. <span className="brand-symbol">
  166. <Terminal size={21} />
  167. </span>
  168. <span>
  169. Agent 开发<span className="brand-sub">后端基础 · 补修课</span>
  170. </span>
  171. </LocalLink>
  172. </SidebarHeader>
  173. <SidebarContent>
  174. <div className="nav-caption">
  175. 课程目录 <span>08 MODULES</span>
  176. </div>
  177. <nav className="module-nav" aria-label="课程模块">
  178. {modules.map((m) => (
  179. <LocalLink
  180. key={m.slug}
  181. module={m.slug}
  182. navigate={go}
  183. className={`module-link ${active === m.slug ? 'selected' : ''}`}
  184. aria-current={active === m.slug && !lesson ? 'page' : undefined}
  185. >
  186. <m.icon size={18} />
  187. <span>{m.name}</span>
  188. {m.slug === 'api' ? (
  189. <span className="nav-dot" />
  190. ) : (
  191. <span className="soon">待编写</span>
  192. )}
  193. </LocalLink>
  194. ))}
  195. </nav>
  196. {active === 'api' && lesson && (
  197. <nav className="lesson-nav" aria-label="API 课时">
  198. <div className="nav-caption">
  199. 本模块课时 <span>16 LESSONS</span>
  200. </div>
  201. {phases.map((p) => (
  202. <div key={p.name}>
  203. <p className="lesson-nav-group">{p.name}</p>
  204. {lessons.slice(p.from, p.to).map((l) => (
  205. <LocalLink
  206. key={l.id}
  207. lesson={l.id}
  208. navigate={go}
  209. className={`lesson-nav-link ${lesson === l.id ? 'current' : ''}`}
  210. aria-current={lesson === l.id ? 'page' : undefined}
  211. >
  212. <span>{l.id}</span>
  213. {l.title}
  214. </LocalLink>
  215. ))}
  216. </div>
  217. ))}
  218. </nav>
  219. )}
  220. </SidebarContent>
  221. <SidebarFooter>
  222. <div className="sidebar-note">
  223. <BookOpen size={17} />
  224. <div>
  225. 从 Python 到后端服务<small>先运行,再理解,再修改。</small>
  226. </div>
  227. </div>
  228. </SidebarFooter>
  229. </Sidebar>
  230. );
  231. }
  232. export default function Course() {
  233. const [module, setModule] = useState('api');
  234. const [lessonId, setLessonId] = useState<string>();
  235. const [presentation, setPresentation] = useState(false);
  236. const [notFound, setNotFound] = useState(false);
  237. const lesson = lessons.find((l) => l.id === lessonId);
  238. const current = modules.find((m) => m.slug === module)!;
  239. useEffect(() => {
  240. const read = () => {
  241. const q = new URLSearchParams(location.search);
  242. const id = q.get('lesson');
  243. const mod = q.get('module') || 'api';
  244. const validLesson = id ? lessons.some((l) => l.id === id) : true;
  245. const validModule = modules.some((m) => m.slug === mod);
  246. setNotFound(!validLesson || !validModule);
  247. setLessonId(validLesson && id ? id : undefined);
  248. setModule(validModule ? mod : 'api');
  249. if (id && validLesson) setModule('api');
  250. };
  251. read();
  252. window.addEventListener('popstate', read);
  253. return () => window.removeEventListener('popstate', read);
  254. }, []);
  255. useEffect(() => {
  256. document.title = `${lesson?.title || current.name} · Agent 开发补修课`;
  257. }, [lesson, current.name]);
  258. const navigate: Navigate = (mod, id) => {
  259. setModule(mod);
  260. setLessonId(id);
  261. setNotFound(false);
  262. history.pushState({}, '', routeLink(mod, id));
  263. window.scrollTo({ top: 0, behavior: 'instant' });
  264. requestAnimationFrame(() =>
  265. document.querySelector<HTMLElement>('h1')?.focus({ preventScroll: true }),
  266. );
  267. };
  268. return (
  269. <SidebarProvider
  270. className={presentation ? 'presentation' : ''}
  271. style={{ '--sidebar-width': '258px' } as React.CSSProperties}
  272. >
  273. <a className="skip-link" href="#main-content">
  274. 跳到课件正文
  275. </a>
  276. <Navigation active={module} lesson={lessonId} navigate={navigate} />
  277. <div className="workspace">
  278. <header className="topbar">
  279. <div className="breadcrumbs">
  280. <SidebarTrigger aria-label="展开或收起课程目录" />
  281. <LocalLink navigate={navigate}>后端基础</LocalLink>
  282. <span>/</span>
  283. <strong>{current.name}</strong>
  284. {lesson && (
  285. <>
  286. <span>/</span>
  287. <span className="crumb-lesson">{lesson.id}</span>
  288. </>
  289. )}
  290. </div>
  291. <div className="topbar-actions">
  292. <a
  293. className="quiet-link docs-link"
  294. href="https://fastapi.tiangolo.com/"
  295. target="_blank"
  296. rel="noreferrer"
  297. >
  298. FastAPI 文档 <ArrowUpRight size={15} />
  299. </a>
  300. <Button
  301. variant="ghost"
  302. onClick={() => setPresentation((v) => !v)}
  303. aria-pressed={presentation}
  304. >
  305. {presentation ? <X /> : <Monitor />}
  306. <span>{presentation ? '退出授课' : '授课视图'}</span>
  307. </Button>
  308. </div>
  309. </header>
  310. <div className="document-grid">
  311. <main id="main-content">
  312. {notFound ? (
  313. <div className="pending-module">
  314. <h1 tabIndex={-1}>未找到该课件</h1>
  315. <p>该地址中的模块或课时不存在,请从课程目录重新选择。</p>
  316. <LocalLink navigate={navigate} className="primary-link">
  317. 返回 API 服务开发 <ArrowRight size={16} />
  318. </LocalLink>
  319. </div>
  320. ) : module !== 'api' ? (
  321. <PendingModule
  322. name={current.name}
  323. index={modules.findIndex((m) => m.slug === module) + 1}
  324. navigate={navigate}
  325. />
  326. ) : lesson ? (
  327. <LessonPage lesson={lesson} navigate={navigate} />
  328. ) : (
  329. <Overview navigate={navigate} />
  330. )}
  331. </main>
  332. <aside className="toc" aria-label="本页导航">
  333. <p>{lesson ? '本节导航' : '本模块导航'}</p>
  334. {(module === 'api'
  335. ? lesson
  336. ? lessonSections
  337. : overviewSections
  338. : [['reserved', '内容状态']]
  339. ).map(([id, label]) => (
  340. <a href={`#${id}`} key={id}>
  341. {label}
  342. </a>
  343. ))}
  344. <div className="toc-note">
  345. <span className="tiny-label">
  346. {lesson ? '本节实战' : '学习准备'}
  347. </span>
  348. <p>
  349. {lesson ? (
  350. <>
  351. {lesson.minutes} 分钟建议录制时长
  352. <br />
  353. 完成代码后检查验收结果
  354. <br />
  355. 遇到错误先看响应与日志
  356. </>
  357. ) : (
  358. <>
  359. 会写 Python 函数
  360. <br />
  361. 了解列表与字典
  362. <br />
  363. 能在终端执行命令
  364. </>
  365. )}
  366. </p>
  367. </div>
  368. {module === 'api' && (
  369. <a
  370. href="/downloads/api-lab.zip"
  371. download
  372. className="toc-download"
  373. >
  374. <Download size={14} />
  375. 示例项目
  376. </a>
  377. )}
  378. </aside>
  379. </div>
  380. <footer className="page-footer">
  381. <span>Agent 开发 · 后端基础补修课</span>
  382. <span>API 模块 / 课件版本 01</span>
  383. </footer>
  384. </div>
  385. </SidebarProvider>
  386. );
  387. }
  388. function Overview({ navigate }: { navigate: Navigate }) {
  389. const minutes = lessons.reduce((a, l) => a + l.minutes, 0);
  390. return (
  391. <>
  392. <div className="eyebrow">
  393. MODULE 01 <span /> 服务与通信
  394. </div>
  395. <h1 tabIndex={-1}>
  396. API 服务开发<span className="title-dot">.</span>
  397. </h1>
  398. <p className="lead">
  399. 把 Python 函数变成可以被调用的服务。
  400. <br />
  401. 从第一个请求开始,完成 REST 接口、流式输出与双向通信。
  402. </p>
  403. <div className="meta-row">
  404. <span>Python 3.11+</span>
  405. <span>FastAPI / Pydantic 2</span>
  406. <span>{lessons.length} 节实战课</span>
  407. <span>
  408. 建议录制约 {Math.floor(minutes / 60)} 小时 {minutes % 60} 分
  409. </span>
  410. </div>
  411. <div className="overview-actions">
  412. <LocalLink lesson="01" navigate={navigate} className="primary-link">
  413. 开始第一节 <ArrowRight size={16} />
  414. </LocalLink>
  415. <a href="/downloads/api-lab.zip" download className="secondary-link">
  416. <Download size={16} />
  417. 下载示例项目
  418. </a>
  419. <span className="material-state">课件可读 · 视频待录制</span>
  420. </div>
  421. <div className="module-intro">
  422. <span className="section-index">BUILD 01</span>
  423. <div>
  424. <h3>贯穿项目:笔记与消息服务</h3>
  425. <p>
  426. 创建与查询笔记,校验请求,逐段输出消息,建立实时连接。数据暂存在内存中,重启后清空。
  427. </p>
  428. </div>
  429. </div>
  430. <section id="path">
  431. <div className="section-heading">
  432. <h2>学习路径</h2>
  433. <span>每节:概念 → 实战 → 验收</span>
  434. </div>
  435. <div className="curriculum">
  436. {phases.map((p, i) => (
  437. <div className="phase" key={p.name}>
  438. <div className="phase-heading">
  439. <span className="phase-marker">
  440. {String(i + 1).padStart(2, '0')}
  441. </span>
  442. <div>
  443. <h3>{p.name}</h3>
  444. <p>{p.description}</p>
  445. </div>
  446. <span className="phase-range">{p.range}</span>
  447. </div>
  448. <div className="lesson-rows">
  449. {lessons.slice(p.from, p.to).map((l) => (
  450. <LocalLink
  451. key={l.id}
  452. lesson={l.id}
  453. navigate={navigate}
  454. className="lesson-row"
  455. >
  456. <span className="lesson-number">{l.id}</span>
  457. <div>
  458. <strong>{l.title}</strong>
  459. <p>{l.summary}</p>
  460. </div>
  461. {l.demo && <span className="interactive-badge">交互</span>}
  462. <span className="lesson-duration">{l.minutes} 分</span>
  463. <ChevronRight size={15} />
  464. </LocalLink>
  465. ))}
  466. </div>
  467. </div>
  468. ))}
  469. </div>
  470. </section>
  471. <section id="communication">
  472. <div className="section-heading">
  473. <h2>HTTP、SSE 与 WebSocket 通信过程</h2>
  474. <LocalLink lesson="11" navigate={navigate} className="quiet-link">
  475. 进入第 11 节 <ArrowUpRight size={14} />
  476. </LocalLink>
  477. </div>
  478. <ProtocolDemo />
  479. <p className="section-caption">
  480. REST 描述接口设计约定;SSE 和 WebSocket
  481. 描述不同的通信方式。流式响应本身仍然可以基于 HTTP。
  482. </p>
  483. </section>
  484. <section id="project">
  485. <div className="section-heading">
  486. <h2>示例项目接口</h2>
  487. <span>api-lab / 本地运行</span>
  488. </div>
  489. <p>
  490. 一个可以通过浏览器、命令行和测试代码调用的 FastAPI
  491. 服务。消息由固定逻辑生成,运行示例不需要模型 API Key。
  492. </p>
  493. <Table className="api-table">
  494. <TableHeader>
  495. <TableRow>
  496. <TableHead>入口</TableHead>
  497. <TableHead>可观察的结果</TableHead>
  498. <TableHead>对应课时</TableHead>
  499. </TableRow>
  500. </TableHeader>
  501. <TableBody>
  502. {[
  503. ['GET /health', '返回服务状态', '01'],
  504. ['GET / POST /notes', '查询与创建笔记', '05—06'],
  505. [
  506. 'GET / PUT / DELETE /notes/{id}',
  507. '读取、完整替换与删除',
  508. '06—07',
  509. ],
  510. ['GET /private', '校验 X-API-Key 请求头', '08'],
  511. ['GET /slow', '并发等待 I/O', '09'],
  512. ['GET /upstream-demo', '调用上游接口并处理超时', '10'],
  513. ['POST /generate', '一次返回完整 JSON', '11'],
  514. ['GET /stream', '连续收到 token,最后收到 done', '12—13'],
  515. ['WS /ws', '收发 echo 与 ping / pong 消息', '14—15'],
  516. ].map(([path, result, id]) => (
  517. <TableRow key={path}>
  518. <TableCell>
  519. <code>{path}</code>
  520. </TableCell>
  521. <TableCell>{result}</TableCell>
  522. <TableCell className="mono">{id}</TableCell>
  523. </TableRow>
  524. ))}
  525. </TableBody>
  526. </Table>
  527. <div className="resource-strip">
  528. <FileCode2 size={25} />
  529. <div>
  530. <h3>配套代码与浏览器客户端</h3>
  531. <p>包含 README、版本依赖、接口测试与 web-client.html。</p>
  532. </div>
  533. <a href="/downloads/api-lab.zip" download className="secondary-link">
  534. 下载 ZIP <Download size={15} />
  535. </a>
  536. </div>
  537. </section>
  538. <section id="ready">
  539. <div className="section-heading">
  540. <h2>模块完成标准</h2>
  541. <span>用实际结果判断是否掌握</span>
  542. </div>
  543. <ul className="check-list">
  544. {[
  545. '能从空目录启动服务,并用 /docs 或 curl 发出请求。',
  546. '能完成笔记的创建、查询、修改与删除,区分 201、204、401、404、422。',
  547. '能解释 await 等待期间另一个请求为什么可以被处理。',
  548. '能逐段显示 SSE 数据,在完成或取消时释放连接。',
  549. '能建立 WebSocket 连接,收发消息并处理断开。',
  550. '能运行测试,独立定位参数、方法、跨域与连接问题。',
  551. ].map((s) => (
  552. <li key={s}>
  553. <Check size={16} />
  554. {s}
  555. </li>
  556. ))}
  557. </ul>
  558. <div className="next-module">
  559. <span>后续模块</span>
  560. <strong>数据库开发</strong>
  561. <span className="soon">待本模块完善后编写</span>
  562. </div>
  563. </section>
  564. </>
  565. );
  566. }
  567. function PendingModule({
  568. name,
  569. index,
  570. navigate,
  571. }: {
  572. name: string;
  573. index: number;
  574. navigate: Navigate;
  575. }) {
  576. return (
  577. <div className="pending-module" id="reserved">
  578. <div className="eyebrow">
  579. MODULE {String(index).padStart(2, '0')} <span /> 课程目录
  580. </div>
  581. <h1 tabIndex={-1}>
  582. {name}
  583. <span className="title-dot">.</span>
  584. </h1>
  585. <span className="status-label">待编写</span>
  586. <p className="lead">
  587. 该模块已保留课程入口。
  588. <br />
  589. API 服务开发模块完善后,再补充本模块的课件、实战与视频。
  590. </p>
  591. <LocalLink navigate={navigate} className="primary-link">
  592. 返回 API 服务开发 <ArrowRight size={16} />
  593. </LocalLink>
  594. </div>
  595. );
  596. }
  597. function CodeBlock({
  598. code,
  599. lang = 'text',
  600. filename,
  601. }: {
  602. code: string;
  603. lang?: string;
  604. filename?: string;
  605. }) {
  606. const [copied, setCopied] = useState(false);
  607. const [error, setError] = useState(false);
  608. useEffect(() => {
  609. if (!copied && !error) return;
  610. const t = setTimeout(() => {
  611. setCopied(false);
  612. setError(false);
  613. }, 2200);
  614. return () => clearTimeout(t);
  615. }, [copied, error]);
  616. const copy = async () => {
  617. try {
  618. await navigator.clipboard.writeText(code);
  619. setCopied(true);
  620. } catch {
  621. setError(true);
  622. }
  623. };
  624. const highlight = (line: string) =>
  625. line
  626. .split(
  627. /("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|#[^\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,
  628. )
  629. .map((part, i) => (
  630. <span
  631. key={i}
  632. className={
  633. part.startsWith('#')
  634. ? 'syntax-comment'
  635. : /^['"]/.test(part)
  636. ? 'syntax-string'
  637. : /^(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(
  638. part,
  639. )
  640. ? 'syntax-keyword'
  641. : undefined
  642. }
  643. >
  644. {part}
  645. </span>
  646. ));
  647. return (
  648. <div className="code-block">
  649. <div className="code-toolbar">
  650. <span>
  651. <FileCode2 size={14} />
  652. {filename || lang}
  653. </span>
  654. <button onClick={copy} className="copy-button" aria-label="复制代码">
  655. {copied ? <Check size={13} /> : <Copy size={13} />}
  656. <span aria-live="polite">
  657. {copied ? '已复制' : error ? '请选择代码手动复制' : '复制'}
  658. </span>
  659. </button>
  660. </div>
  661. <pre>
  662. <code>
  663. {code.split('\n').map((line, i) => (
  664. <span className="code-line" key={i}>
  665. <span className="line-number" aria-hidden="true">
  666. {i + 1}
  667. </span>
  668. <span>{highlight(line) || ' '}</span>
  669. {'\n'}
  670. </span>
  671. ))}
  672. </code>
  673. </pre>
  674. </div>
  675. );
  676. }
  677. function VideoBlock({ id }: { id: string }) {
  678. const video = videos[id];
  679. const valid = video && /^(https?:\/\/|\/(?!\/))/.test(video.url);
  680. return (
  681. <section id="video" className="video-section">
  682. {valid ? (
  683. video.type === 'file' ? (
  684. <div className="video-player">
  685. <video controls preload="metadata" src={video.url} playsInline>
  686. <a href={video.url}>在浏览器打开课程视频</a>
  687. </video>
  688. <a href={video.url} target="_blank" rel="noreferrer">
  689. 无法播放时,直接打开视频 <ExternalLink size={14} />
  690. </a>
  691. </div>
  692. ) : (
  693. <a
  694. href={video.url}
  695. target="_blank"
  696. rel="noreferrer"
  697. className="video-pending"
  698. >
  699. <VideoIcon size={23} />
  700. <div>
  701. <strong>{video.title || '观看本节课程视频'}</strong>
  702. <span>在课程视频平台打开</span>
  703. </div>
  704. <ArrowUpRight size={20} />
  705. </a>
  706. )
  707. ) : (
  708. <div className="video-pending">
  709. <VideoIcon size={23} />
  710. <div>
  711. <strong>本节视频待录制</strong>
  712. <span>可先按下方课件完成实战步骤。</span>
  713. </div>
  714. <span className="status-label">课件已就绪</span>
  715. </div>
  716. )}
  717. </section>
  718. );
  719. }
  720. function LessonPage({
  721. lesson: l,
  722. navigate,
  723. }: {
  724. lesson: Lesson;
  725. navigate: Navigate;
  726. }) {
  727. const index = lessons.findIndex((x) => x.id === l.id);
  728. return (
  729. <article key={l.id} className="lesson-article">
  730. <LocalLink navigate={navigate} className="back-link">
  731. <ArrowLeft size={14} />
  732. API 服务开发 · 全部课时
  733. </LocalLink>
  734. <div className="eyebrow">
  735. LESSON {l.id} / 16 <span />
  736. {l.group}
  737. </div>
  738. <h1 className="lesson-title" tabIndex={-1}>
  739. {l.title}
  740. </h1>
  741. <p className="lead">{l.summary}</p>
  742. <div className="meta-row">
  743. <span>
  744. <Clock3 size={13} />
  745. {l.minutes} 分钟建议录制
  746. </span>
  747. <span>实战课件</span>
  748. <span>{l.demo ? '含交互演示' : '含代码与练习'}</span>
  749. </div>
  750. <section id="objectives" className="objectives">
  751. <h2>本节目标</h2>
  752. <ul>
  753. {l.objectives.map((o, i) => (
  754. <li key={o}>
  755. <span>{String(i + 1).padStart(2, '0')}</span>
  756. {o}
  757. </li>
  758. ))}
  759. </ul>
  760. </section>
  761. <VideoBlock id={l.id} />
  762. <section id="concepts">
  763. <div className="section-heading">
  764. <h2>必要概念</h2>
  765. <span>先明确代码在做什么</span>
  766. </div>
  767. {l.concepts.map((c, i) => (
  768. <div className="concept" key={c.title}>
  769. <h3>
  770. <span>{i + 1}.</span>
  771. {c.title}
  772. </h3>
  773. <p>{c.body}</p>
  774. </div>
  775. ))}
  776. {l.demo === 'http' ? (
  777. <HttpDemo />
  778. ) : l.demo === 'async' ? (
  779. <AsyncDemo />
  780. ) : l.demo === 'protocol' ? (
  781. <ProtocolDemo />
  782. ) : null}
  783. </section>
  784. <section id="steps">
  785. <div className="section-heading">
  786. <h2>动手实现</h2>
  787. <span>{l.steps.length} 个步骤</span>
  788. </div>
  789. {l.steps.map((s, i) => (
  790. <div className="practice-step" key={s.title}>
  791. <div className="practice-title">
  792. <span>{String(i + 1).padStart(2, '0')}</span>
  793. <h3>{s.title}</h3>
  794. </div>
  795. <p>{s.body}</p>
  796. {s.code && (
  797. <CodeBlock code={s.code} lang={s.lang} filename={s.filename} />
  798. )}
  799. </div>
  800. ))}
  801. </section>
  802. <section id="checks">
  803. <div className="section-heading">
  804. <h2>验收结果</h2>
  805. <span>检查响应与运行行为</span>
  806. </div>
  807. <ul className="check-list">
  808. {l.checks.map((c) => (
  809. <li key={c}>
  810. <Check size={16} />
  811. {c}
  812. </li>
  813. ))}
  814. </ul>
  815. </section>
  816. <section id="pitfalls">
  817. <div className="section-heading">
  818. <h2>常见错误</h2>
  819. <span>现象 → 原因 → 处理</span>
  820. </div>
  821. <div className="pitfall-list">
  822. {l.pitfalls.map((p) => (
  823. <div className="pitfall" key={p.symptom}>
  824. <h3>{p.symptom}</h3>
  825. <p>
  826. <strong>原因</strong>
  827. {p.cause}
  828. </p>
  829. <p>
  830. <strong>处理</strong>
  831. {p.fix}
  832. </p>
  833. </div>
  834. ))}
  835. </div>
  836. </section>
  837. <section id="exercise" className="exercise">
  838. <div className="section-heading">
  839. <h2>扩展练习</h2>
  840. <span>课后练习</span>
  841. </div>
  842. <p>{l.exercise.task}</p>
  843. <details>
  844. <summary>
  845. 查看提示 <ChevronRight size={15} />
  846. </summary>
  847. <p>{l.exercise.hint}</p>
  848. </details>
  849. <details>
  850. <summary>
  851. 查看参考答案 <ChevronRight size={15} />
  852. </summary>
  853. <div className="answer-text">{l.exercise.answer}</div>
  854. </details>
  855. </section>
  856. <section id="references">
  857. <div className="section-heading">
  858. <h2>技术文档</h2>
  859. <span>对照原始定义与框架用法</span>
  860. </div>
  861. <div className="reference-links">
  862. {l.refs.map((r) => (
  863. <a key={r.url} href={r.url} target="_blank" rel="noreferrer">
  864. {r.label}
  865. <ArrowUpRight size={15} />
  866. </a>
  867. ))}
  868. </div>
  869. </section>
  870. <div className="lesson-pagination">
  871. {index > 0 ? (
  872. <LocalLink navigate={navigate} lesson={lessons[index - 1].id}>
  873. <span>
  874. <ArrowLeft size={13} />
  875. 上一节
  876. </span>
  877. <strong>{lessons[index - 1].title}</strong>
  878. </LocalLink>
  879. ) : (
  880. <LocalLink navigate={navigate}>
  881. <span>
  882. <ArrowLeft size={13} />
  883. 模块概览
  884. </span>
  885. <strong>查看完整学习路径</strong>
  886. </LocalLink>
  887. )}
  888. {index < lessons.length - 1 ? (
  889. <LocalLink
  890. navigate={navigate}
  891. lesson={lessons[index + 1].id}
  892. className="next"
  893. >
  894. <span>
  895. 下一节
  896. <ArrowRight size={13} />
  897. </span>
  898. <strong>{lessons[index + 1].title}</strong>
  899. </LocalLink>
  900. ) : (
  901. <LocalLink navigate={navigate} className="next">
  902. <span>
  903. 本模块课件结束
  904. <Check size={13} />
  905. </span>
  906. <strong>返回模块完成标准</strong>
  907. </LocalLink>
  908. )}
  909. </div>
  910. </article>
  911. );
  912. }