You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

12 KiB

API 失败统一文案与 Toast 实现计划

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 在客户端为用户可见的 API 调用提供统一的错误文案解析与默认 error toast,消除静默失败与多处重复的 extractError;认证刷新、全站配置 SSR、登录/注册/资料页内联错误等路径使用 notify: false 避免重复提示。

Architecture: 新增纯函数 getApiErrorMessage(与 UI 无关)与 composable useClientApi().fetchData,在单一路径内完成 request + unwrapApiBody 并在失败时按 spec 顺序解析文案;默认 notify: true 时在客户端 toast.addrethrowuseAuthSessionuseGlobalConfig 等 SSR/静默路径继续直接使用 ~/utils/http/factoryrequest,不经过 useClientApi

Tech Stack: Nuxt 4、Nuxt UI useToast、ofetch/$fetch(项目内 request 实例)、现有 ApiResponse / unwrapApiBody

依据 spec: docs/superpowers/specs/2026-04-18-api-error-toast-design.md


文件结构(新增与修改)

路径 动作 职责
app/utils/http/error-message.ts 新建 getApiErrorMessage(error: unknown): string,唯一解析规则
app/composables/useClientApi.ts 新建 fetchData<T>(url, options?),剥离 notify 后调用 request + unwrapApiBody
app/composables/useAuthSession.ts 不改 保持裸 request + unwrapApiBody,401 静默逻辑不变
app/composables/useGlobalConfig.ts 不改 useAsyncData 在服务端执行,禁止在此引入 toast
app/pages/login/index.vue 修改 内联错误:fetchData + notify: falsecatch 中用 getApiErrorMessage
其余见各 Task 修改 交互型 request 迁移为 fetchData;删除重复 error toast / 本地 extract 函数

明确不在本次迁移:unwrapApiBody(useRequestFetch(...))$fetchuseAsyncData 首屏数据(无用户「再点一次」的公开页头图等)——保持现有静默或后续单独加「重试」再挂通知。


Task 1: getApiErrorMessage

Files:

  • Create: app/utils/http/error-message.ts

  • Test: 手工(见 Task 6);本项目无现成 unit test runner

  • Step 1: 新建 error-message.ts

将下列实现原样写入 app/utils/http/error-message.ts(顺序与 spec 第 4 节一致;statusCode 分支在 Error.message 之后,以便 unwrapApiBody 抛出的业务 Error 优先使用其 message)。

export function getApiErrorMessage(error: unknown): string {
  if (error && typeof error === 'object') {
    const e = error as Record<string, unknown>
    const data = e.data
    if (data && typeof data === 'object' && data !== null) {
      const m = (data as Record<string, unknown>).message
      if (typeof m === 'string' && m.trim().length > 0) {
        return m
      }
    }
    const sm = e.statusMessage
    if (typeof sm === 'string' && sm.trim().length > 0) {
      return sm
    }
  }

  if (error instanceof Error && error.message.trim().length > 0) {
    return error.message
  }

  if (error && typeof error === 'object' && 'statusCode' in error) {
    const code = (error as { statusCode?: number }).statusCode
    if (code === 401) {
      return '登录已失效,请重新登录'
    }
    if (code === 403) {
      return '没有权限执行此操作'
    }
    if (code === 404) {
      return '请求的资源不存在'
    }
    if (typeof code === 'number' && code >= 500) {
      return '服务暂时不可用,请稍后重试'
    }
  }

  if (error instanceof TypeError) {
    return '网络异常,请检查连接后重试'
  }

  return '请求失败,请稍后重试'
}
  • Step 2: Commit
git add app/utils/http/error-message.ts
git commit -m "feat(http): add getApiErrorMessage for unified API error copy"

Task 2: useClientApi / fetchData

Files:

  • Create: app/composables/useClientApi.ts

  • Step 1: 新建 composable

写入 app/composables/useClientApi.ts

import { request, unwrapApiBody, type ApiResponse } from '~/utils/http/factory'
import { getApiErrorMessage } from '~/utils/http/error-message'

