GDB
|
|

博客开发历程学习笔记

为了装逼界面开发全用了英文,现在终于要发帖了又懒得写英文, what can I say...

学习问答记录

开发相关的问答记录,便于后续复习。


Q1 - 2026/05/10

问题: 当前的 Nuxt 项目是 TypeScript 的吗?如果不是怎么转?

回答摘要: 项目已经是 TypeScript 的,不需要转换。

要点:

  1. nuxt.config.tstsconfig.json 已存在,Nuxt 4 内置 TS 支持
  2. .vue 文件中加 <script setup lang="ts"> 即可使用 TypeScript
  3. nuxt prepare 会自动生成类型声明文件

Q2 - 2026/05/10

问题: 所有页面两边留白 20%,应该做布局组件还是直接在 app.vue 写?

回答摘要: 所有页面共用同一布局时,直接在 app.vue 里做,不需要 layouts。

要点:

  1. app.vue 是所有页面的最外层壳,直接用 Tailwind 的 max-w-[80%] mx-auto 包裹 <NuxtPage />
  2. Nuxt Layouts(app/layouts/)适用于不同页面需要不同布局结构的场景
  3. 不要提前抽象,等真正需要多布局时再加 layouts

Q3 - 2026/05/10

问题: 如何设置统一的背景主题色?主题色要不要封装到 Tailwind?

回答摘要: 背景色在 body 上设置,主题色用 Tailwind v4 的 @theme 统一管理。

要点:

  1. 背景色必须在 body 层级设置(main.cssbody { @apply bg-background; min-height: 100vh; }),不能放在 app.vue 内容容器里
  2. Tailwind v4 用 @theme 指令定义主题色(--color-background--color-primary 等),不是 v3 的 tailwind.config.js
  3. 主题色集中在 main.css 管理,后续改主题只改这一个文件

Q4 - 2026/05/10

问题: 导航栏需要用到布局(layouts)吗?

回答摘要: 不需要,直接在 app.vue 里加导航栏。

要点:

  1. 所有页面共用导航栏 + 内容区域 = 同一种布局 = 在 app.vue 里做
  2. Layouts 适用于不同页面有不同外层结构的场景(如博客页有侧边栏、文章页全宽)
  3. 单一布局用 app.vue,多布局才用 layouts

Q5 - 2026/05/10

问题: 导航栏要不要封装成组件?

回答摘要: 要,放在 app/components/NavBar.vue,Nuxt 自动导入。

要点:

  1. 导航栏作为独立组件,职责清晰,方便维护
  2. app/components/ 下的组件 Nuxt 自动注册,模板里直接用 <NavBar />,不需要手动 import
  3. 组件命名用大驼峰(NavBar.vue),这是 Vue 社区惯例

Q6 - 2026/05/10

问题: Nuxt 里页面跳转也用 router-link 吗?

回答摘要: 不用,用 <NuxtLink>

要点:

  1. <NuxtLink> 是 Nuxt 对 router-link 的封装,额外支持预加载(鼠标悬停时预加载目标页面)
  2. 用法:<NuxtLink to="/about">关于</NuxtLink>
  3. 路由基于文件系统自动生成:app/pages/index.vue/app/pages/about.vue/about

Q7 - 2026/05/10

问题: 导航栏当前页面高亮怎么做?

回答摘要: <NuxtLink> 激活时自动添加 CSS class,直接写样式即可。

要点:

  1. 激活时自动添加 router-link-active(前缀匹配)和 router-link-exact-active(精确匹配)
  2. 导航栏一般用 router-link-exact-active,在 main.css 中用 @apply 统一处理样式
  3. 示例:.router-link-exact-active { @apply text-primary font-bold; }

Q8 - 2026/05/10

问题: Prisma 配置好了,可以开始写 Nuxt 的接口了吗?

回答摘要: 可以。Prisma 实例在 server/utils/ 下会被自动导入,直接在 server/api/ 目录写接口即可。

要点:

  1. 接口放在 server/api/ 目录,Nuxt 基于文件名自动生成路由
  2. 文件名后缀区分 HTTP 方法:posts.get.ts(GET)、posts.post.ts(POST)、[id].get.ts(动态路由)
  3. server/utils/ 下的变量在 server 端自动导入,不需要手动 import
  4. 处理函数用 defineEventHandler() 导出

