refactor(api): split modules under shared/ with API_PREFIX

Move auth and users into folder modules, centralize path constants, and align MSW with endpoint paths in both template apps.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zuiidea
2026-10-18 21:12:02 +08:00
co-authored by Cursor
parent ee87a66a84
commit c02f6dc2f2
38 changed files with 504 additions and 447 deletions
@@ -17,14 +17,14 @@ vp check --no-fmt
pnpm run test:e2e -- e2e/<slug>.spec.ts
```
## 1. Zod and types (`src/api/schemas.ts`)
## 1. Zod and types (`src/api/<slug>/schemas.ts`)
- Define `<Entity>Schema`, `Create<Entity>RequestSchema`, `Update<Entity>RequestSchema` (`Update` = `Create.partial()`).
- For list `output`, reuse `PaginatedDataSchema(<Entity>Schema)` (unwrapped `data`; envelope stays in `request.ts`).
- Add list query schema (e.g. `List<Entity>QuerySchema`) when the table has filters/pagination.
- Export `type <Entity> = z.infer<typeof ...>`.
## 2. Resource API module (`src/api/<slug>.ts`)
## 2. Resource API module (`src/api/<slug>/index.ts`)
- Export path constants if useful for MSW/docs.
- Define APIs with `defineApi` from `@/api/request`: `list` / `create` / `update` / `delete` (and `queryKeys`).
+1 -1
View File
@@ -37,7 +37,7 @@ Run from **this app root** (directory with `package.json` / `vite.config.ts`):
**Purpose:** **schemas → `defineApi` modules → UI**; transport in `src/api/request.ts` only.
**Do:** Define APIs with `defineApi` in `src/api/<resource>.ts` (`method` default `GET`; `params`/`query`/`body` + `output`). Zod in `src/api/schemas.ts`. MSW envelopes `{ code, data, message }` aligned with schemas. Co-locate query keys (e.g. `userQueryKeys`). `hydrateSession` from `@/api/auth` after login/register/session restore.
**Do:** Define APIs with `defineApi` in `src/api/<module>/index.ts` (`method` default `GET`; `params`/`query`/`body` + `output`). Zod in `src/api/<module>/schemas.ts`; shared helpers in `src/api/shared/`. MSW envelopes `{ code, data, message }` aligned with schemas. Co-locate query keys (e.g. `userQueryKeys`). `hydrateSession` from `@/api/auth` after login/register/session restore.
**Do not:** Call axios or `@/api/request` from routes/components — use resource functions (`listUsers`, …). Ad-hoc response shapes; skip schema parsing; mix unrelated domains in one module.
+1 -1
View File
@@ -106,7 +106,7 @@ pnpm run test:e2e:ui # interactive UI mode
AGENTS.md # Agent rules (frontend/api/testing/refactor)
.agents/skills/add-resource/ # CRUD scaffold checklist (Skill)
src/
├── api/ # schemas, request (axios + defineApi), resource APIs
├── api/ # request.ts, shared/, auth/, users/, … (defineApi per module)
├── components/ # Reusable UI primitives and layout shells
│ ├── Layout/ # MainLayout, Header, Sidebar, UserMenu, AppFooter
│ ├── DataTable/ # Shared table frame/skeleton/empty state
@@ -6,26 +6,19 @@ import {
LoginRequestSchema,
PermissionsListSchema,
RegisterRequestSchema,
UserSchema,
} from "@/api/schemas";
} from "@/api/auth/schemas";
import type {
AuthTokens,
LoginRequest,
User,
PermissionsList,
RegisterRequest,
} from "@/api/schemas";
} from "@/api/auth/schemas";
import { UserSchema, type User } from "@/api/users/schemas";
import { AUTH_ENDPOINTS } from "@/api/auth/paths";
import { APP_MENU_TREE, filterMenuTreeByPermissions } from "@/utils/appMenu";
import { useAuthStore } from "@/stores/auth";
export const AUTH_ENDPOINTS = {
login: "/api/auth/login",
register: "/api/auth/register",
refresh: "/api/auth/refresh",
logout: "/api/auth/logout",
user: "/api/auth/user",
permissions: "/api/auth/permissions",
} as const;
export { AUTH_ENDPOINTS } from "@/api/auth/paths";
export const login = defineApi({
method: "POST",
+12
View File
@@ -0,0 +1,12 @@
import { API_PREFIX } from "@/api/shared/prefix";
const AUTH_BASE = `${API_PREFIX}/auth`;
export const AUTH_ENDPOINTS = {
login: `${AUTH_BASE}/login`,
register: `${AUTH_BASE}/register`,
refresh: `${AUTH_BASE}/refresh`,
logout: `${AUTH_BASE}/logout`,
user: `${AUTH_BASE}/user`,
permissions: `${AUTH_BASE}/permissions`,
} as const;
+48
View File
@@ -0,0 +1,48 @@
import { z } from "zod/v4";
import { UserSchema } from "@/api/users/schemas";
/** GET `/api/auth/user` body (no `permissions`; load via `/api/auth/permissions`). */
export const AuthUserResponseSchema = UserSchema.omit({ permissions: true });
export type AuthUserResponse = z.infer<typeof AuthUserResponseSchema>;
export const AuthTokensSchema = z.object({
accessToken: z.string().min(1),
refreshToken: z.string().min(1),
});
export type AuthTokens = z.infer<typeof AuthTokensSchema>;
export const RefreshTokenRequestSchema = z.object({
refreshToken: z.string().min(1),
});
export type RefreshTokenRequest = z.infer<typeof RefreshTokenRequestSchema>;
export const LoginRequestSchema = z.object({
username: z.string().min(1),
password: z.string().min(1),
});
export type LoginRequest = z.infer<typeof LoginRequestSchema>;
/** Ant Design Form may submit `""` for an empty optional email */
const registerOptionalEmailSchema = z
.union([z.string(), z.undefined()])
.transform((v) => {
if (v === undefined) return undefined;
const t = String(v).trim();
return t === "" ? undefined : t;
})
.pipe(z.union([z.string().email(), z.undefined()]));
export const RegisterRequestSchema = z.object({
username: z.string().min(1),
password: z.string().min(6),
email: registerOptionalEmailSchema,
});
export type RegisterRequest = z.infer<typeof RegisterRequestSchema>;
export const PermissionsListSchema = z.array(z.string());
export type PermissionsList = z.infer<typeof PermissionsListSchema>;
+5 -3
View File
@@ -1,11 +1,13 @@
import axios, { type AxiosError, type InternalAxiosRequestConfig } from "axios";
import { z } from "zod/v4";
import { API_BASE_URL } from "@/utils/constants";
import { AuthTokensSchema, RefreshTokenRequestSchema } from "@/api/schemas";
import { AuthTokensSchema, RefreshTokenRequestSchema } from "@/api/auth/schemas";
import { getAccessToken, useAuthStore } from "@/stores/auth";
/** Avoid importing `@/api/auth` here (circular with `defineApi` consumers). */
const REFRESH_PATH = "/api/auth/refresh";
import { AUTH_ENDPOINTS } from "@/api/auth/paths";
/** Avoid importing `@/api/auth` index here (circular with `defineApi` consumers). */
const REFRESH_PATH = AUTH_ENDPOINTS.refresh;
export class ApiError extends Error {
code: number;
+4 -180
View File
@@ -1,180 +1,4 @@
import { z } from "zod/v4";
export const UserSchema = z.object({
id: z.string(),
username: z.string(),
avatar: z.string().nullable(),
email: z.string().nullable(),
roles: z.array(z.string()),
permissions: z.array(z.string()),
});
export type User = z.infer<typeof UserSchema>;
/** GET `/api/auth/user` body (no `permissions`; load via `/api/auth/permissions`). */
export const AuthUserResponseSchema = UserSchema.omit({ permissions: true });
export type AuthUserResponse = z.infer<typeof AuthUserResponseSchema>;
export const AuthTokensSchema = z.object({
accessToken: z.string().min(1),
refreshToken: z.string().min(1),
});
export type AuthTokens = z.infer<typeof AuthTokensSchema>;
export const RefreshTokenRequestSchema = z.object({
refreshToken: z.string().min(1),
});
export type RefreshTokenRequest = z.infer<typeof RefreshTokenRequestSchema>;
export const LoginRequestSchema = z.object({
username: z.string().min(1),
password: z.string().min(1),
});
export type LoginRequest = z.infer<typeof LoginRequestSchema>;
/** Ant Design Form may submit `""` for an empty optional email */
const registerOptionalEmailSchema = z
.union([z.string(), z.undefined()])
.transform((v) => {
if (v === undefined) return undefined;
const t = String(v).trim();
return t === "" ? undefined : t;
})
.pipe(z.union([z.string().email(), z.undefined()]));
export const RegisterRequestSchema = z.object({
username: z.string().min(1),
password: z.string().min(6),
email: registerOptionalEmailSchema,
});
export type RegisterRequest = z.infer<typeof RegisterRequestSchema>;
export const PermissionsListSchema = z.array(z.string());
export type PermissionsList = z.infer<typeof PermissionsListSchema>;
const BaseMenuNodeSchema = z.object({
id: z.string(),
name: z.string(),
icon: z.string().nullable(),
permissions: z.array(z.string()).nullable(),
sort: z.int(),
hidden: z.boolean(),
});
export type MenuItem =
| (z.infer<typeof BaseMenuNodeSchema> & {
kind: "item";
path: string;
children: MenuItem[] | null;
})
| (z.infer<typeof BaseMenuNodeSchema> & {
kind: "group";
path: null;
children: MenuItem[];
});
export const MenuItemSchema: z.ZodType<MenuItem> = z.lazy(() =>
z.discriminatedUnion("kind", [
BaseMenuNodeSchema.extend({
kind: z.literal("item"),
path: z.string(),
children: z.array(MenuItemSchema).nullable(),
}),
BaseMenuNodeSchema.extend({
kind: z.literal("group"),
path: z.null(),
children: z.array(MenuItemSchema),
}),
]),
);
export function ApiResponseSchema<T extends z.ZodTypeAny>(dataSchema: T) {
return z.object({
code: z.int(),
data: dataSchema,
message: z.string(),
});
}
export type ApiResponse<T> = {
code: number;
data: T;
message: string;
};
export function PaginatedResponseSchema<T extends z.ZodTypeAny>(itemSchema: T) {
return z.object({
code: z.int(),
data: z.object({
list: z.array(itemSchema),
total: z.int(),
}),
message: z.string(),
});
}
export type PaginatedData<T> = {
list: T[];
total: number;
};
/** Unwrapped list payload (envelope `data`); use as `defineApi` `output`. */
export function PaginatedDataSchema<T extends z.ZodTypeAny>(itemSchema: T) {
return z.object({
list: z.array(itemSchema),
total: z.int(),
});
}
export const ListUsersQuerySchema = z.object({
limit: z.number().int().positive().optional(),
offset: z.number().int().nonnegative().optional(),
keyword: z.string().optional(),
role: z.string().optional(),
sortField: z.string().optional(),
sortOrder: z.enum(["ascend", "descend"]).optional(),
});
export type ListUsersQuery = z.infer<typeof ListUsersQuerySchema>;
export const IdParamsSchema = z.object({
id: z.string().min(1),
});
export type IdParams = z.infer<typeof IdParamsSchema>;
export const SearchParamsSchema = z.object({
page: z.number().int().positive().catch(1),
pageSize: z.number().int().positive().catch(10),
sortField: z.string().nullable().catch(null),
sortOrder: z.enum(["ascend", "descend"]).nullable().catch(null),
});
export type SearchParams = z.infer<typeof SearchParamsSchema>;
/** Ant Design Input submits `""` when empty; treat as null for optional email */
const createUserEmailSchema = z
.union([z.string(), z.null(), z.undefined()])
.transform((v) => {
if (v == null) return null;
const t = String(v).trim();
return t === "" ? null : t;
})
.pipe(z.union([z.string().email(), z.null()]));
export const CreateUserRequestSchema = z.object({
username: z.string().min(1),
email: createUserEmailSchema,
roles: z.array(z.string()).min(1),
});
export type CreateUserRequest = z.infer<typeof CreateUserRequestSchema>;
export const UpdateUserRequestSchema = CreateUserRequestSchema.partial();
export type UpdateUserRequest = z.infer<typeof UpdateUserRequestSchema>;
/** @deprecated Import from `@/api/<module>/schemas` or `@/api/shared`; re-exports for compatibility. */
export * from "@/api/shared";
export * from "@/api/users/schemas";
export * from "@/api/auth/schemas";
+26
View File
@@ -0,0 +1,26 @@
import { z } from "zod/v4";
export function ApiResponseSchema<T extends z.ZodTypeAny>(dataSchema: T) {
return z.object({
code: z.int(),
data: dataSchema,
message: z.string(),
});
}
export type ApiResponse<T> = {
code: number;
data: T;
message: string;
};
export function PaginatedResponseSchema<T extends z.ZodTypeAny>(itemSchema: T) {
return z.object({
code: z.int(),
data: z.object({
list: z.array(itemSchema),
total: z.int(),
}),
message: z.string(),
});
}
+5
View File
@@ -0,0 +1,5 @@
export * from "./envelope";
export * from "./menu";
export * from "./pagination";
export * from "./params";
export * from "./prefix";
+37
View File
@@ -0,0 +1,37 @@
import { z } from "zod/v4";
const BaseMenuNodeSchema = z.object({
id: z.string(),
name: z.string(),
icon: z.string().nullable(),
permissions: z.array(z.string()).nullable(),
sort: z.int(),
hidden: z.boolean(),
});
export type MenuItem =
| (z.infer<typeof BaseMenuNodeSchema> & {
kind: "item";
path: string;
children: MenuItem[] | null;
})
| (z.infer<typeof BaseMenuNodeSchema> & {
kind: "group";
path: null;
children: MenuItem[];
});
export const MenuItemSchema: z.ZodType<MenuItem> = z.lazy(() =>
z.discriminatedUnion("kind", [
BaseMenuNodeSchema.extend({
kind: z.literal("item"),
path: z.string(),
children: z.array(MenuItemSchema).nullable(),
}),
BaseMenuNodeSchema.extend({
kind: z.literal("group"),
path: z.null(),
children: z.array(MenuItemSchema),
}),
]),
);
+14
View File
@@ -0,0 +1,14 @@
import { z } from "zod/v4";
export type PaginatedData<T> = {
list: T[];
total: number;
};
/** Unwrapped list payload (envelope `data`); use as `defineApi` `output`. */
export function PaginatedDataSchema<T extends z.ZodTypeAny>(itemSchema: T) {
return z.object({
list: z.array(itemSchema),
total: z.int(),
});
}
+16
View File
@@ -0,0 +1,16 @@
import { z } from "zod/v4";
export const IdParamsSchema = z.object({
id: z.string().min(1),
});
export type IdParams = z.infer<typeof IdParamsSchema>;
export const SearchParamsSchema = z.object({
page: z.number().int().positive().catch(1),
pageSize: z.number().int().positive().catch(10),
sortField: z.string().nullable().catch(null),
sortOrder: z.enum(["ascend", "descend"]).nullable().catch(null),
});
export type SearchParams = z.infer<typeof SearchParamsSchema>;
+2
View File
@@ -0,0 +1,2 @@
/** REST API path prefix (no trailing slash). */
export const API_PREFIX = "/api" as const;
@@ -1,21 +1,23 @@
import { z } from "zod/v4";
import { defineApi } from "@/api/request";
import { IdParamsSchema } from "@/api/shared/params";
import { PaginatedDataSchema } from "@/api/shared/pagination";
import {
CreateUserRequestSchema,
IdParamsSchema,
ListUsersQuerySchema,
PaginatedDataSchema,
UpdateUserRequestSchema,
UserSchema,
} from "@/api/schemas";
import type { CreateUserRequest, ListUsersQuery, UpdateUserRequest, User } from "@/api/schemas";
} from "@/api/users/schemas";
import type {
CreateUserRequest,
ListUsersQuery,
UpdateUserRequest,
User,
} from "@/api/users/schemas";
import { API_PREFIX } from "@/api/shared/prefix";
export const USER_ENDPOINTS = {
list: "/api/users",
create: "/api/users",
update: (id: string) => `/api/users/${id}`,
delete: (id: string) => `/api/users/${id}`,
} as const;
export const USERS_PATH = `${API_PREFIX}/users`;
export const USERS_MEMBER_PATH = `${USERS_PATH}/:id`;
export const userQueryKeys = {
all: ["users"] as const,
@@ -23,21 +25,21 @@ export const userQueryKeys = {
};
export const listUsers = defineApi({
path: USER_ENDPOINTS.list,
path: USERS_PATH,
query: ListUsersQuerySchema,
output: PaginatedDataSchema(UserSchema),
});
export const createUser = defineApi({
method: "POST",
path: USER_ENDPOINTS.list,
path: USERS_PATH,
body: CreateUserRequestSchema,
output: UserSchema,
});
export const updateUser = defineApi({
method: "PUT",
path: "/api/users/:id",
path: USERS_MEMBER_PATH,
params: IdParamsSchema,
body: UpdateUserRequestSchema,
output: UserSchema,
@@ -45,7 +47,7 @@ export const updateUser = defineApi({
export const deleteUser = defineApi({
method: "DELETE",
path: "/api/users/:id",
path: USERS_MEMBER_PATH,
params: IdParamsSchema,
output: z.null(),
});
+45
View File
@@ -0,0 +1,45 @@
import { z } from "zod/v4";
export const UserSchema = z.object({
id: z.string(),
username: z.string(),
avatar: z.string().nullable(),
email: z.string().nullable(),
roles: z.array(z.string()),
permissions: z.array(z.string()),
});
export type User = z.infer<typeof UserSchema>;
export const ListUsersQuerySchema = z.object({
limit: z.number().int().positive().optional(),
offset: z.number().int().nonnegative().optional(),
keyword: z.string().optional(),
role: z.string().optional(),
sortField: z.string().optional(),
sortOrder: z.enum(["ascend", "descend"]).optional(),
});
export type ListUsersQuery = z.infer<typeof ListUsersQuerySchema>;
/** Ant Design Input submits `""` when empty; treat as null for optional email */
const createUserEmailSchema = z
.union([z.string(), z.null(), z.undefined()])
.transform((v) => {
if (v == null) return null;
const t = String(v).trim();
return t === "" ? null : t;
})
.pipe(z.union([z.string().email(), z.null()]));
export const CreateUserRequestSchema = z.object({
username: z.string().min(1),
email: createUserEmailSchema,
roles: z.array(z.string()).min(1),
});
export type CreateUserRequest = z.infer<typeof CreateUserRequestSchema>;
export const UpdateUserRequestSchema = CreateUserRequestSchema.partial();
export type UpdateUserRequest = z.infer<typeof UpdateUserRequestSchema>;
+7 -6
View File
@@ -15,6 +15,7 @@ import {
RefreshTokenRequestSchema,
RegisterRequestSchema,
} from "@/api/schemas";
import { AUTH_ENDPOINTS } from "@/api/auth/paths";
const GUEST_REFRESH = "mock-guest-refresh";
const REGISTERED_ACCESS = "mock-registered-access";
@@ -32,7 +33,7 @@ function isRegisteredAuth(authorization: string | null): boolean {
}
export const authHandlers = [
http.post("/api/auth/login", async ({ request }) => {
http.post(AUTH_ENDPOINTS.login, async ({ request }) => {
await withDelay(300);
let json: unknown;
try {
@@ -60,7 +61,7 @@ export const authHandlers = [
return errorResponse(ERROR_CODES.INVALID_CREDENTIALS, "Invalid username or password");
}),
http.post("/api/auth/register", async ({ request }) => {
http.post(AUTH_ENDPOINTS.register, async ({ request }) => {
await withDelay(300);
let json: unknown;
try {
@@ -90,7 +91,7 @@ export const authHandlers = [
});
}),
http.post("/api/auth/refresh", async ({ request }) => {
http.post(AUTH_ENDPOINTS.refresh, async ({ request }) => {
await withDelay(100);
let json: unknown;
try {
@@ -123,9 +124,9 @@ export const authHandlers = [
});
}),
http.post("/api/auth/logout", () => successWithNullBody()),
http.post(AUTH_ENDPOINTS.logout, () => successWithNullBody()),
http.get("/api/auth/user", ({ request }) => {
http.get(AUTH_ENDPOINTS.user, ({ request }) => {
const auth = request.headers.get("authorization");
if (isRegisteredAuth(auth)) {
const session = mockRegisteredSession;
@@ -146,7 +147,7 @@ export const authHandlers = [
return successWithSchema(AuthUserResponseSchema, userWithoutPermissions);
}),
http.get("/api/auth/permissions", ({ request }) => {
http.get(AUTH_ENDPOINTS.permissions, ({ request }) => {
const auth = request.headers.get("authorization");
if (isRegisteredAuth(auth) && mockRegisteredSession) {
return successWithSchema(PermissionsListSchema, ["user:view"] as string[]);
+5 -4
View File
@@ -10,13 +10,14 @@ import {
successWithSchema,
successWithNullBody,
} from "../createHandler";
import { USERS_MEMBER_PATH, USERS_PATH } from "@/api/users";
let users = [...MOCK_USERS];
export const EXPIRED_ACCESS_TOKEN = "__EXPIRED_ACCESS__";
export const userHandlers = [
http.get("/api/users", async ({ request }) => {
http.get(USERS_PATH, async ({ request }) => {
await withDelay(200);
const auth = request.headers.get("authorization");
if (auth === `Bearer ${EXPIRED_ACCESS_TOKEN}`) {
@@ -33,7 +34,7 @@ export const userHandlers = [
return paginatedWithSchema(UserSchema, { list, total: filtered.length });
}),
http.post("/api/users", async ({ request }) => {
http.post(USERS_PATH, async ({ request }) => {
await withDelay(200);
const body = (await request.json()) as Record<string, unknown>;
const newUser = {
@@ -48,7 +49,7 @@ export const userHandlers = [
return successWithSchema(UserSchema, newUser);
}),
http.put("/api/users/:id", async ({ params, request }) => {
http.put(USERS_MEMBER_PATH, async ({ params, request }) => {
await withDelay(200);
const body = (await request.json()) as Record<string, unknown>;
const idx = users.findIndex((u) => u.id === params.id);
@@ -59,7 +60,7 @@ export const userHandlers = [
return successWithSchema(UserSchema, users[idx]);
}),
http.delete("/api/users/:id", async ({ params }) => {
http.delete(USERS_MEMBER_PATH, async ({ params }) => {
await withDelay(200);
users = users.filter((u) => u.id !== params.id);
return successWithNullBody();
+1 -1
View File
@@ -2,7 +2,7 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { Avatar, Button, Space, Form, App, Dropdown, theme, Tag, Flex } from "antd";
import type { TablePaginationConfig } from "antd/es/table/interface";
import { useMemo, useRef, useState } from "react";
import { createUser, deleteUser, listUsers, updateUser, userQueryKeys } from "@/api/user";
import { createUser, deleteUser, listUsers, updateUser, userQueryKeys } from "@/api/users";
import type { User, CreateUserRequest } from "@/api/schemas";
import { z } from "zod/v4";
import { MoreVertical, Pencil, Trash2 } from "lucide-react";
@@ -17,14 +17,14 @@ pnpm exec vp check --no-fmt
pnpm run test:e2e -- e2e/<slug>.spec.ts
```
## 1. Zod and types (`src/api/schemas.ts`)
## 1. Zod and types (`src/api/<slug>/schemas.ts`)
- Define `<Entity>Schema`, `Create<Entity>RequestSchema`, `Update<Entity>RequestSchema` (`Update` = `Create.partial()`).
- For list `output`, reuse `PaginatedDataSchema(<Entity>Schema)` (unwrapped `data`; envelope stays in `request.ts`).
- Add list query schema (e.g. `List<Entity>QuerySchema`) when the table has filters/pagination.
- Export `type <Entity> = z.infer<typeof ...>`.
## 2. Resource API module (`src/api/<slug>.ts`)
## 2. Resource API module (`src/api/<slug>/index.ts`)
- Export path constants if useful for MSW/docs.
- Define APIs with `defineApi` from `@/api/request`: `list` / `create` / `update` / `delete` (and `queryKeys`).
+1 -1
View File
@@ -40,7 +40,7 @@ Run from **this app root** (directory with `package.json` / `vite.config.ts`):
**Purpose:** **schemas → `defineApi` modules → UI**; transport in `src/api/request.ts` only.
**Do:** Define APIs with `defineApi` in `src/api/<resource>.ts` (`method` default `GET`; `params`/`query`/`body` + `output`). Zod in `src/api/schemas.ts`. MSW envelopes `{ code, data, message }` aligned with schemas. Co-locate query keys (e.g. `userQueryKeys`). `hydrateSession` from `@/api/auth` after login/register/session restore.
**Do:** Define APIs with `defineApi` in `src/api/<module>/index.ts` (`method` default `GET`; `params`/`query`/`body` + `output`). Zod in `src/api/<module>/schemas.ts`; shared helpers in `src/api/shared/`. MSW envelopes `{ code, data, message }` aligned with schemas. Co-locate query keys (e.g. `userQueryKeys`). `hydrateSession` from `@/api/auth` after login/register/session restore.
**Do not:** Call axios or `@/api/request` from routes/components — use resource functions (`listUsers`, …). Ad-hoc response shapes; skip schema parsing; mix unrelated domains in one module.
+1 -1
View File
@@ -109,7 +109,7 @@ pnpm run i18n:extract && git diff --exit-code -- src/locales
AGENTS.md # Agent rules (frontend/api/testing/refactor)
.agents/skills/add-resource/ # CRUD scaffold checklist (Skill)
src/
├── api/ # schemas, request (axios + defineApi), resource APIs
├── api/ # request.ts, shared/, auth/, users/, … (defineApi per module)
├── components/ # Reusable UI primitives and layout shells
│ ├── Layout/ # MainLayout, Header, Sidebar, UserMenu, AppFooter
│ ├── DataTable/ # Shared table frame/skeleton/empty state
@@ -6,26 +6,19 @@ import {
LoginRequestSchema,
PermissionsListSchema,
RegisterRequestSchema,
UserSchema,
} from "@/api/schemas";
} from "@/api/auth/schemas";
import type {
AuthTokens,
LoginRequest,
User,
PermissionsList,
RegisterRequest,
} from "@/api/schemas";
} from "@/api/auth/schemas";
import { UserSchema, type User } from "@/api/users/schemas";
import { AUTH_ENDPOINTS } from "@/api/auth/paths";
import { APP_MENU_TREE, filterMenuTreeByPermissions } from "@/utils/appMenu";
import { useAuthStore } from "@/stores/auth";
export const AUTH_ENDPOINTS = {
login: "/api/auth/login",
register: "/api/auth/register",
refresh: "/api/auth/refresh",
logout: "/api/auth/logout",
user: "/api/auth/user",
permissions: "/api/auth/permissions",
} as const;
export { AUTH_ENDPOINTS } from "@/api/auth/paths";
export const login = defineApi({
method: "POST",
+12
View File
@@ -0,0 +1,12 @@
import { API_PREFIX } from "@/api/shared/prefix";
const AUTH_BASE = `${API_PREFIX}/auth`;
export const AUTH_ENDPOINTS = {
login: `${AUTH_BASE}/login`,
register: `${AUTH_BASE}/register`,
refresh: `${AUTH_BASE}/refresh`,
logout: `${AUTH_BASE}/logout`,
user: `${AUTH_BASE}/user`,
permissions: `${AUTH_BASE}/permissions`,
} as const;
+48
View File
@@ -0,0 +1,48 @@
import { z } from "zod/v4";
import { UserSchema } from "@/api/users/schemas";
/** GET `/api/auth/user` body (no `permissions`; load via `/api/auth/permissions`). */
export const AuthUserResponseSchema = UserSchema.omit({ permissions: true });
export type AuthUserResponse = z.infer<typeof AuthUserResponseSchema>;
export const AuthTokensSchema = z.object({
accessToken: z.string().min(1),
refreshToken: z.string().min(1),
});
export type AuthTokens = z.infer<typeof AuthTokensSchema>;
export const RefreshTokenRequestSchema = z.object({
refreshToken: z.string().min(1),
});
export type RefreshTokenRequest = z.infer<typeof RefreshTokenRequestSchema>;
export const LoginRequestSchema = z.object({
username: z.string().min(1),
password: z.string().min(1),
});
export type LoginRequest = z.infer<typeof LoginRequestSchema>;
/** Ant Design Form may submit `""` for an empty optional email */
const registerOptionalEmailSchema = z
.union([z.string(), z.undefined()])
.transform((v) => {
if (v === undefined) return undefined;
const t = String(v).trim();
return t === "" ? undefined : t;
})
.pipe(z.union([z.string().email(), z.undefined()]));
export const RegisterRequestSchema = z.object({
username: z.string().min(1),
password: z.string().min(6),
email: registerOptionalEmailSchema,
});
export type RegisterRequest = z.infer<typeof RegisterRequestSchema>;
export const PermissionsListSchema = z.array(z.string());
export type PermissionsList = z.infer<typeof PermissionsListSchema>;
+5 -3
View File
@@ -1,11 +1,13 @@
import axios, { type AxiosError, type InternalAxiosRequestConfig } from "axios";
import { z } from "zod/v4";
import { API_BASE_URL } from "@/utils/constants";
import { AuthTokensSchema, RefreshTokenRequestSchema } from "@/api/schemas";
import { AuthTokensSchema, RefreshTokenRequestSchema } from "@/api/auth/schemas";
import { getAccessToken, useAuthStore } from "@/stores/auth";
/** Avoid importing `@/api/auth` here (circular with `defineApi` consumers). */
const REFRESH_PATH = "/api/auth/refresh";
import { AUTH_ENDPOINTS } from "@/api/auth/paths";
/** Avoid importing `@/api/auth` index here (circular with `defineApi` consumers). */
const REFRESH_PATH = AUTH_ENDPOINTS.refresh;
export class ApiError extends Error {
code: number;
+4 -179
View File
@@ -1,179 +1,4 @@
import { z } from "zod/v4";
export const UserSchema = z.object({
id: z.string(),
username: z.string(),
avatar: z.string().nullable(),
email: z.string().nullable(),
roles: z.array(z.string()),
permissions: z.array(z.string()),
});
export type User = z.infer<typeof UserSchema>;
export const AuthUserResponseSchema = UserSchema.omit({ permissions: true });
export type AuthUserResponse = z.infer<typeof AuthUserResponseSchema>;
export const AuthTokensSchema = z.object({
accessToken: z.string().min(1),
refreshToken: z.string().min(1),
});
export type AuthTokens = z.infer<typeof AuthTokensSchema>;
export const RefreshTokenRequestSchema = z.object({
refreshToken: z.string().min(1),
});
export type RefreshTokenRequest = z.infer<typeof RefreshTokenRequestSchema>;
export const LoginRequestSchema = z.object({
username: z.string().min(1),
password: z.string().min(1),
});
export type LoginRequest = z.infer<typeof LoginRequestSchema>;
/** Ant Design Form may submit `""` for an empty optional email */
const registerOptionalEmailSchema = z
.union([z.string(), z.undefined()])
.transform((v) => {
if (v === undefined) return undefined;
const t = String(v).trim();
return t === "" ? undefined : t;
})
.pipe(z.union([z.string().email(), z.undefined()]));
export const RegisterRequestSchema = z.object({
username: z.string().min(1),
password: z.string().min(6),
email: registerOptionalEmailSchema,
});
export type RegisterRequest = z.infer<typeof RegisterRequestSchema>;
export const PermissionsListSchema = z.array(z.string());
export type PermissionsList = z.infer<typeof PermissionsListSchema>;
const BaseMenuNodeSchema = z.object({
id: z.string(),
name: z.string(),
icon: z.string().nullable(),
permissions: z.array(z.string()).nullable(),
sort: z.int(),
hidden: z.boolean(),
});
export type MenuItem =
| (z.infer<typeof BaseMenuNodeSchema> & {
kind: "item";
path: string;
children: MenuItem[] | null;
})
| (z.infer<typeof BaseMenuNodeSchema> & {
kind: "group";
path: null;
children: MenuItem[];
});
export const MenuItemSchema: z.ZodType<MenuItem> = z.lazy(() =>
z.discriminatedUnion("kind", [
BaseMenuNodeSchema.extend({
kind: z.literal("item"),
path: z.string(),
children: z.array(MenuItemSchema).nullable(),
}),
BaseMenuNodeSchema.extend({
kind: z.literal("group"),
path: z.null(),
children: z.array(MenuItemSchema),
}),
]),
);
export function ApiResponseSchema<T extends z.ZodTypeAny>(dataSchema: T) {
return z.object({
code: z.int(),
data: dataSchema,
message: z.string(),
});
}
export type ApiResponse<T> = {
code: number;
data: T;
message: string;
};
export function PaginatedResponseSchema<T extends z.ZodTypeAny>(itemSchema: T) {
return z.object({
code: z.int(),
data: z.object({
list: z.array(itemSchema),
total: z.int(),
}),
message: z.string(),
});
}
export type PaginatedData<T> = {
list: T[];
total: number;
};
/** Unwrapped list payload (envelope `data`); use as `defineApi` `output`. */
export function PaginatedDataSchema<T extends z.ZodTypeAny>(itemSchema: T) {
return z.object({
list: z.array(itemSchema),
total: z.int(),
});
}
export const ListUsersQuerySchema = z.object({
limit: z.number().int().positive().optional(),
offset: z.number().int().nonnegative().optional(),
keyword: z.string().optional(),
role: z.string().optional(),
sortField: z.string().optional(),
sortOrder: z.enum(["ascend", "descend"]).optional(),
});
export type ListUsersQuery = z.infer<typeof ListUsersQuerySchema>;
export const IdParamsSchema = z.object({
id: z.string().min(1),
});
export type IdParams = z.infer<typeof IdParamsSchema>;
export const SearchParamsSchema = z.object({
page: z.number().int().positive().catch(1),
pageSize: z.number().int().positive().catch(10),
sortField: z.string().nullable().catch(null),
sortOrder: z.enum(["ascend", "descend"]).nullable().catch(null),
});
export type SearchParams = z.infer<typeof SearchParamsSchema>;
/** Ant Design Input submits `""` when empty; treat as null for optional email */
const createUserEmailSchema = z
.union([z.string(), z.null(), z.undefined()])
.transform((v) => {
if (v == null) return null;
const t = String(v).trim();
return t === "" ? null : t;
})
.pipe(z.union([z.string().email(), z.null()]));
export const CreateUserRequestSchema = z.object({
username: z.string().min(1),
email: createUserEmailSchema,
roles: z.array(z.string()).min(1),
});
export type CreateUserRequest = z.infer<typeof CreateUserRequestSchema>;
export const UpdateUserRequestSchema = CreateUserRequestSchema.partial();
export type UpdateUserRequest = z.infer<typeof UpdateUserRequestSchema>;
/** @deprecated Import from `@/api/<module>/schemas` or `@/api/shared`; re-exports for compatibility. */
export * from "@/api/shared";
export * from "@/api/users/schemas";
export * from "@/api/auth/schemas";
@@ -0,0 +1,26 @@
import { z } from "zod/v4";
export function ApiResponseSchema<T extends z.ZodTypeAny>(dataSchema: T) {
return z.object({
code: z.int(),
data: dataSchema,
message: z.string(),
});
}
export type ApiResponse<T> = {
code: number;
data: T;
message: string;
};
export function PaginatedResponseSchema<T extends z.ZodTypeAny>(itemSchema: T) {
return z.object({
code: z.int(),
data: z.object({
list: z.array(itemSchema),
total: z.int(),
}),
message: z.string(),
});
}
+5
View File
@@ -0,0 +1,5 @@
export * from "./envelope";
export * from "./menu";
export * from "./pagination";
export * from "./params";
export * from "./prefix";
+37
View File
@@ -0,0 +1,37 @@
import { z } from "zod/v4";
const BaseMenuNodeSchema = z.object({
id: z.string(),
name: z.string(),
icon: z.string().nullable(),
permissions: z.array(z.string()).nullable(),
sort: z.int(),
hidden: z.boolean(),
});
export type MenuItem =
| (z.infer<typeof BaseMenuNodeSchema> & {
kind: "item";
path: string;
children: MenuItem[] | null;
})
| (z.infer<typeof BaseMenuNodeSchema> & {
kind: "group";
path: null;
children: MenuItem[];
});
export const MenuItemSchema: z.ZodType<MenuItem> = z.lazy(() =>
z.discriminatedUnion("kind", [
BaseMenuNodeSchema.extend({
kind: z.literal("item"),
path: z.string(),
children: z.array(MenuItemSchema).nullable(),
}),
BaseMenuNodeSchema.extend({
kind: z.literal("group"),
path: z.null(),
children: z.array(MenuItemSchema),
}),
]),
);
@@ -0,0 +1,14 @@
import { z } from "zod/v4";
export type PaginatedData<T> = {
list: T[];
total: number;
};
/** Unwrapped list payload (envelope `data`); use as `defineApi` `output`. */
export function PaginatedDataSchema<T extends z.ZodTypeAny>(itemSchema: T) {
return z.object({
list: z.array(itemSchema),
total: z.int(),
});
}
+16
View File
@@ -0,0 +1,16 @@
import { z } from "zod/v4";
export const IdParamsSchema = z.object({
id: z.string().min(1),
});
export type IdParams = z.infer<typeof IdParamsSchema>;
export const SearchParamsSchema = z.object({
page: z.number().int().positive().catch(1),
pageSize: z.number().int().positive().catch(10),
sortField: z.string().nullable().catch(null),
sortOrder: z.enum(["ascend", "descend"]).nullable().catch(null),
});
export type SearchParams = z.infer<typeof SearchParamsSchema>;
@@ -0,0 +1,2 @@
/** REST API path prefix (no trailing slash). */
export const API_PREFIX = "/api" as const;
@@ -1,21 +1,23 @@
import { z } from "zod/v4";
import { defineApi } from "@/api/request";
import { IdParamsSchema } from "@/api/shared/params";
import { PaginatedDataSchema } from "@/api/shared/pagination";
import {
CreateUserRequestSchema,
IdParamsSchema,
ListUsersQuerySchema,
PaginatedDataSchema,
UpdateUserRequestSchema,
UserSchema,
} from "@/api/schemas";
import type { CreateUserRequest, ListUsersQuery, UpdateUserRequest, User } from "@/api/schemas";
} from "@/api/users/schemas";
import type {
CreateUserRequest,
ListUsersQuery,
UpdateUserRequest,
User,
} from "@/api/users/schemas";
import { API_PREFIX } from "@/api/shared/prefix";
export const USER_ENDPOINTS = {
list: "/api/users",
create: "/api/users",
update: (id: string) => `/api/users/${id}`,
delete: (id: string) => `/api/users/${id}`,
} as const;
export const USERS_PATH = `${API_PREFIX}/users`;
export const USERS_MEMBER_PATH = `${USERS_PATH}/:id`;
export const userQueryKeys = {
all: ["users"] as const,
@@ -23,21 +25,21 @@ export const userQueryKeys = {
};
export const listUsers = defineApi({
path: USER_ENDPOINTS.list,
path: USERS_PATH,
query: ListUsersQuerySchema,
output: PaginatedDataSchema(UserSchema),
});
export const createUser = defineApi({
method: "POST",
path: USER_ENDPOINTS.list,
path: USERS_PATH,
body: CreateUserRequestSchema,
output: UserSchema,
});
export const updateUser = defineApi({
method: "PUT",
path: "/api/users/:id",
path: USERS_MEMBER_PATH,
params: IdParamsSchema,
body: UpdateUserRequestSchema,
output: UserSchema,
@@ -45,7 +47,7 @@ export const updateUser = defineApi({
export const deleteUser = defineApi({
method: "DELETE",
path: "/api/users/:id",
path: USERS_MEMBER_PATH,
params: IdParamsSchema,
output: z.null(),
});
+45
View File
@@ -0,0 +1,45 @@
import { z } from "zod/v4";
export const UserSchema = z.object({
id: z.string(),
username: z.string(),
avatar: z.string().nullable(),
email: z.string().nullable(),
roles: z.array(z.string()),
permissions: z.array(z.string()),
});
export type User = z.infer<typeof UserSchema>;
export const ListUsersQuerySchema = z.object({
limit: z.number().int().positive().optional(),
offset: z.number().int().nonnegative().optional(),
keyword: z.string().optional(),
role: z.string().optional(),
sortField: z.string().optional(),
sortOrder: z.enum(["ascend", "descend"]).optional(),
});
export type ListUsersQuery = z.infer<typeof ListUsersQuerySchema>;
/** Ant Design Input submits `""` when empty; treat as null for optional email */
const createUserEmailSchema = z
.union([z.string(), z.null(), z.undefined()])
.transform((v) => {
if (v == null) return null;
const t = String(v).trim();
return t === "" ? null : t;
})
.pipe(z.union([z.string().email(), z.null()]));
export const CreateUserRequestSchema = z.object({
username: z.string().min(1),
email: createUserEmailSchema,
roles: z.array(z.string()).min(1),
});
export type CreateUserRequest = z.infer<typeof CreateUserRequestSchema>;
export const UpdateUserRequestSchema = CreateUserRequestSchema.partial();
export type UpdateUserRequest = z.infer<typeof UpdateUserRequestSchema>;
+7 -6
View File
@@ -15,6 +15,7 @@ import {
RefreshTokenRequestSchema,
RegisterRequestSchema,
} from "@/api/schemas";
import { AUTH_ENDPOINTS } from "@/api/auth/paths";
const GUEST_REFRESH = "mock-guest-refresh";
const REGISTERED_ACCESS = "mock-registered-access";
@@ -32,7 +33,7 @@ function isRegisteredAuth(authorization: string | null): boolean {
}
export const authHandlers = [
http.post("/api/auth/login", async ({ request }) => {
http.post(AUTH_ENDPOINTS.login, async ({ request }) => {
await withDelay(300);
let json: unknown;
try {
@@ -60,7 +61,7 @@ export const authHandlers = [
return errorResponse(ERROR_CODES.INVALID_CREDENTIALS, "Invalid username or password");
}),
http.post("/api/auth/register", async ({ request }) => {
http.post(AUTH_ENDPOINTS.register, async ({ request }) => {
await withDelay(300);
let json: unknown;
try {
@@ -90,7 +91,7 @@ export const authHandlers = [
});
}),
http.post("/api/auth/refresh", async ({ request }) => {
http.post(AUTH_ENDPOINTS.refresh, async ({ request }) => {
await withDelay(100);
let json: unknown;
try {
@@ -123,9 +124,9 @@ export const authHandlers = [
});
}),
http.post("/api/auth/logout", () => successWithNullBody()),
http.post(AUTH_ENDPOINTS.logout, () => successWithNullBody()),
http.get("/api/auth/user", ({ request }) => {
http.get(AUTH_ENDPOINTS.user, ({ request }) => {
const auth = request.headers.get("authorization");
if (isRegisteredAuth(auth)) {
const session = mockRegisteredSession;
@@ -146,7 +147,7 @@ export const authHandlers = [
return successWithSchema(AuthUserResponseSchema, userWithoutPermissions);
}),
http.get("/api/auth/permissions", ({ request }) => {
http.get(AUTH_ENDPOINTS.permissions, ({ request }) => {
const auth = request.headers.get("authorization");
if (isRegisteredAuth(auth) && mockRegisteredSession) {
return successWithSchema(PermissionsListSchema, ["user:view"] as string[]);
+5 -4
View File
@@ -10,13 +10,14 @@ import {
successWithSchema,
successWithNullBody,
} from "../createHandler";
import { USERS_MEMBER_PATH, USERS_PATH } from "@/api/users";
let users = [...MOCK_USERS];
export const EXPIRED_ACCESS_TOKEN = "__EXPIRED_ACCESS__";
export const userHandlers = [
http.get("/api/users", async ({ request }) => {
http.get(USERS_PATH, async ({ request }) => {
await withDelay(200);
const auth = request.headers.get("authorization");
if (auth === `Bearer ${EXPIRED_ACCESS_TOKEN}`) {
@@ -33,7 +34,7 @@ export const userHandlers = [
return paginatedWithSchema(UserSchema, { list, total: filtered.length });
}),
http.post("/api/users", async ({ request }) => {
http.post(USERS_PATH, async ({ request }) => {
await withDelay(200);
const body = (await request.json()) as Record<string, unknown>;
const newUser = {
@@ -48,7 +49,7 @@ export const userHandlers = [
return successWithSchema(UserSchema, newUser);
}),
http.put("/api/users/:id", async ({ params, request }) => {
http.put(USERS_MEMBER_PATH, async ({ params, request }) => {
await withDelay(200);
const body = (await request.json()) as Record<string, unknown>;
const idx = users.findIndex((u) => u.id === params.id);
@@ -59,7 +60,7 @@ export const userHandlers = [
return successWithSchema(UserSchema, users[idx]);
}),
http.delete("/api/users/:id", async ({ params }) => {
http.delete(USERS_MEMBER_PATH, async ({ params }) => {
await withDelay(200);
users = users.filter((u) => u.id !== params.id);
return successWithNullBody();
@@ -3,7 +3,7 @@ import { Avatar, Button, Space, Form, App, Dropdown, theme, Tag, Flex } from "an
import type { TablePaginationConfig } from "antd/es/table/interface";
import { useLingui } from "@lingui/react/macro";
import { useMemo, useRef, useState } from "react";
import { createUser, deleteUser, listUsers, updateUser, userQueryKeys } from "@/api/user";
import { createUser, deleteUser, listUsers, updateUser, userQueryKeys } from "@/api/users";
import type { User, CreateUserRequest } from "@/api/schemas";
import { z } from "zod/v4";
import { MoreVertical, Pencil, Trash2 } from "lucide-react";