course.tsx 44 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363
  1. 'use client';
  2. import {
  3. useEffect,
  4. useRef,
  5. useState,
  6. type MouseEvent,
  7. type ReactNode,
  8. } from 'react';
  9. import Image from 'next/image';
  10. import {
  11. ArrowUpRight,
  12. ArrowRight,
  13. ArrowLeft,
  14. BookOpen,
  15. Download,
  16. Check,
  17. Copy,
  18. Video as VideoIcon,
  19. ChevronRight,
  20. FileCode2,
  21. ExternalLink,
  22. RotateCcw,
  23. ShieldCheck,
  24. GitBranch,
  25. House,
  26. } from 'lucide-react';
  27. import {
  28. Sidebar,
  29. SidebarProvider,
  30. SidebarContent,
  31. SidebarHeader,
  32. SidebarFooter,
  33. SidebarTrigger,
  34. useSidebar,
  35. } from '@/components/ui/sidebar';
  36. import { Button } from '@/components/ui/button';
  37. import { Skeleton } from '@/components/ui/skeleton';
  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 { TaskDeliveryDemo } from './task-delivery-demo';
  48. import { CourseHome } from './course-home';
  49. import { ReviewPage } from './review-page';
  50. import {
  51. ModuleDiagrams,
  52. LessonDiagrams,
  53. hasLessonDiagrams,
  54. } from './teaching-diagrams';
  55. import { courseHref, parseCourseQuery } from './course-navigation';
  56. import { modules, getModule, type ModuleInfo } from './module-catalog';
  57. import type { Lesson, TeachingLesson, Video } from './lesson-types';
  58. import apiData from './lessons.json';
  59. import videoData from './videos.json';
  60. const videos = videoData as Record<string, Video>;
  61. type Navigate = (module: string, lesson?: string) => void;
  62. type Route = { module: string; lesson?: string };
  63. const overviewSections = [
  64. ['module-diagrams', '模块图解'],
  65. ['path', '学习路径'],
  66. ['context', '工程边界'],
  67. ['ready', '完成标准'],
  68. ];
  69. const lessonSections = [
  70. ['start', '从问题开始'],
  71. ['objectives', '这节课要完成什么'],
  72. ['steps', '一步步完成案例'],
  73. ['checks', '检查学习结果'],
  74. ['diagrams', '把过程连起来'],
  75. ['production', '进一步验证'],
  76. ['pitfalls', '遇到问题时查这里'],
  77. ['interview', '面试问答'],
  78. ['exercise', '自己试一次'],
  79. ['concepts', '核心概念回顾'],
  80. ['references', '技术文档'],
  81. ];
  82. const reviewSections = [
  83. ['review-start', '如何自测'],
  84. ['review-questions', '核心理解问题'],
  85. ['review-challenges', '情境推演'],
  86. ['review-mastery', '掌握标准与证据'],
  87. ['review-result', '自测结果与回学'],
  88. ];
  89. function routeLink(module: string, lesson?: string) {
  90. return courseHref(module, lesson);
  91. }
  92. function readRoute(): Route {
  93. return parseCourseQuery(location.search);
  94. }
  95. function groups(lessons: Lesson[]) {
  96. const result: { name: string; lessons: Lesson[] }[] = [];
  97. for (const l of lessons) {
  98. const last = result[result.length - 1];
  99. if (last?.name === l.group) last.lessons.push(l);
  100. else result.push({ name: l.group, lessons: [l] });
  101. }
  102. return result;
  103. }
  104. function LocalLink({
  105. module = 'home',
  106. lesson,
  107. navigate,
  108. children,
  109. className,
  110. ...props
  111. }: {
  112. module?: string;
  113. lesson?: string;
  114. navigate: Navigate;
  115. children: ReactNode;
  116. className?: string;
  117. 'aria-current'?: 'page';
  118. }) {
  119. return (
  120. <a
  121. href={routeLink(module, lesson)}
  122. className={className}
  123. {...props}
  124. onClick={(e: MouseEvent<HTMLAnchorElement>) => {
  125. if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey)
  126. return;
  127. e.preventDefault();
  128. navigate(module, lesson);
  129. }}
  130. >
  131. {children}
  132. </a>
  133. );
  134. }
  135. function Navigation({
  136. route,
  137. lessons,
  138. navigate,
  139. }: {
  140. route: Route;
  141. lessons: Lesson[];
  142. navigate: Navigate;
  143. }) {
  144. const { setOpenMobile } = useSidebar();
  145. const go: Navigate = (m, l) => {
  146. navigate(m, l);
  147. setOpenMobile(false);
  148. };
  149. return (
  150. <Sidebar>
  151. <SidebarHeader>
  152. <LocalLink navigate={go} className="brand">
  153. <Image
  154. src="/brand/shuh-logo-white.png"
  155. alt="术核 AI"
  156. width={1100}
  157. height={360}
  158. unoptimized
  159. />
  160. <span className="brand-sub">Agent 开发 · 后端工程课件</span>
  161. </LocalLink>
  162. </SidebarHeader>
  163. <SidebarContent>
  164. <div className="nav-caption">
  165. 课程目录<span>{String(modules.length).padStart(2, '0')} MODULES</span>
  166. </div>
  167. <nav className="module-nav" aria-label="课程模块">
  168. <LocalLink
  169. module="home"
  170. navigate={go}
  171. className={`module-link ${route.module === 'home' ? 'selected' : ''}`}
  172. aria-current={
  173. route.module === 'home' && !route.lesson ? 'page' : undefined
  174. }
  175. >
  176. <House size={18} />
  177. <span>课程总览</span>
  178. </LocalLink>
  179. {modules.map((m, i) => (
  180. <LocalLink
  181. key={m.slug}
  182. module={m.slug}
  183. navigate={go}
  184. className={`module-link ${route.module === m.slug ? 'selected' : ''}`}
  185. aria-current={
  186. route.module === m.slug && !route.lesson ? 'page' : undefined
  187. }
  188. >
  189. <m.icon size={18} />
  190. <span>{m.name}</span>
  191. <span className="module-count">
  192. {String(i + 1).padStart(2, '0')}
  193. </span>
  194. </LocalLink>
  195. ))}
  196. </nav>
  197. {route.lesson && lessons.length > 0 && (
  198. <nav className="lesson-nav" aria-label="本模块课时">
  199. <div className="nav-caption">
  200. 本模块课时<span>{lessons.length} LESSONS</span>
  201. </div>
  202. {groups(lessons).map((g, i) => (
  203. <div key={`${g.name}-${i}`}>
  204. <p className="lesson-nav-group">{g.name}</p>
  205. {g.lessons.map((l) => (
  206. <LocalLink
  207. key={l.id}
  208. module={route.module}
  209. lesson={l.id}
  210. navigate={go}
  211. className={`lesson-nav-link ${route.lesson === l.id ? 'current' : ''}`}
  212. aria-current={route.lesson === l.id ? 'page' : undefined}
  213. >
  214. <span>{l.id}</span>
  215. {l.title}
  216. </LocalLink>
  217. ))}
  218. </div>
  219. ))}
  220. </nav>
  221. )}
  222. </SidebarContent>
  223. <SidebarFooter>
  224. <div className="sidebar-note">
  225. <BookOpen size={17} />
  226. <div>
  227. 后端工程与系统设计<small>理解 · 实现 · 验证 · 恢复</small>
  228. </div>
  229. </div>
  230. </SidebarFooter>
  231. </Sidebar>
  232. );
  233. }
  234. export default function Course() {
  235. const pendingNavigationFocus = useRef(false);
  236. const [route, setRoute] = useState<Route>({ module: 'home' });
  237. const [data, setData] = useState<Record<string, Lesson[]>>({
  238. api: apiData as Lesson[],
  239. });
  240. const [error, setError] = useState('');
  241. const [retry, setRetry] = useState(0);
  242. const current = getModule(route.module);
  243. const lessons = current ? data[current.slug] : undefined;
  244. const lesson = lessons?.find((l) => l.id === route.lesson);
  245. const isHome = route.module === 'home' && !route.lesson;
  246. const invalid =
  247. !isHome && (!current || !!(lessons && route.lesson && !lesson));
  248. useEffect(() => {
  249. const read = () => {
  250. setRoute(readRoute());
  251. setError('');
  252. };
  253. read();
  254. window.addEventListener('popstate', read);
  255. return () => window.removeEventListener('popstate', read);
  256. }, []);
  257. useEffect(() => {
  258. if (!current || lessons) return;
  259. const controller = new AbortController();
  260. fetch(`/content/${current.slug}.json`, { signal: controller.signal })
  261. .then(async (r) => {
  262. if (!r.ok) throw new Error('课件暂时无法载入,请重试。');
  263. const payload = await r.json();
  264. if (
  265. !Array.isArray(payload) ||
  266. payload.length !== current.count ||
  267. payload.some(
  268. (l) =>
  269. !l.id ||
  270. !l.title ||
  271. (current.slug === 'review'
  272. ? l.kind !== 'review' ||
  273. !Array.isArray(l.questions) ||
  274. l.questions.length !== 6 ||
  275. !Array.isArray(l.challenges) ||
  276. l.challenges.length !== 2 ||
  277. !Array.isArray(l.mastery) ||
  278. l.mastery.length !== 4 ||
  279. !l.intro
  280. : !Array.isArray(l.steps)),
  281. )
  282. )
  283. throw new Error('课件文件不完整,请重试。');
  284. setData((previous) => ({ ...previous, [current.slug]: payload }));
  285. })
  286. .catch((e) => {
  287. if (e.name !== 'AbortError')
  288. setError('课件暂时无法载入。请重试,或刷新页面检查登录状态。');
  289. });
  290. return () => controller.abort();
  291. }, [current, lessons, retry]);
  292. useEffect(() => {
  293. document.title = `${isHome ? '课程总览' : lesson?.title || current?.name || '课件'} · 术核 AI 后端工程`;
  294. }, [lesson, current, isHome]);
  295. useEffect(() => {
  296. // Lazy-loaded lessons are not in the DOM when a bookmarked fragment opens.
  297. const scrollToSection = () => {
  298. if (!location.hash) return;
  299. let id: string;
  300. try {
  301. id = decodeURIComponent(location.hash.slice(1));
  302. } catch {
  303. return;
  304. }
  305. document
  306. .getElementById(id)
  307. ?.scrollIntoView({ block: 'start', behavior: 'instant' });
  308. };
  309. const frame = requestAnimationFrame(scrollToSection);
  310. window.addEventListener('hashchange', scrollToSection);
  311. return () => {
  312. cancelAnimationFrame(frame);
  313. window.removeEventListener('hashchange', scrollToSection);
  314. };
  315. }, [route.module, route.lesson, lessons]);
  316. useEffect(() => {
  317. if (
  318. !pendingNavigationFocus.current ||
  319. (!isHome && !invalid && !error && !lessons)
  320. )
  321. return;
  322. const frame = requestAnimationFrame(() => {
  323. const heading = document.querySelector<HTMLElement>('h1');
  324. if (heading) {
  325. heading.focus({ preventScroll: true });
  326. pendingNavigationFocus.current = false;
  327. }
  328. });
  329. return () => cancelAnimationFrame(frame);
  330. }, [route.module, route.lesson, lessons, isHome, invalid, error]);
  331. const navigate: Navigate = (module, id) => {
  332. pendingNavigationFocus.current = true;
  333. setRoute({ module, lesson: id });
  334. setError('');
  335. history.pushState({}, '', routeLink(module, id));
  336. window.scrollTo({ top: 0, behavior: 'instant' });
  337. };
  338. const toc = isHome
  339. ? [
  340. ['architecture', 'Agent 架构全景'],
  341. ['modules', '模块定位与作用'],
  342. ['journey', '一次长任务的旅程'],
  343. ['learning-path', '推荐学习顺序'],
  344. ]
  345. : lesson?.kind === 'review'
  346. ? reviewSections
  347. : lesson
  348. ? lessonSections
  349. .filter(
  350. ([id]) =>
  351. (id !== 'start' || lesson.intro) &&
  352. (id !== 'production' || lesson.production) &&
  353. (id !== 'interview' || lesson.interview) &&
  354. (id !== 'diagrams' ||
  355. hasLessonDiagrams(route.module, lesson.id)),
  356. )
  357. .map(([id, label]) => [
  358. id,
  359. lesson.kind === 'theory'
  360. ? (
  361. {
  362. steps: '一步步推演场景',
  363. checks: '检查推演结果',
  364. pitfalls: '常见误区',
  365. exercise: '自己设计一次',
  366. references: '延伸阅读',
  367. } as Record<string, string>
  368. )[id] || label
  369. : label,
  370. ])
  371. : route.module === 'review'
  372. ? [
  373. ['path', '选择自测模块'],
  374. ['context', '如何判断掌握'],
  375. ['ready', '总体完成标准'],
  376. ]
  377. : overviewSections;
  378. return (
  379. <SidebarProvider
  380. style={{ '--sidebar-width': '270px' } as React.CSSProperties}
  381. >
  382. <a className="skip-link" href="#main-content">
  383. 跳到课件正文
  384. </a>
  385. <Navigation route={route} lessons={lessons || []} navigate={navigate} />
  386. <div className="workspace">
  387. <header className="topbar">
  388. <div className="breadcrumbs">
  389. <SidebarTrigger aria-label="展开或收起课程目录" />
  390. <LocalLink navigate={navigate}>后端工程</LocalLink>
  391. <span>/</span>
  392. <strong>{isHome ? '课程总览' : current?.name || '课件'}</strong>
  393. {lesson && (
  394. <>
  395. <span>/</span>
  396. <span className="crumb-lesson">{lesson.id}</span>
  397. </>
  398. )}
  399. </div>
  400. <a
  401. className="quiet-link"
  402. href="https://shuh.ai/"
  403. target="_blank"
  404. rel="noreferrer"
  405. >
  406. 术核 AI
  407. <ArrowUpRight size={15} />
  408. </a>
  409. </header>
  410. <div className={`document-grid${isHome ? ' document-grid-home' : ''}`}>
  411. <main id="main-content">
  412. {isHome ? (
  413. <CourseHome navigate={navigate} />
  414. ) : invalid ? (
  415. <div className="pending-module">
  416. <h1 tabIndex={-1}>未找到该课件</h1>
  417. <p>该模块或课时不存在,请从目录重新选择。</p>
  418. <LocalLink navigate={navigate} className="primary-link">
  419. 返回课程
  420. <ArrowRight size={16} />
  421. </LocalLink>
  422. </div>
  423. ) : error ? (
  424. <div className="load-error" role="alert">
  425. <h1 tabIndex={-1}>{current?.name}</h1>
  426. <p>{error}</p>
  427. <Button
  428. onClick={() => {
  429. setError('');
  430. setRetry((r) => r + 1);
  431. }}
  432. >
  433. <RotateCcw />
  434. 重新载入
  435. </Button>
  436. </div>
  437. ) : !lessons || !current ? (
  438. <div aria-busy="true" aria-label="正在载入课件">
  439. <div className="eyebrow">{current?.name}</div>
  440. <Skeleton className="h-10 w-3/4 my-7" />
  441. <Skeleton className="h-5 w-full mb-3" />
  442. <Skeleton className="h-5 w-4/5 mb-12" />
  443. {[1, 2, 3].map((n) => (
  444. <Skeleton key={n} className="h-24 w-full my-4" />
  445. ))}
  446. </div>
  447. ) : lesson?.kind === 'review' ? (
  448. <ReviewPage
  449. key={`${route.module}/${lesson.id}`}
  450. lesson={lesson}
  451. lessons={lessons}
  452. navigate={navigate}
  453. />
  454. ) : lesson ? (
  455. <LessonPage
  456. key={`${route.module}/${lesson.id}`}
  457. module={current}
  458. lessons={lessons}
  459. lesson={lesson}
  460. navigate={navigate}
  461. />
  462. ) : (
  463. <Overview
  464. module={current}
  465. lessons={lessons}
  466. navigate={navigate}
  467. />
  468. )}
  469. </main>
  470. <aside className="toc" aria-label="本页导航">
  471. <p>{isHome ? '课程导览' : lesson ? '本节导航' : '本模块导航'}</p>
  472. {!invalid &&
  473. !error &&
  474. (isHome || !!lessons) &&
  475. toc.map(([id, label]) => (
  476. <a key={id} href={`#${id}`}>
  477. {label}
  478. </a>
  479. ))}
  480. <div className="toc-note">
  481. <span className="tiny-label">学习准备</span>
  482. <p>
  483. {(isHome
  484. ? ['有 Python 基础即可开始', '先看全景,再逐模块深入']
  485. : current?.prerequisites
  486. )?.map((p) => (
  487. <span key={p} className="prerequisite-line">
  488. {p}
  489. </span>
  490. ))}
  491. </p>
  492. </div>
  493. {current && (
  494. <a
  495. className="toc-download"
  496. href={`/downloads/${current.slug}-lessons.md`}
  497. download
  498. >
  499. <Download size={14} />
  500. 下载模块课件
  501. </a>
  502. )}
  503. {route.module === 'api' && (
  504. <a
  505. className="toc-download"
  506. href="/downloads/api-lab.zip"
  507. download
  508. >
  509. <FileCode2 size={14} />
  510. API 示例项目
  511. </a>
  512. )}
  513. </aside>
  514. </div>
  515. <footer className="page-footer">
  516. <span>术核 AI · Agent 开发前置知识</span>
  517. <span>接口 / 数据 / 并发 / 交付 / 运维 / 架构</span>
  518. </footer>
  519. </div>
  520. </SidebarProvider>
  521. );
  522. }
  523. function Overview({
  524. module: m,
  525. lessons,
  526. navigate,
  527. }: {
  528. module: ModuleInfo;
  529. lessons: Lesson[];
  530. navigate: Navigate;
  531. }) {
  532. const index = modules.findIndex((x) => x.slug === m.slug);
  533. const next = modules[index + 1];
  534. const theory = m.slug === 'distributed';
  535. const review = m.slug === 'review';
  536. return (
  537. <>
  538. <div className="eyebrow">
  539. MODULE {String(index + 1).padStart(2, '0')}
  540. <span />
  541. {m.kicker}
  542. </div>
  543. <h1 tabIndex={-1}>
  544. {m.name}
  545. <span className="title-dot">.</span>
  546. </h1>
  547. <p className="lead">{m.description}</p>
  548. <div className="meta-row">
  549. <span>{m.stack}</span>
  550. <span>
  551. {lessons.length}{' '}
  552. {review ? '个自测单元' : `节${theory ? '理论课' : '实战课'}`}
  553. </span>
  554. <span>
  555. {review
  556. ? '逐模块检验理解与应用'
  557. : theory
  558. ? '架构设计与面试'
  559. : m.slug === 'api'
  560. ? '接口基础'
  561. : '生产系统实践'}
  562. </span>
  563. </div>
  564. <div className="overview-actions">
  565. <LocalLink
  566. module={m.slug}
  567. lesson="01"
  568. navigate={navigate}
  569. className="primary-link"
  570. >
  571. {review ? '开始第一个自测' : '开始第一节'}
  572. <ArrowRight size={16} />
  573. </LocalLink>
  574. <a
  575. className="secondary-link"
  576. href={`/downloads/${m.slug}-lessons.md`}
  577. download
  578. >
  579. <Download size={16} />
  580. 下载模块课件
  581. </a>
  582. {m.slug === 'api' && (
  583. <a href="/downloads/api-lab.zip" download className="secondary-link">
  584. <FileCode2 size={16} />
  585. 示例项目
  586. </a>
  587. )}
  588. </div>
  589. <div className="module-intro">
  590. <span className="section-index">
  591. {review ? 'REVIEW' : theory ? 'DESIGN' : 'PROJECT'}
  592. </span>
  593. <div>
  594. <h3>{m.project}</h3>
  595. <p>{m.scope}</p>
  596. </div>
  597. </div>
  598. {!review && <ModuleDiagrams module={m.slug} />}
  599. <section id="path">
  600. <div className="section-heading">
  601. <h2>{review ? '选择要自测的模块' : '学习路径'}</h2>
  602. <span>
  603. {review
  604. ? '独立回答 → 对照要点 → 验证掌握 → 回学'
  605. : theory
  606. ? '问题 → 推演 → 图解 → 概念回顾'
  607. : '问题 → 实践 → 验证 → 概念回顾'}
  608. </span>
  609. </div>
  610. <div className="curriculum">
  611. {groups(lessons).map((g, i) => (
  612. <div className="phase" key={`${g.name}-${i}`}>
  613. <div className="phase-heading">
  614. <span className="phase-marker">
  615. {String(i + 1).padStart(2, '0')}
  616. </span>
  617. <div>
  618. <h3>{g.name}</h3>
  619. </div>
  620. <span className="phase-range">
  621. {g.lessons[0].id}—{g.lessons[g.lessons.length - 1].id}
  622. </span>
  623. </div>
  624. <div className="lesson-rows">
  625. {g.lessons.map((l) => (
  626. <LocalLink
  627. key={l.id}
  628. module={m.slug}
  629. lesson={l.id}
  630. navigate={navigate}
  631. className="lesson-row"
  632. >
  633. <span className="lesson-number">{l.id}</span>
  634. <div>
  635. <strong>{l.title}</strong>
  636. <p>{l.summary}</p>
  637. </div>
  638. {l.kind !== 'review' &&
  639. (l.demo || hasLessonDiagrams(m.slug, l.id)) && (
  640. <span className="interactive-badge">
  641. {l.demo ? '交互' : '图解'}
  642. </span>
  643. )}
  644. <ChevronRight size={15} />
  645. </LocalLink>
  646. ))}
  647. </div>
  648. </div>
  649. ))}
  650. </div>
  651. </section>
  652. <section id="context">
  653. <div className="section-heading">
  654. <h2>{review ? '如何判断掌握' : '工程边界'}</h2>
  655. <span>{m.stack}</span>
  656. </div>
  657. {review ? (
  658. <div className="engineering-context">
  659. <div className="context-heading">
  660. <ShieldCheck size={22} />
  661. <h3>能解释,能换个条件推演,也能拿出证据</h3>
  662. </div>
  663. <p>
  664. 每个单元先独立回答六个理解问题和两个情境题,再展开参考要点。只在合上要点后仍能说明原因、边界和验证方式时,标记为独立完成。
  665. </p>
  666. <div className="context-columns">
  667. <div>
  668. <span>本轮达到标准</span>
  669. <p>
  670. 八项回答都能独立完成,四项掌握证据均已核对。操作类需要自己的运行记录,理论类需要完整的设计或故障推演。
  671. </p>
  672. </div>
  673. <div>
  674. <span>发现缺口之后</span>
  675. <p>
  676. 沿题目下方链接回到原课时,补做相关实验或推演,再回来闭卷回答。页面汇总你的自评,不自动判断文字答案的正确性。
  677. </p>
  678. </div>
  679. </div>
  680. </div>
  681. ) : m.slug === 'api' ? (
  682. <>
  683. <ProtocolDemo />
  684. <p className="section-caption">
  685. REST 是接口设计约定;SSE 和 WebSocket
  686. 提供不同的通信方式。真实收发实验请运行配套 API 项目。
  687. </p>
  688. <ApiEndpoints />
  689. </>
  690. ) : theory ? (
  691. <div className="engineering-context">
  692. <div className="context-heading">
  693. <GitBranch size={22} />
  694. <h3>从业务约束推导方案,再讨论失效边界</h3>
  695. </div>
  696. <p>
  697. 本模块通过架构图、故障时间线、设计练习和面试问答展开。无需安装服务或运行代码;阅读时先判断业务允许多旧的数据、能等待多久、失败后能否重试。
  698. </p>
  699. <div className="context-columns">
  700. <div>
  701. <span>设计依据</span>
  702. <p>业务不变量、容量估算、一致性与可用性目标。</p>
  703. </div>
  704. <div>
  705. <span>方案取舍</span>
  706. <p>正常路径、失败路径、运维成本与恢复条件。</p>
  707. </div>
  708. </div>
  709. </div>
  710. ) : (
  711. <div className="engineering-context">
  712. <div className="context-heading">
  713. <ShieldCheck size={22} />
  714. <h3>正常运行之后,还要验证失败路径</h3>
  715. </div>
  716. <p>
  717. 本模块每节都明确使用场景、技术取舍、故障操作与恢复条件。命令和故障注入应在该节指定的隔离练习环境执行。
  718. </p>
  719. <div className="context-columns">
  720. <div>
  721. <span>实现约束</span>
  722. <p>资源上限、超时、事务与权限边界。</p>
  723. </div>
  724. <div>
  725. <span>故障恢复</span>
  726. <p>错误可观察,恢复有依据,结果可核对。</p>
  727. </div>
  728. </div>
  729. </div>
  730. )}
  731. {m.slug === 'tasks' && (
  732. <>
  733. <TaskExecutionMatrix />
  734. <TaskDeliveryDemo />
  735. </>
  736. )}
  737. {m.depends.length > 0 && (
  738. <div className="dependencies">
  739. <span>前置模块</span>
  740. {m.depends.map((slug) => (
  741. <LocalLink key={slug} module={slug} navigate={navigate}>
  742. {getModule(slug)?.name}
  743. <ArrowUpRight size={13} />
  744. </LocalLink>
  745. ))}
  746. </div>
  747. )}
  748. </section>
  749. <section id="ready">
  750. <div className="section-heading">
  751. <h2>模块完成标准</h2>
  752. <span>
  753. {review
  754. ? '用具体证据支持自评'
  755. : theory
  756. ? '说清选择、代价与适用边界'
  757. : '以行为和数据验证结果'}
  758. </span>
  759. </div>
  760. <ul className="check-list">
  761. {m.outcomes.map((o) => (
  762. <li key={o}>
  763. <Check size={16} />
  764. {o}
  765. </li>
  766. ))}
  767. </ul>
  768. {!review && (
  769. <div className="overview-actions">
  770. <LocalLink
  771. module="review"
  772. lesson={String(index + 1).padStart(2, '0')}
  773. navigate={navigate}
  774. className="secondary-link"
  775. >
  776. 自测本模块:理解问题与掌握证据
  777. <ArrowRight size={16} />
  778. </LocalLink>
  779. </div>
  780. )}
  781. {next && (
  782. <LocalLink
  783. module={next.slug}
  784. navigate={navigate}
  785. className="next-module"
  786. >
  787. <span>下一模块</span>
  788. <strong>{next.name}</strong>
  789. <ArrowRight size={16} />
  790. </LocalLink>
  791. )}
  792. </section>
  793. </>
  794. );
  795. }
  796. function TaskExecutionMatrix() {
  797. return (
  798. <div className="execution-matrix">
  799. <h3>按工作负载与可靠性要求选择执行方式</h3>
  800. <Table className="api-table">
  801. <TableHeader>
  802. <TableRow>
  803. <TableHead>执行方式</TableHead>
  804. <TableHead>主要用途</TableHead>
  805. <TableHead>必须明确的边界</TableHead>
  806. </TableRow>
  807. </TableHeader>
  808. <TableBody>
  809. {[
  810. [
  811. 'asyncio',
  812. '支持异步调用的网络 I/O',
  813. '协作式调度;并发必须有上限,任务状态不持久。',
  814. ],
  815. [
  816. '线程池',
  817. '阻塞式 I/O 库',
  818. '共享进程内存;超时后不能直接强杀运行中的线程。',
  819. ],
  820. [
  821. '进程池',
  822. '可拆分的 CPU 计算',
  823. '参数需要可序列化;控制进程数量与内存。',
  824. ],
  825. [
  826. 'BackgroundTasks',
  827. '同进程短小的后续操作',
  828. '随服务进程退出而丢失,不能充当持久队列。',
  829. ],
  830. [
  831. 'Celery + broker',
  832. '跨进程执行与可恢复的长任务',
  833. '持久性依赖配置;处理重复投递、幂等和状态恢复。',
  834. ],
  835. ].map(([mode, use, boundary]) => (
  836. <TableRow key={mode}>
  837. <TableCell>
  838. <code>{mode}</code>
  839. </TableCell>
  840. <TableCell>{use}</TableCell>
  841. <TableCell>{boundary}</TableCell>
  842. </TableRow>
  843. ))}
  844. </TableBody>
  845. </Table>
  846. <p className="section-caption">
  847. 常规 CPython 的线程不会自动加速 Python CPU
  848. 密集计算。先确认工作负载、第三方库行为和解释器构建方式,再选择模型。详细实现见本模块第
  849. 02—08 节。
  850. </p>
  851. </div>
  852. );
  853. }
  854. function ApiEndpoints() {
  855. return (
  856. <>
  857. <h3>API 参考项目</h3>
  858. <Table className="api-table">
  859. <TableHeader>
  860. <TableRow>
  861. <TableHead>接口</TableHead>
  862. <TableHead>行为</TableHead>
  863. </TableRow>
  864. </TableHeader>
  865. <TableBody>
  866. {[
  867. ['GET /health', '健康检查'],
  868. ['GET / POST /products', '查询与创建商品'],
  869. ['GET / PUT / DELETE /products/{id}', '读取、完整替换与删除'],
  870. ['GET /private', '请求头依赖与鉴权'],
  871. ['GET /slow · GET /upstream-demo', '异步等待、上游调用与超时'],
  872. ['POST /generate · GET /stream', '完整 JSON 与 SSE 流式响应'],
  873. ['WS /ws', 'echo、ping/pong 与关闭连接'],
  874. ].map(([path, result]) => (
  875. <TableRow key={path}>
  876. <TableCell>
  877. <code>{path}</code>
  878. </TableCell>
  879. <TableCell>{result}</TableCell>
  880. </TableRow>
  881. ))}
  882. </TableBody>
  883. </Table>
  884. <div className="resource-strip">
  885. <FileCode2 size={24} />
  886. <div>
  887. <h3>可运行的 FastAPI 参考实现</h3>
  888. <p>含版本依赖、浏览器客户端与 41 项商品服务测试。</p>
  889. </div>
  890. <a href="/downloads/api-lab.zip" download className="secondary-link">
  891. 下载 ZIP
  892. <Download size={15} />
  893. </a>
  894. </div>
  895. </>
  896. );
  897. }
  898. function CodeBlock({
  899. code,
  900. lang = 'text',
  901. filename,
  902. }: {
  903. code: string;
  904. lang?: string;
  905. filename?: string;
  906. }) {
  907. const [copied, setCopied] = useState(false);
  908. const [error, setError] = useState(false);
  909. useEffect(() => {
  910. if (!copied && !error) return;
  911. const t = setTimeout(() => {
  912. setCopied(false);
  913. setError(false);
  914. }, 2200);
  915. return () => clearTimeout(t);
  916. }, [copied, error]);
  917. const copy = async () => {
  918. try {
  919. await navigator.clipboard.writeText(code);
  920. setCopied(true);
  921. } catch {
  922. setError(true);
  923. }
  924. };
  925. const highlight = (line: string) =>
  926. line
  927. .split(
  928. /("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|#[^\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,
  929. )
  930. .map((part, i) => (
  931. <span
  932. key={i}
  933. className={
  934. part.startsWith('#')
  935. ? 'syntax-comment'
  936. : /^['"]/.test(part)
  937. ? 'syntax-string'
  938. : /^(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(
  939. part,
  940. )
  941. ? 'syntax-keyword'
  942. : undefined
  943. }
  944. >
  945. {part}
  946. </span>
  947. ));
  948. return (
  949. <div className="code-block">
  950. <div className="code-toolbar">
  951. <span>
  952. <FileCode2 size={14} />
  953. {filename || lang}
  954. </span>
  955. <button onClick={copy} className="copy-button" aria-label="复制代码">
  956. {copied ? <Check size={13} /> : <Copy size={13} />}
  957. <span aria-live="polite">
  958. {copied ? '已复制' : error ? '请选择代码手动复制' : '复制'}
  959. </span>
  960. </button>
  961. </div>
  962. <pre>
  963. <code>
  964. {code.split('\n').map((line, i) => (
  965. <span className="code-line" key={i}>
  966. <span className="line-number" aria-hidden="true">
  967. {i + 1}
  968. </span>
  969. <span>{highlight(line) || ' '}</span>
  970. {'\n'}
  971. </span>
  972. ))}
  973. </code>
  974. </pre>
  975. </div>
  976. );
  977. }
  978. function VideoBlock({ module, id }: { module: string; id: string }) {
  979. const video =
  980. videos[`${module}/${id}`] || (module === 'api' ? videos[id] : undefined);
  981. if (!video || !/^(https?:\/\/|\/(?!\/))/.test(video.url)) return null;
  982. return (
  983. <section id="video" className="video-section">
  984. {video.type === 'file' ? (
  985. <div className="video-player">
  986. <video controls preload="metadata" src={video.url} playsInline>
  987. <track
  988. kind="captions"
  989. src={video.captions}
  990. srcLang="zh"
  991. label="中文字幕"
  992. default={!!video.captions}
  993. />
  994. <a href={video.url}>打开视频</a>
  995. </video>
  996. <a href={video.url} target="_blank" rel="noreferrer">
  997. 直接打开视频
  998. <ExternalLink size={14} />
  999. </a>
  1000. </div>
  1001. ) : (
  1002. <a
  1003. href={video.url}
  1004. target="_blank"
  1005. rel="noreferrer"
  1006. className="video-pending"
  1007. >
  1008. <VideoIcon size={23} />
  1009. <div>
  1010. <strong>{video.title || '观看本节视频'}</strong>
  1011. <span>在视频平台打开</span>
  1012. </div>
  1013. <ArrowUpRight size={20} />
  1014. </a>
  1015. )}
  1016. </section>
  1017. );
  1018. }
  1019. function LessonPage({
  1020. module: m,
  1021. lessons,
  1022. lesson: l,
  1023. navigate,
  1024. }: {
  1025. module: ModuleInfo;
  1026. lessons: Lesson[];
  1027. lesson: TeachingLesson;
  1028. navigate: Navigate;
  1029. }) {
  1030. const index = lessons.findIndex((x) => x.id === l.id);
  1031. const nextModule = modules[modules.findIndex((x) => x.slug === m.slug) + 1];
  1032. const theory = l.kind === 'theory';
  1033. return (
  1034. <article className="lesson-article">
  1035. <LocalLink module={m.slug} navigate={navigate} className="back-link">
  1036. <ArrowLeft size={14} />
  1037. {m.name} · 全部课时
  1038. </LocalLink>
  1039. <div className="eyebrow">
  1040. LESSON {l.id} / {lessons.length}
  1041. <span />
  1042. {l.group}
  1043. </div>
  1044. <h1 className="lesson-title" tabIndex={-1}>
  1045. {l.title}
  1046. </h1>
  1047. <p className="lead">{l.summary}</p>
  1048. <div className="meta-row">
  1049. <span>{m.name}</span>
  1050. <span>
  1051. {theory ? '理论与面试' : l.production ? '生产系统实践' : '实战课件'}
  1052. </span>
  1053. <span>
  1054. {theory
  1055. ? '图解 · 场景推演 · 设计取舍'
  1056. : l.demo
  1057. ? '含交互演示'
  1058. : '含代码与验收'}
  1059. </span>
  1060. </div>
  1061. {l.intro && (
  1062. <section id="start" className="lesson-start">
  1063. <h2>从一个问题开始</h2>
  1064. <p className="lesson-scenario">{l.intro.scenario}</p>
  1065. <div className="lesson-outcome">
  1066. <span>这次的终点</span>
  1067. <p>{l.intro.outcome}</p>
  1068. </div>
  1069. <h3>开始前,确认你在这里</h3>
  1070. <ul>
  1071. {l.intro.startingPoint.map((item) => (
  1072. <li key={item}>{item}</li>
  1073. ))}
  1074. </ul>
  1075. <LocalLink
  1076. module={m.slug}
  1077. lesson={index > 0 ? lessons[index - 1].id : undefined}
  1078. navigate={navigate}
  1079. className="lesson-start-link"
  1080. >
  1081. <ArrowLeft size={14} />
  1082. {index > 0
  1083. ? `需要时复习上一课:${lessons[index - 1].title}`
  1084. : '查看本模块的学习准备'}
  1085. </LocalLink>
  1086. <p className="lesson-reading-guide">
  1087. {theory
  1088. ? '先按每一步的问题作出判断,再对照推演结果和解释;完成后用课末回顾整理概念。'
  1089. : '每次只做一个步骤,先核对结果,再读原因。结果不一致时,先排查当前步骤,再继续往下。'}
  1090. </p>
  1091. </section>
  1092. )}
  1093. <section id="objectives" className="objectives">
  1094. <h2>这节课要完成什么</h2>
  1095. <ul>
  1096. {l.objectives.map((o, i) => (
  1097. <li key={o}>
  1098. <span>{String(i + 1).padStart(2, '0')}</span>
  1099. {o}
  1100. </li>
  1101. ))}
  1102. </ul>
  1103. </section>
  1104. <VideoBlock module={m.slug} id={l.id} />
  1105. <section id="steps">
  1106. <div className="section-heading">
  1107. <h2>{theory ? '一步步推演场景' : '一步步完成案例'}</h2>
  1108. <span>
  1109. {l.steps.length} 个{theory ? '分析环节' : '步骤'}
  1110. </span>
  1111. </div>
  1112. {l.steps.map((s, i) => (
  1113. <div className="practice-step" key={`${s.title}-${i}`}>
  1114. <div className="practice-title">
  1115. <span>{String(i + 1).padStart(2, '0')}</span>
  1116. <h3>{s.title}</h3>
  1117. </div>
  1118. <p>{s.body}</p>
  1119. {s.code && (
  1120. <CodeBlock code={s.code} lang={s.lang} filename={s.filename} />
  1121. )}
  1122. {s.expected && (
  1123. <div className="step-observation">
  1124. <h4>{theory ? '对照你的推演' : '你应该看到什么'}</h4>
  1125. <p>{s.expected}</p>
  1126. </div>
  1127. )}
  1128. {s.explanation && (
  1129. <div className="step-explanation">
  1130. <h4>{theory ? '从这里理解原因' : '刚才发生了什么'}</h4>
  1131. <p>{s.explanation}</p>
  1132. </div>
  1133. )}
  1134. </div>
  1135. ))}
  1136. </section>
  1137. <section id="checks">
  1138. <div className="section-heading">
  1139. <h2>{theory ? '检查推演结果' : '检查学习结果'}</h2>
  1140. <span>
  1141. {theory ? '能否解释因果、代价与边界' : '核对响应、日志与数据'}
  1142. </span>
  1143. </div>
  1144. <ul className="check-list">
  1145. {l.checks.map((c) => (
  1146. <li key={c}>
  1147. <Check size={16} />
  1148. {c}
  1149. </li>
  1150. ))}
  1151. </ul>
  1152. </section>
  1153. <LessonDiagrams module={m.slug} lesson={l.id} />
  1154. {l.demo === 'http' ? (
  1155. <HttpDemo />
  1156. ) : l.demo === 'async' ? (
  1157. <AsyncDemo />
  1158. ) : l.demo === 'protocol' ? (
  1159. <ProtocolDemo />
  1160. ) : null}
  1161. {m.slug === 'tasks' && l.id === '09' && <TaskDeliveryDemo />}
  1162. {l.production && (
  1163. <section id="production" className="production-panel">
  1164. <div className="section-heading">
  1165. <h2>进一步验证:约束与故障</h2>
  1166. <GitBranch size={21} />
  1167. </div>
  1168. <p className="learning-section-note">
  1169. 先通过上面的学习结果检查,再做这一组实验,观察条件变化以后原来的办法是否仍然有效。
  1170. </p>
  1171. <div className="production-scenario">
  1172. <span>使用场景</span>
  1173. <p>{l.production.scenario}</p>
  1174. </div>
  1175. <h3>实现决策</h3>
  1176. <ul className="decision-list">
  1177. {l.production.decisions.map((d) => (
  1178. <li key={d}>{d}</li>
  1179. ))}
  1180. </ul>
  1181. <div className="failure-drill">
  1182. <span>隔离环境演练</span>
  1183. <p>{l.production.failureDrill}</p>
  1184. </div>
  1185. <h3>恢复验收</h3>
  1186. <ul className="check-list">
  1187. {l.production.acceptance.map((a) => (
  1188. <li key={a}>
  1189. <Check size={15} />
  1190. {a}
  1191. </li>
  1192. ))}
  1193. </ul>
  1194. </section>
  1195. )}
  1196. <section id="pitfalls">
  1197. <div className="section-heading">
  1198. <h2>{theory ? '常见误区' : '遇到问题时查这里'}</h2>
  1199. <span>现象 → 原因 → 处理</span>
  1200. </div>
  1201. <div className="pitfall-list">
  1202. {l.pitfalls.map((p) => (
  1203. <div className="pitfall" key={p.symptom}>
  1204. <h3>{p.symptom}</h3>
  1205. <p>
  1206. <strong>原因</strong>
  1207. {p.cause}
  1208. </p>
  1209. <p>
  1210. <strong>处理</strong>
  1211. {p.fix}
  1212. </p>
  1213. </div>
  1214. ))}
  1215. </div>
  1216. </section>
  1217. {l.interview && (
  1218. <section id="interview" className="exercise interview-panel">
  1219. <div className="section-heading">
  1220. <h2>面试问答</h2>
  1221. <span>先独立回答,再展开参考思路</span>
  1222. </div>
  1223. <h3>{l.interview.question}</h3>
  1224. <details>
  1225. <summary>
  1226. 查看回答思路
  1227. <ChevronRight size={15} />
  1228. </summary>
  1229. <div className="answer-text">{l.interview.answer}</div>
  1230. </details>
  1231. <h3 className="interview-followups">继续追问</h3>
  1232. <ul className="decision-list">
  1233. {l.interview.followUps.map((question) => (
  1234. <li key={question}>{question}</li>
  1235. ))}
  1236. </ul>
  1237. </section>
  1238. )}
  1239. <section id="exercise" className="exercise">
  1240. <div className="section-heading">
  1241. <h2>{theory ? '自己设计一次' : '自己试一次'}</h2>
  1242. <span>完成后再核对答案</span>
  1243. </div>
  1244. <p>{l.exercise.task}</p>
  1245. <details>
  1246. <summary>
  1247. 查看提示
  1248. <ChevronRight size={15} />
  1249. </summary>
  1250. <p>{l.exercise.hint}</p>
  1251. </details>
  1252. <details>
  1253. <summary>
  1254. 查看参考答案
  1255. <ChevronRight size={15} />
  1256. </summary>
  1257. <div className="answer-text">{l.exercise.answer}</div>
  1258. </details>
  1259. </section>
  1260. <section id="concepts" className="lesson-recap">
  1261. <div className="section-heading">
  1262. <h2>核心概念回顾</h2>
  1263. <span>把刚才的操作与现象整理成知识</span>
  1264. </div>
  1265. <p className="learning-section-note">
  1266. {theory
  1267. ? '回想刚才每一种选择造成的结果,再用下面的概念解释自己的判断。'
  1268. : '回想刚才在哪一步用到了它、看到了什么结果。能结合自己的操作解释清楚,才算理解了这个概念。'}
  1269. </p>
  1270. {l.concepts.map((c, i) => (
  1271. <div className="concept" key={c.title}>
  1272. <h3>
  1273. <span>{i + 1}.</span>
  1274. {c.title}
  1275. </h3>
  1276. <p>{c.body}</p>
  1277. </div>
  1278. ))}
  1279. </section>
  1280. <section id="references">
  1281. <div className="section-heading">
  1282. <h2>{theory ? '延伸阅读' : '技术文档'}</h2>
  1283. <span>
  1284. {theory ? '原始论文与官方设计资料' : '原始定义与框架用法'}
  1285. </span>
  1286. </div>
  1287. <div className="reference-links">
  1288. {l.refs.map((r) => (
  1289. <a key={r.url} href={r.url} target="_blank" rel="noreferrer">
  1290. {r.label}
  1291. <ArrowUpRight size={15} />
  1292. </a>
  1293. ))}
  1294. </div>
  1295. </section>
  1296. {index === lessons.length - 1 && (
  1297. <div className="overview-actions">
  1298. <LocalLink
  1299. module="review"
  1300. lesson={String(
  1301. modules.findIndex((item) => item.slug === m.slug) + 1,
  1302. ).padStart(2, '0')}
  1303. navigate={navigate}
  1304. className="secondary-link"
  1305. >
  1306. 学完本模块,去复盘与自测
  1307. <ArrowRight size={16} />
  1308. </LocalLink>
  1309. </div>
  1310. )}
  1311. <div className="lesson-pagination">
  1312. {index > 0 ? (
  1313. <LocalLink
  1314. module={m.slug}
  1315. lesson={lessons[index - 1].id}
  1316. navigate={navigate}
  1317. >
  1318. <span>
  1319. <ArrowLeft size={13} />
  1320. 上一节
  1321. </span>
  1322. <strong>{lessons[index - 1].title}</strong>
  1323. </LocalLink>
  1324. ) : (
  1325. <LocalLink module={m.slug} navigate={navigate}>
  1326. <span>
  1327. <ArrowLeft size={13} />
  1328. 模块概览
  1329. </span>
  1330. <strong>完整学习路径</strong>
  1331. </LocalLink>
  1332. )}
  1333. {index < lessons.length - 1 ? (
  1334. <LocalLink
  1335. module={m.slug}
  1336. lesson={lessons[index + 1].id}
  1337. navigate={navigate}
  1338. className="next"
  1339. >
  1340. <span>
  1341. 下一节
  1342. <ArrowRight size={13} />
  1343. </span>
  1344. <strong>{lessons[index + 1].title}</strong>
  1345. </LocalLink>
  1346. ) : (
  1347. <LocalLink
  1348. module={nextModule?.slug || m.slug}
  1349. navigate={navigate}
  1350. className="next"
  1351. >
  1352. <span>
  1353. {nextModule ? '下一模块' : '模块概览'}
  1354. <ArrowRight size={13} />
  1355. </span>
  1356. <strong>{nextModule?.name || '检查完成标准'}</strong>
  1357. </LocalLink>
  1358. )}
  1359. </div>
  1360. </article>
  1361. );
  1362. }