Q9 - 2026/05/13

问题: 通用 API 响应接口(data、status 字段)应该写在哪?每个接口的 data 类型不同怎么处理?

回答摘要: 通用响应接口用 TypeScript 泛型定义,放在 app/types/api.ts;各接口通过泛型参数指定自己的 data 类型。

要点:

  1. app/types/ 是 Nuxt 4 自动导入目录,前后端都能直接使用,适合放通用类型
  2. 用泛型 ApiResponse<T> 定义通用结构,T 占位具体数据类型:interface ApiResponse<T = unknown> { status: ApiStatus; message?: string; data: T }
  3. 每个业务接口只需定义自己的数据类型(如 PostList),然后组合:type PostListResponse = ApiResponse<PostList[]>
  4. 服务端返回类型标注为 Promise<ApiResponse<PostList[]>>,前端 useFetch<PostListResponse>() 也有完整类型推导
  5. 核心思路:写一次公共结构 + 每个接口只关心自己的 data 类型,避免重复

Q10 - 2026/05/13

问题: 在 app/types/api.ts 定义了 APIResponse<T> 后,怎么在 server/api/ 的接口中使用?

回答摘要: 给 defineEventHandler 的返回值标注 Promise<APIResponse<具体类型>>,类型会自动导入。

要点:

  1. app/types/ 下的类型 Nuxt 自动导入,APIResponsePostList 等不需要手动 import
  2. 用法:defineEventHandler(async (): Promise<APIResponse<PostList[]>> => { ... }),返回类型标注确保返回值结构正确
  3. 接口返回值必须包含 APIResponse 的所有必填字段(datastatusmessage),缺少字段 TS 会报错
  4. 泛型参数 PostList[] 决定了 data 字段的类型约束

Q11 - 2026/05/13

问题: app/types/ 下的类型在 server/api/ 中自动导入不生效,VSCode 报错找不到类型?

回答摘要: app/types/ 只在前端自动导入,server 端需要手动 import,或者把共享类型放到 shared/ 目录。

要点:

  1. Nuxt 前端自动导入范围:app/ 下的组件、composables、types
  2. Nuxt 后端自动导入范围:只有 server/utils/,不包含 app/types/
  3. server 端使用 app/types/ 的类型需要手动 import:import type { APIResponse } from '~/types/api'
  4. 如果类型前后端都要用,更推荐放到 shared/ 目录,Nuxt 4 专为前后端共享设计

Q12 - 2026/05/13

问题: 数据库 BigInt 类型的 id 用函数转成了 string,但 TS 仍然报类型错误?

回答摘要: serializeBigInt 函数签名是 <T>(data: T): T,输入输出类型相同,TS 不知道运行时类型变了。改用双泛型 <T, U> 声明输入输出类型不同即可。

要点:

  1. 问题根源:泛型 <T>(data: T): T 表示输入什么类型就输出什么类型,Prisma 返回 BigInt,TS 认为输出也是 BigInt
  2. 解决方案:函数签名改为 <T, U>(data: T): U,用两个泛型参数分别表示输入和输出类型
  3. 调用时指定两个类型:serializeBigInt<typeof posts, PostList[]>(posts),TS 就知道返回的是 PostList[]
  4. 本质是类型断言——告诉 TS "我相信运行时转换后的类型是 U",JSON.parse 本身返回 any,所以赋值给 U 不会报错

Q13 - 2026/05/13

问题: 接口返回值的 status 和 message 写死在每个接口里,这样做对吗?

回答摘要: 不推荐写死,应封装 success() / error() 工具函数统一构造响应,放在 server/utils/api/response.ts

要点:

  1. 每个接口手写 status: '200', message: 'success' 重复且容易出错(拼写不一致、遗漏字段)
  2. 封装 success<T>(data, message?)error(status, message) 两个函数,所有接口统一调用
  3. 好处:格式统一、改响应结构只改一处、server/utils/ 自动导入无需手动 import

Q14 - 2026/05/13

问题: 封装了 error() 函数后,什么场景下会触发错误响应?

回答摘要: 常见场景有参数校验失败(400)、资源不存在(404)、数据库异常(500),在接口逻辑中主动判断并返回对应错误。

要点:

  1. 参数校验失败:缺少必填参数、格式不对 → error('400', '缺少文章 ID')
  2. 资源不存在:查询结果为空 → error('404', '文章不存在')
  3. 数据库异常try/catch 捕获 → error('500', '服务器内部错误')
  4. 这些都是接口逻辑中主动判断并返回的,不是自动触发的

Q15 - 2026/05/13

问题: serializeBigInt<typeof posts, PostList[]>(posts)PostList 增加字段后为什么不报类型错误?

回答摘要: serializeBigInt 内部用 JSON.parse 返回 any,双泛型 <T, U> 本质是类型断言,TS 不校验数据结构是否真的匹配 U

要点:

  1. JSON.parse 返回 any,赋值给任何类型都不会报错,所以 <T, U> 等价于 as U
  2. 这意味着即使查询结果缺少字段,TS 也不会提示——类型安全是假的
  3. 正确做法:serializeBigInt 只保留 <T>(data: T): T 做 BigInt 转换,类型映射在接口里用 .map() 显式处理
  4. .map() 逐字段赋值,缺字段 TS 立刻报错,类型检查才真正生效
  5. 用了显式 .map()serializeBigInt 就不需要了,post.id.toString() 直接处理 BigInt

Q16 - 2026/05/14

问题: 为什么 published_at 需要非空断言而 idtitle 不需要?

回答摘要: Prisma schema 中 published_at?(可为空),生成类型是 Date | null,而接口类型定义为 Date(不可空),类型冲突。idtitle 在 schema 中没有 ?,本身就是非空类型,不冲突。

要点:

  1. Prisma schema 的 ? 标记决定字段是否可为 nullDateTime?Date | nullStringstring
  2. 类型冲突发生在 Prisma 生成类型与手写接口类型不一致时:Date | null 不能赋给 Date
  3. 解决方案:非空断言 !(确信数据不为空时)、改接口类型为 Date | null、或给默认值 ?? new Date()
  4. 本例中只查 post_status: 'published' 的数据,published_at 理论上必有值,用 ! 断言合理

Q17 - 2026/05/15

问题: 博客详情页的路由用 id 还是 slug?RESTful 接口和 Nuxt 文件结构怎么安排?

回答摘要: 用 id 查询最简单直接,URL 也用 id。RESTful 接口为 GET /api/posts/:id,Nuxt 前后端都用 [id] 动态参数,结构对称。

要点:

  1. 三种路由方案:纯 id(/posts/42)、纯 slug(/posts/my-first-post,需 slug 加唯一索引)、id+slug 组合(/posts/42-my-first-post,需截取 id)
  2. 个人博客推荐纯 id 方案,简单高效,WHERE id = 42 主键查询性能最好
  3. RESTful 约定:资源名用复数,操作用 HTTP 方法区分——GET /api/posts(列表)、GET /api/posts/:id(详情)、POST /api/posts(创建)等
  4. Nuxt 服务端 server/api/posts/[id].get.tsgetRouterParam(event, 'id') 获取动态参数,prisma.findUnique({ where: { id } }) 查单条
  5. Nuxt 前端 app/pages/posts/[id].vueuseRoute().params.id 获取参数,useFetch('/api/posts/' + id) 请求数据
  6. 前后端文件结构对称:server/api/posts/[id].get.tsapp/pages/posts/[id].vue,清晰一致

Q18 - 2026/05/15

问题: server/api/posts/[id].get.ts 详情页接口的写法原理是什么?

回答摘要: Nuxt 通过文件名约定生成路由,[id].get.ts 匹配 GET /api/posts/:id,处理器内用 H3 的方法获取参数、查询数据、返回响应。

要点:

  1. 文件名即路由[id] 是动态参数,.get 限定 HTTP 方法,所以 server/api/posts/[id].get.ts 处理 GET /api/posts/:id
  2. defineEventHandler:H3 提供的请求处理器定义函数,event 参数包含请求的所有信息(headers、params、body 等)
  3. getRouterParam(event, 'id'):从 URL 路径中取动态参数,'id' 对应文件名 [id],返回的是字符串
  4. prisma.findUnique:按唯一字段(主键或唯一索引)精确查一条记录,需要 Number(id) 把字符串转成数字
  5. createError({ statusCode: 404 }):H3 提供的错误抛出方法,自动返回对应的 HTTP 错误响应
  6. 与列表接口对比:列表用 findMany 查多条,详情用 findUnique 查一条,其余结构(prisma 查询、success() 包装响应)模式一致

Q19 - 2026/05/15

问题: route.params 的类型是 string | string[],但 API 参数类型是 string,应该改 API 类型还是转换 params?

回答摘要: 在调用处转换 params 类型,不改 API 类型。API 参数就应该是 string,类型不匹配是调用方的问题。

要点:

  1. route.params 的值类型是 string | string[],这是 Vue Router 的设计(参数可能重复),不是 API 的问题
  2. API 函数参数定义 string 是正确的,不应为了迁就调用方放宽类型
  3. 在页面组件中用 as string 断言(单参数路由运行时必定是 string)或 String() 转换即可
  4. 核心原则:类型转换发生在边界处(页面组件获取路由参数的地方),而不是侵入到业务逻辑层

Q20 - 2026/05/19

问题: 映射 postDetail 为 Posts 类型时,content_html 字段有 undefined 的可能,但 content 没有,为什么?

回答摘要: 由 Prisma schema 中字段是否标注 ? 决定。content 没有 ? 是必填字段,content_html? 是可选字段。

要点:

  1. Prisma schema 中 content String @db.LongText(无 ?)→ 生成的 TS 类型是 string,不可能为 null
  2. Prisma schema 中 content_html String? @db.LongText(有 ?)→ 生成的 TS 类型是 string | null,可能为 null
  3. ? 对应数据库中该字段允许 NULLcontent_html 允许为空是因为 HTML 可能在后续渲染步骤才生成
  4. 当前 [slug].get.ts 的映射代码中缺少 contentHTML 字段的赋值,需要补充如 contentHTML: postDetail.content_html ?? ''

Q21 - 2026/05/19

问题: 开发阶段用 npx prisma migrate dev 可以吗?dev 是什么意思?

回答摘要: 可以,migrate dev 就是开发阶段专用的命令,会自动生成迁移文件、应用到数据库、并重新 generate Client。

要点:

  1. migrate dev 的完整流程:检测 schema 差异 → 生成迁移 SQL 文件 → 应用到数据库 → 自动执行 prisma generate
  2. dev 表示开发环境专用,生产环境对应 prisma migrate deploy(只应用已有迁移,不创建新的)
  3. migrate dev vs db push:前者生成迁移文件有历史记录可回溯,后者不生成迁移文件适合快速实验
  4. 即使开发阶段也推荐用 migrate dev,积累的迁移文件上线时会顺序执行到生产数据库,保证结构一致

Q22 - 2026/05/25

问题: Nuxt 项目给每个页面设置 title 的最佳实践是什么?

回答摘要: 使用 useHead()useSeoMeta() composable,在 <script setup> 中直接调用即可,配合 titleTemplate 实现全局标题模板。

要点:

  1. useHead({ title: '页面标题' }) 设置简单静态标题,Nuxt 自动导入无需手动 import
  2. 响应式标题用 computeduseHead({ title: computed(() => data.value?.title) })
  3. useSeoMeta() 更适合 SEO 场景,可同时设置 titledescriptionogTitle 等元标签
  4. app.vue 或 layout 中用 titleTemplate 设全局模板:titleTemplate: (title) => title ? '${title} - Gerden Blog' : 'Gerden Blog'
  5. 子页面只需 useHead({ title: 'xxx' }),最终显示为 xxx - Gerden Blog

Q23 - 2026/05/26

问题: 某个页面不想带上 app.vue 中的 NavBar,应该怎么做?

回答摘要: 使用 Nuxt Layouts 机制,将 NavBar 移入 default layout,不需要 NavBar 的页面(如登录页)指定 blank layout。

要点:

  1. Nuxt Layouts 是控制页面外框布局的机制,适合区分有/无导航栏的页面
  2. app.vue 中的 NavBar 移到 app/layouts/default.vue,用 <slot /> 插入页面内容
  3. 创建 app/layouts/blank.vue 只含 <slot />,不包含 NavBar
  4. app.vue 简化为 <NuxtLayout><NuxtPage /></NuxtLayout>
  5. 不需要 NavBar 的页面用 definePageMeta({ layout: 'blank' }) 指定空白布局
  6. 不指定 layout 的页面默认使用 default layout(带 NavBar)

Q24 - 2026/05/27

问题: Prisma Json 类型字段赋值 TocItem[] 时 TS 报错"不能将类型 TocItem[] 分配给类型 InputJsonValue",如何解决?

回答摘要: Prisma 的 Json 字段期望 InputJsonObject 类型(带 string 索引签名的对象),TS 数组类型缺少该签名,需要通过 JSON.parse(JSON.stringify(...)) 转为普通对象并断言类型。

要点:

  1. Prisma schema 中 Json? 类型对应的 TS 输入类型是 InputJsonValue,它要求对象有 [key: string]: ... 索引签名
  2. TypeScript 数组类型 TocItem[] 只有数字索引,缺少字符串索引签名,所以赋值报错
  3. 修复:toc: JSON.parse(JSON.stringify(toc)) as object,先序列化再反序列化得到普通 JS 对象,断言为 object 让 Prisma 接受
  4. 运行时数组本身就是合法 JSON 值,这只是 TS 类型层面的兼容问题,不影响数据存储

Q25 - 2026/05/27

问题: 参照 posts.ts 的规范,登录的 API 模块怎么写?登录用到了 POST 方法,和 GET 不一样。

回答摘要: useAPI 底层是 useFetch,第二个参数支持 methodbody,POST 请求只需显式指定即可。

要点:

  1. useAPI 是对 useFetch 的封装,第二个参数接受所有 useFetch 标准选项(methodbodyheaders 等)
  2. GET 请求可省略 method(默认 GET),POST 需要显式传 { method: 'POST', body: { ... } }
  3. 泛型参数对应后端返回类型:登录接口返回 { ok: true },写 useAPI<APIResponse<{ ok: boolean }>>
  4. 示例:login(username, password) { return useAPI<...>('admin/login', { method: 'POST', body: { username, password } }) }

Q26 - 2026/05/27

问题: 登录页面的错误处理最佳实践是什么?已有拦截器但不清楚如何配合使用

回答摘要: 错误处理分两层——拦截器处理全局通用逻辑,组件处理业务特定错误。关键是 useFetch(即 useAPI)的 HTTP 错误不会 throw,需要检查返回的 error.value

要点:

  1. useFetch 的错误机制: HTTP 错误(400/401/500)不会抛异常,而是返回 { data, error },其中 error 是 ref。try/catch 只能捕获网络断连等非 HTTP 异常,捕获不到"用户名或密码错误"
  2. 拦截器职责(app/plugins/api.ts: 处理全局通用逻辑。比如非登录页收到 401 → 跳转到登录页;所有接口错误统一上报日志。拦截器对请求是"拦截"不是"消费",错误仍会传到组件
  3. 组件职责(login.vue: 从 useAPI 返回值中解构 error,检查 error.value 是否存在,提取 error.value.data?.message 显示给用户
  4. 具体改法:
    • 组件中:const { data, error } = await loginAPI.login(username, password),然后 if (error.value) { logMsg = error.value.data?.message || '登录失败'; logError = true; return }
    • 拦截器中:onResponseError 里判断 URL 不是登录接口时,401 才跳转登录页,避免登录页自身 401 被误跳转
  5. 不要在拦截器里替登录页处理登录错误: 登录失败(401)是登录页的正常业务逻辑,拦截器不应跳转或弹窗,应交给组件展示

Q27 - 2026/05/27

问题: navigateTo('/admin') 有什么用?

回答摘要: navigateTo 是 Nuxt 提供的路由导航函数,用于在代码中程序化地跳转页面,等同于用户点击了 <NuxtLink to="/admin">

要点:

  1. 用途: 在 JS/TS 代码中触发页面跳转,而不是依赖用户点击链接
  2. 常见场景: 登录成功后跳转到管理页、表单提交完成后跳转到详情页、权限校验失败跳转到登录页
  3. 在登录页的具体问题: 当前登录成功后只设了 logSuccess = true 显示 "Redirecting...",但没有调用 navigateTo('/admin'),用户会一直卡在登录页
  4. 用法: await navigateTo('/admin')——直接传目标路径即可,支持服务端和客户端

Q28 - 2026/05/27

问题: Nuxt 4 项目配置路由守卫的最佳实践是什么?

回答摘要: 使用 Route Middleware(路由中间件)实现,分为命名中间件(按需声明)和全局中间件(自动生效)。项目只有 /admin/* 需要鉴权,建议用命名中间件。

要点:

  1. 中间件目录: 在 app/middleware/ 下创建文件,Nuxt 自动注册。命名中间件文件名如 auth.ts,全局中间件文件名加 .global 后缀如 auth.global.ts
  2. 命名中间件: 只对声明了 definePageMeta({ middleware: 'auth' }) 的页面生效,适合只有部分页面需要守卫的场景
  3. 全局中间件: 每次路由变化都执行,适合大部分页面都需要守卫的场景,在中间件内用 to.path 判断排除特定页面
  4. 核心 API: defineNuxtRouteMiddleware((to, from) => { ... }),返回 navigateTo('/path') 跳转,返回 abortNavigation() 阻止导航,不返回值则放行
  5. 执行时机: SSR 时服务端执行一次,客户端导航时客户端执行
  6. 推荐做法: 你的项目只有 admin 页面需要登录,用命名中间件在每个 admin 页面声明即可,不需要全局中间件

Q29 - 2026/05/28

问题: 为什么定义了防止重复登录的路由守卫但没有生效?

回答摘要: 路由中间件中调用 navigateTo() 必须 return,否则跳转不会生效。

要点:

  1. 根本原因: 中间件中 if (loggedIn.value) navigateTo('/') 没有 return,navigateTo() 调用了但返回值被丢弃,导航不会被中断,页面正常渲染
  2. 正确写法: if (loggedIn.value) return navigateTo('/')——return 告诉 Nuxt 中间件要中断当前导航并执行跳转
  3. 中间件返回值规则: return navigateTo('/path') = 跳转;return abortNavigation() = 阻止导航;不 return = 放行,继续正常渲染

Q30 - 2026/05/28

问题: 通过 session 判断用户登录,session 的生命周期是怎样的?

回答摘要: 项目使用 nuxt-auth-utils 模块(底层 h3 的 useSession),session 默认存在服务端内存中,通过 cookie 传递 session ID。生命周期:创建(登录)→ 携带(每次请求)→ 读取(鉴权)→ 销毁(登出/过期)。

要点:

  1. 创建: setUserSession(event, { user: {...} }) 时,h3 生成 session ID,数据存服务端内存,通过 Set-Cookie 写入浏览器
  2. 携带: 浏览器每次请求自动带 cookie,h3 从 cookie 中取 session ID 查出 session 数据
  3. 读取: requireUserSession(event) 服务端鉴权;useUserSession() 客户端组合式函数获取登录状态
  4. 销毁: clearUserSession(event) 清除服务端数据 + 删除浏览器 cookie
  5. 过期: 默认存在内存中,服务重启 session 全部丢失,用户需重新登录
  6. 生产环境注意: 默认内存存储不适合生产,需要配置持久化(Redis/数据库)和过期时间(nuxt.config.tssession: { maxAge: 秒数 }

Q31 - 2026/05/28

问题: 调用 logout 后跳转成功,但路由守卫没生效,仍然能访问 admin 页面(session 没被清除)?

回答摘要: 服务端 session 已清除,但客户端 useUserSession() 的状态仍缓存在内存中,路由守卫读到的是旧状态。登出后需调用 useUserSession() 返回的 clear() 方法清除客户端缓存。

要点:

  1. 根本原因: 服务端 clearUserSession 清了 session,但客户端 useUserSession() 组合式函数在内存中缓存了旧的 loggedInuser 状态,路由守卫读的是客户端缓存而非实时请求服务端
  2. 登出两步缺一不可: ① 服务端清除 session(clearUserSession(event))② 客户端清除状态(useUserSession().clear()
  3. 修复方式: handleLogout 中在调用 loginAPI.logout() 后,再调用 const { clear } = useUserSession(); await clear() 清除客户端缓存,然后再 navigateTo('/')
  4. 教训: Nuxt 的 session 是双层状态——服务端真实数据 + 客户端缓存快照,登出时两边都要清

Q32 - 2026/05/28

问题: 登录页面登录成功后 navigateTo('/admin') 没有执行,无法跳转到管理页面?

回答摘要: loginAPI.login() 在服务端创建了 session,但客户端 useUserSession() 的状态还是 loggedIn=falsenavigateTo('/admin') 触发 admin 路由守卫时,守卫读到的是旧的客户端状态,将用户重定向回了登录页。

要点:

  1. 根本原因: 和登出问题一样——服务端 session 变了但客户端缓存没同步。登录成功后客户端 loggedIn 仍为 false
  2. 修复: 登录成功后、跳转前,调用 await useUserSession().fetch() 从服务端拉取最新 session 状态同步到客户端
  3. 代码:
    // 登录成功分支
    else {
      logSuccess.value = true
      const { fetch } = useUserSession()
      await fetch()              // 同步客户端状态
      navigateTo('/admin')       // 现在守卫能读到 loggedIn=true
    }
  4. 通用规律: 任何改变服务端 session 的操作(登录/登出)之后,都必须显式同步客户端状态——登录用 fetch(),登出用 clear()

Q33 - 2026/05/28

问题: 想给管理页面做新增/修改博客的弹窗,应该怎么做?

回答摘要: 创建一个 Modal 组件,通过 props 控制显示/隐藏和模式(新增/编辑),表单提交时构造 FormData 匹配现有后端接口。编辑模式需要额外新增 PUT 接口。

要点:

  1. 组件结构: AdminPostModal.vue,props 包含 visible(是否显示)和 post(编辑时传入已有数据),emit 包含 closesuccess
  2. 表单字段: 标题、摘要、slug、标签(逗号分隔)、状态(draft/published/hidden)、Markdown 文件上传,与 createPost 的 Zod schema 对应
  3. 提交方式: 使用 FormDatafile 字段放 md 文件,meta 字段放 JSON 字符串(含 title/summary/slug/post_status/tags)
  4. 父组件集成: admin/index.vue 中维护 modalVisibleeditingPost 状态,"+"按钮打开新增弹窗,UPDATE 按钮打开编辑弹窗,提交成功后重新拉取列表
  5. 修改功能前提: 目前后端只有 POST /posts(创建),修改功能需要新增 PUT /posts/[id] 接口
  6. 弹窗 UI 关键: 固定定位遮罩层(fixed inset-0 bg-black/50)、点击遮罩关闭(@click.self)、表单在白色卡片中、z-index 确保在最上层

Q34 - 2026/06/04

问题: 如何编写根据 id 删除博客的接口?

回答摘要: 创建 server/api/posts/[id].delete.ts 路由文件,在 service 层新增 deletePost 函数,用 Prisma 的 delete 方法按 id 删除。关联的 post_tags 因 schema 中 onDelete: Cascade 会自动级联删除。

要点:

  1. 文件命名: server/api/posts/[id].delete.ts[id] 是动态路由参数,.delete 限定只响应 DELETE 请求
  2. 路由层: getRouterParam(e, 'id') 从 URL 路径中取出 id 字符串,如 DELETE /api/posts/123 得到 "123"
  3. BigInt 转换: posts 表主键是 @db.UnsignedBigInt,Prisma 生成的类型是 bigint,需用 BigInt(id) 将字符串转换
  4. Service 层: 先用 findUnique 检查文章是否存在(不存在抛 404),再用 prisma.posts.delete({ where: { id } }) 删除
  5. 级联删除: schema 中 post_tags 表的 posts 关系已设 onDelete: Cascade,删 posts 时关联的 post_tags 记录会被数据库自动删除,无需手动处理
  6. 前端调用: 在 services/posts.ts 中新增 delete(id) 方法,调用 useAPI('posts/' + id, { method: 'DELETE' })

Q35 - 2026/06/05

问题: Nuxt SSR 项目中想在 script 里写 setInterval,但没有 onUnmounted,应该怎么清除避免内存泄漏?

回答摘要: Nuxt 中 onUnmounted 是可用的,它和 onMounted 一样只在客户端执行。setInterval 放在 onMounted 里启动,onUnmounted 里清除即可。

要点:

  1. 常见误解: 以为 SSR 没有 onUnmounted,实际上它一直存在,只是 SSR 阶段不会触发(和 onMounted 一样只在客户端运行)
  2. 标准写法: onMountedsetIntervalonUnmountedclearInterval,两边都只在客户端执行,SSR 完全不涉及
  3. composable 场景: 推荐用 onScopeDispose 代替 onUnmounted,它绑定的是 Vue 的 effect scope 而非组件实例,更适合 composable 夽数
  4. 核心原则: 所有浏览器 API(setIntervalsetTimeoutdocument 等)都放在 onMounted 里启动,onUnmounted / onScopeDispose 里清理
  5. 类型技巧: ReturnType<typeof setInterval> 用来声明 timer 变量类型,兼容浏览器和 Node 环境

Q36 - 2026/06/06

问题: Nuxt 中已经封装了全局请求 API:app/composables/useAPI.ts。后来发现自己只是封装了 useFetch(),而 useFetch 不适合客户端交互请求,比如增删改。useFetch() 封装里有 $fetch: useNuxtApp().$api,这是 $fetch 吗?useNuxtApp().$api 又是什么?

回答摘要: useNuxtApp().$api 是项目里通过 Nuxt 插件注入的自定义 $fetch 实例,它来自 app/plugins/api.ts 中的 $fetch.create()useAPI() 本质上仍然是一个带默认配置的 useFetch,只是告诉 useFetch 内部请求时使用自定义的 $api。读取页面初始数据适合用 useAPI/useFetch,按钮点击、表单提交、增删改等交互请求更适合直接用 $api/$fetch

要点:

  1. $fetch 是什么: $fetch 是 Nuxt 内置的请求函数,基于 ofetch,可以在客户端和服务端发 HTTP 请求
  2. $api 是什么: $api 不是 Nuxt 原生变量,而是项目里在 app/plugins/api.ts 通过 provide: { api } 注入出来的自定义请求实例
  3. 为什么能写 $api: Nuxt 插件 provide 的字段会自动加 $ 挂到 NuxtApp 上,所以 provide: { api } 对应的访问方式是 useNuxtApp().$api
  4. useAPI 的本质: useAPI = createUseFetch(...),所以它仍然返回 data/error/status/refresh 这些 useFetch 风格的响应式状态
  5. $fetch: useNuxtApp().$api 的含义: 这不是直接调用 $fetch,而是把 useFetch 底层默认使用的 $fetch 替换为项目自定义的 $api
  6. 场景区分: 页面初始化、SSR 数据读取、列表详情读取适合 useAPI/useFetch;点击按钮、登录、登出、上传、删除、更新适合 $api/$fetch
  7. 错误处理差异: useFetch/useAPI 的错误通常在 error.value 中;$fetch/$api 遇到非 2xx 响应通常会 throw,更适合 try/catch

项目里的对应关系:

// app/plugins/api.ts
export default defineNuxtPlugin(() => {
  const config = useRuntimeConfig()

  const api = $fetch.create({
    baseURL: config.public.apiBase,
    onRequest() {
      // 请求拦截器
    },
    onResponse() {
      // 响应拦截器
    },
    onResponseError({ response }) {
      console.log('响应错误!错误代码:', response.status, ' 错误内容:', response._data)
    }
  })

  return {
    provide: {
      api
    }
  }
})
// app/composables/useAPI.ts
export const useAPI = createUseFetch((callerOptions) => {
  const config = useRuntimeConfig()

  return {
    baseURL: config.public.apiBase,
    $fetch: useNuxtApp().$api as typeof $fetch,
    ...callerOptions,
  }
})

推荐写法:

// 页面初始化 / SSR 数据读取:用 useAPI
const { data, error, refresh } = await useAPI('posts')
// 点击按钮 / 表单提交 / 增删改:用 $api
const { $api } = useNuxtApp()

const result = await $api('posts', {
  method: 'POST',
  body: formData
})

实际改造方向:

  1. PostApi.getList()PostApi.getDetail(slug) 这类读取接口可以继续保留 useAPI
  2. loginAPI.login()loginAPI.logout()PostApi.create()PostApi.deletePost() 这类交互请求更适合改成直接调用 useNuxtApp().$api
  3. BaseModal.vue 里已有 try/catch,如果 API 方法改成 $api,错误捕获方式会更自然

Comments

No comments yet.