Browse Source

docs: add API error toast implementation plan

Made-with: Cursor
main
npmrun 6 hours ago
parent
commit
c41ba6bf04
  1. 320
      docs/superpowers/plans/2026-04-18-api-error-toast-implementation-plan.md

320
docs/superpowers/plans/2026-04-18-api-error-toast-implementation-plan.md

@ -0,0 +1,320 @@
# 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`)。
```typescript
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**
```bash
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`
```typescript
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**
```bash
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` 合并为:
```typescript
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**
```bash
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**
```bash
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` 中 error `toast.add`
- `useAsyncData` 拉评论列表 **不改**(服务端/首屏无 toast)。
- [ ] **Step 5: 首页与 `AppShell` 退出登录**
- `unwrapApiBody(await request('/api/auth/logout', ...))` 改为 `fetchData`(默认 toast),`catch` 中视需要仍 `clear()` / 导航;若退出失败需用户感知,保留 `finally` 重置 `logoutLoading`
- [ ] **Step 6: Commit**
```bash
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: 构建**
运行:
```bash
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`
**可选执行方式:**
1. **Subagent-Driven(推荐)** — 每个 Task 单独开 subagent,任务间人工快速过目。
2. **Inline Execution** — 在同一会话按 Task 顺序改完,关键点自检。
你要用哪一种?
Loading…
Cancel
Save