1 changed files with 320 additions and 0 deletions
@ -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…
Reference in new issue