type RequestOptions = NonNullable<Parameters<typeof request>[1]>
export type ClientFetchOptions = RequestOptions & { notify?: boolean }

export function useClientApi() {
  const toast = useToast()

  async function fetchData<T>(url: string, options?: ClientFetchOptions): Promise<T> {
    const { notify = true, ...rest } = options ?? {}
    try {
      const res = await request<ApiResponse<T>>(url, rest)
      return unwrapApiBody(res)
    } catch (e: unknown) {
      if (import.meta.client && notify) {
        toast.add({ title: getApiErrorMessage(e), color: 'error' })
      }
      throw e
    }
  }

  return { fetchData, getApiErrorMessage }
}

说明:notify: false 时不弹 toast,调用方用 getApiErrorMessage(e) 写入表单/内联 message(登录、注册、资料保存/上传)。

  • Step 2: Commit
git add app/composables/useClientApi.ts
git commit -m "feat: add useClientApi fetchData with optional error toast"

Task 3: 登录 / 注册(内联错误,禁止 toast)

Files:

  • Modify: app/pages/login/index.vue

  • Modify: app/pages/register/index.vue

  • Step 1: 登录页

  • import { getApiErrorMessage } from '~/utils/http/error-message'(或 useClientApi 解构出的 getApiErrorMessage,二选一保持文件内一致)。

  • request + unwrapApiBody 合并为:

const { fetchData } = useClientApi()
// ...
const res = await fetchData<LoginResult>('/api/auth/login', {
  method: 'POST',
  body: { username: state.username, password: state.password },
  notify: false,
})
// 后续仍使用 res.user.username 等与现逻辑一致
  • catch 中:resultMessage.value = getApiErrorMessage(error)(删除仅看 statusMessage 的分支)。

  • Step 2: 注册页

  • 同样使用 fetchData(..., { notify: false }) 替代 unwrapApiBody(await request(...))

  • catch 使用 getApiErrorMessage(error)

  • Step 3: Commit

git add app/pages/login/index.vue app/pages/register/index.vue
git commit -m "fix(auth): unify login/register error copy without duplicate toast"

Task 4: 控制台与后台 — 列表加载与变更

Files:

  • Modify: app/pages/me/admin/users/index.vue

  • Modify: app/pages/me/admin/config/index.vue

  • Modify: app/pages/me/posts/index.vue

  • Modify: app/pages/me/posts/new.vue

  • Modify: app/pages/me/posts/[id].vue

  • Modify: app/pages/me/timeline/index.vue

  • Modify: app/pages/me/rss/index.vue

  • Modify: app/pages/me/index.vue

  • Step 1: 各文件统一模式

  • const { fetchData } = useClientApi()(或仅在异步函数内调用 composable,遵循 Vue/Nuxt 规则)。

  • 所有「用户等待结果」的 unwrapApiBody(await request<ApiResponse<...>>(...)) 改为 await fetchData<...>(...)(默认 notify: true)。

  • 仅 HTTPawait request(...) 且未 unwrap 的调用(例如旧版 createUser):改为 fetchData,确保 code !== 0 也会抛错并提示(此前可能静默收到 code: 1 体)。

  • Step 2: me/admin/users/index.vue 特别说明

  • loadcreateUsersetStatus 全部使用 fetchDatacreateUser 成功后保留清空表单与 load()

  • 无需再手写 try/catch 仅为了 toast。

  • Step 3: me/index.vue

  • onMounted 中拉取 /api/me/profile:使用 fetchData(默认 toast);catch 中置 publicSlugnull 的逻辑保留在 catch 内(避免未登录时刷屏时可考虑 notify: false——若当前接口未登录返回 401,可按产品选择;推荐 维持 notify: true 与 spec 401 文案一致,仍 catch 置空 slug)。

  • Step 4: Commit

git add app/pages/me/admin/users/index.vue app/pages/me/admin/config/index.vue \
  app/pages/me/posts/index.vue app/pages/me/posts/new.vue app/pages/me/posts/[id].vue \
  app/pages/me/timeline/index.vue app/pages/me/rss/index.vue app/pages/me/index.vue
git commit -m "fix(me): use fetchData for admin and console API calls"

Task 5: 资料页、媒体工具、组件

Files:

  • Modify: app/pages/me/profile/index.vue

  • Modify: app/pages/me/admin/media-storage.vue

  • Modify: app/pages/me/media/orphans.vue

  • Modify: app/components/PostBodyMarkdownEditor.vue

  • Modify: app/components/PostComments.vue

  • Modify: app/pages/index/index.vue

  • Modify: app/components/AppShell.vue

  • Step 1: me/profile/index.vue

  • load:可用 fetchData默认 toast;若希望仅顶部 message 而不 toast,两路统一为 notify: false 并在 catchmessage——推荐:加载失败用 toast(notify: true),上传/保存继续用页面 messagefetchData(..., { notify: false })catchmessage.value = getApiErrorMessage(e),删除手写 statusMessage 判断。

  • saverefreshAuthSessioncatch 保持空(不 toast)。

  • Step 2: media-storage.vue / orphans.vue

  • fetchData 替换 request + unwrapApiBody;删除 extractErrorcatch 内的 toast.add(避免双弹)。

  • 成功路径的 success toast 保留。

  • Step 3: PostBodyMarkdownEditor.vue

  • 上传改为 fetchData;删除 extractUploadError 与错误 toast;错误由 fetchData 统一 toast。

  • Step 4: PostComments.vue

  • 删除 extractErrorMessage

  • deleteComment / submitComment 中有登录分支用 request、访客用 $fetch:统一为 fetchData(底层仍走 request 同源 cookie);删除 catch 中 error toast.add

  • useAsyncData 拉评论列表 不改(服务端/首屏无 toast)。

  • Step 5: 首页与 AppShell 退出登录

  • unwrapApiBody(await request('/api/auth/logout', ...)) 改为 fetchData(默认 toast),catch 中视需要仍 clear() / 导航;若退出失败需用户感知,保留 finally 重置 logoutLoading

  • Step 6: Commit

git add app/pages/me/profile/index.vue app/pages/me/admin/media-storage.vue \
  app/pages/me/media/orphans.vue app/components/PostBodyMarkdownEditor.vue \
  app/components/PostComments.vue app/pages/index/index.vue app/components/AppShell.vue
git commit -m "fix(ui): migrate profile, media, comments, logout to fetchData"

Task 6: 验证与回归清单

Files: 无新增

  • Step 1: 构建

运行:

cd /home/dash/projects/person-panel && bun run build

预期:构建成功(若仓库当前已有无关 TS 错误,先记录;本任务不强制修复无关文件)。

  • Step 2: 手工回归(与 spec §7 对齐)
场景 预期
错误密码登录 表单下方错误文案; error toast
注册冲突/校验失败 同上
管理端「创建用户」业务失败 至少一条 error toast,文案可读
文章下发表评论失败 error toast
图片上传失败 error toast
任意 403/404 API(可用 DevTools 模拟) 文案符合 getApiErrorMessage 规则
退出登录失败 error toast(若接口可模拟失败)
  • Step 3: Commit(仅当有文档或小修时)

若仅验证无代码变更,可跳过 commit。


Spec 对照(自检)

Spec 章节 对应任务
§3 薄封装 + notify Task 2
§4 文案顺序 Task 1
§5 401 / 登录注册 / refresh 静默 Task 2–3;useAuthSession 未改
§6 迁移与防双弹 Task 4–5 删除重复 toast 与 extract
§7 测试 Task 6

占位符扫描: 本计划不含 TBD/TODO 实现步骤。

类型一致性: fetchData<T>ApiResponse<T>unwrapApiBody 一致;ClientFetchOptionsrequest 第二参数兼容。


执行交接

Plan 已保存至 docs/superpowers/plans/2026-04-18-api-error-toast-implementation-plan.md

可选执行方式:

  1. Subagent-Driven(推荐) — 每个 Task 单独开 subagent,任务间人工快速过目。
  2. Inline Execution — 在同一会话按 Task 顺序改完,关键点自检。

你要用哪一种?