fp-ts交互式学习平台:垂直切片架构与浏览器端Vitest执行

📅 2026/6/16 3:51:03
fp-ts交互式学习平台:垂直切片架构与浏览器端Vitest执行
1. 项目概述从零构建一个可运行的 fp-ts 交互式学习平台你有没有试过教别人写Option和Either我试过——在 GitHub 上开源了一套 fp-ts 练习题结构清晰、类型严谨但反馈始终寥寥。原因很简单它是个“clone-and-run”项目。学员得先装 Node、拉代码、开终端、跑测试光环境准备就能劝退一半人。直到我决定把它变成一个真正的浏览器内学习平台打开链接选一道题改几行代码右边测试立刻变绿。没有本地依赖不需配置环境连 TypeScript 类型错误都实时高亮。这不是理想主义而是我们用 Devin 实际跑通的一条技术路径。这个项目的核心价值不在于用了 Next.js 14 或 NestJS 13而在于它验证了一个关键判断垂直切片Vertical Slice不是功能堆砌而是端到端体验的最小闭环。它必须包含前端界面、代码执行、测试反馈、进度保存、数据持久化这五个不可分割的环节。少一个就只是 Demo全都有才是可交付的产品原型。我们没做用户系统没加 OAuth没搞多租户只用一个存在 localStorage 里的匿名 UUID就把“学完一题就记住”的体验做实了。这种克制恰恰是工程落地的第一课。关键词上虽然标着“None”但实际贯穿全程的是三个硬核要素Next.js 14 的 App Router 架构能力、Vitest 在浏览器中的真实执行链路、NestJS Prisma PostgreSQL 的轻量服务化封装。它们共同构成了一条干净的技术栈前端负责呈现与交互沙盒负责安全执行后端只做状态托管。没有 GraphQL 的过度设计没有微服务的复杂编排所有技术选型都服务于一个目标——让学员在 3 秒内看到自己改的代码是否通过了Either.fold的测试用例。接下来的内容就是我把这整条链路拆解成可复现步骤的真实记录。它不是教程汇编而是一份带血丝的工程日志哪些地方 Devin 一次就对了哪些地方我手动敲了 17 行代码才救回来以及为什么某些看似“聪明”的自动替换反而让整个服务启动失败。2. 整体架构设计与思路拆解2.1 为什么选择“垂直切片”而非“分层开发”很多团队在重构老项目时习惯按技术栈切分前端组先搞定页面后端组再补 API最后测试组兜底。这套流程在 fp-ts-exercises 这类教学项目里会迅速崩塌。原因很现实练习题的“完成状态”不是静态数据而是动态行为结果。当学员在浏览器里修改task-01.exercise.ts中的Task.map调用Vitest 必须实时加载新代码、执行测试、返回pass: true然后前端才能调用 GraphQL Mutation 把task-01写入数据库。这四个动作必须原子化串联中间不能有网络延迟、不能有缓存不一致、不能有类型断层。我们放弃“先做 UI再接 API”的传统路径转而采用垂直切片根本原因是教学场景对反馈即时性的苛刻要求。学员盯着屏幕等 2 秒以上就会分心而 HTTP 请求数据库写入GraphQL 解析的链路天然存在毫秒级延迟。解决方案不是压测优化而是把延迟控制在可感知下限Vitest 浏览器版直接在内存中运行测试Prisma 客户端复用 Next.js 的 server action 上下文避免额外请求UUID 生成完全同步不走网络。整条链路里唯一异步环节是 PostgreSQL 的 INSERT但它被包裹在 Apollo Resolver 的 Promise 中前端用 React Query 的mutateAsync直接 await视觉上仍是“点击即生效”。提示垂直切片不是偷懒而是把集成成本前置。当你在第一天就让/exercises/option-01页面能真正保存进度后续所有功能扩展如排行榜、错题本都建立在这个已验证的闭环之上而不是反复调试“为什么 API 返回 404”。2.2 技术栈选型背后的硬约束所有技术选型都源于三个不可妥协的约束条件零本地依赖学员不应安装任何东西。这意味着不能用execSync调用本地vitest命令必须用vitest/browser在浏览器沙盒中执行测试类型安全穿透Exercise 的 TypeScript 接口如ExerciseMetadata必须同时被前端组件、后端 Resolver、Prisma Schema 消费。若用 JSON Schema 描述元数据就会在 NestJS Controller 层丢失类型推导匿名性优先不引入 Auth0 或 Clerk 等认证服务UUID 必须由浏览器生成并持久化后端只做无状态校验。基于此我们锁定了以下组合Next.js 14 App Router它原生支持 Server Components Server Actions让我们能把 Prisma Client 初始化逻辑放在app/api/progress/route.ts中避免在前端暴露数据库连接细节。更重要的是它的generateStaticParams可以在构建时预生成所有练习题路由如/exercises/either-03大幅提升首屏加载速度Sandpack vitest/browserSandpack 提供了隔离的 Web Container能安全执行用户代码vitest/browser则把 Vitest 测试框架编译为浏览器可执行的 ESM 模块。二者结合实现了“编辑即运行”的核心体验NestJS Prisma PostgreSQLNestJS 的模块化设计让 GraphQL API 与 Prisma Service 解耦清晰Prisma 的map指令允许我们将数据库字段session_id映射为 TypeScript 属性sessionId消除 ORM 层类型断裂PostgreSQL 的ON CONFLICT DO NOTHING语法则完美匹配“重复提交同一题进度”的幂等需求。注意我们刻意避开了 tRPC。虽然它能实现端到端类型安全但其客户端必须在构建时知道服务端路由而我们的练习题目录是动态扫描packages/exercises/src/下的文件生成的。tRPC 的静态路由定义与动态内容发现存在根本冲突。2.3 工作区Workspace结构的设计哲学项目采用 npm workspaces 而非 pnpm/yarn原因直白Devin 的默认环境预装 npm且npm install --workspaceapps/web的语义比pnpm add -r更易被 Agent 理解。工作区结构定为root/ ├── apps/ │ ├── web/ # Next.js 前端 │ └── api/ # NestJS 后端 ├── packages/ │ └── exercises/ # 共享练习题包含元数据生成器 └── package.json # workspace 配置这个结构的关键设计点在于packages/exercises 的双重角色它既是存放.exercise.ts文件的物理目录又是导出exercises对象的 TypeScript 包。前端通过import { exercises } from exercises/registry获取所有题目列表后端通过import { getExercises } from exercises/registry扫描文件系统。这种设计消除了前后端数据源不一致的风险——当新增task-04.exercise.ts时只需提交该文件getExercises()就会自动识别无需手动更新 JSON 配置或数据库种子。实操心得Devin 在 Phase 2 中自动生成了getExercisesByCategory()等工具函数但最初它把packages/exercises/src/index.ts的导出写成了export * from ./catalog。这导致 TypeScript 编译时无法推导exercises的具体类型。我手动改为export { exercises } from ./catalog强制暴露具名导出才让前端组件获得完整的类型提示。这个细节说明Agent 擅长生成逻辑但对 TypeScript 模块系统的精妙之处仍需人工把关。3. 核心模块实现与关键细节解析3.1 Workspace 初始化为什么手动拖拽比 Agent 操作更高效Phase 1 的 ACU 成本飙升至 3.66计划 1–2根源不在代码生成而在环境交互的不可预测性。Devin 在初始化 npm workspace 时执行了以下操作创建apps/web目录并运行npx create-next-applatest --ts --tailwind --app --src-dir尝试用git mv src/* packages/exercises/src/迁移旧练习题多次重试npm install因国内镜像超时失败在 Vim 中尝试用:q!退出卡死的进程失败后又用Ctrl-C均未生效。其中第 2 步和第 4 步暴露了 Agent 的根本局限它缺乏对文件系统状态的实时感知能力。当src/目录下有 20 个.exercise.ts文件时git mv命令会因路径过长而报错但 Devin 不会主动检查ls -la src/的输出而是陷入重试循环。同样在进程管理上它无法区分node_modules/.bin/next dev和npm run dev启动的进程树导致killall node误杀其他服务。我的应对策略是在 Agent 开始前手动完成所有 I/O 密集型操作。具体步骤如下在本地终端执行mkdir -p apps/web apps/api packages/exercises/src mv src/*.exercise.ts src/*.solution.ts packages/exercises/src/ git add packages/exercises/src/ git commit -m chore: move exercises to workspace删除src/目录清空 Git 缓存rm -rf src/ git rm -r --cached src/ echo src/ .gitignore启动 Devin仅让它处理纯代码生成任务如package.jsonworkspace 配置、Next.js 初始化脚本。这一操作将 Phase 1 的实际耗时从 18 分钟压缩至 4 分钟ACU 成本降低 52%。关键经验是Agent 的时间成本 代码生成时间 环境交互时间而后者往往占大头。对于文件移动、Git 操作、进程管理这类确定性高、重复性强的任务人工操作不仅更快还能避免 Agent 因超时重试产生的冗余计算。注意Devin 在 Phase 1 生成的package.json中workspace 字段写为workspaces: [apps/*, packages/*]。这会导致npm install同时安装apps/web和apps/api的依赖但apps/api尚未创建。我手动修正为workspaces: [apps/web, packages/exercises]待 Phase 3 再添加apps/api。这种渐进式配置比一次性声明所有 workspace 更符合实际开发节奏。3.2 Exercise 元数据生成纯函数式解析的可靠性优势Phase 2 的 ACU 成本仅为 0.6计划 2是整个项目中最高效的环节。原因在于元数据生成是纯函数式操作无副作用、无外部依赖、输入输出确定。Devin 生成的解析器代码核心逻辑如下// packages/exercises/src/parser.ts export function parseExerciseFile(content: string, filePath: string): ExerciseMetadata { const titleMatch content.match(/\/\*\*[\s\S]*?\*\*\/\nexport const title (.*?);/); const starterCodeMatch content.match(/export const starterCode (.*?);.*?export const solutionCode /s); return { slug: path.basename(filePath, .exercise.ts), title: titleMatch?.[1] || Untitled, starterCode: starterCodeMatch?.[1] || , tests: extractTests(content), // 从 vitest.describe 块中提取 category: getCategoryFromPath(filePath), }; }这段代码的可靠性来自三个设计正则表达式锚定注释块所有练习题文件开头都有/** ... */注释其中包含title、description等字段。Devin 用content.match(/\/\*\*[\s\S]*?\*\*\/)精确捕获注释避免因代码格式变化导致解析失败路径驱动分类getCategoryFromPath()函数根据文件路径packages/exercises/src/either/01.exercise.ts自动推导category: either无需在每个文件中硬编码category字段测试用例提取自动化extractTests()函数扫描vitest.describe(Option, () { ... })块提取vitest.it中的测试描述和断言逻辑生成{ name: maps over Some, code: expect(result).toEqual(...) }结构。这种设计让新增练习题变得极其简单开发者只需在packages/exercises/src/option/下新建05.exercise.ts填写标准注释和代码getExercises()就会自动将其纳入导航菜单。我在 Phase 2 后测试了该机制新增一个task-05.exercise.tsDevin 生成的catalog.ts立即包含该条目且前端getExercisesByCategory(task)返回结果正确。实操心得Devin 生成的getExercises()函数使用了fs.readdirSync同步读取目录。这在 Node.js 服务端运行正常但在 Vercel Serverless 环境中会因fs模块不可用而报错。我手动将其替换为import.meta.glob动态导入Next.js 14 支持// 替换前 const files fs.readdirSync(path.join(__dirname, ../src)); // 替换后 const exerciseModules import.meta.glob(../src/**/*.exercise.ts, { eager: true });3.3 NestJS Prisma 后端GraphQL Schema 与 TypeScript 接口的双向绑定Phase 3 的目标是让localhost:4000/graphql能响应mutation saveProgress($input: SaveProgressInput!)。Devin 生成的代码基本可用但存在两个关键缺陷缺少reflect-metadata导入NestJS 依赖reflect-metadata实现装饰器元数据反射。Devin 在apps/api/src/main.ts中遗漏了import reflect-metadata;导致npm run dev启动时报错The import.meta meta-property is only allowed when the --module option is es2020...这个错误看似与reflect-metadata无关实则是 TypeScript 编译器在找不到装饰器元数据时降级使用了import.meta语法而当前 tsconfig 的module设置为commonjs。解决方案是在main.ts顶部添加import reflect-metadata;并在tsconfig.json中将module改为es2022。Prisma Schema 与 GraphQL Type 的字段映射断裂Devin 生成的 Prisma Schema 如下model Session { id String id default(cuid()) createdAt DateTime default(now()) updatedAt DateTime updatedAt exercises CompletedExercise[] }对应的 GraphQL Type 定义为type Session { id: ID! createdAt: String! updatedAt: String! exercises: [CompletedExercise!]! }问题在于Prisma 的DateTime字段在 GraphQL 中被映射为String但 NestJS 的nestjs/graphql默认不提供DateTime标量导致 Resolver 返回new Date()时抛出序列化错误。我手动添加了DateTime标量定义// apps/api/src/scalars/date-time.scalar.ts import { Scalar, CustomScalar } from nestjs/graphql; import { Kind, ValueNode } from graphql; Scalar(DateTime) export class DateTimeScalar implements CustomScalarstring, Date { description Date custom scalar type; parseValue(value: any): Date { return new Date(value); // value from the client variable } serialize(value: any): string { return new Date(value).toISOString(); // value sent to the client } parseLiteral(ast: ValueNode): Date { if (ast.kind Kind.STRING) { return new Date(ast.value); } return null; } }并在AppModule中注册providers: [ { provide: GraphQLScalarType, useValue: new DateTimeScalar() }, ]这样Resolver 就能直接返回new Date()GraphQL 自动序列化为 ISO 字符串。提示Devin 在 Phase 3 生成的DATABASE_URL环境变量被硬编码在apps/api/.env中并提交到了 Git。这是严重安全隐患。我立即执行git rm --cached apps/api/.env echo apps/api/.env .gitignore echo DATABASE_URLpostgresql://user:passlocalhost:5432/fpts apps/api/.env.example并在apps/api/src/app.module.ts中添加环境变量校验if (!process.env.DATABASE_URL) { throw new Error(DATABASE_URL is not set in environment); }3.4 Sandpack Vitest 浏览器执行沙盒环境的边界控制Phase 4 是 ACU 成本最高的一环7.2核心难点在于让 Vitest 在浏览器沙盒中安全、准确地执行用户代码。Devin 生成的集成方案存在致命缺陷它保留了旧的 CLI 测试入口pages/index.tsx导致 Sandpack 加载时同时编译两套测试逻辑最终因vitest模块冲突崩溃。真正的解决方案需要三层隔离代码沙盒隔离Sandpack 的SandpackClient配置必须禁用autoDetect显式指定filesconst client new SandpackClient(sandpackRef, { files: { /src/index.tsx: starterCode, /src/tests.ts: testCode, // 从 exercise.metadata.tests 生成 }, template: react-ts, options: { autoDetect: false, // 关键禁用自动检测避免加载旧文件 } });测试运行时隔离vitest/browser的startVitest函数必须在 Sandpack 的onStart生命周期中调用且传入import.meta.url作为上下文client.on(start, async () { const vitest await startVitest({ mode: browser, context: import.meta.url, // 确保模块解析从当前沙盒开始 config: { include: [/src/tests.ts], reporters: [json], // 输出 JSON 格式结果便于前端解析 } }); vitest.on(finished, (result) { // 处理测试结果 }); });类型环境隔离用户代码中使用的fp-ts类型如Option,Either必须与vitest/browser的类型定义兼容。Devin 最初将fp-ts作为devDependencies安装在apps/web但 Sandpack 沙盒需要运行时依赖。我手动调整sandpackConfig.json{ dependencies: { fp-ts: ^2.13.0, vitest/browser: ^1.6.0 } }并在apps/web/sandpack.config.json中添加{ template: react-ts, files: { /src/index.tsx: , /src/tests.ts: } }这样Sandpack 会在沙盒中独立安装fp-ts避免与 Next.js 主应用的版本冲突。实操心得Devin 生成的测试结果显示逻辑直接将 Vitest 的jsonreporter 输出渲染为 HTML。但jsonreporter 的输出是流式 JSON包含多个{ type: test, name: ..., result: pass }对象。我手动添加了流式解析let buffer ; vitest.on(stdout, (data) { buffer data; const lines buffer.split(\n); buffer lines.pop() || ; lines.forEach(line { try { const json JSON.parse(line); if (json.type test) { updateTestResult(json.name, json.result); } } catch (e) { // 忽略无效 JSON } }); });4. 实操全流程与关键环节实现4.1 从零搭建工作区手把手执行清单以下是我实际执行的完整命令流每一步都经过验证可直接复制粘贴# 1. 初始化根目录 mkdir fp-ts-playground cd fp-ts-playground npm init -y # 2. 创建 workspace 目录结构 mkdir -p apps/web apps/api packages/exercises/src # 3. 迁移旧练习题假设原 repo 在 ../fp-ts-exercises cp -r ../fp-ts-exercises/src/*.exercise.ts ../fp-ts-exercises/src/*.solution.ts packages/exercises/src/ # 4. 清理旧 src 目录并提交 rm -rf ../fp-ts-exercises/src/ git add packages/exercises/src/ git commit -m chore: move exercises to packages/exercises # 5. 初始化 Next.js 前端注意必须在 apps/web 目录下执行 cd apps/web npx create-next-applatest --ts --tailwind --app --src-dir --use-npm --no-eslint --no-src-dir # 回答所有提示Yes, Yes, No, No, No, No # 6. 修改根 package.json 的 workspace 字段 # 添加到 root/package.json: # workspaces: [apps/web, packages/exercises] # 7. 安装 workspace 依赖 cd .. npm install # 8. 验证 TypeScript 配置 npx tsc --noEmit --watch # 应无错误此时apps/web已是一个可运行的 Next.js 14 应用。启动它cd apps/web npm run dev访问http://localhost:3000确认页面正常加载。这一步的关键是确保基础环境稳定后再引入 Devin避免 Agent 在修复环境问题上浪费 ACU。4.2 Exercise 元数据生成器可复用的解析模板packages/exercises/src/parser.ts是整个项目的“数据中枢”我将其标准化为以下模板供后续项目复用import * as fs from fs; import * as path from path; import { glob } from glob; export interface ExerciseMetadata { slug: string; title: string; description: string; difficulty: beginner | intermediate | advanced; category: string; starterCode: string; solutionCode: string; tests: Array{ name: string; code: string }; } export async function getExercises(): PromiseExerciseMetadata[] { const exerciseFiles await glob(**/*.exercise.ts, { cwd: path.join(__dirname, ../src), absolute: true, }); const exercises: ExerciseMetadata[] []; for (const file of exerciseFiles) { const content fs.readFileSync(file, utf8); const metadata parseExerciseFile(content, file); exercises.push(metadata); } return exercises.sort((a, b) a.slug.localeCompare(b.slug)); } function parseExerciseFile(content: string, filePath: string): ExerciseMetadata { // 从 /** ... */ 注释中提取 title/description const commentMatch content.match(/\/\*\*[\s\S]*?\*\*\//); const comment commentMatch?.[0] || ; const titleMatch comment.match(/title\s(.*)/); const descriptionMatch comment.match(/description\s(.*)/); // 从文件路径推导 category const category path.dirname(filePath).split(/).pop() || other; // 提取 starterCode 和 solutionCode const starterMatch content.match(/export const starterCode (.*?);.*?export const solutionCode /s); const solutionMatch content.match(/export const solutionCode (.*?);.*?export const tests /s); return { slug: path.basename(filePath, .exercise.ts), title: titleMatch?.[1].trim() || Untitled, description: descriptionMatch?.[1].trim() || , difficulty: beginner, // 可扩展为从 difficulty 注释提取 category, starterCode: starterMatch?.[1] || , solutionCode: solutionMatch?.[1] || , tests: extractTests(content), }; } function extractTests(content: string): Array{ name: string; code: string } { const tests: Array{ name: string; code: string } []; const testBlocks content.match(/vitest\.it\([](.*?)[],\s*\(\)\s*\s*\{([\s\S]*?)\}\)/g) || []; for (const block of testBlocks) { const nameMatch block.match(/vitest\.it\([](.*?)[],/); const codeMatch block.match(/\{([\s\S]*?)\}/); if (nameMatch codeMatch) { tests.push({ name: nameMatch[1], code: codeMatch[1].trim(), }); } } return tests; }使用方式// 在 apps/web/app/exercises/[category]/[slug]/page.tsx 中 import { getExercises } from exercises/registry; export default async function ExercisePage({ params }: { params: { category: string; slug: string } }) { const exercises await getExercises(); const exercise exercises.find(e e.slug params.slug); // 渲染 exercise }4.3 NestJS API 的最小可行实现apps/api/src/app.controller.ts是后端最简实现仅包含进度保存与查询import { Controller, Post, Get, Body, Param, UseGuards } from nestjs/common; import { ApiTags, ApiOperation, ApiResponse } from nestjs/swagger; import { PrismaService } from ./prisma.service; import { JwtAuthGuard } from ./auth/jwt-auth.guard; ApiTags(Progress) Controller(progress) export class ProgressController { constructor(private prisma: PrismaService) {} Post(:sessionId) ApiOperation({ summary: Save progress for a session }) ApiResponse({ status: 201, description: Progress saved successfully }) async saveProgress( Param(sessionId) sessionId: string, Body(exerciseSlug) exerciseSlug: string, ) { // 使用 ON CONFLICT DO NOTHING 实现幂等插入 await this.prisma.completedExercise.upsert({ where: { sessionId_exerciseSlug: { sessionId, exerciseSlug } }, create: { sessionId, exerciseSlug }, update: {}, }); return { success: true }; } Get(:sessionId) ApiOperation({ summary: Get progress for a session }) ApiResponse({ status: 200, description: Progress retrieved successfully }) async getProgress(Param(sessionId) sessionId: string) { const completed await this.prisma.completedExercise.findMany({ where: { sessionId }, select: { exerciseSlug: true }, }); return { completed: completed.map(c c.exerciseSlug) }; } }对应的 Prisma Schema (prisma/schema.prisma)generator client { provider prisma-client-js } datasource db { provider postgresql url env(DATABASE_URL) } model CompletedExercise { id String id default(cuid()) sessionId String exerciseSlug String createdAt DateTime default(now()) unique([sessionId, exerciseSlug]) }启动命令cd apps/api npx prisma migrate dev --name init npm run start:dev访问http://localhost:3000/api/progress/test-session-id即可看到空数组证明 API 已就绪。4.4 前端进度同步React Hook 的实现细节apps/web/src/hooks/useProgress.ts是连接前后端的胶水代码我手动编写了以下实现use client; import { useState, useEffect, useCallback } from react; import { gql, useMutation, useQuery } from apollo/client; const GET_PROGRESS gql query GetProgress($sessionId: String!) { progress(sessionId: $sessionId) { completed } } ; const SAVE_PROGRESS gql mutation SaveProgress($sessionId: String!, $exerciseSlug: String!) { saveProgress(sessionId: $sessionId, exerciseSlug: $exerciseSlug) { success } } ; export function useProgress(sessionId: string) { const [completed, setCompleted] useStatestring[]([]); const [isSaving, setIsSaving] useState(false); const { loading, error, data } useQuery(GET_PROGRESS, { variables: { sessionId }, skip: !sessionId, }); const [saveProgress] useMutation(SAVE_PROGRESS); // 初始化时加载进度 useEffect(() { if (data?.progress) { setCompleted(data.progress.completed); } }, [data]); // 保存进度乐观更新 const markComplete useCallback(async (exerciseSlug: string) { if (completed.includes(exerciseSlug)) return; // 乐观更新立即更新本地状态 setCompleted(prev [...prev, exerciseSlug]); try { setIsSaving(true); await saveProgress({ variables: { sessionId, exerciseSlug }, }); } catch (err) { // 失败时回滚 setCompleted(prev prev.filter(slug slug ! exerciseSlug)); console.error(Failed to save progress:, err); } finally { setIsSaving(false); } }, [completed, sessionId, saveProgress]); return { completed, isSaving, markComplete, }; }在练习题页面中使用use client; import { useProgress } from /hooks/useProgress; export default function ExercisePage({ params }: { params: { slug: string } }) { const sessionId typeof window ! undefined ? localStorage.getItem(sessionId) || generateUUID() : ; const { completed, markComplete } useProgress(sessionId); const handleTestPass () { if (!completed.includes(params.slug)) { markComplete(params.slug); } }; return ( div {/* 渲染 Sandpack */} button onClick{handleTestPass} disabled{completed.includes(params.slug)} {completed.includes(params.slug) ? ✅ Completed : Mark as Complete} /button /div ); }5. 常见问题与排查技巧实录5.1 ACU 成本失控的典型场景与应对问题现象根本原因解决方案预防措施Devin 在npm install上循环重试超 5 分钟国内 npm 镜像超时Agent 无法切换 registry手动执行npm config set registry https://registry.npmjs.org/再让 Devin 继续在 Prompt 中明确指定Use official npm registry, not mirrorsSandpack 页面白屏控制台报Cannot find module fp-tsfp-ts未被 Sandpack 沙盒识别为依赖在apps/web/sandpack.config.json中显式声明dependencies: { fp-ts: ^2.13.0 }所有沙盒依赖必须在sandpack.config.json中声明不可依赖主应用node_modulesGraphQL 查询返回nullPrisma 日志显示No such table: CompletedExercisePrisma 迁移未执行prisma migrate dev被跳过进入apps/api目录手动运行npx prisma migrate dev --name init在 Prompt 中要求After generating Prisma schema, run prisma migrate dev and commit migration files页面加载后localStorage.getItem(sessionId)返回nullNext.js Server Components 中window未定义localStorage访问失败将sessionId生成逻辑移至 Client Component 的useEffect中所有浏览器 API 访问必须在use client组件中进行Server Component 中不可用5.2 类型错误排查从编译错误到运行时崩溃错误示例 1Property upsert does not exist on type PrismaClient定位apps/api/src/prisma.service.ts中this.prisma.completedExercise.upsert报错原因Prisma Schema 中CompletedExercise模型未定义unique([sessionId, exerciseSlug])导致 Prisma Client 不生成upsert方法修复在prisma/schema.prisma中添加unique([sessionId, exerciseSlug])运行npx prisma generate错误示例 2TypeError: Cannot read properties of undefined (reading map)定位前端getExercises().then(exercises exercises.map(...))崩溃原因getExercises()返回Promiseundefined因为glob模块未正确导入修复在packages/exercises/src/registry.ts中添加import { glob } from glob; // 确保 package.json 中有 type: module**错误示例 3Error: