diff --git a/docs/superpowers/plans/2026-04-18-api-error-toast-implementation-plan.md b/docs/superpowers/plans/2026-04-18-api-error-toast-implementation-plan.md new file mode 100644 index 0000000..79a1775 --- /dev/null +++ b/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(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 + const data = e.data + if (data && typeof data === 'object' && data !== null) { + const m = (data as Record).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[1]> +export type ClientFetchOptions = RequestOptions & { notify?: boolean } + +export function useClientApi() { + const toast = useToast() + + async function fetchData(url: string, options?: ClientFetchOptions): Promise { + const { notify = true, ...rest } = options ?? {} + try { + const res = await request>(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('/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>(...))` 改为 `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` 与 `ApiResponse`、`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 顺序改完,关键点自检。 + +你要用哪一种?