Jelajahi Sumber

feat(dashboard-v6): 类型化 v3 客户端与统一错误出口,heartbeat 切 v3

v3 调用走 openapi-fetch,路径字面量、HTTP 方法、路径参数名、响应字段类型
全部编译期检查——手写 URL 拼错了编译器抓不到。

- src/api/client.ts:共享客户端,baseUrl=/api,token 由中间件注入。
  unwrap() 失败时弹提示并抛出,unwrapQuiet() 只抛不弹(给心跳轮询这类
  自己渲染失败状态的调用,否则网络一断就每隔几秒弹一次)
- src/api/error.ts:ApiError 与提示的唯一出口,将来换 Notification 只改这里。
  installApiErrorHandler() 装全局兜底,消掉未捕获 promise 的噪音,且只认
  已提示过的 ApiError,v2 的 HttpError 行为不变
- 业务代码从此只写成功路径:不判断 ok 字段(v2 那套在全仓库重复了 221 处)、
  不写 try/catch 只为弹窗。表单要字段级错误时用 fieldErrorsOf(e) 取 422 的 errors
- api-health.ts 彻底切到 v3:删掉运行时开关、v2 分支与抹平两种响应的归一化层。
  重构是为了提升不是为了兼容,回退靠 git revert;生产上本来也没有运行时切换,
  前端是静态构建产物,任何改动都要重新构建部署
- v3 不 import v2 的任何东西,等 v2 调用点消失 request.ts 可整个删掉

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
visuddhinanda 3 minggu lalu
induk
melakukan
e3ef0bc20c

+ 18 - 7
dashboard-v6/src/api/api-health.ts

@@ -1,10 +1,21 @@
 // src/api/api-health.ts
+//
+// 心跳检测。**已完全切到 v3**,没有 v2 分支——迁移是一步到位的,
+// 要回退就 revert 那个 commit,不在代码里留开关。
 
-import { get } from "../request";
+import { api, unwrapQuiet } from "./client";
+import type { paths } from "./schema";
 
-export interface IHealth {
-  createdAt: string;
-}
-export const apiServerHealth = (): Promise<IHealth> => {
-  return get<IHealth>("/api/v2/heartbeat");
-};
+/** 响应类型直接取自规格,不手写 */
+export type Heartbeat = NonNullable<
+  paths["/v3/heartbeat"]["get"]["responses"][200]["content"]["application/json"]["data"]
+>;
+
+/**
+ * 服务是否在正常响应。
+ *
+ * 用 unwrapQuiet:NetworkStatus 会把失败渲染成状态条,不该再弹窗。
+ * 停机维护时后端回 503,这里会抛。
+ */
+export const apiServerHealth = async (): Promise<Heartbeat> =>
+  unwrapQuiet(await api.GET("/v3/heartbeat")).data ?? {};

+ 85 - 0
dashboard-v6/src/api/client.ts

@@ -0,0 +1,85 @@
+// src/api/client.ts
+//
+// 共享的类型化 API 客户端。v3 的新调用一律走这里,v2 的老调用继续用 src/request.ts。
+//
+// 与手写 URL 相比,它把「路径字面量」和「HTTP 方法」也纳入了类型检查:
+// 路径拼错、对只有 GET 的端点用 POST、路径参数名写错,都会在编译期报错。
+
+import createClient, { type Middleware } from "openapi-fetch";
+
+import { get as getToken } from "../reducers/session";
+import { ApiError, reportApiError } from "./error";
+import type { paths } from "./schema";
+
+/** 每次请求注入 Bearer token,等价于 request.ts 里 options() 做的事 */
+const authMiddleware: Middleware = {
+  onRequest({ request }) {
+    const token = getToken();
+    if (token) {
+      request.headers.set("Authorization", `Bearer ${token}`);
+    }
+    return request;
+  },
+};
+
+/**
+ * baseUrl 是 "/api",所以调用时写的是 OpenAPI 规格里的 path(如 "/v3/heartbeat"),
+ * 最终请求 "/api/v3/heartbeat"。开发期由 vite proxy 转发到后端。
+ */
+export const api = createClient<paths>({
+  baseUrl: "/api",
+  credentials: "include",
+  mode: "cors",
+});
+
+api.use(authMiddleware);
+
+/** openapi-fetch 的返回值:非 2xx 时不抛异常,而是把错误放进 error */
+type FetchResult<T> = { data?: T; error?: unknown; response: Response };
+
+/** RFC 9457 的错误体 */
+type ProblemDetails = {
+  detail?: string;
+  title?: string;
+  errors?: Record<string, string[]>;
+};
+
+const toApiError = (result: FetchResult<unknown>): ApiError => {
+  const problem = result.error as ProblemDetails | undefined;
+  // 422 的字段级错误一并带上,表单可以直接用
+  return new ApiError(
+    result.response.status,
+    problem?.detail ?? problem?.title,
+    problem?.errors,
+  );
+};
+
+/**
+ * 取出数据;失败则**弹出提示并抛出**。
+ *
+ * 于是调用点之后的代码保证是成功路径,既不用判断 `ok` 字段(v2 那套在全仓库
+ * 重复了 221 处),也不用写 try/catch 去弹窗——异常由 error.ts 的全局兜底收尾。
+ *
+ * 需要拿到字段级错误时(表单),再自己 catch 并用 `fieldErrorsOf(e)`。
+ */
+export const unwrap = <T>(result: FetchResult<T>): T => {
+  if (result.error !== undefined || result.data === undefined) {
+    const error = toApiError(result);
+    reportApiError(error);
+    throw error;
+  }
+  return result.data;
+};
+
+/**
+ * 同 unwrap,但**不弹提示**,只抛异常。
+ *
+ * 给那些自己渲染失败状态的调用用——例如心跳轮询:网络一断就每隔几秒弹一次窗,
+ * 比不弹更糟。见 `api-health.ts`。
+ */
+export const unwrapQuiet = <T>(result: FetchResult<T>): T => {
+  if (result.error !== undefined || result.data === undefined) {
+    throw toApiError(result);
+  }
+  return result.data;
+};

+ 61 - 0
dashboard-v6/src/api/error.ts

@@ -0,0 +1,61 @@
+// src/api/error.ts
+//
+// v3 API 的错误类型与统一出口。
+//
+// **与 v2 完全独立**:v2 用 `src/request.ts` 的 HttpError,有自己的错误语义,
+// 两边不共用类型、不互相 import。等资源全部迁完,`request.ts` 连同 HttpError
+// 一起删掉即可,不会牵连这里。
+
+import { message } from "antd";
+
+/** 422 带回的字段级错误,可直接喂给 antd Form 的 setFields */
+export type FieldErrors = Record<string, string[]>;
+
+/**
+ * v3 接口返回非 2xx 时抛出的错误。
+ *
+ * message 取自后端 RFC 9457 的 detail,已由后端本地化,前端不用再编文案。
+ */
+export class ApiError extends Error {
+  readonly status: number;
+
+  readonly fields?: FieldErrors;
+
+  /** 已经提示过,全局兜底据此消掉重复弹窗与控制台噪音 */
+  reported = false;
+
+  constructor(status: number, message?: string, fields?: FieldErrors) {
+    super(message ?? `HTTP ${status}`);
+    this.name = "ApiError";
+    this.status = status;
+    this.fields = fields;
+  }
+}
+
+export const fieldErrorsOf = (e: unknown): FieldErrors | undefined =>
+  e instanceof ApiError ? e.fields : undefined;
+
+/**
+ * 提示一条 API 错误。
+ *
+ * 将来要换成 Notification、或按状态码分级展示,只改这一个函数。
+ */
+export const reportApiError = (e: ApiError): void => {
+  e.reported = true;
+  message.error(e.message);
+};
+
+/**
+ * 全局兜底:业务代码不 catch 时,浏览器会报 "Uncaught (in promise)"。
+ * 这里把**已经提示过的** v3 错误标记为已处理,消掉那条噪音。
+ *
+ * 只认 ApiError,v2 的 HttpError 不受影响。
+ */
+export const installApiErrorHandler = (): void => {
+  window.addEventListener("unhandledrejection", (event) => {
+    const reason: unknown = event.reason;
+    if (reason instanceof ApiError && reason.reported) {
+      event.preventDefault();
+    }
+  });
+};

File diff ditekan karena terlalu besar
+ 342 - 637
dashboard-v6/src/api/schema.d.ts


+ 4 - 0
dashboard-v6/src/main.tsx

@@ -1,6 +1,7 @@
 import { StrictMode } from "react";
 import { createRoot } from "react-dom/client";
 
+import { installApiErrorHandler } from "./api/error";
 import App from "./App.tsx";
 
 import "./index.css";
@@ -17,6 +18,9 @@ const removeAppLoading = () => {
   }
 };
 
+// v3 API 错误的全局兜底:业务代码不 catch 时消掉 "Uncaught (in promise)" 噪音
+installApiErrorHandler();
+
 createRoot(document.getElementById("root")!).render(
   <StrictMode>
     <App />

Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini