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.add 后 rethrow。useAuthSession、useGlobalConfig 等 SSR/静默路径继续直接使用 ~/utils/http/factory 的 request,不经过 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: false,catch 中用 getApiErrorMessage |
| 其余见各 Task | 修改 | 交互型 request 迁移为 fetchData;删除重复 error toast / 本地 extract 函数 |
明确不在本次迁移: 仅 unwrapApiBody(useRequestFetch(...)) 或 $fetch 的 useAsyncData 首屏数据(无用户「再点一次」的公开页头图等)——保持现有静默或后续单独加「重试」再挂通知。
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)。 -
对 仅 HTTP 的
await request(...)且未unwrap的调用(例如旧版createUser):改为fetchData,确保code !== 0也会抛错并提示(此前可能静默收到code: 1体)。 -
Step 2:
me/admin/users/index.vue特别说明 -
load、createUser、setStatus全部使用fetchData;createUser成功后保留清空表单与load()。 -
无需再手写 try/catch 仅为了 toast。
-
Step 3:
me/index.vue -
onMounted中拉取/api/me/profile:使用fetchData(默认 toast);catch中置publicSlug为null的逻辑保留在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并在catch设message——推荐:加载失败用 toast(notify: true),上传/保存继续用页面message:fetchData(..., { notify: false }),catch里message.value = getApiErrorMessage(e),删除手写statusMessage判断。 -
save内refreshAuthSession的catch保持空(不 toast)。 -
Step 2:
media-storage.vue/orphans.vue -
用
fetchData替换request+unwrapApiBody;删除extractError与catch内的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中 errortoast.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 一致;ClientFetchOptions 与 request 第二参数兼容。
执行交接
Plan 已保存至 docs/superpowers/plans/2026-04-18-api-error-toast-implementation-plan.md。
可选执行方式:
- Subagent-Driven(推荐) — 每个 Task 单独开 subagent,任务间人工快速过目。
- Inline Execution — 在同一会话按 Task 顺序改完,关键点自检。
你要用哪一种?