{"id":29196565,"url":"https://github.com/hans774882968/teaching-plan-analytic-geometry","last_synced_at":"2026-02-04T00:03:05.093Z","repository":{"id":301942738,"uuid":"1009581031","full_name":"Hans774882968/teaching-plan-analytic-geometry","owner":"Hans774882968","description":"通过为前端项目接入GeoGebra，初步研究AI时代数学教案的生成方案","archived":false,"fork":false,"pushed_at":"2026-01-31T05:56:13.000Z","size":41211,"stargazers_count":19,"open_issues_count":0,"forks_count":3,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-02-01T09:51:28.329Z","etag":null,"topics":["babel","claude-code","geogebra","highlightjs","react","shadcn-ui","vite","zustand"],"latest_commit_sha":null,"homepage":"https://hans774882968.github.io/teaching-plan-analytic-geometry/","language":"JavaScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/Hans774882968.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2025-06-27T11:12:33.000Z","updated_at":"2026-01-31T05:55:28.000Z","dependencies_parsed_at":"2025-06-29T18:43:01.762Z","dependency_job_id":"ba7d8832-6166-4334-ad32-1ad4dba7515d","html_url":"https://github.com/Hans774882968/teaching-plan-analytic-geometry","commit_stats":null,"previous_names":["hans774882968/teaching-plan-analytic-geometry"],"tags_count":0,"template":false,"template_full_name":null,"purl":"pkg:github/Hans774882968/teaching-plan-analytic-geometry","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/Hans774882968%2Fteaching-plan-analytic-geometry","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/Hans774882968%2Fteaching-plan-analytic-geometry/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/Hans774882968%2Fteaching-plan-analytic-geometry/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/Hans774882968%2Fteaching-plan-analytic-geometry/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/Hans774882968","download_url":"https://codeload.github.com/Hans774882968/teaching-plan-analytic-geometry/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/Hans774882968%2Fteaching-plan-analytic-geometry/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":29062483,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-02-03T23:14:54.203Z","status":"ssl_error","status_checked_at":"2026-02-03T23:14:50.873Z","response_time":96,"last_error":"SSL_read: unexpected eof while reading","robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":false,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["babel","claude-code","geogebra","highlightjs","react","shadcn-ui","vite","zustand"],"created_at":"2025-07-02T06:07:22.022Z","updated_at":"2026-02-04T00:03:05.085Z","avatar_url":"https://github.com/Hans774882968.png","language":"JavaScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"[TOC]\r\n\r\n# 通过为前端项目接入GeoGebra，初步研究AI时代数学教案的生成方案（已部署GitHub Pages）\r\n\r\n## 引言\r\n\r\n我偶然刷到了[这个视频](https://www.bilibili.com/video/BV1UeK8zAErv)，它给我一种强烈的感觉，就是下一代的每一个学生，只要会说HTML、Tailwind CSS、JavaScript这少数几个词，就能轻松生成属于自己的互动性极强的数学课件。我感受到了极其强烈的焦虑，下一代的学生，肯定能比我们这一代人更快地更新迭代自己的知识体系。我们一天才能学会的东西，他们可能一小时就能学会。以后他们的工作能力肯定能轻松碾压我们。所以至少在LLM尚未十分成熟的2025，我想走在下一代人的前面，哪怕只是用行动骗骗自己，有能力比下一代人更快驾驭LLM……\r\n\r\n咳咳，先不抒情了。我写这个开源项目的初衷主要有：\r\n\r\n1. 研究如何用大语言模型快速生成覆盖从小学到数学专业/研究生难度的，不同类型的数学教案。\r\n2. 研究如何在前端页面嵌入**GeoGebra**，增强数学教案的互动性。\r\n\r\n[本项目GitHub传送门](https://github.com/Hans774882968/teaching-plan-analytic-geometry)\r\n\r\n注意：为了减少该项目的占用空间，本项目并未包含GeoGebra源码。如果想要在本地跑起来这个项目，请自行下载[GeoGebra Math Apps Bundle](https://download.geogebra.org/package/geogebra-math-apps-bundle)，并复制里面的web3d文件夹和css文件夹到本项目的`public\\geogebra`。\r\n\r\n### 250726：本项目已部署到 GitHub Pages\r\n\r\n[传送门](https://hans774882968.github.io/teaching-plan-analytic-geometry)。欢迎参观~\r\n\r\n现阶段的整体效果-PC端：\r\n\r\n![5-现阶段的整体效果.png](./README_assets/5-现阶段的整体效果.png)\r\n\r\n移动端：\r\n\r\n![5-现阶段的整体效果-移动端.png](./README_assets/5-现阶段的整体效果-移动端.png)\r\n\r\n本文 52pojie：https://www.52pojie.cn/thread-2048343-1-1.html\r\n\r\n本文 CSDN：https://blog.csdn.net/hans774882968/article/details/149613117\r\n\r\n本文 juejin：https://juejin.cn/post/7530464752063348782\r\n\r\n**作者：[hans774882968](https://blog.csdn.net/hans774882968)以及[hans774882968](https://juejin.cn/user/1464964842528888)以及[hans774882968](https://www.52pojie.cn/home.php?mod=space\u0026uid=1906177)**\r\n\r\n## 【困难】如何给React项目接入GeoGebra\r\n\r\n翻了下高中数学必修一（进入 https://jc.pep.com.cn/ ，选择高中数学必修第一册B版），现在已经升级为使用GeoGebra了。我还清楚地记得，15年的数学课本还是用几何画板举例的。\r\n\r\n![1-高中数学必修一已经更新为使用GeoGebra.png](./README_assets/1-高中数学必修一已经更新为使用GeoGebra.png)\r\n\r\n为什么这个项目要接入GeoGebra不用多说了吧~伟大，无需多言！\r\n\r\n参考[GeoGebra官方文档](https://geogebra.github.io/docs/reference/en/GeoGebra_Apps_Embedding/)，我们主要需要：\r\n\r\n1. 导入：`\u003cscript src=\"GeoGebra/deployggb.js\"\u003e\u003c/script\u003e`。\r\n2. 如果是**自托管**，需要加上这句：`applet.setHTML5Codebase('GeoGebra/HTML5/5.0/web3d/');`。否则忽略。\r\n\r\n### 封装 Geogebra.jsx\r\n\r\n为了方便在React中使用GeoGebra，我们不妨封装一个`Geogebra.jsx`。我找到了一个叫`react-geogebra`的npm包，但看了眼那个源码。天哪！代码质量不太得，eslint报错有十几个！索性复制下来，自己改改。\r\n\r\n[src\\component\\Geogebra.jsx](https://github.com/Hans774882968/teaching-plan-analytic-geometry/blob/main/src/component/Geogebra.jsx), copy from https://github.com/pfaffmann/react-geogebra/blob/master/src/index.js\r\n\r\n```jsx\r\nimport { useEffect, useState, useRef, useCallback } from 'react';\r\nimport styles from './Geogebra.module.scss';\r\nimport { cn } from '@/lib/utils';\r\n\r\nconst Geogebra = (props) =\u003e {\r\n  const defaultProps = {\r\n    appName: 'classic',\r\n    height: 600,\r\n    showToolBar: true,\r\n    showAlgebraInput: true,\r\n    showMenuBar: true,\r\n  };\r\n  const mergedProps = { ...defaultProps, ...props };\r\n  const refProps = useRef(mergedProps);\r\n\r\n  const { onReady, appletOnLoad } = refProps.current;\r\n  let { id, LoadComponent, debug } = refProps.current;\r\n  if (!id) {\r\n    id = 'ggb-applet';\r\n  }\r\n  if (!debug) {\r\n    debug = false;\r\n  }\r\n  // if a JSX Component is not given as a prop, use h3 with children\r\n  if (!LoadComponent) {\r\n    LoadComponent = ({ children }) =\u003e \u003ch3\u003e{children}\u003c/h3\u003e;\r\n  }\r\n\r\n  const [watchPropsChange, setWatchPropsChange] = useState(false);\r\n  // gets called by Geogebra after the Applet is ready\r\n  const onAppletReady = useCallback(\r\n    (ggbApi) =\u003e {\r\n      if (appletOnLoad) appletOnLoad(ggbApi);\r\n      if (onReady) onReady();\r\n      debug \u0026\u0026 console.log(`Applet with id \"${id}\" is ready`);\r\n    },\r\n    [appletOnLoad, debug, id, onReady]\r\n  );\r\n\r\n  useEffect(() =\u003e {\r\n    if (window.GGBApplet) {\r\n      const parameter = JSON.parse(JSON.stringify(refProps.current));\r\n      parameter.appletOnLoad = onAppletReady;\r\n      const ggbApp = new window.GGBApplet(parameter, true);\r\n      ggbApp.setHTML5Codebase('/geogebra/web3d/');\r\n      ggbApp.inject(id);\r\n      setWatchPropsChange(false);\r\n      debug \u0026\u0026\r\n        console.log(`applet with id \"${id}\" successfully injected into the DOM`);\r\n    }\r\n\r\n    return () =\u003e {\r\n      const tag = document.getElementById(`${id}-holder`);\r\n      if (tag) {\r\n        tag.lastChild.textContent = '';\r\n      }\r\n    };\r\n  }, [watchPropsChange, debug, id, onAppletReady]);\r\n\r\n  return (\r\n    \u003cdiv id={`${id}-holder`} className={cn(styles.geogebraWrapper, props.className)}\u003e\r\n      \u003cdiv id={id} style={{ width: '100%' }}\u003e\u003c/div\u003e\r\n    \u003c/div\u003e\r\n  );\r\n};\r\n\r\nexport default Geogebra;\r\n```\r\n\r\n这里的核心代码是：\r\n\r\n```js\r\nconst ggbApp = new window.GGBApplet(parameter, true);\r\nggbApp.setHTML5Codebase('/geogebra/web3d/');\r\nggbApp.inject(id);\r\n```\r\n\r\n调用：\r\n\r\n```jsx\r\n\u003cGeogebra\r\n  id=\"geogebra\"\r\n  width={1200}\r\n  height={600}\r\n  showToolbar={true}\r\n  showMenuBar={true}\r\n  allowStyleBar={true}\r\n  showAlgebraInput={true}\r\n  enableLabelDrags={false}\r\n  enableShiftDragZoom={true}\r\n  capturingThreshold={null}\r\n  showToolBarHelp={false}\r\n  errorDialogsActive={true}\r\n  showTutorialLink={true}\r\n/\u003e\r\n```\r\n\r\n然后就到踩坑时间！\r\n\r\n### GeoGebra 的自托管解决方案\r\n\r\n如果把`ggbApp.setHTML5Codebase('/geogebra/web3d/')`去掉，那么geogebra已经能正常工作，但静态资源必须通过网络下载，而且资源总共有几十MB，所以加载时间有点长。于是我们不得不考虑自托管解决方案。\r\n\r\n首先，按[官方文档](https://geogebra.github.io/docs/reference/en/GeoGebra_Apps_Embedding/)指示下载[GeoGebra Math Apps Bundle](https://download.geogebra.org/package/geogebra-math-apps-bundle)，接着设置`ggbApp.setHTML5Codebase('/geogebra/web3d/')`，然后把下载的包的web3d文件夹复制到本项目的`public\\geogebra\\web3d`。\r\n\r\n到此为止都自我感觉良好。运行！不出所料，报错了！这是因为它请求了 http://localhost:5215/261BBF4225A3B6C8FD1B8B949B793666.cache.js 而非预期的 http://localhost:5215/geogebra/web3d/261BBF4225A3B6C8FD1B8B949B793666.cache.js 。直接挪动这个`cache.js`似乎可以解决，但不太优雅。\r\n\r\n于是我排查源码，发现这个cache文件的路径是由`web3d.__moduleBase`（位于`geogebra\\web3d\\web3d.nocache.js`）决定的。搜索这个变量名，发现`web3d.__moduleBase = B()`这句赋值决定了其值，外部没法直接修改它。所以我们继续看B函数，发现最终是这句代码决定其路径：\r\n\r\n```js\r\n// geogebra\\web3d\\web3d.nocache.js\r\n// 我把原有代码的 O, P, W 等还原为常量\r\nfunction e(a) {\r\n  var b = a.lastIndexOf('#');\r\n  if (b == -1) {\r\n    b = a.length;\r\n  }\r\n  var c = a.indexOf('?');\r\n  if (c == -1) {\r\n    c = a.length;\r\n  }\r\n  var d = a.lastIndexOf('/', Math.min(c, b));\r\n  return d \u003e= 0 ? a.substring(0, d + 1) : '';\r\n}\r\n\r\nif(k == W \u0026\u0026 j()) {k = e(o.location.href);}\r\nk = f(k);\r\nreturn k;\r\n```\r\n\r\n我们把它改成`k = e(o.location.href + 'geogebra/web3d/');`，问题解决！\r\n\r\n#### 250629更新\r\n\r\n接入react-router后，需要改为`k = e('/geogebra/web3d/');`。\r\n\r\n#### 250726更新\r\n\r\n为了能够部署到GitHub Pages，我们进一步更新为：`k = e(window.GGB_WEB3D_NO_CACHE_BASE_PATH);`。这个全局变量我们约定在`main.jsx`设置：\r\n\r\n```js\r\nwindow.GGB_WEB3D_NO_CACHE_BASE_PATH = getGeogebraHTML5Codebase();\r\n\r\n// src\\lib\\getWebsiteBasePath.js\r\nexport function getGeogebraHTML5Codebase() {\r\n  const isGitHubPages = import.meta.env.VITE_DEPLOY_TARGET === 'github-pages';\r\n  const codebase = isGitHubPages ? `${GITHUB_PAGE_BASE}geogebra/web3d/` : '/geogebra/web3d/';\r\n  return codebase;\r\n}\r\n```\r\n\r\n详见后文《部署到GitHub Pages》一节。\r\n\r\n之后发现，控制台没有报错了，但样式不对劲。这是因为它请求了`geogebra/css/...`。所以我们不能只复制web3d文件夹，还要把同级的css文件夹复制过去。至此搞定！\r\n\r\n### GeoGebra 的神秘 bug\r\n\r\n打开控制台，窗口自动缩小，此时点击设置，选择坐标轴的颜色，会发现点击对应格子，选中的颜色却不是鼠标对应的格子的颜色。把控制台关了，窗口无法回到原来的大小，于是问题还在。控制台从未开启则没有这个问题。\r\n\r\n### GeoGebra 接入总结\r\n\r\n1. 按官方文档说的做。\r\n2. 复制web3d和css两个文件夹到`public\\geogebra`。\r\n3. `geogebra\\web3d\\web3d.nocache.js`的`k = e(o.location.href)`改成`k = e('/geogebra/web3d/');`。\r\n\r\n## 用GeoGebra进行数学教学\r\n\r\n可参考[docs\\Geogebra组件文档.md](https://github.com/Hans774882968/teaching-plan-analytic-geometry/blob/main/docs/Geogebra%E7%BB%84%E4%BB%B6%E6%96%87%E6%A1%A3.md)。下面仅给出验证椭圆第一定义的示例脚本：\r\n\r\n```jsx\r\nconst drawEllipse = (applet) =\u003e {\r\n  applet.evalCommand('ellipse: x^2/25 + y^2/9 = 1'); // 绘制椭圆\r\n  applet.setColor('ellipse', 255, 0, 0);\r\n  applet.setLineThickness('ellipse', 3);\r\n  applet.setCaption('ellipse', '椭圆: \\\\frac{x^2}{25} + \\\\frac{y^2}{9} = 1');\r\n\r\n  applet.evalCommand('A: Point(ellipse)'); // 绘制椭圆上的动点 A\r\n  applet.evalCommand('C1: (4, 0)'); // 绘制椭圆的右焦点 C1\r\n  applet.evalCommand('C2: (-4, 0)'); // 绘制椭圆的左焦点 C2\r\n  applet.evalCommand('s1: Segment(C1, A)'); // 线段 C1A\r\n  applet.evalCommand('s2: Segment(C2, A)'); // 线段 C2A\r\n  applet.evalCommand('lenSum: s1 + s2'); // 验证椭圆上任意一点 A 到两个焦点的距离和为定值\r\n\r\n  applet.setCoordSystem(-6, 6, -4, 4);\r\n};\r\n\r\n\u003cGeogebra\r\n  id=\"geogebra\"\r\n  width={1200}\r\n  height={600}\r\n  appletOnLoad={drawEllipse}\r\n/\u003e\r\n```\r\n\r\n### 如何快速学习GeoGebra的语法？以“双曲线的反函数”为例\r\n\r\n除了看官方文档以外，还有一些更简单的方式：\r\n\r\n1. 操作一下软件的上方工具栏，比如“Reflect about Point”（设置关于点A关于点B对称的点），然后点击平面直角坐标系中已有的两个对象，比如在这个例子中，就是两个点，就能看到等式列表新增了一条等式。\r\n2. 可以猜测“Reflect about Point”的命令含有Reflect字样，在等式输入框输入“Reflect”，便能根据软件的提示方便地补全命令。\r\n\r\n另外，我还尝试问DeepSeek：\r\n\r\n\u003e 大佬，我有如下Geogebra代码：/* 省略 */。请问如何写代码验证A0点确实在hyperbola2上\r\n\r\n它确实回答出来了，但给的回答太复杂了。其实，“验证双曲线的反函数还是双曲线”可以通过Reflect和Distance命令简单实现。如下（来自[src\\hyperbolaDefinition\\config.jsx](https://github.com/Hans774882968/teaching-plan-analytic-geometry/blob/main/src/hyperbolaDefinition/config.jsx)）：\r\n\r\n```js\r\n(applet) =\u003e {\r\n  applet.evalCommand('hyperbola1: x^2/16 - y^2/9 = 1');\r\n  applet.setColor('hyperbola1', 255, 0, 0);\r\n  applet.setLineThickness('hyperbola1', 3);\r\n  applet.evalCommand('hyperbola2: y^2/16 - x^2/9 = 1');\r\n  applet.setColor('hyperbola2', 0, 0, 255);\r\n  applet.setLineThickness('hyperbola2', 3);\r\n\r\n  applet.evalCommand('A: Point(hyperbola1)');\r\n  applet.evalCommand('l: y = x');\r\n  applet.evalCommand('A\\': Reflect(A, l)');\r\n  applet.setLineThickness('l', 1);\r\n  applet.evalCommand('Distance(A\\', hyperbola2)');\r\n\r\n  applet.setCoordSystem(-10, 10, -8, 8);\r\n}\r\n```\r\n\r\n## 在React项目中用`framer-motion`实现动画\r\n\r\n`framer-motion`其实很强大，但是我在此只是把它当作CSS动画的语法糖。看一个例子就秒懂了（[`src\\TeachingPlanList.jsx`](https://github.com/Hans774882968/teaching-plan-analytic-geometry/blob/main/src/TeachingPlanList.jsx)）：\r\n\r\n```jsx\r\n\u003cmotion.div\r\n  initial={{ opacity: 0, y: 20 }}\r\n  animate={{ opacity: 1, y: 0 }}\r\n  transition={{ duration: 0.5, delay: index * 0.1 }}\r\n  whileHover={{ scale: 1.03 }}\r\n  className={cn(\r\n    styles.lessonCard,\r\n    styles.bounceInAnimation,\r\n    'bg-white rounded-xl overflow-hidden shadow-lg'\r\n  )}\r\n/\u003e\r\n```\r\n\r\n`y`控制盒子往上飞，鼠标放上去盒子会变大。\r\n\r\n## 【常规】React项目如何支持Katex公式\r\n\r\n### 在 ReactNode 中：@matejmazur/react-katex\r\n\r\n相关文件：\r\n\r\n- `src\\ellipseDefinition\\EllipseDefinition.jsx`\r\n- `src\\ellipseDefinition\\config.jsx`\r\n\r\n首先\r\n\r\n```powershell\r\nbun add katex @matejmazur/react-katex\r\n```\r\n\r\n接着在`src\\App.jsx`：\r\n\r\n```jsx\r\nimport 'katex/dist/katex.min.css';\r\n```\r\n\r\n然后直接引用：\r\n\r\n```jsx\r\nimport TeX from '@matejmazur/react-katex';\r\n\r\n\u003cTeX\u003e{'c = \\\\sqrt{a^2 - b^2}'}\u003c/TeX\u003e\r\n\u003cTeX block\u003e{String.raw`\\frac{x^2}{a^2} + \\frac{y^2}{b^2} = 1`}\u003c/TeX\u003e\r\n```\r\n\r\n因为我希望DeepSeek生成一个`config.js`的Schema方便后续配置，所以我实际上是在`src\\ellipseDefinition\\config.jsx`导入的`TeX`。大致方案如下：\r\n\r\nconfig配置示例：\r\n\r\n```jsx\r\nexport const config = {\r\n  properties: {\r\n    title: '椭圆的重要性质',\r\n    items: [\r\n      \u003c\u003e\u003cstrong className=\"highlight\"\u003e离心率\u003c/strong\u003e：\u003cTeX\u003e{'e = \\\\frac{c}{a} \\\\ (0 \u003c e \u003c 1)'}\u003c/TeX\u003e，表示椭圆的扁平程度\u003c/\u003e,\r\n      \u003c\u003e\u003cstrong className=\"highlight\"\u003e焦点性质\u003c/strong\u003e：从椭圆一个焦点发出的光线，经椭圆反射后会经过另一个焦点\u003c/\u003e,\r\n      \u003c\u003e\u003cstrong className=\"highlight\"\u003e对称性\u003c/strong\u003e：椭圆关于长轴、短轴和中心对称\u003c/\u003e,\r\n      \u003c\u003e\u003cstrong className=\"hard\"\u003e面积公式\u003c/strong\u003e：\u003cTeX\u003e{'S = \\\\pi \\\\times a \\\\times b'}\u003c/TeX\u003e\u003c/\u003e,\r\n    ],\r\n  },\r\n}\r\n```\r\n\r\n`EllipseDefinition.jsx`调用：\r\n\r\n```jsx\r\n\u003cdiv className=\"knowledge-point\"\u003e\r\n  \u003ch3\u003e🔍 {config.properties.title}\u003c/h3\u003e\r\n  {config.properties.items.map((item, i) =\u003e (\r\n    \u003cp key={i}\u003e{i + 1}. {item}\u003c/p\u003e\r\n  ))}\r\n\u003c/div\u003e\r\n```\r\n\r\n### 在 Markdown String 中：marked + marked-katex-extension\r\n\r\n为了方便LLM生成课件，我们需要让项目支持渲染Markdown。之后我们只需要在配置文件中写Markdown字符串即可。为了在marked中支持Katex公式，我们可以自己写一个renderer（我确实有搜到采用这种方案的），也可以用现成的包：`marked-katex-extension`。\r\n\r\n首先\r\n\r\n```powershell\r\nbun add marked highlight.js marked-katex-extension\r\n```\r\n\r\n接着我们可以直接实现一个React组件[`src\\component\\MarkdownRenderer.jsx`](https://github.com/Hans774882968/teaching-plan-analytic-geometry/blob/main/src/component/MarkdownRenderer.jsx)，调用marked，渲染Markdown String：\r\n\r\n```jsx\r\nimport { processMarkdown } from '@/lib/marked';\r\nimport { useEffect, useState } from 'react';\r\nimport { cn } from '@/lib/utils';\r\nimport 'highlight.js/styles/paraiso-light.css';\r\nimport useCodeBlockSetup from '../hooks/useCodeBlockSetup';\r\nimport './MarkdownRenderer.scss';\r\n\r\nexport default function MarkdownRenderer({ className, content, ...rest }) {\r\n  const [htmlContent, setHtmlContent] = useState('');\r\n  const [isLoading, setIsLoading] = useState(false);\r\n\r\n  const tpmMdContainerRef = useCodeBlockSetup();\r\n\r\n  useEffect(() =\u003e {\r\n    setIsLoading(true);\r\n    const parseResult = processMarkdown(content);\r\n    parseResult\r\n      .then(result =\u003e {\r\n        setHtmlContent(result);\r\n        setIsLoading(false);\r\n      })\r\n      .catch(error =\u003e {\r\n        console.error('Error processing markdown:', error);\r\n        setHtmlContent(content);\r\n        setIsLoading(false);\r\n      });\r\n  }, [content]);\r\n\r\n  if (isLoading) {\r\n    return \u003cdiv className={className} {...rest}\u003e加载中...\u003c/div\u003e;\r\n  }\r\n  if (typeof content === 'string') {\r\n    return (\r\n      \u003cdiv\r\n        ref={tpmMdContainerRef}\r\n        dangerouslySetInnerHTML={{ __html: htmlContent }}\r\n        className={cn('tpm-markdown-container max-w-none', className)}\r\n        {...rest}\r\n      /\u003e\r\n    );\r\n  }\r\n  return content;\r\n}\r\n```\r\n\r\n我们完全可以把marked初始化`marked-katex-extension`等插件的代码写在jsx文件里，但为了让代码更清晰，我们不妨抽出一个文件`src\\lib\\marked.js`，专门处理marked的初始化：\r\n\r\n```js\r\nimport { marked } from 'marked';\r\nimport markedKatex from 'marked-katex-extension';\r\nimport { markedHighlight } from 'marked-highlight';\r\nimport hljs from 'highlight.js';\r\n\r\n// 为了简单，先使用 marked-highlight ，后面会改成古法手作 renderer\r\nmarked.use(\r\n  markedHighlight({\r\n    emptyLangClass: 'hljs',\r\n    langPrefix: 'hljs language-',\r\n    highlight(code, lang) {\r\n      const language = hljs.getLanguage(lang) ? lang : 'plaintext';\r\n      return hljs.highlight(code, { language }).value;\r\n    },\r\n  })\r\n);\r\n\r\nmarked.use(markedKatex({\r\n  throwOnError: false,\r\n}));\r\n\r\n/**\r\n * 注意：\r\n * 1. markdown 文本尽量不要以多余的空格开头，否则 marked 会将其解析为代码块，导致结果不合预期\r\n * 2. katex 格式的公式和前面的文本留一个空格，否则会报错\r\n * @param {string} _content markdown content\r\n * @returns {string} html or content\r\n */\r\nexport const processMarkdown = async (_content) =\u003e {\r\n  if (typeof _content !== 'string') {\r\n    return _content;\r\n  }\r\n  // _content 以 white spaces 开头时，marked 会将其解析为代码块，因此要 trim\r\n  const content = _content.trim();\r\n  // Handle both Promise and string return types from marked()\r\n  const result = marked(content);\r\n  const html = typeof result === 'string' ? result : await result;\r\n  return html;\r\n  // TODO: 接入 sanitize html 会导致 style 属性被吞，设置 allowedAttributes 未生效\r\n};\r\n\r\n```\r\n\r\n这里有一个悬而未决的问题：接入`sanitize-html`会导致 style 属性被吞，导致Katex出现样式错误。官方文档说设置`allowedAttributes`就行，但我设置以后没生效。我懒得研究这个了，先放着吧。\r\n\r\n## 【常规】数学教案生成方案探究：从抽象出搭积木的组件，到彻底Schema化\r\n\r\n我最初的想法是，先用DeepSeek直接生成第一个教案《椭圆的定义与性质》的HTML代码，接着将其改造为React代码，然后编写规范，让LLM根据规范生成其他数学教案的React组件和`config.jsx`。但我发现了不少痛点：\r\n\r\n1. LLM生成的教案和已有的教案代码采用完全不一样的代码规范、不一样的技术选型、不一样的CSS代码。要在一个单页应用中统一它们存在困难。\r\n2. 看似让LLM生成完整HTML代码的方案可以激发其创造力，但实测发现，LLM真的很懒，写东西语焉不详，也不怎么生成第一个教案完全没有的新东西、新模式。\r\n\r\n但我的幻想还没完全磨灭。我想着，抽象出一些组件，让LLM像搭积木一样搭出页面，也许能激发其创造力。于是有了[docs\\新课件提示词\\生成jsx.md](https://github.com/Hans774882968/teaching-plan-analytic-geometry/blob/main/docs/%E6%96%B0%E8%AF%BE%E4%BB%B6%E6%8F%90%E7%A4%BA%E8%AF%8D/%E7%94%9F%E6%88%90jsx.md)这个提示词文件，以及下面这些组件：\r\n\r\n```jsx\r\nimport Section from '@/component/teachingPlan/Section';\r\nimport KnowledgePoint from '@/component/teachingPlan/KnowledgePoint';\r\nimport Card from '@/component/teachingPlan/Card';\r\nimport Header from '@/component/teachingPlan/Header';\r\nimport LearningPartnerCard from '@/component/teachingPlan/LearningPartnerCard';\r\nimport Think from '@/component/teachingPlan/Think';\r\nimport Footer from '@/component/teachingPlan/Footer';\r\n```\r\n\r\n- @/src\\component\\teachingPlan\\Section.jsx ：包裹每个模块\r\n- @/src/component/teachingPlan/KnowledgePoint.jsx ：包裹一个知识点\r\n- @/src\\component\\teachingPlan\\Card.jsx ：可用于包裹各种内容\r\n- @/src\\component\\teachingPlan\\Header.jsx ：标题栏\r\n- @/src\\component\\teachingPlan\\LearningPartnerCard.jsx ：学习伙伴\r\n- @/src/component/teachingPlan/Think.jsx ：包裹一道思考题。该组件可出现在 KnowledgePoint 或 Card 中。如果在 Card 中，则它是 KnowledgePoint 的 sibling\r\n- @/src\\component\\teachingPlan\\Footer.jsx ：页脚\r\n\r\n但我发现，只要保留自由度，仍然允许LLM生成整个页面React组件，上面的痛点1和2就无法解决。我仍然需要手动更改大量代码。既然LLM在教案生成方面就是没啥创造力，我就想，干脆彻底Schema化，页面一律用标准页面生成算了。这样，LLM也能专注于内容，就像在生成用于Marp产出PPT文件的Markdown文档一样。\r\n\r\n于是有了：\r\n\r\n1. [docs\\新课件提示词\\生成schema.md](https://github.com/Hans774882968/teaching-plan-analytic-geometry/blob/main/docs/%E6%96%B0%E8%AF%BE%E4%BB%B6%E6%8F%90%E7%A4%BA%E8%AF%8D/%E7%94%9F%E6%88%90schema.md)\r\n2. 页面结构的类型描述：[src\\component\\teachingPlan\\StandardPageStructure.d.ts](https://github.com/Hans774882968/teaching-plan-analytic-geometry/blob/main/src/component/teachingPlan/StandardPageStructure.d.ts)\r\n3. 标准页面：[src\\component\\teachingPlan\\StandardPage.jsx](https://github.com/Hans774882968/teaching-plan-analytic-geometry/blob/main/src/component/teachingPlan/StandardPage.jsx)\r\n\r\n我决定先用提示词《生成schema.md》生成《平面向量的定义及其线性运算》课件的`src\\planeVectorDefinition\\config.jsx`，再看DeepSeek的反馈慢慢调整提示词。实验表明，这条路走得通，只需要依据DeepSeek欠考虑的点微调上述几个文档即可。TODO: 开发一个网页，方便地完成提示词的拼接。左边是编辑器，右边是预览区。仍然是用vite的虚拟模块实现。\r\n\r\n### 【常规】Vite的虚拟模块：在页面中展示提示词的Markdown文档\r\n\r\n我希望在我的网站展示项目用到的提示词。这是一类静态站点生成的需求，vitepress之类的框架都有这个能力，但我们在此想要一个足够轻量的方案。我通过搜索引擎已经了解到，Vite的**虚拟模块**借用了Vite的开发服务器的能力，可以达到类似于后端接口的效果。\r\n\r\n于是我问DeepSeek：“大佬，你是一名专家前端工程师，精通前端工程化。请叫我hans7。我有一个React+vite+react-router-dom+marked的项目，希望实现以下功能：在打包时能够读取一个本地文件系统的markdown文件的内容，如README.md，通过marked渲染，然后生成一个新的组件，这个组件的路由是/prompt-display。请问如何用自定义vite插件实现？”DeepSeek给了我完整代码，可惜它的代码不能跑，因为Vite的虚拟模块不支持JSX。所以我改了下代码，让虚拟模块仅返回markdown字符串。[`src\\plugins\\vite-plugin-prompt-display.js`](https://github.com/Hans774882968/teaching-plan-analytic-geometry/blob/main/src/plugins/vite-plugin-prompt-display.js)：\r\n\r\n```js\r\nimport fs from 'fs';\r\nimport path from 'path';\r\n\r\nconst genSchemaPromptPath = path.resolve(process.cwd(), 'docs', '新课件提示词', '生成schema.md');\r\nconst genSchemaRelativePath = path.relative(process.cwd(), genSchemaPromptPath);\r\nconst genJsxPromptPath = path.resolve(process.cwd(), 'docs', '新课件提示词', '生成jsx.md');\r\nconst genJsxRelativePath = path.relative(process.cwd(), genJsxPromptPath);\r\nconst promptFilePaths = [genSchemaPromptPath, genJsxPromptPath];\r\n\r\nfunction getEncodedPromptContent(filePath) {\r\n  const content = fs.readFileSync(filePath, 'utf-8');\r\n  return encodeURI(content);\r\n}\r\n\r\nexport default function promptDisplayPlugin() {\r\n  const virtualModuleId = 'virtual:prompt-display';\r\n  const resolvedVirtualModuleId = '\\0' + virtualModuleId;\r\n\r\n  return {\r\n    name: 'vite-plugin-prompt-display',\r\n    configureServer(server) {\r\n      // 监听提示词文件变化\r\n      promptFilePaths.forEach((promptFilePath) =\u003e {\r\n        server.watcher.add(promptFilePath);\r\n      });\r\n\r\n      // 文件变化时触发 HMR\r\n      server.watcher.on('change', (file) =\u003e {\r\n        if (!promptFilePaths.includes(file)) {\r\n          return;\r\n        }\r\n        // 1. 使虚拟模块缓存失效\r\n        const module = server.moduleGraph.getModuleById(resolvedVirtualModuleId);\r\n        if (module) {\r\n          server.moduleGraph.invalidateModule(module);\r\n        }\r\n\r\n        // 2. 通知客户端重新加载模块\r\n        server.ws.send({\r\n          type: 'full-reload',\r\n          path: '*',\r\n        });\r\n\r\n        console.log('[tpm] 📄 提示词文件更新', file);\r\n      });\r\n    },\r\n    resolveId(id) {\r\n      if (id === virtualModuleId) return resolvedVirtualModuleId;\r\n    },\r\n    load(id) {\r\n      if (id === resolvedVirtualModuleId) {\r\n        const genSchemaPromptContent = getEncodedPromptContent(genSchemaPromptPath);\r\n        const genJsxPromptContent = getEncodedPromptContent(genJsxPromptPath);\r\n\r\n        return `\r\n          export const genSchemaRelativePath = String.raw\\`${genSchemaRelativePath}\\`;\r\n          export const genJsxRelativePath = String.raw\\`${genJsxRelativePath}\\`;\r\n\r\n          export const genSchemaPrompt = decodeURI(\\`${genSchemaPromptContent}\\`);\r\n          export const genJsxPrompt = decodeURI(\\`${genJsxPromptContent}\\`);\r\n        `.trim();\r\n      }\r\n    },\r\n  };\r\n}\r\n```\r\n\r\n一些说明：\r\n\r\n1. 这个虚拟模块的格式挺标准的，适合教学。\r\n2. 之所以使用`decodeURI`是因为原始字符串有单引号、双引号之类的字符，无法直接拼接为JS代码，我们需要找一种最简单的方式转义这些字符。\r\n3. 这版代码和最新版代码功能一致，区别是最新版封装了两个函数。\r\n\r\n在React组件中，直接像import其他文件一样import即可：\r\n\r\n```js\r\nimport {\r\n  genSchemaPrompt,\r\n  genSchemaRelativePath,\r\n} from 'virtual:prompt-display';\r\n```\r\n\r\n查看打包产物可知，Markdown字符串会在构建阶段被完整地打包进JS文件。\r\n\r\n### 在网站中展示README\r\n\r\n类似地，我还在网站中展示了`README.md`，传送门：[`src\\plugins\\vite-plugin-readme-display.js`](https://github.com/Hans774882968/teaching-plan-analytic-geometry/blob/main/src/plugins/vite-plugin-readme-display.js)。我项目的README有图片，所以还需要手动把图片复制到`dist`文件夹。配置`rollup-plugin-copy`：\r\n\r\n```js\r\n import copy from 'rollup-plugin-copy';\r\n\r\n    copy({\r\n      targets: [\r\n        { src: 'README_assets', dest: 'dist' },\r\n      ],\r\n      hook: 'writeBundle',\r\n    }),\r\n```\r\n\r\n\r\n\r\n### 提示词缺陷修复技巧举例\r\n\r\n写一个**多次使用的模板提示词**（在本节中特指[`docs\\新课件提示词\\生成schema.md`](https://github.com/Hans774882968/teaching-plan-analytic-geometry/blob/main/docs/%E6%96%B0%E8%AF%BE%E4%BB%B6%E6%8F%90%E7%A4%BA%E8%AF%8D/%E7%94%9F%E6%88%90schema.md)）就像写代码一样，不太可能一次性就考虑到所有的细节。所以我们需要根据LLM的反馈，尤其是在“深度思考”过程中的反馈，优化自己的模板提示词。比如：\r\n\r\n一、我在生成《函数及其表示方法》课件时，看到DeepSeek在深度思考中输出：“我们在配置文件中只需要按照规范写类名，比如`\u003ch4 className={styles.teachingPlanH4}\u003e`，但是这里我们无法获取styles，所以实际上主组件会处理样式类。但是，为了类型正确，我们按照规范写类名字符串？不，这样不行。因为styles是一个模块，我们必须在配置文件中使用与主组件相同的样式模块。但配置文件是独立的，所以主组件在渲染时会传入styles。（省略更多废话）”就立刻意识到我忘记import styles了，而DeepSeek能力太差，推不出实际情况，需要补充：\r\n\r\n```jsx\r\nimport styles from '@/component/teachingPlan/basic.module.scss'; // 补上\r\n\u003ch4 className={styles.teachingPlanH4}\u003e\u003c/h4\u003e\r\n\u003ch5 className={styles.teachingPlanH5}\u003e\u003c/h5\u003e\r\n\u003ch6 className={styles.teachingPlanH6}\u003e\u003c/h6\u003e\r\n```\r\n\r\n二、发现DeepSeek没有输出`appletOnLoad`函数。一开始有点恼火，但后来注意到，DeepSeek在深度思考中输出：“具体的绘制命令在appletOnLoad中，但这里我们只写命令字符串，因为配置对象中的函数无法序列化，所以实际上在GeogebraItem中，config是一个普通的对象，而绘制命令我们将在描述中说明，或者在conclusion中分析。”\r\n\r\n后来我们在`docs\\Geogebra组件文档.md`中补充了“如何在JS Config中使用”一节，它也就知道如何实现`appletOnLoad`方法了。\r\n\r\n## 【常规】支持路由\r\n\r\n我有不止一个课件，所以这个项目自然要支持路由。支持路由挺常规的，跟往常一样`bun add react-router-dom`即可。主要需要注意改一下这句`k = e('/geogebra/web3d/');`（详见《geogebra的自托管解决方案》一节）。`src\\App.jsx`：\r\n\r\n```jsx\r\nimport { BrowserRouter as Router, Routes, Route } from 'react-router-dom';\r\nimport { HelmetProvider } from 'react-helmet-async';\r\n\r\nconst routes = [\r\n  { path: '/', element: \u003cTeachingPlanList /\u003e },\r\n  { path: '/plane-vector-definition', element: \u003cPlaneVectorDefinition /\u003e },\r\n  // ...\r\n  { path: '/404', element: \u003cNotFound /\u003e },\r\n  { path: '*', element: \u003cNotFound /\u003e },\r\n];\r\n\r\nfunction App() {\r\n  return (\r\n    \u003cHelmetProvider\u003e\r\n      \u003cRouter\u003e\r\n        \u003cLayout\u003e\r\n          \u003cRoutes\u003e\r\n            {\r\n              routes.map((route, index) =\u003e (\r\n                \u003cRoute\r\n                  key={route.path || `route-${index}`}\r\n                  path={route.path}\r\n                  element={route.element}\r\n                /\u003e\r\n              ))\r\n            }\r\n          \u003c/Routes\u003e\r\n        \u003c/Layout\u003e\r\n      \u003c/Router\u003e\r\n    \u003c/HelmetProvider\u003e\r\n  );\r\n}\r\n```\r\n\r\n\r\n\r\n## 【常规】AI生成教案网页场景下如何解决样式冲突\r\n\r\n我们先生成第一个教案网页，然后研究如何生成其他教案网页。这就有一个绕不过去的问题：如何解决样式冲突？我想到两条路：\r\n\r\n1. 尽量少更改AI生成的第一个教案网页，将其放入shadow dom。我尝试了一下，发现主要的困难在于geogebra。这条路也许能走通，但风险太高，**舍弃**。\r\n    1. 它的源码引用了`document.getElementById、document.querySelector`等方法。这些方法都会失效。这个是可解的，hook它们就行。\r\n    2. geogebra引入了5个CSS文件（可以用`document.querySelectorAll('link.ggw_resource')`拿到）。它们无法作用到shadow dom内部。这个也好处理，写段JS手动把它们插入到shadow dom里即可。\r\n    3. 处理上面两点了，就不再有大错误了，但还有一些隐蔽的报错。比如：`:root`指定的CSS变量无法引用到，导致设置坐标轴颜色的OK按钮失去背景色。修改`geogebra/css/bundles/bundle.css`的`:root`为`:root, :host`可解决。\r\n    4. 等式栏的每个条目左上角的三个点点击两次才能出现菜单栏。这个确实不懂怎么解了。\r\n2. 引入scss和css modules。手动或者让AI改好第一个教案网页。然后用第一个教案网页的代码生成开发规范，在后续生成其他教案网页时将开发规范一起输入到提示词里。最后我**选择**了这条路。\r\n\r\n附：如何hook `document.getElementById`：\r\n\r\n```js\r\nexport function getShadowRootWrap() {\r\n  return document.querySelector('.shadow-root-div')?.shadowRoot;\r\n}\r\n\r\nconst settings = {\r\n  showLog: false,\r\n};\r\n\r\nexport function hookGetEleById() {\r\n  settings.showLog \u0026\u0026 console.log('%c[teaching-plan-analytic-geometry] 正在 hook document.getElementById', 'background: #3a2e00; color: #e2c041; padding: 3px 6px;');\r\n  const originalGetEleById = document.getElementById;\r\n  document.getElementById = function (id) {\r\n    settings.showLog \u0026\u0026 console.log('[teaching-plan-analytic-geometry] document.getElementById matched param', id);\r\n    const shadowRootWrap = getShadowRootWrap();\r\n    if (!shadowRootWrap) {\r\n      return originalGetEleById.call(document, id);\r\n    }\r\n    const res = shadowRootWrap?.querySelector(`#${id}`);\r\n    return res || null;\r\n  };\r\n}\r\n```\r\n\r\n## 【常规】接入`shadcn-ui`组件库\r\n\r\n我发现这个项目还是需要一些表单组件的，所以我决定接入`shadcn-ui`组件库。我们已经配过Tailwind CSS了，根据官方文档，接下来大致要做：\r\n\r\n- 引入shadcn用到的几十个CSS变量\r\n- 引入`jsconfig.json`\r\n- 新建`components.json`\r\n\r\n引入CSS变量：我们不妨去 https://tweakcn.com/editor/theme 直接拿好看的主题，我选择的是`modern-minimal`，目前和我项目的蓝色主题比较配。\r\n\r\n`components.json`不需要特别配置，直接从官方文档复制粘贴，改下`css`和`components`属性就行。\r\n\r\n`jsconfig.json`可以理解为`tsconfig.json`在无TS的开发环境的简化版，可以方便IDE实现智能补全等特性，但shadcn的CLI也会用到，所以我们不得不配置一下。\r\n\r\n```json\r\n{\r\n  \"compilerOptions\": {\r\n    \"baseUrl\": \".\",\r\n    \"paths\": {\r\n      \"@/*\": [\r\n        \"src/*\"\r\n      ]\r\n    }\r\n  },\r\n  \"exclude\": [\r\n    \"node_modules\",\r\n    \"dist\"\r\n  ],\r\n  \"include\": [\r\n    \"src/**/*\",\r\n    \"tests/**/*\"\r\n  ]\r\n}\r\n```\r\n\r\n接下来可以直接尝试下载 21st.dev 这个网站提供的一些好看的组件：`bunx --bun shadcn@latest add \"https://21st.dev/r/originui/numbered-pagination\"`。\r\n\r\n## 【常规】`Think`组件\r\n\r\n效果：\r\n\r\n![4-Think组件效果.png](./README_assets/4-Think组件效果.png)\r\n\r\n这个组件唯一值得说的就是如何实现展开/收起的过渡动画。我们用一个变量记录是否展开：`const [isOpen, setIsOpen] = useState(false);`，然后用CSS transition或者`framer-motion`控制`max-height`的增减。\r\n\r\n```jsx\r\n\u003cmotion.div\r\n  className={styles.answerRow}\r\n  initial={{ maxHeight: 0, marginTop: '0' }}\r\n  animate={{\r\n    maxHeight: isOpen ? answerRowMaxHeight || '100px' : 0,\r\n    marginTop: isOpen ? '8px' : '0',\r\n  }}\r\n  transition={{ duration: 0.3 }}\r\n\u003e\r\n  \u003cMarkdownRenderer content={answer} /\u003e\r\n\u003c/motion.div\u003e\r\n```\r\n\r\n我们必须要指定一个具体的`max-height`，否则不会有动画。因此我选择让外界传入这个属性，然后手工调整。\r\n\r\n另外，上面设置的`marginTop`是为了照顾问题和答案之间的分割线。\r\n\r\n## 【困难】Markdown 代码块交互升级：展示行号、支持展开代码块、复制代码、下载代码\r\n\r\n呜呜呜，我确实后悔没有做足够多技术调研，一直没发现有`react-markdown`这个包，所以现在也只能将就用marked渲染Markdown。\r\n\r\n效果：\r\n\r\n![2-增强的Markdown代码块效果展示.png](./README_assets/2-增强的Markdown代码块效果展示.png)\r\n\r\n如果只使用`marked-highlight`插件，拿到的代码块就只展示了代码块。但我们看DeepSeek官网等人们熟悉的页面，里面的代码块都有行号，支持复制，支持展开代码等功能。我问LLM以及在互联网上搜，都没有搜到现成的解决方案，所以我认定这是要古法手作编程的。网上冲浪一小会以后，我觉得掘金的“展开代码块”的设计和`vitepress`的复制按钮的点击效果不错，因此决定把它们抄过来。\r\n\r\n### 【困难】不得不在React中写原生HTML代码\r\n\r\n我翻了`marked-highlight`的源码，发现它提供的自定义能力太弱了，所以我决定抛弃`marked-highlight`，自定义renderer实现（这其实也是marked-highlight的实现方式）。我们不妨把相关逻辑单独提取出一个文件，于是有了[`src\\lib\\hljsRenderer.js`](https://github.com/Hans774882968/teaching-plan-analytic-geometry/blob/main/src/lib/hljsRenderer.js)：\r\n\r\n```js\r\nimport hljs from 'highlight.js';\r\n\r\nfunction getLineNumbersHtml(lineLength) {\r\n  const lineNumbersCode = [...Array(lineLength)]\r\n    .map(\r\n      (_, index) =\u003e\r\n        `\u003cspan class=\"line-number\"\u003e${index + 1}\u003c/span\u003e\u003cbr\u003e`\r\n    )\r\n    .join('');\r\n\r\n  const lineNumbersWrapperCode = `\u003cdiv class=\"line-numbers-wrapper\" aria-hidden=\"true\"\u003e${lineNumbersCode}\u003c/div\u003e`;\r\n  return lineNumbersWrapperCode;\r\n}\r\n\r\nexport default {\r\n  code({ lang, text: code }) {\r\n    const language = hljs.getLanguage(lang) ? lang : 'plaintext';\r\n    const highlighted = hljs.highlight(code, { language }).value;\r\n\r\n    const dataCode = encodeURI(code);\r\n\r\n    const lineLength = code.split('\\n').length;\r\n    const lineNumbersHtml = getLineNumbersHtml(lineLength);\r\n\r\n    return `\r\n\u003cdiv class=\"code-block-wrapper\" data-language=\"${language}\" data-line-count=\"${lineLength}\"\u003e\r\n  \u003cdiv class=\"code-header\"\u003e\r\n    \u003cdiv class=\"header-left-part\"\u003e\r\n      \u003cdiv class=\"svg-chevron-down-wrapper\" title=\"展开代码块\"\u003e\u003c/div\u003e\r\n      \u003cspan class=\"language-tag\"\u003e${language}\u003c/span\u003e\r\n    \u003c/div\u003e\r\n    \u003cdiv class=\"header-right-part\"\u003e\r\n      \u003cbutton title=\"复制代码\" class=\"copy-button\" data-code=\"${dataCode}\"\u003e\u003c/button\u003e\r\n      \u003cdiv class=\"svg-download-wrapper\" title=\"下载代码\"\u003e\u003c/div\u003e\r\n    \u003c/div\u003e\r\n  \u003c/div\u003e\r\n  \u003cdiv class=\"code-body\"\u003e\r\n    ${lineNumbersHtml}\r\n    \u003cpre class=\"code-pre\"\u003e\u003ccode class=\"highlighted-code hljs language-${language}\"\u003e${highlighted}\u003c/code\u003e\u003c/pre\u003e\r\n  \u003c/div\u003e\r\n\u003c/div\u003e`.trim();\r\n  },\r\n};\r\n```\r\n\r\n在renderer里拼接一大段HTML确实挺难绷的，而且有XSS风险，但我确实没找到能在React中优雅地完成这件事的方案。可以看到我在上面设置了一个留空的`svg-chevron-down-wrapper`，这是为了方便JS插入“展开代码块”的svg图标。\r\n\r\n1. 为了给这些HTML代码加上CSS，我写了[`src\\styles\\code-block.scss`](https://github.com/Hans774882968/teaching-plan-analytic-geometry/blob/main/src/styles/code-block.scss)，供入口`main.jsx`调用。\r\n2. 为了实现复制代码等功能，我们需要写大段大段的原生JS。为此，我们写一个自定义hook（[`src\\hooks\\useCodeBlockSetup.js`](https://github.com/Hans774882968/teaching-plan-analytic-geometry/blob/main/src/hooks/useCodeBlockSetup.js)），供`MarkdownRenderer`组件调用。\r\n\r\n### `src\\hooks\\useCodeBlockSetup.js`的大致框架\r\n\r\n```js\r\nimport { useEffect, useRef } from 'react';\r\nimport ChevronDown from '@/assets/fa-chevron-down.svg';\r\nimport { CODE_BODY_INITIAL_MAX_HEIGHT } from '@/common/consts';\r\n\r\nexport default function useCodeBlockSetup() {\r\n  const tpmMdContainerRef = useRef(null);\r\n\r\n  useEffect(() =\u003e {\r\n    const codeBlockWrapperSetup = () =\u003e {\r\n      tpmMdContainerRef.current?.querySelectorAll('.code-block-wrapper')?.forEach((codeBlockWrapper) =\u003e {\r\n        // 设置 code-block-wrapper 的样式\r\n      });\r\n\r\n      tpmMdContainerRef.current?.querySelectorAll('.code-block-wrapper .svg-chevron-down-wrapper')?.forEach((svgChevronDownWrapper) =\u003e {\r\n        if (svgChevronDownWrapper.children.length) {\r\n          return;\r\n        }\r\n        // 插入展开代码块的 svg 图标、注册点击事件\r\n      });\r\n\r\n      tpmMdContainerRef.current?.querySelectorAll('.code-block-wrapper .copy-button')?.forEach((button) =\u003e {\r\n        // 注册点击事件\r\n      });\r\n    };\r\n\r\n    codeBlockWrapperSetup();\r\n\r\n    const setupObserver = new MutationObserver(codeBlockWrapperSetup);\r\n    tpmMdContainerRef.current \u0026\u0026 setupObserver.observe(tpmMdContainerRef.current, {\r\n      subtree: true,\r\n      childList: true,\r\n    });\r\n\r\n    return () =\u003e {\r\n      setupObserver.disconnect();\r\n    };\r\n  }, []);\r\n\r\n  return tpmMdContainerRef;\r\n}\r\n```\r\n\r\n在`useEffect`里用`MutationObserver`是基础操作了，而对`tpmMdContainerRef.current`的操作就是在管理一个`Markdown`块下所有的`code-block-wrapper`。\r\n\r\n`MarkdownRenderer`调用：\r\n\r\n```jsx\r\nconst tpmMdContainerRef = useCodeBlockSetup();\r\n\u003cdiv ref={tpmMdContainerRef} /\u003e\r\n```\r\n\r\n\r\n\r\n### 【常规】让`code-block-wrapper`的样式与代码块保持一致\r\n\r\n250811更新：该方案已被废弃，请参考《编写 postcss 插件：如何在 marked + highlight.js 中实现切换代码块风格》。\r\n\r\n我之前引用了`paraiso-light`主题，这个主题会设置代码块的背景色和默认文字颜色，所以我的`code-block-wrapper`需要拿到这个主题设置以后的CSS。这个活我认为只能用JS实现。\r\n\r\n```js\r\nconst codeNode = codeBlockWrapper.querySelector('.highlighted-code');\r\nconst codeNodeComputedStyle = getComputedStyle(codeNode, null);\r\nconst codeNodeBgColor = codeNodeComputedStyle.getPropertyValue('background-color');\r\nconst codeNodeColor = codeNodeComputedStyle.getPropertyValue('color');\r\ncodeBlockWrapper.style.backgroundColor = codeNodeBgColor;\r\ncodeBlockWrapper.style.color = codeNodeColor;\r\n```\r\n\r\n\r\n\r\n### 【常规】展开、收起代码块支持过渡动画\r\n\r\n这个可以直接挪用我之前的[Think组件](https://github.com/Hans774882968/teaching-plan-analytic-geometry/blob/main/src/component/teachingPlan/Think.jsx)的实现方案。我们让DeepSeek把motion转为原生CSS：\r\n\r\n```markdown\r\n大佬，你是一名专家前端工程师，精通前端工程化。请叫我hans7。我有一个react + framer-motion项目，有如下代码：\r\n请忽略其他代码，将motion.span和motion.div的initial、animate、transition属性翻译为css代码，类名分别叫.span和.div。\r\n```\r\n\r\n[`src\\styles\\code-block.scss`](https://github.com/Hans774882968/teaching-plan-analytic-geometry/blob/main/src/styles/code-block.scss)相关代码大致如下：\r\n\r\n```scss\r\n.code-body {\r\n  // ...\r\n  transition: max-height 0.3s ease;\r\n  max-height: var(--code-body-initial-max-height);\r\n\r\n  .line-numbers-wrapper {\r\n    // ...\r\n  }\r\n\r\n  .code-pre {\r\n    flex: 1;\r\n    overflow-x: auto;\r\n\r\n    .highlighted-code {\r\n      max-height: var(--code-body-initial-max-height);\r\n      // pre code.hljs 已经设置 overflow-x ，在此把 x 和 y 的都写上了\r\n      overflow-x: auto;\r\n      overflow-y: hidden;\r\n    }\r\n  }\r\n}\r\n```\r\n\r\n然后在JS中（[`src\\hooks\\useCodeBlockSetup.js`](https://github.com/Hans774882968/teaching-plan-analytic-geometry/blob/main/src/hooks/useCodeBlockSetup.js)）：\r\n\r\n```js\r\nconst img = document.createElement('img');\r\nimg.classList.add('svg-chevron-down-wrapper-img');\r\nimg.src = ChevronDown;\r\nimg.addEventListener('click', () =\u003e {\r\n  const hasExpanded = img.classList.contains('expanded');\r\n  img.classList.toggle('expanded');\r\n\r\n  svgChevronDownWrapper.title = hasExpanded ? '展开代码块' : '收起代码块';\r\n  const codeBody = targetCodeBlockWrapper.querySelector('.code-body');\r\n  if (!codeBody) return;\r\n  // 每行其实不到 28px ，但设置这个稍大的数并不太影响动画效果\r\n  const maxHeight = hasExpanded ? CODE_BODY_INITIAL_MAX_HEIGHT : (28 + targetCodeBlockWrapper.dataset.lineCount * 28);\r\n  codeBody.classList.toggle('expanded');\r\n  codeBody.style.maxHeight = `${maxHeight}px`;\r\n\r\n  const codeNode = codeBody.querySelector('.highlighted-code');\r\n  if (!codeNode) return;\r\n  codeNode.style.maxHeight = `${maxHeight}px`;\r\n});\r\nsvgChevronDownWrapper.appendChild(img);\r\n```\r\n\r\n### 【常规】复制按钮\r\n\r\n为了减轻工作量，我决定~~抄袭~~参考vitepress的源码。结合vitepress渲染出的HTML，不难定位到复制按钮的HTML位于[`src\\node\\markdown\\plugins\\preWrapper.ts`](https://github.com/vuejs/vitepress/blob/db58af5c66e563e7663084057a9853d8f2da984c/src/node/markdown/plugins/preWrapper.ts)，搜copied类名，不难定位到其CSS位于[`src\\client\\theme-default\\styles\\components\\vp-doc.css`](https://github.com/vuejs/vitepress/blob/a64334753079a5b874a482508d9ee255d2a0ea38/src/client/theme-default/styles/components/vp-doc.css)。\r\n\r\n复制按钮的样式：\r\n\r\n```scss\r\n.copy-button {\r\n  // 默认的 svg\r\n  background-image: url(\"svg1\");\r\n\r\n  \u0026.copied {\r\n    /*rtl:ignore*/\r\n    // 复制成功的 svg\r\n    background-image: url(\"svg2\");\r\n  }\r\n\r\n  // 复制成功时，展示“已复制”文字\r\n  \u0026.copied::before {\r\n    content: '已复制';\r\n  }\r\n}\r\n```\r\n\r\n为了拿到原汁原味的代码，我们并没有直接取`code`标签的`innerText`，而是给button添加一个`data-code`属性。所以相关代码如下：\r\n\r\n```js\r\nbutton.addEventListener('click', () =\u003e {\r\n  const code = decodeURI(button.dataset.code);\r\n  navigator.clipboard.writeText(code);\r\n  button.classList.add('copied');\r\n  // 模仿 vitepress 2s 后由“已复制”恢复原状\r\n  setTimeout(() =\u003e button.classList.remove('copied'), 2000);\r\n});\r\n```\r\n\r\n### 【常规】下载代码\r\n\r\n我们发现 [DeepSeek官网](https://chat.deepseek.com/) 的Markdown代码块有一个“下载”按钮，我们不妨也实现一个。[`src\\hooks\\useCodeBlockSetup.js`](https://github.com/Hans774882968/teaching-plan-analytic-geometry/blob/main/src/hooks/useCodeBlockSetup.js)：\r\n\r\n```js\r\ntpmMdContainerRef.current?.querySelectorAll('.code-block-wrapper')?.forEach((codeBlockWrapper) =\u003e {\r\n  const downloadWrapper = codeBlockWrapper.querySelector('.svg-download-wrapper');\r\n  if (downloadWrapper.children.length) {\r\n    return;\r\n  }\r\n\r\n  const img = document.createElement('img');\r\n  img.classList.add('svg-download-wrapper-img');\r\n  img.src = DownloadSvg;\r\n  img.addEventListener('click', () =\u003e {\r\n    downloadCode(codeBlockWrapper);\r\n  });\r\n  downloadWrapper.appendChild(img);\r\n});\r\n```\r\n\r\n`downloadCode`函数把字符串装进`Blob`里，然后创建`a`标签、设置`download`属性，属于模板了，已经刻在每一位前端工程师的DNA里。\r\n\r\n```js\r\nfunction downloadCode(codeBlockWrapper) {\r\n  const copyBtn = codeBlockWrapper.querySelector('.copy-button');\r\n  if (!copyBtn) {\r\n    console.error('[tpm] failed to find .copy-button');\r\n    return;\r\n  }\r\n  const language = codeBlockWrapper.dataset.language;\r\n  const extName = hljsLangToExtName(language);\r\n  const code = decodeURI(copyBtn.dataset.code);\r\n  const blob = new Blob([code], { type: 'text/plain' });\r\n  const downloadUrl = URL.createObjectURL(blob);\r\n  const a = document.createElement('a');\r\n  a.href = downloadUrl;\r\n  a.download = `tpm-${document.title}.${extName}`;\r\n  document.body.appendChild(a);\r\n  a.click();\r\n  setTimeout(() =\u003e {\r\n    document.body.removeChild(a);\r\n    URL.revokeObjectURL(downloadUrl);\r\n    toast.success('下载成功！');\r\n  }, 100);\r\n}\r\n```\r\n\r\n这里用到`sonner`包的`toast`实现消息提示，以及`hljsLangToExtName`函数获取正确的文件后缀名，我将在下面两个小节分别细讲。\r\n\r\n### 【常规】`sonner`包配置\r\n\r\n查[`sonner`官方文档](https://sonner.emilkowal.ski/styling)可知，可以用`style`配外层盒子的样式，用`classNames`写Tailwind CSS可以配置里面各个UI元素的样式。\r\n\r\n```jsx\r\n\u003cToaster\r\n  position='top-center'\r\n  toastOptions={{\r\n    style: {\r\n      color: 'white',\r\n      backgroundColor: 'var(--vivid-blue-bg)',\r\n      borderColor: '#4cc9f0',\r\n    },\r\n    classNames: {\r\n      title: '!font-(family-name:--tpm-font-family) !font-bold !text-base',\r\n    },\r\n  }}\r\n/\u003e\r\n```\r\n\r\nTailwind可以引用CSS变量，在`src\\index.css`配一下就行。\r\n\r\n### 【简单】`hljsLangToExtName`函数：将`highlight.js`的`language`信息转为恰当的文件后缀名\r\n\r\nDeepSeek给了我两个方案：\r\n\r\n- [language-map](https://github.com/blakeembrey/language-map)\r\n- [mime-types](https://github.com/jshttp/mime-types)\r\n\r\n前者不符合我需求，后者OK。[`src\\lib\\hljsLangToExtName.js`](https://github.com/Hans774882968/teaching-plan-analytic-geometry/blob/main/src/lib/hljsLangToExtName.js)：\r\n\r\n```js\r\nimport mimeTypes from 'mime-types';\r\n\r\nexport default function hljsLangToExtName(language) {\r\n  const contentType = mimeTypes.lookup(language) || 'txt';\r\n  const extName = mimeTypes.extension(contentType) || 'txt';\r\n  return extName;\r\n}\r\n```\r\n\r\n直接运行会遇到报错：`extname`未定义。稍微瞟一眼源码，不难发现是因为在浏览器环境引用了node环境的`path`包。`bun add -D vite-ping-plan-analytic-geometry`，然后配置一下`polyfill`即可：\r\n\r\n```js\r\nimport { nodePolyfills } from 'vite-plugin-node-polyfills';\r\n\r\nplugins: [\r\n  nodePolyfills(),\r\n]\r\n```\r\n\r\n### 【常规】引入`@tailwindcss/typography`，一键美化已渲染为HTML的博客\r\n\r\n照官方文档说的做就能轻松引入：\r\n\r\n```css\r\n/* src\\index.css */\r\n@plugin \"@tailwindcss/typography\";\r\n```\r\n\r\n然后在`src\\component\\MarkdownRenderer.jsx`里新增类名`prose dark:prose-invert`即可。但我希望自定义Markdown HTML中的一些元素，比如`blockquote`。完成这个需求最简单的做法是直接新开一个css文件，正常书写，覆盖typography的默认样式。[`src\\styles\\prose-overwrite.scss`](https://github.com/Hans774882968/teaching-plan-analytic-geometry/blob/main/src/styles/prose-overwrite.scss)：\r\n\r\n```scss\r\n.prose {\r\n  blockquote {\r\n    background-color: var(--blockquote-bg);\r\n    overflow: auto;\r\n\r\n    p {\r\n      margin: 10px 0;\r\n    }\r\n  }\r\n  // ...\r\n}\r\n```\r\n\r\n为了方便地更改`blockquote`的左边框颜色和背景色，我们需要新建一个`tailwind.config.js`，覆盖`typography`内置的变量值。\r\n\r\n```js\r\nmodule.exports = {\r\n  theme: {\r\n    extend: {\r\n      typography: () =\u003e ({\r\n        DEFAULT: {\r\n          css: {\r\n            '--tw-prose-quote-borders': 'var(--tpm-primary)',\r\n          },\r\n        },\r\n        invert: {\r\n          css: {\r\n            '--tw-prose-quote-borders': 'var(--tpm-primary-invert)',\r\n          },\r\n        },\r\n      }),\r\n    },\r\n  },\r\n  plugins: [require('@tailwindcss/typography')],\r\n};\r\n```\r\n\r\n然后还需要在`src\\index.css`导入：`@config \"../tailwind.config.js\";`。\r\n\r\n### 【困难】编写 postcss 插件：如何在 marked + highlight.js 中实现切换代码块风格\r\n\r\n回顾一下我之前设想的更新颜色的方式：`const codeNodeComputedStyle = getComputedStyle(codeNode, null);`，然后写入对应DOM元素的style属性。现在我希望实现切换代码块风格的能力，却悲痛地发现之前设想的做法不可行。只要我需要在切换时发起请求，那`getComputedStyle`的可行的调用时机就取决于网络请求了，这就导致我们就无法确定一个合理的`setTimeout`的延迟秒数。详见我之前记录的[废案文档：`docs\\样式类需求\\【废案】hljsTheme废案记录.md`](https://github.com/Hans774882968/teaching-plan-analytic-geometry/blob/main/docs/%E6%A0%B7%E5%BC%8F%E7%B1%BB%E9%9C%80%E6%B1%82/%E3%80%90%E5%BA%9F%E6%A1%88%E3%80%91hljsTheme%E5%BA%9F%E6%A1%88%E8%AE%B0%E5%BD%95.md)。\r\n\r\n后来我想到了一种不错的做法：把`highlight.js`提供的所有CSS代码都拿到，存在某个JS文件里，然后用`postcss`插件修改选择器为我们所期望的样子，最后把CSS代码插入到页面。这个方案的好处是，只要能看见网页，所有的CSS代码就都在本地，切换时不需要发起网络请求。但其坏处是，一个用户偏好的主题可能就那几种，但所有用户都被迫在看见网页前等待含有所有CSS代码的JS代码加载完毕。我看到`highlight.js`的CSS代码（我把它们存储在`src\\common\\hljsThemeCssText.js`）大概有115kb，感觉没有非常糟糕，决定就先这样做。\r\n\r\n首先，我们写一个[脚本：`src\\scripts\\generate-new-hljs-theme-css.js`](https://github.com/Hans774882968/teaching-plan-analytic-geometry/blob/main/src/scripts/generate-new-hljs-theme-css.js)生成所有需要的CSS代码。\r\n\r\n1. 同样使用了对键值对排序的技巧。在`highlight.js`更新后，需要再次运行脚本时，可以减少 git diff 信息。\r\n2. 我们看看这个脚本用到的一个我自己编写的postcss插件。这个插件需要把：\r\n\r\n```css\r\n.hljs {\r\n  background: #e7e9db;\r\n  color: #4f424c\r\n}\r\n```\r\n\r\n改写为：\r\n\r\n```css\r\n.code-block-wrapper, .hljs {\r\n  background: #e7e9db;\r\n  color: #4f424c\r\n}\r\n```\r\n\r\n但实测发现，并非所有主题的CSS格式都这么简单。比如`node_modules\\highlight.js\\styles\\nord.css`是这样的：\r\n\r\n```css\r\n.hljs {\r\n  background: #2E3440\r\n}\r\n.hljs,\r\n.hljs-subst {\r\n  color: #D8DEE9\r\n}\r\n```\r\n\r\n但不符合上述简单格式的CSS主题只有2个，出于对快速实现需求的考虑，我决定在运行postcss插件后，手动修改这两个CSS主题的代码，详见[`docs\\样式类需求\\hljsTheme.md`](https://github.com/Hans774882968/teaching-plan-analytic-geometry/blob/main/docs/%E6%A0%B7%E5%BC%8F%E7%B1%BB%E9%9C%80%E6%B1%82/hljsTheme.md)。\r\n\r\npostcss插件的调用方式如下：\r\n\r\n```js\r\nimport postcss from 'postcss';\r\n\r\nconst result = await postcss([\r\n  modifyHljsSelectorPlugin,\r\n]).process(cssContent, { from: undefined });\r\n```\r\n\r\n这个`modifyHljsSelectorPlugin`是一个没有参数的函数，并且需要设置`modifyHljsSelectorPlugin.postcss = true`。但为了优雅地收集我修改的选择器，我把它包了一层，变成了2阶函数，如下（[`src\\scripts\\modifyHljsSelectorPlugin.js`](https://github.com/Hans774882968/teaching-plan-analytic-geometry/blob/main/src/scripts/modifyHljsSelectorPlugin.js)）：\r\n\r\n```js\r\nexport default function getModifyHljsSelectorPlugin(onModify) {\r\n  const modifyHljsSelectorPlugin = () =\u003e {\r\n    // ...\r\n  };\r\n  modifyHljsSelectorPlugin.postcss = true;\r\n  return modifyHljsSelectorPlugin;\r\n}\r\n```\r\n\r\n插件的实现细节就不说啦，毕竟~~这是前端工程师的核心竞争力~~，比较简单，用DeepSeek生成（直接用网页版生成，会使用已经废弃的老写法），然后搜索引擎搜下最新用法，照着改即可。\r\n\r\n咳咳，还是简单说说。`Rule(rule)`方法让我们可以通过`rule.selector`拿到每一个选择器，直接对它赋值就能修改选择器。我们用`selectorParser`（`import selectorParser from 'postcss-selector-parser'`）拿到选择器的一些属性，筛选出只有`.hljs`的。然后用postcss遍历代码块的每一条声明：\r\n\r\n```js\r\nlet hasBackground = false, hasColor = false;\r\nrule.walkDecls(decl =\u003e {\r\n  if (decl.prop === 'background' || decl.prop === 'background-color') hasBackground = true;\r\n  if (decl.prop === 'color') hasColor = true;\r\n});\r\nif (!hasBackground || !hasColor) return;\r\n```\r\n\r\n就完成了整个判定工作。\r\n\r\n另外，我调试时发现主题`pojoaque.css`和`brown-paper.css`是有背景图片的，我们把代码复制到JS文件后，它们的背景URL的写法就需要改。最容易想到的办法就是引入一个占位符，在恰当时机更新。比如`pojoaque.css`：\r\n\r\n```css\r\n.code-block-wrapper, .hljs {\r\n  color: #dccf8f;\r\n  background: url(__VITE_BASE_PATH__/pojoaque.jpg) repeat scroll left top #181914\r\n}\r\n```\r\n\r\n我选择的更新时机是创建style标签插入页面时：\r\n\r\n```js\r\n// src\\hooks\\useHljsTheme.js\r\nfunction getThemeCssContent(actualHljsTheme) {\r\n  const cssContent = hljsThemeCssText[actualHljsTheme] || '';\r\n  const basePath = getWebsiteBasePath().slice(0, -1);\r\n  return cssContent.replaceAll('__VITE_BASE_PATH__', basePath);\r\n}\r\n\r\n// call\r\nstyleEl.textContent = getThemeCssContent(actualHljsTheme);\r\n```\r\n\r\n接下来我们把生成的CSS代码插入到页面：\r\n\r\n```jsx\r\n// src\\component\\layout\\SettingsDialog.jsx\r\nconst { hljsTheme, setHljsTheme } = useSettingsStore();\r\n\u003cdiv className={widthClassName}\u003e\r\n  \u003cTpmCombobox\r\n    value={hljsTheme}\r\n    setValue={setHljsTheme}\r\n    options={hljsThemeOptions}\r\n  /\u003e\r\n\u003c/div\u003e\r\n```\r\n\r\n然后我们直接在所有页面都引入一个副作用：\r\n\r\n```js\r\n// src\\component\\layout\\Layout.jsx\r\nexport default function Layout({ children }) {\r\n  useHljsTheme();\r\n  // ...\r\n}\r\n```\r\n\r\n为优雅起见，我们不妨封装一个自定义hook（`src\\hooks\\useHljsTheme.js`），这个hook做的事很简单：读取`const { hljsTheme } = useSettingsStore()`并插入一个style标签。\r\n\r\n## 用插值算法确定网站的配色\r\n\r\n作为一名前端工程师，没有设计基础，确定网站的配色这件事还是有点伤脑筋。\r\n\r\n- 如果项目用到`antd`，那么不太需要思考这个问题，选背景色和字体就OK。\r\n- 如果项目用到`shadcn-ui`，那么可以去 https://tweakcn.com/ 找现成的主题。\r\n\r\n我的项目没有用以上组件库，但我们已经进入LLM时代，不妨借助LLM确定初始的网站样式风格。我目前采用的思路是：先让DeepSeek生成一个网站，看着样式OK，就用它的配色和字体了。之后再用**插值算法**确定一些配色的细节。\r\n\r\nDeepSeek给我生成了一个天蓝色（`b = RGB(178, 222, 236)`）背景的HTML，我的目标是确定导航栏等部分的配色。LLM生成的配色一般都是大渐变，我只要确定渐变的两端即可。我在DeepSeek的输出中找到了一种更鲜艳的天蓝色`#4cc9f0`，这个就作为起点，然后用下面这段代码在两者之间取点作为终点即可：\r\n\r\n```js\r\nfunction getGradientColor(color1, color2, percent) {\r\n  // HEX转RGB函数\r\n  const hexToRgb = hex =\u003e\r\n    hex.replace(/^#?([a-f\\d])([a-f\\d])([a-f\\d])$/i, (_, r, g, b) =\u003e '#' + r + r + g + g + b + b)\r\n      .substring(1).match(/.{2}/g)\r\n      .map(x =\u003e parseInt(x, 16));\r\n\r\n  // RGB转HEX函数\r\n  const rgbToHex = (r, g, b) =\u003e\r\n    '#' + [r, g, b].map(x =\u003e x.toString(16).padStart(2, '0')).join('');\r\n\r\n  const [r1, g1, b1] = hexToRgb(color1);\r\n  const [r2, g2, b2] = hexToRgb(color2);\r\n  const ratio = percent / 100;\r\n\r\n  const r = Math.round(r1 + (r2 - r1) * ratio);\r\n  const g = Math.round(g1 + (g2 - g1) * ratio);\r\n  const b = Math.round(b1 + (b2 - b1) * ratio);\r\n\r\n  return rgbToHex(r, g, b);\r\n}\r\n\r\n// 使用示例（获取50%位置的颜色）\r\nconst midColor = getGradientColor('#4CA1A3', '#82CDE6', 50);\r\nconsole.log(midColor); // 输出 #67b7c5\r\n```\r\n\r\n这段代码也不需要自己写，只需要问DeepSeek要：“大佬，如何获得CSS线性渐变的两个颜色之间的某个颜色值？以background: linear-gradient(135deg, #4CA1A3 0%, #82CDE6 100%);为例。”\r\n\r\n## 字体选择\r\n\r\n效果：\r\n\r\n![3-字体展示.png](./README_assets/3-字体展示.png)\r\n\r\n我选择在body标签设置默认字体为“站酷快乐体”，标题标签h1到h6我则选择了“荆南波波黑”（来源都是： https://zhuanlan.zhihu.com/p/690446851/ ）。我觉得这两个字体的颜值都不错，但荆南波波黑默认就是加粗的，再设置加粗就会太拥挤了，所以我没有把它设为默认字体，而是仅用在标题。\r\n\r\n`public\\chinese-fonts.css`：\r\n\r\n```css\r\n/* 来源： https://zhuanlan.zhihu.com/p/690446851/ */\r\n\r\n@font-face {\r\n  font-family: 'Jing Nan Bo Bo Hei';\r\n  font-display: swap;\r\n  src: url(jing-nan-bo-bo-hei-bold.ttf);\r\n}\r\n\r\n@font-face {\r\n  font-family: 'Zhan Ku Kuai Le Ti';\r\n  font-display: swap;\r\n  src: url(zhan-ku-kuai-le-ti.ttf);\r\n}\r\n```\r\n\r\n`index.html`：\r\n\r\n```html\r\n\u003clink\r\n  href=\"/chinese-fonts.css\"\r\n  rel=\"stylesheet\"\r\n\u003e\r\n```\r\n\r\n使用：\r\n\r\n```scss\r\nbody {\r\n  font-family: 'Zhan Ku Kuai Le Ti', 'Fredoka One', sans-serif;\r\n}\r\n\r\n.teachingPlanH1,\r\n.teachingPlanH2,\r\n.teachingPlanH3,\r\n.teachingPlanH4,\r\n.teachingPlanH5,\r\n.teachingPlanH6 {\r\n  font-family: 'Jing Nan Bo Bo Hei', 'Fredoka One', cursive;\r\n  font-weight: bold;\r\n  color: var(--text-dark);\r\n}\r\n```\r\n\r\n## 【困难】类似于组件库文档展示部分源码的需求：在课件中展示GeoGebra的`appletOnLoad`的代码\r\n\r\n我的所有课件的`appletOnLoad`方法都是写在配置文件里的，因此我最开始采用了非常简单粗暴的实现（`src\\component\\teachingPlan\\StandardPage.jsx`）：\r\n\r\n```jsx\r\nfunction Inner({ config }) {\r\n  // ...\r\n  const appletOnLoadCodeBlockList = useMemo(() =\u003e {\r\n    return config.geogebraSection.geogebraList.map((geogebra) =\u003e {\r\n      const appletOnLoadSrcCode = geogebra.config.appletOnLoad.toString();\r\n      const appletOnLoadFormattedCode = js_beautify(appletOnLoadSrcCode, JS_BEAUTIFY_OPTIONS);\r\n      const res = `\r\n\\`\\`\\`js\r\n${appletOnLoadFormattedCode}\r\n\\`\\`\\`\r\n`;\r\n      return res;\r\n    });\r\n  }, [config]);\r\n  // ...\r\n}\r\n```\r\n\r\n这个实现的问题是，开发阶段能拿到源码，但构建阶段源码被压缩了，信息丢失了。因此我打算写一个nodejs脚本（传送门：[`src\\scripts\\geogebra-src-code-collect.js`](https://github.com/Hans774882968/teaching-plan-analytic-geometry/blob/main/src/scripts/geogebra-src-code-collect.js)），用Babel读取这些配置文件，找到这些代码后把它们收集起来，放到一个叫`src\\appletOnLoadCollection.js`的新文件里。简单总结一下想达到的效果：\r\n\r\n1. 收集所有`appletOnLoad`函数，生成`src\\appletOnLoadCollection.js`。它是一个哈希表。\r\n2. 为了让`StandardPage.jsx`方便拿到代码，脚本需要自动修改课件配置文件，加上访问key（我命名为`appletOnLoadId`）。并且我们希望修改得尽量少，让git diff更好看。\r\n3. 监听课件配置文件的更改，自动执行以上流程。\r\n\r\n这个自然不可能古法手作，当然是让DeepSeek生成。我们最终确定的技术栈如下：\r\n\r\n1. 用Babel提取信息，但不能直接使用`@babel/generator`重新生成代码，因为会有大量git diff。我们需要以行或字符为单位，直接进行字符串拼接。为此，我们不得不依赖**文件的特定格式**，比如：每个键值对都单独占一行，且每个属性后都有逗号。幸好我们已经配置了eslint。\r\n2. 生成`src\\appletOnLoadCollection.js`时，我们不妨对键排序，以减少git diff。\r\n3. 用`glob`定位所有的课件配置文件，用`chokidar`监听文件修改。\r\n\r\n代码总共有300多行，就不完整贴出来了。有兴趣的同学可查看：[`src\\scripts\\geogebra-src-code-collect.js`](https://github.com/Hans774882968/teaching-plan-analytic-geometry/blob/main/src/scripts/geogebra-src-code-collect.js)。\r\n\r\n最后还需要配个命令：\r\n\r\n```json\r\n\"dev\": \"concurrently -n 'geog,vite' 'bun watch:geogebra' 'bunx --bun vite'\",\r\n\"watch:geogebra\": \"bun src/scripts/geogebra-src-code-collect.js\",\r\n```\r\n\r\n我们希望同时执行vite命令和`watch:geogebra`命令，所以需要下一个叫`concurrently`的包：`bun add -D concurrently`。\r\n\r\n### vitest单测\r\n\r\n给DeepSeek的提示词：\r\n\r\n```markdown\r\n大佬，你是一名专家前端工程师，精通前端工程化。请叫我hans7。我有一个react + vite项目，有src\\scripts\\geogebra-src-code-collect.js代码如下：\r\n\r\n请编写里面除main函数以外的所有函数的单元测试。注意：框架为vitest。\r\n```\r\n\r\n它生成的代码挺敷衍的，但修改一下勉强能用。传送门：[`tests\\geogebra-src-code-collect.test.js`](https://github.com/Hans774882968/teaching-plan-analytic-geometry/blob/main/tests/geogebra-src-code-collect.test.js)。\r\n\r\n如何mock模块：\r\n\r\n```js\r\nvi.mock('fs');\r\nvi.mock('path');\r\nvi.mock('glob');\r\nvi.mock('chokidar', () =\u003e ({\r\n  default: {\r\n    watch: vi.fn().mockImplementation(() =\u003e {\r\n      const ret = {};\r\n      ret.on = vi.fn().mockImplementation(() =\u003e {\r\n        return ret;\r\n      });\r\n      return ret;\r\n    }),\r\n  },\r\n}));\r\nvi.mock('js-beautify', () =\u003e ({\r\n  js_beautify: vi.fn().mockImplementation(code =\u003e `beautified(${code})`),\r\n}));\r\n\r\n  // describe 中：\r\n  beforeEach(() =\u003e {\r\n    vi.clearAllMocks();\r\n  });\r\n```\r\n\r\n如何在VSCode中调试vitest：[参考链接2](https://cn.vitest.dev/guide/debugging)。打开一个新的`JavaScript Debug Terminal`，然后正常执行`bun run test`即可。\r\n\r\n### 250729：如何解决配置`VITE_DEPLOY_TARGET`环境变量后，vitest单测运行失败的问题\r\n\r\n我问DeepSeek的Prompt：\r\n\r\n```markdown\r\n大佬，我执行`bun run test`时发现，测试用例运行之前会运行vite.config.js，而它引用了`import.meta.env.VITE_DEPLOY_TARGET`环境变量，导致报错：\r\n\r\nfailed to load config from teaching-plan-analytic-geometry\\vite.config.js\r\n\r\nTypeError: Cannot read properties of undefined (reading 'VITE_DEPLOY_TARGET')\r\n\r\n请问如何解决？\r\n```\r\n\r\n它给的解决方案大都不靠谱，但我选择了其中一个：新建一个空的`vitest.config.js`，亲测OK。\r\n\r\n```js\r\nimport { defineConfig } from 'vitest/config';\r\nimport path from 'path';\r\n\r\nexport default defineConfig({\r\n  test: {\r\n    // ... Specify options here.\r\n  },\r\n  // 应和 vite.config.js 配的一样\r\n  resolve: {\r\n    alias: {\r\n      '@': path.resolve(__dirname, './src'),\r\n    },\r\n  },\r\n});\r\n```\r\n\r\n### 在vitest中如何测试组件\r\n\r\n安装相关的包：\r\n\r\n```bash\r\nbun add -D @testing-library/react @vitejs/plugin-react jsdom @testing-library/user-event\r\n```\r\n\r\n修改`vitest.config.js`配置：\r\n\r\n```js\r\nimport { defineConfig } from 'vitest/config';\r\nimport react from '@vitejs/plugin-react';\r\n// ...\r\n\r\nexport default defineConfig({\r\n  plugins: [\r\n    react(),\r\n  ],\r\n  test: {\r\n    environment: 'jsdom',\r\n    setupFiles: 'tests/rtlInVitest.setup.js',\r\n  },\r\n  // ...\r\n});\r\n```\r\n\r\n这个 setup 文件不能省，否则运行会说不存在`Chai`（另一个测试框架）属性之类的。`tests\\rtlInVitest.setup.js`：\r\n\r\n```js\r\nimport { afterEach } from 'vitest';\r\nimport { cleanup } from '@testing-library/react';\r\nimport '@testing-library/jest-dom/vitest';\r\n\r\n// 全局设置清理函数，避免每个测试文件手动清理\r\nafterEach(() =\u003e {\r\n  cleanup();\r\n});\r\n```\r\n\r\n### 踩坑：不建议使用`fireEvent`，建议使用`userEvent`\r\n\r\n我在写`pagination-with-toolbar`这个组件的单测用例时，有如下代码（`tests\\pagination-with-toolbar.test.jsx`）：\r\n\r\n```jsx\r\nvi.mock('@/component/ui/select', () =\u003e ({\r\n  Select: vi.fn(({ children, defaultValue, onValueChange }) =\u003e (\r\n    \u003cselect data-testid=\"select\" defaultValue={defaultValue} onChange={(e) =\u003e {\r\n      onValueChange(e.target.value);\r\n    }}\u003e\r\n      {children}\r\n    \u003c/select\u003e\r\n  )),\r\n  SelectContent: vi.fn(({ children }) =\u003e children),\r\n  SelectItem: vi.fn(({ value, children }) =\u003e \u003coption value={value}\u003e{children}\u003c/option\u003e),\r\n  SelectTrigger: vi.fn(({ children }) =\u003e children),\r\n  SelectValue: vi.fn(() =\u003e 'Select Value Placeholder'),\r\n}));\r\n```\r\n\r\n然后用如下代码触发选中事件，期望执行上面的`onValueChange`方法：\r\n\r\n```js\r\nimport { fireEvent } from '@testing-library/react';\r\n\r\nconst select = screen.getByTestId('select');\r\nfireEvent.change(select, { target: { value: '25' } });\r\n```\r\n\r\n却发现，`e.target.value`始终都只能拿到空字符串。我问了DeepSeek两次，第一次它建议我手动改`select.value = '20'`。但我发现`select`是一个Proxy，改不了。我就问它第二次，它这次告诉我可以改用`@testing-library/user-event`包。我试了试，果然OK了。相关代码：\r\n\r\n```js\r\nimport userEvent from '@testing-library/user-event';\r\n\r\nit('changes items per page and resets to page 1', async () =\u003e {\r\n  const setItemsPerPage = vi.fn();\r\n  const onPageChange = vi.fn();\r\n\r\n  render(\r\n    \u003cPaginationWithToolbar\r\n      {...defaultProps}\r\n      setItemsPerPage={setItemsPerPage}\r\n      onPageChange={onPageChange}\r\n    /\u003e\r\n  );\r\n\r\n  const select = screen.getByTestId('select');\r\n\r\n  await userEvent.selectOptions(select, '20');\r\n\r\n  expect(setItemsPerPage).toHaveBeenCalledWith('20');\r\n  expect(onPageChange).toHaveBeenCalledWith(1);\r\n\r\n  await userEvent.selectOptions(select, '50');\r\n\r\n  expect(setItemsPerPage).toHaveBeenCalledWith('50');\r\n  expect(onPageChange).toHaveBeenCalledWith(1);\r\n});\r\n```\r\n\r\n## 【常规】实现博客列表和博客详情页\r\n\r\n我最近刷到一些讲数学题的B站视频，又想起了自己高中当做题家的峥嵘岁月，于是抽空闲时间在obsidian写了些笔记。所以我希望在这个项目实现一些页面，把这些数学博客展示出来，并且和课件，以及GeoGebra纯享等模块关联起来。我这些数学博客会充分使用LLM、GeoGebra等工具，实现快速编写和足够硬核的平衡。\r\n\r\n### 虚拟模块：`virtual:blog-data`\r\n\r\n按常规的前后端项目，展示博客是需要一个后端的。但这个项目是纯前端项目，因此我打算写一个虚拟模块来CRUD博客的一些属性。在古法手作编程的时代，我会直接投降，启动后端项目。但现在有LLM的加持，我认为这个工作量是可以接受的。我打算在前端展示博客的创建时间、最近修改时间、标签，相关的`front-matter`示例如下：\r\n\r\n```yaml\r\ntags:\r\n  - 正用韦达定理\r\n  - 分类讨论\r\n  - 圆锥曲线硬解定理\r\n  - 待定系数法\r\ntitle: 存在过椭圆右焦点的直线，使得OA⊥OB，求离心率范围\r\nctime: '1754292981127'\r\nctime_f: '2025-08-04 15:36:21'\r\nmtime: '1754587593215'\r\nmtime_f: '2025-08-08 01:26:33'\r\n```\r\n\r\n[完整代码传送门：`src\\plugins\\vite-plugin-blog-data.js`](https://github.com/Hans774882968/teaching-plan-analytic-geometry/blob/main/src/plugins/vite-plugin-blog-data.js)\r\n\r\n我是用DeepSeek生成的主体代码，但它没有我项目的上下文信息，生成代码的很多细节不是很好，我还是手动改了不少。和前面提到的两个虚拟模块不一样的是，我们这次采用了之前用过的`chokidar`来监听博客文件变化：\r\n\r\n```js\r\nconst blogsDir = path.resolve(process.cwd(), 'docs', 'blogs');\r\n      \r\nconst watcher = chokidar.watch(blogsDir, {\r\n  ignored: (path, stats) =\u003e {\r\n    return stats?.isFile() \u0026\u0026 !path.endsWith('.md');\r\n  },\r\n  ignoreInitial: true,\r\n  persistent: true,\r\n});\r\n\r\n// 文件变化时触发 HMR\r\nwatcher\r\n  .on('add', (filePath) =\u003e {\r\n    console.log(`[${virtualModuleId}] File added: ${filePath}`);\r\n    serverNotifyReload(server, resolvedVirtualModuleId);\r\n  })\r\n  .on('change', (filePath) =\u003e {\r\n    console.log(`[${virtualModuleId}] File changed: ${filePath}`);\r\n    serverNotifyReload(server, resolvedVirtualModuleId);\r\n  })\r\n  .on('unlink', (filePath) =\u003e {\r\n    console.log(`[${virtualModuleId}] File removed: ${filePath}`);\r\n    serverNotifyReload(server, resolvedVirtualModuleId);\r\n  });\r\n```\r\n\r\n另外，为了读取博客的`front-matter`信息，我们需要一个叫`gray-matter`的包。用它读写`front-matter`信息都很方便。读信息：\r\n\r\n```js\r\nimport matter from 'gray-matter';\r\nconst { data, content } = matter(rawContent);\r\nconst title = data.title || path.basename(mdFile, '.md');\r\n```\r\n\r\n写信息：\r\n\r\n```js\r\nconst newContent = matter.stringify(content, newFrontmatter, {\r\n  language: 'yaml',\r\n  delimiters: '---',\r\n});\r\n```\r\n\r\n这个stringify方法有时候会导致我的日期变为Date对象直接toString的格式，这个小bug我暂时懒得处理。\r\n\r\n### 自动为博客添加Markdown格式的标题，以及`ctime, mtime`\r\n\r\n[完整代码：`src\\scripts\\blog-metadata-processor.js`](https://github.com/Hans774882968/teaching-plan-analytic-geometry/blob/main/src/scripts/blog-metadata-processor.js)\r\n\r\n这个脚本我是在Claude Code Cli让Kimi K2生成的（提示词：`docs\\blog功能开发\\自动添加title、ctime等属性.md`），但它生成的代码质量确实不得行，连用`gray-matter`读写`front-matter`信息都不知道，相比之下DeepSeek的表现就好得多。\r\n\r\n为了解决递归更新的问题，我又修改脚本，让它生成了个cache文件，保存每个文件的文本，仅在不包括`front-matter`信息的文件内容变化时更新修改时间。这个cache文件不会被打包进产物，就无伤大雅。这个cache文件一样要先对键值对排序再写入，这样可以让 git diff 更少。\r\n\r\n### 列表页常见的筛选能力\r\n\r\n既然有博客列表页，那就必须有筛选能力，这事做B端的同学都很熟悉了。和普通的前后端项目（前端给后端发送接口，带`pageno`和`count`参数）不同，这是个纯前端项目，所以我是通过截取一个完整数组的方式来实现分页的。我看shadcn并没有提供antd风格的分页组件，所以我自己写了一个。相关代码：\r\n\r\n- [`src\\component\\ui\\numbered-pagination.jsx`](https://github.com/Hans774882968/teaching-plan-analytic-geometry/blob/main/src/component/ui/numbered-pagination.jsx) ：结合 https://21st.dev/originui/numbered-pagination/default 和 https://21st.dev/originui/joined-pagination/default 魔改的\r\n- [`src\\component\\ui\\pagination-with-toolbar.jsx`](https://github.com/Hans774882968/teaching-plan-analytic-geometry/blob/main/src/component/ui/pagination-with-toolbar.jsx) ：antd 风格的 Pagination 组件。可以自定义每页显示条数\r\n- [相关单测：`tests\\pagination-with-toolbar.test.jsx`](https://github.com/Hans774882968/teaching-plan-analytic-geometry/blob/main/tests/pagination-with-toolbar.test.jsx)\r\n\r\n因为这是个受控组件（我确实感觉让它支持非受控模式比较麻烦），即`setCurrentPage`要由外部提供，所以每个筛选项生效都需要额外调用`setCurrentPage`，所以我为每个筛选项都写了一个方法，比如（`src\\mathBlog\\BlogList.jsx`）：\r\n\r\n```js\r\nconst [ctimeRange, setCtimeRange] = useState({\r\n  from: undefined,\r\n  to: undefined,\r\n});\r\n\r\nconst onTagChange = (tag) =\u003e {\r\n  setTagFilter(tag);\r\n  setCurrentPage(1);\r\n};\r\n\r\nconst handleCtimeRangeChange = (range) =\u003e {\r\n  setCtimeRange(range);\r\n  setCurrentPage(1);\r\n};\r\n```\r\n\r\n### 特殊的筛选项： `ctime, mtime`\r\n\r\n我不满足于只支持到一天的时间筛选，所以查了下，找到[这个组件](https://21st.dev/openstatusHQ/datetime-picker/default)，但我要的是时间范围筛选，所以我结合这个组件和shadcn的date-picker，魔改了一下。相关代码：\r\n\r\n- [`src\\component\\ui\\datetime-range-picker.jsx`](https://github.com/Hans774882968/teaching-plan-analytic-geometry/blob/main/src/component/ui/datetime-range-picker.jsx) ：我魔改的组件，支持时间范围筛选。因为shadcn的`Calendar`组件只支持传入`mode=\"range\"`参数，所以我们只能把两个`TimePicker`放在`Calendar`下方的同一个div，并保证它们的总宽度比`Calendar`小，做出那种看上去像是两个相同的由日历+`TimePicker`构成的组件的感觉。\r\n- [`src\\component\\ui\\datetime-picker.jsx`](https://github.com/Hans774882968/teaching-plan-analytic-geometry/blob/main/src/component/ui/datetime-picker.jsx) ：只能选时间\r\n\r\n最后成功让用法足够简单：\r\n\r\n```jsx\r\nconst [mtimeRange, setMtimeRange] = useState({\r\n  from: undefined,\r\n  to: undefined,\r\n});\r\n\r\n\u003cDatetimeRangePicker\r\n  date={mtimeRange}\r\n  setDate={handleMtimeRangeChange}\r\n/\u003e\r\n```\r\n\r\n## 【常规】设置对话框\r\n\r\n我一开始在 21st.dev 上看到了一个更为酷炫的[带动画的对话框](https://21st.dev/aceternity/animated-modal/default)，想接入它。但发现它一直有一个bug，就是它始终在下拉框的下拉菜单的上面。我很长时间没找到修它的办法，问LLM，调`z-index`之类的手段都失败，决定放弃，回去使用shadcn的标准对话框。但是标准对话框没有动画，比较生硬。我在shadcn的官方文档打开F12排查发现，官方文档用的是Tailwind 3.x，所以它的对话框有动画，而我的项目用的是4.x，一些相关的CSS变量没有定义，不知道是被废弃了还是怎么滴，官方文档也没说，所以没有动画。于是Kimi K2帮我生成了一些相关变量：\r\n\r\n```css\r\n/* src\\index.css */\r\n@theme inline {\r\n  /* Animation variables polyfill for Tailwind 3.x (shadcn uses) */\r\n  --animate-in: animate-in 0.15s ease-out;\r\n  --animate-out: animate-out 0.15s ease-in;\r\n  /* ... */\r\n}\r\n\r\n/* src\\styles\\animate-polyfill.css */\r\n@keyframes animate-in {\r\n  from {\r\n    opacity: 0;\r\n    transform: scale(0.95);\r\n  }\r\n\r\n  to {\r\n    opacity: 1;\r\n    transform: scale(1);\r\n  }\r\n}\r\n```\r\n\r\n将就用着先吧。\r\n\r\n### 设置对话框的每个设置项都需要存储到`localStorage`\r\n\r\n这个需求我在使用React的开源项目里也做过两三次了。我目前觉得最趁手的还是`zustand`，同时支持状态管理和写入`localStorage`，API通俗易懂，LLM也能轻松生成。\r\n\r\n## 【常规】移动端适配\r\n\r\n移动端适配是个没啥技术含量的杂活，但是看到自己的网站在移动端的显示效果不好，想要优化它的强迫症就会发作。我们现在有Tailwind CSS和LLM，做移动端适配会比以前容易一些。一般来说，在LLM时代做移动端适配有两种方式：\r\n\r\n1. 在需求开发时就顺便让LLM生成移动端适配的代码\r\n2. 在开发的代码积累到一定量级后，集中做移动端适配\r\n\r\n具体选哪个，我认为要一事一议。如果你用的是AI开发平台，比如cursor、Claude Code Cli，因为LLM有你项目的上下文，那么我认为一般来说第一种方式会更好。如果你不想付费上班，用的网页免费版LLM，比如我这个项目，那么我认为第二种方式更可能更好。\r\n\r\n最常规的移动端适配就是写media query（下面代码来自`src\\component\\teachingPlan\\basic.module.scss`）：\r\n\r\n```css\r\n.container {\r\n  padding: 24px;\r\n  /* ... */\r\n  gap: 24px;\r\n}\r\n\r\n@media (min-width: 48rem) {\r\n  .container {\r\n    padding: 30px;\r\n    gap: 30px;\r\n  }\r\n}\r\n\r\n@media (min-width: 64rem) {\r\n  .container {\r\n    padding: 30px 100px;\r\n  }\r\n}\r\n```\r\n\r\n我比较认可Tailwind的理念，所以media query的写法刻意与Tailwind保持一致。用Tailwind CSS做移动端适配则更简单。以以下代码为例：`className: 'w-50 sm:w-70 md:w-90'`表示，在最小的屏幕里宽度是200px，大于等于640px宽度是280px，大于等于768px宽度是360px。可以看到，Tailwind是移动端优先的，默认样式作用于屏幕最小的移动端。\r\n\r\n### 导航栏变汉堡菜单\r\n\r\n效果：\r\n\r\n![6-移动端适配-导航栏.png](./README_assets/6-移动端适配-导航栏.png)\r\n\r\n[完整代码传送门：`src\\component\\layout\\Navbar.jsx`](https://github.com/Hans774882968/teaching-plan-analytic-geometry/blob/main/src/component/layout/Navbar.jsx)\r\n\r\n也有少数的移动端适配工作不止调字体、组件大小那么简单，比如这个导航栏。在小屏幕下，导航栏不能平铺展示。于是我决定在小屏幕下展示一个汉堡菜单。相关代码：\r\n\r\n```jsx\r\nconst [isMenuOpen, setIsMenuOpen] = useState(false);\r\n\r\nconst toggleMenu = () =\u003e {\r\n  setIsMenuOpen(!isMenuOpen);\r\n};\r\n\r\n// 触发汉堡菜单的按钮\r\n\u003cbutton\r\n  className=\"md:hidden ...\"\r\n  onClick={toggleMenu}\r\n  aria-label=\"菜单\"\r\n  title=\"菜单\"\r\n\u003e\r\n  {isMenuOpen ? \u003cFaTimes /\u003e : \u003cFaBars /\u003e}\r\n\u003c/button\u003e\r\n\r\n// 原有的PC端导航栏\r\n\u003cdiv className=\"hidden md:flex ...\" /\u003e\r\n\r\n// 小屏幕下的汉堡菜单\r\n{isMenuOpen \u0026\u0026 (\r\n  \u003cmotion.div\r\n    className=\"flex md:hidden ...\"\r\n  /\u003e\r\n)}\r\n```\r\n\r\n原有的PC端导航栏是有点schema化的，所以移动端的导航栏也要复用已有的数据结构，代码大致如下：\r\n\r\n```js\r\nconst navigationItems = [promptDisplayDropdown, blogDropdown, aboutThisProjectDropdown, aboutUsDropdown].map((dropdown) =\u003e {\r\n  return {\r\n    label: dropdown.children,\r\n    items: dropdown.urls,\r\n    ...dropdown,\r\n  };\r\n});\r\n```\r\n\r\n## 【常规】部署到 GitHub Pages\r\n\r\nGitHub Pages不是部署到根路径的，而是部署到你的仓库名的。以我的项目为例，我项目的访问URL将是：`https://hans774882968.github.io/teaching-plan-analytic-geometry`。所以：\r\n\r\n1. vite项目需要修改`base`为`/teaching-plan-analytic-geometry/`。\r\n2. 项目用到了`react-router-dom`，所以我们需要传入`basename`为`/teaching-plan-analytic-geometry/`。\r\n3. GeoGebra的codebase也需要相应地更改。\r\n\r\n我希望可随时通过环境变量切换是部署到根路径，还是部署到仓库名。并且希望代码容易测试，所以抽象了一个公共文件[`src\\lib\\getWebsiteBasePath.js`](https://github.com/Hans774882968/teaching-plan-analytic-geometry/blob/main/src/lib/getWebsiteBasePath.js)。\r\n\r\n```js\r\nconst GITHUB_PAGE_BASE = '/teaching-plan-analytic-geometry/';\r\n\r\nexport function getWebsiteBasePath() {\r\n  const isGitHubPages = import.meta.env.VITE_DEPLOY_TARGET === 'github-pages';\r\n  const basePath = isGitHubPages ? GITHUB_PAGE_BASE : '/';\r\n  return basePath;\r\n}\r\n\r\nexport function getGeogebraHTML5Codebase() {\r\n  const isGitHubPages = import.meta.env.VITE_DEPLOY_TARGET === 'github-pages';\r\n  const codebase = isGitHubPages ? `${GITHUB_PAGE_BASE}geogebra/web3d/` : '/geogebra/web3d/';\r\n  return codebase;\r\n}\r\n```\r\n\r\n\r\n\r\n### `vite.config.js`修改`base`\r\n\r\n```js\r\nexport default defineConfig(() =\u003e {\r\n  const basePath = getWebsiteBasePath();\r\n\r\n  console.log(`[tpm] Base path: ${basePath}`);\r\n\r\n  return {\r\n    base: basePath,\r\n  };\r\n});\r\n```\r\n\r\n### `react-router-dom`传入`basename`\r\n\r\n```js\r\nimport { BrowserRouter as Router, Routes, Route } from 'react-router-dom';\r\nconst basePath = getWebsiteBasePath();\r\n\u003cRouter basename={basePath} /\u003e\r\n```\r\n\r\n### 修改GeoGebra的codebase\r\n\r\n`src\\component\\Geogebra.jsx`：\r\n\r\n```js\r\nconst ggbApp = new window.GGBApplet(parameter, true);\r\nggbApp.setHTML5Codebase(ggbHTML5Codebase);\r\n```\r\n\r\n我们还需要修改上文提到的`k = e('...')`这句话。但这句话在静态资源中，无法直接拿到环境变量。所以我采用最简单粗暴的做法：用全局变量通信。首先在`src\\main.jsx`写入环境变量：\r\n\r\n```js\r\nwindow.GGB_WEB3D_NO_CACHE_BASE_PATH = getGeogebraHTML5Codebase();\r\n\r\n// src\\lib\\getWebsiteBasePath.js\r\nexport function getGeogebraHTML5Codebase() {\r\n  const isGitHubPages = import.meta.env.VITE_DEPLOY_TARGET === 'github-pages';\r\n  const codebase = isGitHubPages ? `${GITHUB_PAGE_BASE}geogebra/web3d/` : '/geogebra/web3d/';\r\n  return codebase;\r\n}\r\n```\r\n\r\n然后在`web3d.nocache.js`中引用：\r\n\r\n```js\r\nk = e(window.GGB_WEB3D_NO_CACHE_BASE_PATH);\r\n```\r\n\r\n### 调试踩坑：环境变量问题\r\n\r\n做完上面3步以后，这个项目的开发阶段和打包产物应该都是OK的，`bun preview`也能正常执行。如果踩坑，那就是因为环境变量没有配置好。\r\n\r\n接下来配置[`.github\\workflows\\main.yml`](https://github.com/Hans774882968/teaching-plan-analytic-geometry/blob/main/.github/workflows/main.yml)：\r\n\r\n```yaml\r\nname: Deploy to GitHub Pages\r\non:\r\n  push:\r\n    branches:\r\n      - main\r\njobs:\r\n  build-and-deploy:\r\n    runs-on: ubuntu-latest\r\n    permissions:\r\n      contents: write\r\n    steps:\r\n    - name: Setup Node.js\r\n      uses: actions/setup-node@v4\r\n      with:\r\n        node-version: 20\r\n\r\n    - name: Setup Bun\r\n      run: npm install -g bun\r\n\r\n    - name: Checkout\r\n      uses: actions/checkout@v4\r\n\r\n    - name: Fetch geogebra assets\r\n      run: |\r\n        git clone https://github.com/Hans774882968/geogebra-resource.git temp_geogebra\r\n        mkdir -p public/geogebra\r\n        cp -r temp_geogebra/* public/geogebra/\r\n        rm -rf temp_geogebra\r\n\r\n    - name: Install dependencies\r\n      run:\r\n        bun install\r\n\r\n    - name: Build Project\r\n      run: |\r\n        bun run build\r\n      env:\r\n        # TODO: 不知道为什么配置了 GitHub Pages 的 environment  variables 以后，这里还是拿不到。暂且这么写着兜底\r\n        VITE_DEPLOY_TARGET: ${{ vars.VITE_DEPLOY_TARGET || 'github-pages' }}\r\n\r\n    - name: Deploy\r\n      uses: peaceiris/actions-gh-pages@v4\r\n      with:\r\n        github_token: ${{ secrets.GITHUB_TOKEN }}\r\n        publish_dir: ./dist\r\n        commit_message: \"Deploy tpm - ${{ github.event.head_commit.message }}\"\r\n```\r\n\r\n知识点：\r\n\r\n1. `name`可以自定义一个步骤。在这里写我们的`bun run build`之类的命令。\r\n2. `jobs`下一个job的名字可以任取，但一般是叫`build`或`build-and-deploy`。\r\n3. 为了将`public\\geogebra`复制到部署服务器的对应文件夹下，我采用的解决方案是：新建一个独立的仓库，然后在部署阶段手动复制过去。相关命令：\r\n\r\n```bash\r\ngit clone https://github.com/Hans774882968/geogebra-resource.git temp_geogebra\r\nmkdir -p public/geogebra\r\ncp -r temp_geogebra/* public/geogebra/\r\nrm -rf temp_geogebra\r\n```\r\n\r\n最后根据[参考链接3](https://blog.csdn.net/qq_20042935/article/details/133920722)，设置一下开源项目的GitHub Pages和Environments tab里的environment  variables。刷新一下就OK~\r\n\r\n### GitHub Pages直接通过链接访问只能拿到404的问题：做一个`404.html`\r\n\r\n问LLM或者查搜索引擎不难得知，GitHub Pages没有能力支持`react-router-dom`。要么改用哈希路由，要么做一个`404.html`作为跳板，先转到首页或其他支持`react-router-dom`的页面，再转到目标页面。我不想换成哈希路由，因此需要做一个`404.html`。步骤：\r\n\r\n1. 在`404.html`写JS，转到首页，并带上一个query参数，让首页能够拿到目标网址\r\n2. 首页拿到目标位置，切过去\r\n\r\n`404.html`可以用我之前写好的`NotFound.jsx`改改拿到，但我连这个改动工作都不想做了，直接交给DeepSeek：\r\n\r\n```markdown\r\n大佬，你是一名专家前端工程师，精通前端工程化。请叫我hans7。我有一个VIte + React + Tailwind CSS + framer-motion项目，它的404页面组件代码如下：\r\nsrc\\NotFound.jsx：\r\n\r\n配套的样式 src\\NotFound.module.scss：\r\n\r\n我现在需要一个404.html。请结合这两个文件，帮我完成转换。要求如下：\r\n\r\n1. 请输出一个没有任何CDN依赖的纯HTML文件，仅包含CSS和HTML。\r\n2. cn函数用于拼接类名。请将所有的Tailwind CSS类名转为纯CSS。\r\n3. react-router-dom的Link标签转化为a标签。\r\n4. framer-motion相关的代码转化为CSS代码。\r\n```\r\n\r\n它完成的质量很高，我基本上没做任何修改就直接用起来了。`404.html`仅有的JS如下：\r\n\r\n```js\r\ndocument.getElementById('currentYear').textContent = new Date().getFullYear();\r\n\r\nconst basePath = '/teaching-plan-analytic-geometry/';\r\nconst currentPath = window.location.pathname.replace(basePath, '');\r\ndocument.getElementById('target-path').textContent = currentPath;\r\n// 重定向到 SPA\r\nwindow.location.href = `${basePath}?redirect=${encodeURIComponent(currentPath)}`;\r\n```\r\n\r\n首页帮忙送到目标位置的代码`src\\NavigateForGitHubPages.jsx`：\r\n\r\n```jsx\r\nimport { useEffect } from 'react';\r\nimport { useLocation, useNavigate } from 'react-router-dom';\r\n\r\nexport default function NavigateForGitHubPages({ children }) {\r\n  const navigate = useNavigate();\r\n  const location = useLocation();\r\n\r\n  useEffect(() =\u003e {\r\n    // 处理从 GitHub Pages 的 404 页面重定向过来的请求\r\n    const urlParams = new URLSearchParams(location.search);\r\n    const redirectPath = urlParams.get('redirect');\r\n\r\n    if (redirectPath) {\r\n      // 移除重定向参数\r\n      const cleanPath = window.location.pathname + window.location.search.replace(/\\?redirect=.*/, '');\r\n      window.history.replaceState(null, '', cleanPath);\r\n\r\n      // 导航到目标路径\r\n      navigate(redirectPath);\r\n    }\r\n  }, [location, navigate]);\r\n\r\n  return children;\r\n}\r\n```\r\n\r\n使用：\r\n\r\n```jsx\r\n\u003cNavigateForGitHubPages\u003e\r\n  \u003cThemeProvider defaultTheme=\"light\"\u003e\r\n    ...\r\n  \u003c/ThemeProvider\u003e\r\n\u003c/NavigateForGitHubPages\u003e\r\n```\r\n\r\n\r\n\r\n## 参考资料\r\n\r\n1. GeoGebra官方文档： https://geogebra.github.io/docs/reference/en/GeoGebra_Apps_Embedding/\r\n2. 如何在VSCode中调试vitest： https://cn.vitest.dev/guide/debugging\r\n3. 保姆级教程：从零构建GitHub Pages静态网站： https://blog.csdn.net/qq_20042935/article/details/133920722\r\n\r\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fhans774882968%2Fteaching-plan-analytic-geometry","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fhans774882968%2Fteaching-plan-analytic-geometry","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fhans774882968%2Fteaching-plan-analytic-geometry/lists"}