feat(templates): remove orders sample, align basic with with-lingui

- Drop orders routes, API, MSW, and e2e; users-only CRUD template
- Port HTTP refresh, hooks, RouteError, and core e2e to basic
- Lingui extract --clean in script; refresh catalogs
- Update add-resource docs, cursor rule, and optimization spec

Made-with: Cursor
This commit is contained in:
zuiidea
2026-04-27 15:53:47 +08:00
parent 4406e57556
commit 39a6339c59
50 changed files with 1236 additions and 1267 deletions
+1 -1
View File
@@ -10,6 +10,6 @@ alwaysApply: false
`apps/with-lingui/.github/instructions/add-resource.instructions.md`
When adding a resource, follow that recipe (mirror the `users` / `orders` examples). On conflict, the `.instructions.md` file wins.
When adding a resource, follow that recipe (mirror the `users` example). On conflict, the `.instructions.md` file wins.
`@apps/with-lingui/.github/instructions/add-resource.instructions.md`
+39
View File
@@ -0,0 +1,39 @@
import { test, expect } from "@playwright/test";
import { loginAsAdmin } from "./helpers";
const expiredAuthStorage = () =>
JSON.stringify({
state: {
tokens: {
accessToken: "__EXPIRED_ACCESS__",
refreshToken: "mock-refresh-token",
},
isAuthenticated: true,
},
version: 0,
});
test.describe("Auth refresh", () => {
test.describe.configure({ retries: 2 });
test("401 on list triggers refresh then succeeds", async ({ page }) => {
await loginAsAdmin(page);
await page.evaluate((payload) => {
localStorage.setItem("auth-storage", payload);
}, expiredAuthStorage());
await page.reload({ waitUntil: "load" });
const usersListOk = page.waitForResponse(
(res) => res.url().includes("/api/users") && res.request().method() === "GET" && res.ok(),
{ timeout: 60_000 },
);
await page.goto("/users", { waitUntil: "domcontentloaded" });
await usersListOk;
await expect(page).toHaveURL(/\/users(\/|\?|$)/);
const thead = page.locator(".ant-table-thead");
await expect(thead.getByText(/Username|用户名/)).toBeVisible({ timeout: 30_000 });
});
});
+8
View File
@@ -14,3 +14,11 @@ export async function gotoUsers(page: Page) {
await page.goto("/users");
await expect(page).toHaveURL(/users/);
}
export async function loginAsGuest(page: Page) {
await page.goto("/login");
await page.getByLabel(/Username|用户名/).fill("guest");
await page.getByLabel(/Password|密码/).fill("guest");
await page.getByRole("button", { name: /Sign In|登录/ }).click();
await expect(page).toHaveURL(/dashboard/);
}
+10
View File
@@ -0,0 +1,10 @@
import { test, expect } from "@playwright/test";
import { loginAsGuest } from "./helpers";
test.describe("RBAC", () => {
test("guest without user:view cannot open /users", async ({ page }) => {
await loginAsGuest(page);
await page.goto("/users");
await expect(page).toHaveURL(/403/);
});
});
+19
View File
@@ -0,0 +1,19 @@
import { test, expect } from "@playwright/test";
import { loginAsAdmin } from "./helpers";
test.describe("URL search state", () => {
test("persists keyword after reload", async ({ page }) => {
await loginAsAdmin(page);
await page.goto("/users");
await expect(page.getByPlaceholder(/Search User|搜索用户/)).toBeVisible({ timeout: 15_000 });
const searchInput = page.getByPlaceholder(/Search User|搜索用户/);
await searchInput.fill("zhao.ming");
await searchInput.press("Enter");
await expect(page).toHaveURL(/keyword=zhao\.ming/);
await page.reload();
await expect(page).toHaveURL(/keyword=zhao\.ming/);
await expect(page.getByText("zhao.ming", { exact: true })).toBeVisible();
});
});
-2
View File
@@ -18,7 +18,6 @@ test.describe("User Management", () => {
await searchInput.fill("zhao.ming");
await searchInput.press("Enter");
await expect(page).toHaveURL(/keyword=zhao\.ming/);
/* Avoid strict-mode match on email cell containing substring "zhao.ming" */
await expect(page.getByText("zhao.ming", { exact: true })).toBeVisible();
});
@@ -28,7 +27,6 @@ test.describe("User Management", () => {
});
});
/** Avoid a second full `page.goto` after `/users`: reload can land on login before auth rehydrates. */
test.describe("User Management — role in URL", () => {
test.beforeEach(async ({ page }) => {
await loginAsAdmin(page);
+4 -2
View File
@@ -11,8 +11,9 @@
"fmt": "vp fmt",
"lint": "vp lint",
"check": "vp check --no-fmt",
"test:unit": "vitest run",
"test:e2e": "playwright test",
"test:e2e:core": "playwright test e2e/login.spec.ts e2e/users.spec.ts",
"test:e2e:core": "playwright test e2e/login.spec.ts e2e/users.spec.ts e2e/auth-refresh.spec.ts e2e/rbac.spec.ts e2e/url-state.spec.ts",
"test:e2e:ui": "playwright test --ui"
},
"dependencies": {
@@ -34,7 +35,8 @@
"msw": "^2.12.12",
"typescript": "~5.9.3",
"vite": "npm:@voidzero-dev/vite-plus-core@latest",
"vite-plus": "latest"
"vite-plus": "latest",
"vitest": "3.0.5"
},
"packageManager": "pnpm@10.32.1",
"pnpm": {
-1
View File
@@ -1,6 +1,5 @@
import { defineConfig, devices } from "@playwright/test";
/** Node global when `tsconfig` does not include `@types/node` (pre-commit may lint before lockfile updates). */
declare const process: { env: { CI?: string } };
const ci = Boolean(process.env.CI);
+6
View File
@@ -22,6 +22,12 @@ export const AuthTokensSchema = z.object({
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),
+24
View File
@@ -0,0 +1,24 @@
import { Button, Result, theme } from "antd";
import { useRouter, type ErrorComponentProps } from "@tanstack/react-router";
export function RouteError({ error }: ErrorComponentProps) {
const router = useRouter();
const { token } = theme.useToken();
const detail = error instanceof Error ? error.message : String(error);
return (
<div style={{ padding: token.paddingLG, maxWidth: 560, margin: "48px auto" }}>
<Result
status="error"
title="Something went wrong"
subTitle={detail || "Please try again or return to the dashboard."}
extra={
<Button type="primary" onClick={() => void router.invalidate()}>
Retry
</Button>
}
/>
</div>
);
}
+66
View File
@@ -0,0 +1,66 @@
import { useMemo } from "react";
import type { MessageInstance } from "antd/es/message/interface";
type MutationLifecycle<TValues> = {
onMutate?: (values: TValues) => void;
onSuccess?: (values: TValues) => void;
onError?: (values: TValues) => void;
};
export type CrudToastLifecycles<TCreate, TUpdate> = {
createLifecycle?: MutationLifecycle<TCreate>;
updateLifecycle?: MutationLifecycle<TUpdate>;
deleteLifecycle?: MutationLifecycle<string>;
};
export function useCrudToasts<TCreate, TUpdate>(options: {
message: MessageInstance;
resourceKey: string;
}): CrudToastLifecycles<TCreate, TUpdate> {
const { message, resourceKey } = options;
const keys = useMemo(
() => ({
create: `${resourceKey}-mutation-create`,
update: `${resourceKey}-mutation-update`,
delete: `${resourceKey}-mutation-delete`,
}),
[resourceKey],
);
return useMemo(
() => ({
createLifecycle: {
onSuccess: () => {
message.success({ content: "Created successfully", key: keys.create });
},
onError: () => {
message.error({ content: "Create failed", key: keys.create });
},
},
updateLifecycle: {
onMutate: () => {
message.loading({ content: "Updating…", key: keys.update, duration: 0 });
},
onSuccess: () => {
message.success({ content: "Updated successfully", key: keys.update });
},
onError: () => {
message.error({ content: "Update failed", key: keys.update });
},
},
deleteLifecycle: {
onMutate: () => {
message.loading({ content: "Deleting…", key: keys.delete, duration: 0 });
},
onSuccess: () => {
message.success({ content: "Deleted successfully", key: keys.delete });
},
onError: () => {
message.error({ content: "Delete failed", key: keys.delete });
},
},
}),
[keys.create, keys.delete, keys.update, message],
);
}
@@ -0,0 +1,67 @@
import { describe, expect, it } from "vitest";
import { applyOptimisticListDelete, applyOptimisticListUpdate } from "./useResourceCRUD";
type Row = { id: string; title: string; n: number };
describe("applyOptimisticListUpdate", () => {
it("merges patch into the matching row by id", () => {
const data: { list: Row[]; total: number } = {
list: [
{ id: "1", title: "A", n: 1 },
{ id: "2", title: "B", n: 2 },
],
total: 2,
};
const next = applyOptimisticListUpdate(data, { id: "2", title: "B2" });
expect(next.list).toEqual([
{ id: "1", title: "A", n: 1 },
{ id: "2", title: "B2", n: 2 },
]);
expect(next.total).toBe(2);
});
});
describe("applyOptimisticListDelete", () => {
it("removes row and decrements total", () => {
const data: { list: Row[]; total: number } = {
list: [
{ id: "1", title: "A", n: 1 },
{ id: "2", title: "B", n: 2 },
],
total: 2,
};
const next = applyOptimisticListDelete(data, "1");
expect(next.list).toEqual([{ id: "2", title: "B", n: 2 }]);
expect(next.total).toBe(1);
});
it("does not take total below zero", () => {
const data = { list: [{ id: "1", title: "A", n: 0 }] as Row[], total: 1 };
const next = applyOptimisticListDelete(data, "1");
expect(next.list).toEqual([]);
expect(next.total).toBe(0);
});
});
describe("rollback contract (QueryClient simulation)", () => {
it("restore snapshot after failed optimistic update", () => {
const key = ["items", 10, 0] as const;
const before = {
list: [
{ id: "1", title: "A", n: 1 },
{ id: "2", title: "B", n: 2 },
],
total: 2,
};
const snapshot = structuredClone(before);
let cache: typeof before = structuredClone(before);
const setData = (next: typeof before) => {
cache = next;
};
setData(applyOptimisticListUpdate(cache, { id: "2", title: "BAD" }));
expect(cache.list[1]?.title).toBe("BAD");
setData(snapshot);
expect(cache).toEqual(before);
expect(key[0]).toBe("items");
});
});
+109 -12
View File
@@ -1,4 +1,5 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { UseMutationResult } from "@tanstack/react-query";
type MutationLifecycle<TValues> = {
onMutate?: (values: TValues) => void;
@@ -6,9 +7,23 @@ type MutationLifecycle<TValues> = {
onError?: (values: TValues) => void;
};
type UseResourceCRUDOptions<TListData, TCreateValues, TUpdateValues> = {
export type ResourceListData<TItem extends { id: string }> = {
list: readonly TItem[];
total: number;
};
type OptimisticFlags = {
update?: boolean;
delete?: boolean;
};
type UseResourceCRUDOptions<
TListData extends ResourceListData<{ id: string }>,
TCreateValues,
TUpdateValues extends { id: string },
> = {
queryKey: readonly unknown[];
invalidateKey: readonly unknown[];
invalidateKey?: readonly unknown[];
queryFn: () => Promise<unknown>;
select: (raw: unknown) => TListData;
createFn: (values: TCreateValues) => Promise<unknown>;
@@ -17,15 +32,63 @@ type UseResourceCRUDOptions<TListData, TCreateValues, TUpdateValues> = {
createLifecycle?: MutationLifecycle<TCreateValues>;
updateLifecycle?: MutationLifecycle<TUpdateValues>;
deleteLifecycle?: MutationLifecycle<string>;
optimistic?: OptimisticFlags;
};
export function useResourceCRUD<TListData, TCreateValues, TUpdateValues>(
export type ResourceCRUDResult<TListData, TCreateValues, TUpdateValues> = {
data: TListData | undefined;
isLoading: boolean;
createMutation: UseMutationResult<unknown, Error, TCreateValues>;
updateMutation: UseMutationResult<unknown, Error, TUpdateValues>;
deleteMutation: UseMutationResult<unknown, Error, string>;
};
/** @deprecated Prefer `ResourceCRUDResult` — alias kept for plan wording compatibility */
export type CrudResult<TListData, TCreateValues, TUpdateValues> = ResourceCRUDResult<
TListData,
TCreateValues,
TUpdateValues
>;
type RollbackCtx<TListData> = { snapshot: TListData | undefined };
export function applyOptimisticListUpdate<
TItem extends { id: string },
TUpdate extends { id: string },
>(data: ResourceListData<TItem>, values: TUpdate): { list: TItem[]; total: number } {
const { id, ...patch } = values as TUpdate & Record<string, unknown>;
return {
list: data.list.map((row) =>
row.id === id ? ({ ...(row as object), ...patch } as TItem) : row,
),
total: data.total,
};
}
export function applyOptimisticListDelete<TItem extends { id: string }>(
data: ResourceListData<TItem>,
id: string,
): { list: TItem[]; total: number } {
return {
list: data.list.filter((row) => row.id !== id),
total: Math.max(0, data.total - 1),
};
}
export function useResourceCRUD<
TListData extends ResourceListData<{ id: string }>,
TCreateValues,
TUpdateValues extends { id: string },
>(
options: UseResourceCRUDOptions<TListData, TCreateValues, TUpdateValues>,
) {
): ResourceCRUDResult<TListData, TCreateValues, TUpdateValues> {
const queryClient = useQueryClient();
const invalidateKey = options.invalidateKey ?? [options.queryKey[0]];
const queryKey = options.queryKey;
const opt = options.optimistic;
const query = useQuery({
queryKey: options.queryKey,
queryKey,
queryFn: options.queryFn,
select: options.select,
});
@@ -36,7 +99,7 @@ export function useResourceCRUD<TListData, TCreateValues, TUpdateValues>(
options.createLifecycle?.onMutate?.(values);
},
onSuccess: (_data, values) => {
void queryClient.invalidateQueries({ queryKey: options.invalidateKey });
void queryClient.invalidateQueries({ queryKey: invalidateKey });
options.createLifecycle?.onSuccess?.(values);
},
onError: (_error, values) => {
@@ -46,28 +109,62 @@ export function useResourceCRUD<TListData, TCreateValues, TUpdateValues>(
const updateMutation = useMutation({
mutationFn: options.updateFn,
onMutate: (values) => {
onMutate: async (values): Promise<RollbackCtx<TListData>> => {
await queryClient.cancelQueries({ queryKey });
const previous = queryClient.getQueryData<TListData>(queryKey);
const applied = Boolean(opt?.update && previous);
if (applied) {
queryClient.setQueryData<TListData>(
queryKey,
applyOptimisticListUpdate(
previous as ResourceListData<{ id: string }>,
values as { id: string } & Record<string, unknown>,
) as unknown as TListData,
);
}
options.updateLifecycle?.onMutate?.(values);
return { snapshot: applied ? previous : undefined };
},
onSuccess: (_data, values) => {
void queryClient.invalidateQueries({ queryKey: options.invalidateKey });
void queryClient.invalidateQueries({ queryKey: invalidateKey });
options.updateLifecycle?.onSuccess?.(values);
},
onError: (_error, values) => {
onError: (_error, values, context) => {
const snap = context?.snapshot;
if (snap !== undefined) {
queryClient.setQueryData(queryKey, snap);
}
options.updateLifecycle?.onError?.(values);
},
});
const deleteMutation = useMutation({
mutationFn: options.deleteFn,
onMutate: (id) => {
onMutate: async (id): Promise<RollbackCtx<TListData>> => {
await queryClient.cancelQueries({ queryKey });
const previous = queryClient.getQueryData<TListData>(queryKey);
const applied = Boolean(opt?.delete && previous);
if (applied) {
queryClient.setQueryData<TListData>(
queryKey,
applyOptimisticListDelete(
previous as ResourceListData<{ id: string }>,
id,
) as unknown as TListData,
);
}
options.deleteLifecycle?.onMutate?.(id);
return { snapshot: applied ? previous : undefined };
},
onSuccess: (_data, id) => {
void queryClient.invalidateQueries({ queryKey: options.invalidateKey });
void queryClient.invalidateQueries({ queryKey: invalidateKey });
options.deleteLifecycle?.onSuccess?.(id);
},
onError: (_error, id) => {
onError: (_error, id, context) => {
const snap = context?.snapshot;
if (snap !== undefined) {
queryClient.setQueryData(queryKey, snap);
}
options.deleteLifecycle?.onError?.(id);
},
});
+133
View File
@@ -0,0 +1,133 @@
import { useLayoutEffect, useRef, useState, type RefObject } from "react";
export type UseTableFitHeightArgs = {
pageShellRef: RefObject<HTMLDivElement | null>;
toolbarRef: RefObject<HTMLDivElement | null>;
middleRef: RefObject<HTMLDivElement | null>;
tableFrameRef: RefObject<HTMLDivElement | null>;
marginLG: number;
rowCount: number;
isLoading: boolean;
showPagination: boolean;
};
export function useTableFitHeight(args: UseTableFitHeightArgs) {
const {
pageShellRef,
toolbarRef,
middleRef,
tableFrameRef,
marginLG,
rowCount,
isLoading,
showPagination,
} = args;
const tableAvailableRef = useRef(0);
const [tableAreaMaxHeight, setTableAreaMaxHeight] = useState<number | undefined>(() =>
typeof window !== "undefined" ? Math.max(240, Math.floor(window.innerHeight - 280)) : undefined,
);
const [tableScrollY, setTableScrollY] = useState<number | undefined>(() => {
if (typeof window === "undefined") return undefined;
const maxH = Math.max(240, Math.floor(window.innerHeight - 280));
return Math.max(120, maxH - 40);
});
useLayoutEffect(() => {
const shell = pageShellRef.current;
const toolbarEl = toolbarRef.current;
const mid = middleRef.current;
if (!shell || !toolbarEl || !mid) return;
const headReserve = 40;
const rowEstimate = 44;
const paginationBlock = 56;
let rafRetry = 0;
const maxRafRetries = 8;
const measure = () => {
const toolbarRect = toolbarEl.getBoundingClientRect();
const mainEl = shell.closest<HTMLElement>(".main-layout-main");
const mainRect = mainEl?.getBoundingClientRect();
const viewportBottom =
typeof window !== "undefined" ? window.innerHeight : Number.POSITIVE_INFINITY;
const clipBottom = mainRect
? mainRect.bottom
: Math.min(shell.getBoundingClientRect().bottom, viewportBottom);
const maxMiddleFromShell = Math.max(
0,
Math.floor(clipBottom - toolbarRect.bottom - marginLG),
);
const midHRaw = mid.clientHeight;
const midH = midHRaw > 0 ? Math.min(midHRaw, maxMiddleFromShell) : maxMiddleFromShell;
if (maxMiddleFromShell === 0 && rafRetry < maxRafRetries) {
rafRetry += 1;
requestAnimationFrame(() => requestAnimationFrame(measure));
return;
}
const frameInner = Math.max(0, Math.floor(midH));
tableAvailableRef.current = frameInner;
setTableAreaMaxHeight(frameInner > 0 ? frameInner : undefined);
const pagReserve = showPagination ? paginationBlock : 0;
const bodyMax = Math.max(120, frameInner - headReserve - pagReserve);
if (isLoading && rowCount === 0) {
setTableScrollY(bodyMax);
return;
}
if (rowCount === 0 && !isLoading) {
setTableScrollY(undefined);
return;
}
const naturalBodyH = headReserve + rowCount * rowEstimate;
const naturalTotalH = naturalBodyH + pagReserve;
const slack = 12;
if (naturalTotalH + slack > frameInner) {
setTableScrollY(bodyMax);
return;
}
setTableScrollY(undefined);
requestAnimationFrame(() => {
const frame = tableFrameRef.current;
const avail = tableAvailableRef.current;
const minFrame = headReserve + pagReserve;
if (!frame || avail <= minFrame) return;
if (frame.scrollHeight > frame.clientHeight + 1) {
setTableScrollY(Math.max(120, avail - headReserve - pagReserve));
}
});
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(shell);
ro.observe(mid);
return () => ro.disconnect();
}, [
showPagination,
rowCount,
isLoading,
marginLG,
pageShellRef,
toolbarRef,
middleRef,
tableFrameRef,
]);
return {
tableAreaMaxHeight,
tableScrollY,
lockScrollHeight: tableScrollY != null,
};
}
+29
View File
@@ -0,0 +1,29 @@
import { useCallback, useEffect, useState } from "react";
export function useUrlSearchState<TSearch extends { keyword: string; offset: number }>(args: {
search: TSearch;
setSearch: (next: TSearch) => void;
}) {
const { search, setSearch } = args;
const [keywordInput, setKeywordInput] = useState(search.keyword);
useEffect(() => {
setKeywordInput(search.keyword);
}, [search.keyword]);
const commitKeyword = useCallback(() => {
const trimmed = keywordInput.trim();
setSearch({ ...search, keyword: trimmed, offset: 0 });
}, [keywordInput, search, setSearch]);
const applyKeyword = useCallback(
(kw: string) => {
const trimmed = kw.trim();
setKeywordInput(trimmed);
setSearch({ ...search, keyword: trimmed, offset: 0 });
},
[search, setSearch],
);
return { keywordInput, setKeywordInput, commitKeyword, applyKeyword };
}
+4 -1
View File
@@ -3,9 +3,12 @@ import ReactDOM from "react-dom/client";
import { RouterProvider, createRouter } from "@tanstack/react-router";
import { routeTree } from "./routeTree.gen";
import { useAuthStore } from "./stores/auth";
import { useSettingsStore } from "./stores/settings";
import { fetchSessionAndApplyToStore } from "./utils/session";
import { installHttpRouter } from "./utils/http";
const router = createRouter({ routeTree });
installHttpRouter(router);
declare module "@tanstack/react-router" {
interface Register {
@@ -22,7 +25,7 @@ async function enableMocking() {
enableMocking()
.then(async () => {
await useAuthStore.persist.rehydrate();
await Promise.all([useSettingsStore.persist.rehydrate(), useAuthStore.persist.rehydrate()]);
const { isAuthenticated, tokens } = useAuthStore.getState();
if (isAuthenticated && tokens) {
try {
@@ -0,0 +1,71 @@
import { describe, expect, it } from "vitest";
import { z } from "zod/v4";
import {
paginatedListSchema,
paginatedWithSchema,
successWithNullBody,
successWithSchema,
} from "./createHandler";
import { UserSchema } from "@/api/schemas";
describe("successWithSchema", () => {
it("throws ZodError when payload does not match schema (mock drift surfaces here)", () => {
expect(() =>
successWithSchema(UserSchema, {
id: "1",
username: "x",
} as unknown),
).toThrow(z.ZodError);
});
it("returns JSON body with parsed data when valid", async () => {
const row = {
id: "1",
username: "u",
avatar: null,
email: null,
roles: [] as string[],
permissions: [] as string[],
};
const res = successWithSchema(UserSchema, row);
const body = (await res.json()) as { code: number; data: typeof row };
expect(body.code).toBe(0);
expect(body.data.username).toBe("u");
});
});
describe("paginatedListSchema / paginatedWithSchema", () => {
it("rejects invalid list shape", () => {
const schema = paginatedListSchema(UserSchema);
expect(() =>
schema.parse({
list: [{ id: "1" }],
total: 1,
}),
).toThrow(z.ZodError);
});
it("accepts valid paginated payload via paginatedWithSchema", async () => {
const row = {
id: "1",
username: "u",
avatar: null,
email: null,
roles: [] as string[],
permissions: [] as string[],
};
const res = paginatedWithSchema(UserSchema, { list: [row], total: 1 });
const body = (await res.json()) as { data: { list: (typeof row)[]; total: number } };
expect(body.data.total).toBe(1);
expect(body.data.list).toHaveLength(1);
});
});
describe("successWithNullBody", () => {
it("wraps null data with schema validation", async () => {
const res = successWithNullBody();
const body = (await res.json()) as { code: number; data: null };
expect(body.data).toBeNull();
expect(body.code).toBe(0);
});
});
+33 -21
View File
@@ -1,30 +1,17 @@
import { HttpResponse, delay as mswDelay } from "msw";
import { z } from "zod/v4";
import type { ZodTypeAny } from "zod/v4";
/**
* Standard API response format
*/
export interface ApiResponse<T> {
code: number;
data: T;
message: string;
}
/**
* MSW handler factory functions
* Provides consistent response wrapping and error handling
*/
/**
* Wrap a handler function with standard response format
* Automatically handles delay, error responses, and data wrapping
*/
export async function withDelay(ms: number = 200): Promise<void> {
await mswDelay(ms);
}
/**
* Create a success response
*/
export function successResponse<T>(data: T, message: string = "ok") {
return HttpResponse.json({
code: 0,
@@ -33,9 +20,37 @@ export function successResponse<T>(data: T, message: string = "ok") {
});
}
/**
* Create an error response
*/
/** Parse `data` with Zod before wrapping — catches mock ↔ contract drift early. */
export function successWithSchema<T extends ZodTypeAny>(
schema: T,
data: unknown,
message: string = "ok",
) {
const parsed = schema.parse(data);
return successResponse(parsed, message);
}
export function paginatedListSchema<T extends ZodTypeAny>(itemSchema: T) {
return z.object({
list: z.array(itemSchema),
total: z.number().int().nonnegative(),
});
}
export function paginatedWithSchema<T extends ZodTypeAny>(
itemSchema: T,
data: unknown,
message: string = "ok",
) {
return successWithSchema(paginatedListSchema(itemSchema), data, message);
}
const NullBodySchema = z.null();
export function successWithNullBody(message: string = "ok") {
return successWithSchema(NullBodySchema, null, message);
}
export function errorResponse(code: number, message: string, data: unknown = null) {
return HttpResponse.json({
code,
@@ -44,9 +59,6 @@ export function errorResponse(code: number, message: string, data: unknown = nul
});
}
/**
* Predefined error codes
*/
export const ERROR_CODES = {
INVALID_CREDENTIALS: 1001,
NOT_FOUND: 1002,
+8 -1
View File
@@ -1,7 +1,6 @@
import type { User } from "@/api/schemas";
import { vercelAvatarUrl } from "./utils";
/** Demo logins look like real org accounts (first.last @ northstar.io). Index 0 stays `admin` for mock login. */
const MOCK_IDENTITIES: ReadonlyArray<[string, string]> = [
["admin", "ops.admin@northstar.io"],
["zhao.ming", "zhao.ming@northstar.io"],
@@ -16,6 +15,14 @@ const MOCK_IDENTITIES: ReadonlyArray<[string, string]> = [
["mia.silva", "mia.silva@northstar.io"],
];
export const GUEST_AUTH_USER_BODY = {
id: "99",
username: "guest",
avatar: null as null,
email: "guest@example.com",
roles: [] as string[],
};
export const MOCK_USERS: User[] = MOCK_IDENTITIES.map(([username, email], i) => ({
id: String(i + 1),
username,
+72 -14
View File
@@ -1,16 +1,48 @@
import { http } from "msw";
import { MOCK_USERS } from "../data";
import { withDelay, successResponse, errorResponse, ERROR_CODES } from "../createHandler";
import { MOCK_USERS, GUEST_AUTH_USER_BODY } from "../data";
import {
withDelay,
errorResponse,
ERROR_CODES,
successWithSchema,
successWithNullBody,
} from "../createHandler";
import {
AuthTokensSchema,
AuthUserResponseSchema,
LoginRequestSchema,
PermissionsListSchema,
RefreshTokenRequestSchema,
} from "@/api/schemas";
const GUEST_REFRESH = "mock-guest-refresh";
function isGuestAuth(authorization: string | null): boolean {
return (authorization ?? "").includes("mock-guest-access");
}
export const authHandlers = [
http.post("/api/auth/login", async ({ request }) => {
await withDelay(300);
const body = (await request.json()) as {
username: string;
password: string;
};
if (body.username === "admin" && body.password === "admin") {
return successResponse({
let json: unknown;
try {
json = await request.json();
} catch {
return errorResponse(ERROR_CODES.BAD_REQUEST, "Invalid JSON body");
}
const parsed = LoginRequestSchema.safeParse(json);
if (!parsed.success) {
return errorResponse(ERROR_CODES.BAD_REQUEST, "Invalid login body");
}
const { username, password } = parsed.data;
if (username === "guest" && password === "guest") {
return successWithSchema(AuthTokensSchema, {
accessToken: "mock-guest-access",
refreshToken: GUEST_REFRESH,
});
}
if (username === "admin" && password === "admin") {
return successWithSchema(AuthTokensSchema, {
accessToken: "mock-access-token",
refreshToken: "mock-refresh-token",
});
@@ -18,20 +50,46 @@ export const authHandlers = [
return errorResponse(ERROR_CODES.INVALID_CREDENTIALS, "Invalid username or password");
}),
http.post("/api/auth/refresh", async () => {
http.post("/api/auth/refresh", async ({ request }) => {
await withDelay(100);
return successResponse({
let json: unknown;
try {
json = await request.json();
} catch {
return errorResponse(ERROR_CODES.BAD_REQUEST, "Invalid JSON body");
}
const parsed = RefreshTokenRequestSchema.safeParse(json);
if (!parsed.success) {
return errorResponse(ERROR_CODES.BAD_REQUEST, "refreshToken is required");
}
if (parsed.data.refreshToken === GUEST_REFRESH) {
return successWithSchema(AuthTokensSchema, {
accessToken: "mock-guest-access-refreshed",
refreshToken: "mock-guest-refresh-refreshed",
});
}
return successWithSchema(AuthTokensSchema, {
accessToken: "mock-new-access-token",
refreshToken: "mock-new-refresh-token",
});
}),
http.post("/api/auth/logout", () => successResponse(null)),
http.post("/api/auth/logout", () => successWithNullBody()),
http.get("/api/auth/user", () => {
http.get("/api/auth/user", ({ request }) => {
const auth = request.headers.get("authorization");
if (isGuestAuth(auth)) {
return successWithSchema(AuthUserResponseSchema, GUEST_AUTH_USER_BODY);
}
const { permissions: _permissions, ...userWithoutPermissions } = MOCK_USERS[0]!;
return successResponse(userWithoutPermissions);
return successWithSchema(AuthUserResponseSchema, userWithoutPermissions);
}),
http.get("/api/auth/permissions", () => successResponse(MOCK_USERS[0]!.permissions)),
http.get("/api/auth/permissions", ({ request }) => {
const auth = request.headers.get("authorization");
if (isGuestAuth(auth)) {
return successWithSchema(PermissionsListSchema, [] as string[]);
}
return successWithSchema(PermissionsListSchema, MOCK_USERS[0]!.permissions);
}),
];
+22 -8
View File
@@ -1,13 +1,27 @@
import { http } from "msw";
import { http, HttpResponse } from "msw";
import { UserSchema } from "@/api/schemas";
import { MOCK_USERS } from "../data";
import { filterUsers, paginateList, parsePaginationParams } from "../utils";
import { withDelay, successResponse, errorResponse, ERROR_CODES } from "../createHandler";
import {
withDelay,
errorResponse,
ERROR_CODES,
paginatedWithSchema,
successWithSchema,
successWithNullBody,
} from "../createHandler";
let users = [...MOCK_USERS];
export const EXPIRED_ACCESS_TOKEN = "__EXPIRED_ACCESS__";
export const userHandlers = [
http.get("/api/users", async ({ request }) => {
await withDelay(200);
const auth = request.headers.get("authorization");
if (auth === `Bearer ${EXPIRED_ACCESS_TOKEN}`) {
return new HttpResponse(null, { status: 401 });
}
const url = new URL(request.url);
const { limit, offset } = parsePaginationParams(url.searchParams);
const keyword = url.searchParams.get("keyword") ?? "";
@@ -16,7 +30,7 @@ export const userHandlers = [
const filtered = filterUsers(users, { keyword, role });
const list = paginateList(filtered, limit, offset);
return successResponse({ list, total: filtered.length });
return paginatedWithSchema(UserSchema, { list, total: filtered.length });
}),
http.post("/api/users", async ({ request }) => {
@@ -28,10 +42,10 @@ export const userHandlers = [
avatar: null,
email: typeof body.email === "string" ? body.email : null,
roles: (body.roles as string[]) ?? [],
permissions: [],
permissions: [] as string[],
};
users.push(newUser);
return successResponse(newUser);
return successWithSchema(UserSchema, newUser);
}),
http.put("/api/users/:id", async ({ params, request }) => {
@@ -41,13 +55,13 @@ export const userHandlers = [
if (idx === -1) {
return errorResponse(ERROR_CODES.NOT_FOUND, "User not found");
}
users[idx] = { ...users[idx], ...body };
return successResponse(users[idx]);
users[idx] = { ...users[idx], ...body } as (typeof users)[number];
return successWithSchema(UserSchema, users[idx]);
}),
http.delete("/api/users/:id", async ({ params }) => {
await withDelay(200);
users = users.filter((u) => u.id !== params.id);
return successResponse(null);
return successWithNullBody();
}),
];
+22 -6
View File
@@ -1,4 +1,4 @@
import { useEffect, useLayoutEffect } from "react";
import { useEffect, useMemo } from "react";
import { createRootRoute, Outlet } from "@tanstack/react-router";
import { ConfigProvider, App } from "antd";
import enUS from "antd/locale/en_US";
@@ -6,19 +6,34 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useSettingsStore } from "@/stores/settings";
import { useAppTheme } from "@/hooks/useAppTheme";
import { NotFound } from "@/components/NotFound";
import { RouteError } from "@/components/RouteError";
import "@/index.css";
const queryClient = new QueryClient({
function AppQueryBridge({ children }: { children: React.ReactNode }) {
const { message } = App.useApp();
const queryClient = useMemo(
() =>
new QueryClient({
defaultOptions: {
queries: { retry: 1, refetchOnWindowFocus: false },
mutations: {
onError: (err) => {
message.error(err instanceof Error ? err.message : String(err));
},
});
},
},
}),
[message],
);
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}
function RootComponent() {
const darkMode = useSettingsStore((s) => s.darkMode);
const configProviderProps = useAppTheme();
useLayoutEffect(() => {
useEffect(() => {
document.documentElement.lang = "en";
}, []);
@@ -27,17 +42,18 @@ function RootComponent() {
}, [darkMode]);
return (
<QueryClientProvider client={queryClient}>
<ConfigProvider {...configProviderProps} locale={enUS}>
<App>
<AppQueryBridge>
<Outlet />
</AppQueryBridge>
</App>
</ConfigProvider>
</QueryClientProvider>
);
}
export const Route = createRootRoute({
component: RootComponent,
notFoundComponent: NotFound,
errorComponent: RouteError,
});
+14 -3
View File
@@ -2,18 +2,29 @@ import { createFileRoute, redirect } from "@tanstack/react-router";
import { useAuthStore } from "@/stores/auth";
import { MainLayout } from "@/components/Layout/MainLayout";
import { canAccessPath, normalizeAppPath } from "@/utils/appMenu";
import { fetchSessionAndApplyToStore } from "@/utils/session";
export const Route = createFileRoute("/_auth")({
beforeLoad: ({ location }) => {
const { isAuthenticated, user } = useAuthStore.getState();
beforeLoad: async ({ location }) => {
const { isAuthenticated, user, tokens } = useAuthStore.getState();
if (!isAuthenticated) {
throw redirect({ to: "/login" });
}
if (tokens && !user) {
try {
await fetchSessionAndApplyToStore();
} catch {
useAuthStore.getState().logout();
throw redirect({ to: "/login" });
}
}
const { user: nextUser } = useAuthStore.getState();
const path = normalizeAppPath(location.pathname);
if (path === "/403") return;
if (!canAccessPath(location.pathname, user?.permissions)) {
if (!canAccessPath(location.pathname, nextUser?.permissions)) {
throw redirect({ to: "/403" });
}
},
+47 -178
View File
@@ -1,7 +1,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 { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useMemo, useRef, useState } from "react";
import { httpClient } from "@/utils/http";
import { USER_ENDPOINTS } from "@/api/user";
import { PaginatedResponseSchema, UserSchema, CreateUserRequestSchema } from "@/api/schemas";
@@ -10,6 +10,9 @@ import { z } from "zod/v4";
import { MoreVertical, Pencil, Trash2 } from "lucide-react";
import { DataTable } from "@/components/DataTable";
import { useResourceCRUD } from "@/hooks/useResourceCRUD";
import { useTableFitHeight } from "@/hooks/useTableFitHeight";
import { useCrudToasts } from "@/hooks/useCrudToasts";
import { useUrlSearchState } from "@/hooks/useUrlSearchState";
import { Toolbar } from "./-Toolbar";
import { FormModal } from "./-FormModal";
@@ -22,6 +25,8 @@ const UserSearchParamsSchema = z.object({
role: z.string().catch(""),
});
type UserSearch = z.infer<typeof UserSearchParamsSchema>;
export const Route = createFileRoute("/_auth/users/")({
validateSearch: (search) => UserSearchParamsSchema.parse(search),
component: UsersPage,
@@ -29,10 +34,6 @@ export const Route = createFileRoute("/_auth/users/")({
const paginatedUserSchema = PaginatedResponseSchema(UserSchema);
const MESSAGE_KEY_USER_CREATE = "user-mutation-create";
const MESSAGE_KEY_USER_UPDATE = "user-mutation-update";
const MESSAGE_KEY_USER_DELETE = "user-mutation-delete";
function UsersPage() {
const search = Route.useSearch();
const navigate = useNavigate({ from: Route.fullPath });
@@ -43,27 +44,23 @@ function UsersPage() {
const toolbarRowRef = useRef<HTMLDivElement>(null);
const middleSectionRef = useRef<HTMLDivElement>(null);
const tableFrameRef = useRef<HTMLDivElement>(null);
const tableAvailableRef = useRef(0);
/**
* If both start as undefined on the first frame, the table grows to full row height (flex min-height:auto)
* and the main content area scrolls. Seed from the viewport; useLayoutEffect then refines (e.g. available≈901, bodyMax≈861).
*/
const [tableAreaMaxHeight, setTableAreaMaxHeight] = useState<number | undefined>(() =>
typeof window !== "undefined" ? Math.max(240, Math.floor(window.innerHeight - 280)) : undefined,
);
const [tableScrollY, setTableScrollY] = useState<number | undefined>(() => {
if (typeof window === "undefined") return undefined;
const maxH = Math.max(240, Math.floor(window.innerHeight - 280));
return Math.max(120, maxH - 40);
});
const [editingUser, setEditingUser] = useState<User | null>(null);
const [form] = Form.useForm();
const [keywordInput, setKeywordInput] = useState(search.keyword);
const currentPage = Math.floor(search.offset / search.limit) + 1;
useEffect(() => {
setKeywordInput(search.keyword);
}, [search.keyword]);
const setSearch = (next: UserSearch) => {
void navigate({ search: next });
};
const { keywordInput, setKeywordInput, applyKeyword } = useUrlSearchState({
search,
setSearch,
});
const crudToasts = useCrudToasts<CreateUserRequest, CreateUserRequest & { id: string }>({
message,
resourceKey: "users",
});
const { data, isLoading, createMutation, updateMutation, deleteMutation } = useResourceCRUD<
{ list: User[]; total: number },
@@ -79,7 +76,6 @@ function UsersPage() {
search.sortField,
search.sortOrder,
],
invalidateKey: ["users"],
queryFn: () =>
httpClient.get(USER_ENDPOINTS.list, {
params: {
@@ -96,66 +92,29 @@ function UsersPage() {
httpClient.post(USER_ENDPOINTS.create, CreateUserRequestSchema.parse(values)),
updateFn: ({ id, ...values }) => httpClient.put(USER_ENDPOINTS.update(id), values),
deleteFn: (id) => httpClient.delete(USER_ENDPOINTS.delete(id)),
optimistic: { update: true, delete: true },
createLifecycle: {
onSuccess: () => {
message.success({
content: "Created successfully",
key: MESSAGE_KEY_USER_CREATE,
});
onSuccess: (values) => {
crudToasts.createLifecycle?.onSuccess?.(values);
setModalOpen(false);
form.resetFields();
},
onError: () => {
message.error({
content: "Create failed",
key: MESSAGE_KEY_USER_CREATE,
});
},
onError: crudToasts.createLifecycle?.onError,
},
updateLifecycle: {
onMutate: () => {
message.loading({
content: "Updating…",
key: MESSAGE_KEY_USER_UPDATE,
duration: 0,
});
},
onSuccess: () => {
message.success({
content: "Updated successfully",
key: MESSAGE_KEY_USER_UPDATE,
});
onMutate: crudToasts.updateLifecycle?.onMutate,
onSuccess: (values) => {
crudToasts.updateLifecycle?.onSuccess?.(values);
setModalOpen(false);
setEditingUser(null);
form.resetFields();
},
onError: () => {
message.error({
content: "Update failed",
key: MESSAGE_KEY_USER_UPDATE,
});
},
onError: crudToasts.updateLifecycle?.onError,
},
deleteLifecycle: {
onMutate: () => {
message.loading({
content: "Deleting…",
key: MESSAGE_KEY_USER_DELETE,
duration: 0,
});
},
onSuccess: () => {
message.success({
content: "Deleted successfully",
key: MESSAGE_KEY_USER_DELETE,
});
},
onError: () => {
message.error({
content: "Delete failed",
key: MESSAGE_KEY_USER_DELETE,
});
},
onMutate: crudToasts.deleteLifecycle?.onMutate,
onSuccess: crudToasts.deleteLifecycle?.onSuccess,
onError: crudToasts.deleteLifecycle?.onError,
},
});
@@ -268,118 +227,28 @@ function UsersPage() {
}}
placement="bottomRight"
>
<Button type="text" icon={<MoreVertical size={token.fontSize} />} />
<Button
type="text"
icon={<MoreVertical size={token.fontSize} />}
aria-label="Row actions"
/>
</Dropdown>
),
},
];
const applySearch = (keyword: string) => {
void navigate({
search: {
...search,
keyword: keyword.trim(),
offset: 0,
},
});
};
const showPagination = (data?.total ?? 0) > search.limit;
useLayoutEffect(() => {
const shell = pageShellRef.current;
const toolbarEl = toolbarRowRef.current;
const mid = middleSectionRef.current;
if (!shell || !toolbarEl || !mid) return;
/** Fixed small table header row height */
const headReserve = 40;
/** Ant Design Table size="small" row ~39px; slightly higher for Tags / selection column */
const rowEstimate = 44;
const paginationBlock = 56;
let rafRetry = 0;
const maxRafRetries = 8;
const measure = () => {
const toolbarRect = toolbarEl.getBoundingClientRect();
/**
* Do not use the page shell bottom: when the table stretches the document, shell.bottom moves down and
* `available` becomes too large, so we skip scroll.y. Use the main content box bottom (viewport-bounded) instead.
*/
const mainEl = shell.closest<HTMLElement>(".main-layout-main");
const mainRect = mainEl?.getBoundingClientRect();
const viewportBottom =
typeof window !== "undefined" ? window.innerHeight : Number.POSITIVE_INFINITY;
const clipBottom = mainRect
? mainRect.bottom
: Math.min(shell.getBoundingClientRect().bottom, viewportBottom);
const maxMiddleFromShell = Math.max(
0,
Math.floor(clipBottom - toolbarRect.bottom - token.marginLG),
);
const midHRaw = mid.clientHeight;
const midH = midHRaw > 0 ? Math.min(midHRaw, maxMiddleFromShell) : maxMiddleFromShell;
if (maxMiddleFromShell === 0 && rafRetry < maxRafRetries) {
rafRetry += 1;
requestAnimationFrame(() => requestAnimationFrame(measure));
return;
}
/** Bordered frame now includes Table built-in pagination */
const frameInner = Math.max(0, Math.floor(midH));
tableAvailableRef.current = frameInner;
setTableAreaMaxHeight(frameInner > 0 ? frameInner : undefined);
const rowCount = data?.list?.length ?? 0;
const pagReserve = showPagination ? paginationBlock : 0;
const bodyMax = Math.max(120, frameInner - headReserve - pagReserve);
if (isLoading && rowCount === 0) {
setTableScrollY(bodyMax);
return;
}
if (rowCount === 0 && !isLoading) {
setTableScrollY(undefined);
return;
}
const naturalBodyH = headReserve + rowCount * rowEstimate;
const naturalTotalH = naturalBodyH + pagReserve;
const slack = 12;
/**
* Without scroll.y, Ant Design does not scroll .ant-table-body; the full table height moves scrolling to the page.
* Row-height estimates can wrongly say “fits”. After clearing scroll.y, re-check frame scrollHeight vs clientHeight.
*/
if (naturalTotalH + slack > frameInner) {
setTableScrollY(bodyMax);
return;
}
setTableScrollY(undefined);
requestAnimationFrame(() => {
const frame = tableFrameRef.current;
const avail = tableAvailableRef.current;
const minFrame = headReserve + pagReserve;
if (!frame || avail <= minFrame) return;
if (frame.scrollHeight > frame.clientHeight + 1) {
setTableScrollY(Math.max(120, avail - headReserve - pagReserve));
}
const { tableAreaMaxHeight, tableScrollY, lockScrollHeight } = useTableFitHeight({
pageShellRef,
toolbarRef: toolbarRowRef,
middleRef: middleSectionRef,
tableFrameRef,
marginLG: token.marginLG,
rowCount: data?.list?.length ?? 0,
isLoading,
showPagination,
});
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(shell);
ro.observe(mid);
return () => ro.disconnect();
}, [showPagination, data?.list?.length, isLoading, token.marginLG]);
const tablePagination: false | TablePaginationConfig = useMemo(
() =>
@@ -415,10 +284,10 @@ function UsersPage() {
ref={toolbarRowRef}
keywordInput={keywordInput}
onKeywordChange={setKeywordInput}
onSearch={applySearch}
onSearch={applyKeyword}
onClearSearch={() => {
setKeywordInput("");
applySearch("");
setSearch({ ...search, keyword: "", offset: 0 });
}}
roleValue={search.role || undefined}
onRoleChange={(role) =>
@@ -440,7 +309,7 @@ function UsersPage() {
<DataTable<User>
layoutRef={middleSectionRef}
frameRef={tableFrameRef}
lockScrollHeight={tableScrollY != null}
lockScrollHeight={lockScrollHeight}
maxHeight={tableAreaMaxHeight}
frameHeight={
tableScrollY != null && tableAreaMaxHeight != null ? tableAreaMaxHeight : undefined
+8
View File
@@ -55,3 +55,11 @@ export const useAuthStore = createPersistentStore<AuthState>(
},
},
);
export function getAuthTokens(): AuthTokens | null {
return useAuthStore.getState().tokens;
}
export function getAccessToken(): string | null {
return useAuthStore.getState().tokens?.accessToken ?? null;
}
-9
View File
@@ -1,9 +1,5 @@
import type { MenuItem } from "@/api/schemas";
/**
* Built-in menu tree. Visibility is computed with {@link filterMenuTreeByPermissions}
* from `GET /api/auth/permissions` (must stay consistent with route permission map below).
*/
export const APP_MENU_TREE: MenuItem[] = [
{
id: "g-platform",
@@ -113,16 +109,11 @@ export function filterMenuTreeByPermissions(
return walk(nodes);
}
/** Normalized pathname (no trailing slash except `/`) */
export function normalizeAppPath(pathname: string): string {
if (pathname === "/") return pathname;
return pathname.replace(/\/+$/, "") || "/";
}
/**
* Route → permission required to open the page. `null` = no permission gate.
* Keep in sync with {@link APP_MENU_TREE} paths.
*/
export function requiredPermissionForPath(pathname: string): string | null {
const p = normalizeAppPath(pathname);
const map: Record<string, string | null> = {
+96 -13
View File
@@ -1,4 +1,7 @@
import { API_BASE_URL } from "./constants";
import { AUTH_ENDPOINTS } from "@/api/auth";
import { AuthTokensSchema, RefreshTokenRequestSchema } from "@/api/schemas";
import { getAccessToken, useAuthStore } from "@/stores/auth";
export class ApiError extends Error {
code: number;
@@ -22,6 +25,70 @@ type RequestOptions = Omit<RequestInit, "method" | "body"> & {
params?: Record<string, string | number | null | undefined>;
};
type HttpRouter = {
navigate: (opts: { to: string }) => void | Promise<void>;
};
let httpRouter: HttpRouter | null = null;
export function installHttpRouter(router: HttpRouter): void {
httpRouter = router;
}
let inflightRefresh: Promise<boolean> | null = null;
async function doRefreshSessionTokens(): Promise<boolean> {
const { tokens, setTokens, logout } = useAuthStore.getState();
const refreshToken = tokens?.refreshToken;
if (!refreshToken) {
logout();
navigateToLogin();
return false;
}
const body = RefreshTokenRequestSchema.parse({ refreshToken });
const url = buildUrl(AUTH_ENDPOINTS.refresh);
try {
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
logout();
navigateToLogin();
return false;
}
const json: unknown = await res.json();
const envelope = json as { code?: number; data?: unknown; message?: string };
if (envelope.code !== undefined && envelope.code !== 0) {
logout();
navigateToLogin();
return false;
}
const next = AuthTokensSchema.parse(envelope.data ?? envelope);
setTokens(next);
return true;
} catch {
logout();
navigateToLogin();
return false;
}
}
async function refreshSessionTokens(): Promise<boolean> {
if (inflightRefresh) return inflightRefresh;
inflightRefresh = doRefreshSessionTokens().finally(() => {
inflightRefresh = null;
});
return inflightRefresh;
}
function buildUrl(path: string, params?: RequestOptions["params"]): string {
const base = path.startsWith("http") ? path : `${API_BASE_URL}${path}`;
if (!params) return base;
@@ -33,23 +100,25 @@ function buildUrl(path: string, params?: RequestOptions["params"]): string {
}
function getAuthHeaders(): Record<string, string> {
try {
const raw = localStorage.getItem("auth-storage");
if (!raw) return {};
const parsed = JSON.parse(raw);
const token = parsed?.state?.tokens?.accessToken;
const token = getAccessToken();
if (token) return { Authorization: `Bearer ${token}` };
} catch {
// noop
}
return {};
}
function navigateTo403(): void {
void httpRouter?.navigate({ to: "/403" });
}
function navigateToLogin(): void {
void httpRouter?.navigate({ to: "/login" });
}
async function request<T>(
method: string,
path: string,
body?: unknown,
options?: RequestOptions,
isAfterRefresh = false,
): Promise<T> {
const url = buildUrl(path, options?.params);
const headers: Record<string, string> = {
@@ -61,21 +130,35 @@ async function request<T>(
const res = await fetch(url, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
body: body !== undefined ? JSON.stringify(body) : undefined,
...options,
});
if (res.status === 401 && !isAfterRefresh && path !== AUTH_ENDPOINTS.refresh) {
const refreshed = await refreshSessionTokens();
if (refreshed) {
return request<T>(method, path, body, options, true);
}
throw new HttpError(401, "Unauthorized");
}
if (res.status === 403) {
navigateTo403();
throw new HttpError(403, "Forbidden");
}
if (!res.ok) {
throw new HttpError(res.status, `HTTP ${res.status}: ${res.statusText}`);
}
const json = await res.json();
const json: unknown = await res.json();
const envelope = json as { code?: number; data?: unknown; message?: string };
if (json.code !== undefined && json.code !== 0) {
throw new ApiError(json.code, json.message ?? "Unknown error");
if (envelope.code !== undefined && envelope.code !== 0) {
throw new ApiError(envelope.code, envelope.message ?? "Unknown error");
}
return json.data !== undefined ? json.data : json;
return (envelope.data !== undefined ? envelope.data : json) as T;
}
export const httpClient = {
+15
View File
@@ -0,0 +1,15 @@
import path from "node:path";
import { defineConfig } from "vitest/config";
export default defineConfig({
resolve: {
alias: {
"@": path.resolve(__dirname, "src"),
},
},
test: {
environment: "node",
include: ["src/**/*.test.ts"],
passWithNoTests: false,
},
});
+1 -1
View File
@@ -8,7 +8,7 @@ This directory contains scoped instruction files used by AI coding agents.
- `testing.instructions.md`: Playwright/MSW/test changes
- `api.instructions.md`: API client/schema/handler changes
- `refactor.instructions.md`: behavior-preserving cleanup and deduplication
- `add-resource.instructions.md`: add a new CRUD resource (manual / Agent checklist; mirror `users` + `orders`)
- `add-resource.instructions.md`: add a new CRUD resource (manual / Agent checklist; mirror `users`)
## Authoring Rules
@@ -1,11 +1,11 @@
---
applyTo: "src/api/**/*.ts,src/mocks/**/*.ts,src/routes/_auth/**/*.tsx,e2e/**/*.spec.ts"
description: "Use when adding a new CRUD resource (orders, roles, tenants, etc.). Keywords: add resource, new resource, scaffold, CRUD page."
description: "Use when adding a new CRUD resource (roles, tenants, etc.). Keywords: add resource, new resource, scaffold, CRUD page."
---
# Add Resource Recipe
Add a list CRUD flow in `apps/with-lingui` that mirrors **Users** (TanStack Router + Query + MSW + Lingui). There is **no** codegen script: **copy and adapt by hand** using the steps below, or have an Agent follow this checklist. Primary references: `src/routes/_auth/users/`, the sample `src/routes/_auth/orders/`, and the matching `e2e/*.spec.ts` files.
Add a list CRUD flow in `apps/with-lingui` that mirrors **Users** (TanStack Router + Query + MSW + Lingui). There is **no** codegen script: **copy and adapt by hand** using the steps below, or have an Agent follow this checklist. Primary references: `src/routes/_auth/users/` and the matching `e2e/*.spec.ts` files.
## 0. Verification commands (after your changes)
@@ -32,7 +32,7 @@ pnpm run test:e2e -- e2e/<slug>.spec.ts
## 4. MSW (`src/mocks/handlers/<slug>.ts` + `handlers/index.ts`)
- Follow `handlers/user.ts` / `handlers/orders.ts`: paginated `GET` + optional `keyword`, `POST` / `PUT` / `DELETE`; use `successWithSchema` / `paginatedWithSchema` / `successWithNullBody` for success and `errorResponse` for errors.
- Follow `handlers/user.ts`: paginated `GET` + optional `keyword`, `POST` / `PUT` / `DELETE`; use `successWithSchema` / `paginatedWithSchema` / `successWithNullBody` for success and `errorResponse` for errors.
- Register `...<slug>Handlers` in `handlers/index.ts`.
## 5. Route page (`src/routes/_auth/<slug>/`)
@@ -42,7 +42,7 @@ pnpm run test:e2e -- e2e/<slug>.spec.ts
## 6. Menu and permissions (`src/utils/appMenu.ts`)
- Add a menu entry to `APP_MENU_TREE`: `path: "/<slug>"`, `permissions: ["<slug>:view"]`, `icon` using a Sidebar-registered key such as `IconLucidePackage` (see existing `IconLucide*` entries in `Sidebar`).
- Add a menu entry to `APP_MENU_TREE`: `path: "/<slug>"`, `permissions: ["<slug>:view"]`, `icon` using a Sidebar-registered key (see existing `IconLucide*` entries in `Sidebar`).
- Keep behavior consistent with `canAccessPath` / 403.
## 6b. Sidebar labels and icons (`src/components/Layout/Sidebar/index.tsx`)
@@ -51,11 +51,9 @@ pnpm run test:e2e -- e2e/<slug>.spec.ts
- In `MENU_ICON_MAP`, map the new `icon` key to a `lucide-react` icon and add the matching `import`.
- Then run `pnpm run i18n:extract && pnpm run i18n:compile` and fill in `zh` (and other locale) translations.
> In this template repo, the `orders` sample is already aligned with the Sidebar next to `appMenu`. When adding a resource from scratch, double-check this section.
## 7. E2E (`e2e/<slug>.spec.ts`)
- Copy structure from `users.spec.ts` / `orders.spec.ts`: `loginAsAdmin` → `goto /<slug>`, assert headers / search placeholder, etc.
- Copy structure from `users.spec.ts`: `loginAsAdmin` → `goto /<slug>`, assert headers / search placeholder, etc.
## 8. Verification
@@ -1,7 +1,8 @@
---
applyTo: "e2e/**/*.ts,e2e/**/*.tsx,src/mocks/**/*.ts,playwright.config.ts"
## applyTo: "e2e/**/\*.ts,e2e/**/_.tsx,src/mocks/\*\*/_.ts,playwright.config.ts"
description: "Use when adding or editing tests, Playwright cases, MSW handlers, mock data, or test configuration. Keywords: test, e2e, playwright, mock, msw, coverage."
---
# Testing Instructions
+1 -1
View File
@@ -84,6 +84,6 @@ This repository also uses scoped AI instruction files in `.github/instructions/`
- `testing.instructions.md` for Playwright/MSW/test updates
- `api.instructions.md` for API/schema/handler changes
- `refactor.instructions.md` for behavior-preserving cleanup
- `add-resource.instructions.md` for adding a new CRUD resource (checklist; mirror `users` / `orders`)
- `add-resource.instructions.md` for adding a new CRUD resource (checklist; mirror `users`)
See `.github/instructions/README.md` for usage and authoring rules.
+4 -8
View File
@@ -92,8 +92,7 @@ src/
├── api/ # Zod schemas, endpoint constants, type exports
│ ├── schemas.ts # Domain models (User, AuthTokens, MenuItem, etc.)
│ ├── auth.ts # Auth endpoint constants
│ ├── user.ts # User CRUD endpoint constants
│ └── orders.ts # Example second resource API (mirror `user.ts`)
│ └── user.ts # User CRUD endpoint constants
├── components/
│ ├── Aurora/ # Login background effect
│ ├── Auth/ # Auth (permission gate): index.tsx
@@ -123,13 +122,12 @@ src/
│ ├── createHandler.ts # Shared MSW success/error/delay helpers
│ ├── data.ts # Mock seed data (users, menus)
│ ├── utils.ts # Mock-only helpers (filters, pagination, demo avatar URLs)
│ └── handlers/ # Request handlers (auth, users, orders, …)
│ └── handlers/ # Request handlers (auth, users, …)
├── routes/ # TanStack Router file-based routes
│ ├── __root.tsx # Root layout (QueryClient, ConfigProvider, I18n)
│ ├── _auth.tsx # Auth guard layout (redirects to /login)
│ ├── _auth/dashboard/index.tsx
│ ├── _auth/users/index.tsx # Full CRUD with URL-synced search params
│ ├── _auth/orders/ # Example second resource (mirror users CRUD)
│ ├── _auth/403/index.tsx
│ ├── login/index.tsx
│ ├── 404/index.tsx
@@ -143,8 +141,7 @@ src/
e2e/ # Playwright E2E tests
├── helpers.ts
├── login.spec.ts
├── users.spec.ts
└── orders.spec.ts # Example resource smoke test
└── users.spec.ts
```
@@ -163,7 +160,7 @@ Ant Design strings follow the active locale via `ConfigProvider` in `__root.tsx`
## Extending the template
- **New CRUD resource:** See `.github/instructions/add-resource.instructions.md` (checklist; copy from `users` / `orders`), then `pnpm run i18n:extract && pnpm run i18n:compile`, wire Sidebar labels/icons if needed (§6b in that doc), and `pnpm exec vp check --no-fmt`.
- **New CRUD resource:** See `.github/instructions/add-resource.instructions.md` (checklist; copy from `users`), then `pnpm run i18n:extract && pnpm run i18n:compile`, wire Sidebar labels/icons if needed (§6b in that doc), and `pnpm exec vp check --no-fmt`.
- **Real backend:** Point `VITE_API_BASE_URL` in env and disable or remove MSW in `main.tsx` when you no longer need mocks.
- **Drop i18n:** Remove Lingui packages, vite/swc plugins, and replace `t` macros with plain strings (larger refactor).
@@ -186,7 +183,6 @@ Ant Design strings follow the active locale via `ConfigProvider` in `__root.tsx`
| `/login` | Login form with validation |
| `/dashboard` | Statistics overview cards |
| `/users` | User CRUD table with search, pagination, create/edit/delete |
| `/orders` | Example orders list (title/status) — second CRUD sample alongside `users` |
| `/403` | Forbidden error page |
| `/404` | Not found error page |
-14
View File
@@ -1,14 +0,0 @@
import { test, expect } from "@playwright/test";
import { loginAsAdmin } from "./helpers";
test.describe("Order list", () => {
test.beforeEach(async ({ page }) => {
await loginAsAdmin(page);
await page.goto("/orders");
await expect(page.getByPlaceholder(/Search Order|搜索/)).toBeVisible({ timeout: 15_000 });
});
test("should display table", async ({ page }) => {
await expect(page.locator(".ant-table-thead").getByText(/Title|标题/)).toBeVisible();
});
});
+1 -1
View File
@@ -15,7 +15,7 @@
"test:e2e": "playwright test",
"test:e2e:core": "playwright test e2e/login.spec.ts e2e/users.spec.ts e2e/auth-refresh.spec.ts e2e/rbac.spec.ts e2e/url-state.spec.ts",
"test:e2e:ui": "playwright test --ui",
"i18n:extract": "vp exec lingui extract",
"i18n:extract": "vp exec lingui extract -- --clean",
"i18n:compile": "vp exec lingui compile"
},
"dependencies": {
-10
View File
@@ -1,10 +0,0 @@
import type { CreateOrderRequest, UpdateOrderRequest, Order } from "./schemas";
export const ORDER_ENDPOINTS = {
list: "/api/orders",
create: "/api/orders",
update: (id: string) => `/api/orders/${id}`,
delete: (id: string) => `/api/orders/${id}`,
} as const;
export type { CreateOrderRequest, UpdateOrderRequest, Order };
-30
View File
@@ -134,33 +134,3 @@ export type CreateUserRequest = z.infer<typeof CreateUserRequestSchema>;
export const UpdateUserRequestSchema = CreateUserRequestSchema.partial();
export type UpdateUserRequest = z.infer<typeof UpdateUserRequestSchema>;
export const CreateTestRequestSchema = z.object({
title: z.string().min(1),
status: z.enum(["draft", "open", "done"]),
});
export type CreateTestRequest = z.infer<typeof CreateTestRequestSchema>;
export const UpdateTestRequestSchema = CreateTestRequestSchema.partial();
export type UpdateTestRequest = z.infer<typeof UpdateTestRequestSchema>;
export const OrderSchema = z.object({
id: z.string(),
title: z.string(),
status: z.enum(["draft", "open", "done"]),
});
export type Order = z.infer<typeof OrderSchema>;
export const CreateOrderRequestSchema = z.object({
title: z.string().min(1),
status: z.enum(["draft", "open", "done"]),
});
export type CreateOrderRequest = z.infer<typeof CreateOrderRequestSchema>;
export const UpdateOrderRequestSchema = CreateOrderRequestSchema.partial();
export type UpdateOrderRequest = z.infer<typeof UpdateOrderRequestSchema>;
@@ -9,7 +9,6 @@ import {
CircleDashed,
Folder,
Home,
Package,
PanelLeft,
SlidersHorizontal,
Star,
@@ -32,7 +31,6 @@ const MENU_LABELS: Record<string, ReturnType<typeof msg>> = {
Projects: msg`Projects`,
Dashboard: msg`Dashboard`,
Users: msg`Users`,
Orders: msg`Orders`,
"Design Engineering": msg`Design Engineering`,
"Sales & Marketing": msg`Sales & Marketing`,
};
@@ -55,7 +53,6 @@ const MENU_ICON_MAP: Record<string, LucideIcon> = {
IconLucideBookOpen: Book,
IconLucideFolderKanban: Folder,
IconLucideSparkles: Zap,
IconLucidePackage: Package,
};
function renderMenuIcon(icon: string | null, size = 16) {
+58 -138
View File
@@ -13,46 +13,40 @@ msgstr ""
"Language-Team: \n"
"Plural-Forms: \n"
#: src/routes/_auth/users/index.tsx:464
#~ msgid "{n} rows"
#~ msgstr "{n} rows"
#: src/routes/_auth/orders/index.tsx:186
#: src/routes/_auth/users/index.tsx:262
#: src/routes/_auth/users/index.tsx:263
msgid "{total} rows"
msgstr "{total} rows"
#: src/routes/_auth/dashboard/index.tsx:144
#: src/routes/_auth/dashboard/index.tsx:141
msgid "+180.1% from last month"
msgstr "+180.1% from last month"
#: src/routes/_auth/dashboard/index.tsx:150
#: src/routes/_auth/dashboard/index.tsx:147
msgid "+19% from last month"
msgstr "+19% from last month"
#: src/routes/_auth/dashboard/index.tsx:138
#: src/routes/_auth/dashboard/index.tsx:135
msgid "+20.1% from last month"
msgstr "+20.1% from last month"
#: src/routes/_auth/dashboard/index.tsx:156
#: src/routes/_auth/dashboard/index.tsx:153
msgid "+201 since last hour"
msgstr "+201 since last hour"
#: src/components/Layout/Header/index.tsx:83
#: src/components/Layout/Header/index.tsx:79
msgid "403"
msgstr "403"
#: src/routes/_auth/dashboard/index.tsx:154
#: src/routes/_auth/dashboard/index.tsx:151
msgid "Active Now"
msgstr "Active Now"
#: src/routes/_auth/users/-FormModal.tsx:52
#: src/routes/_auth/users/-Toolbar.tsx:64
#: src/routes/_auth/users/-Toolbar.tsx:63
msgid "Admin"
msgstr "Admin"
#: src/routes/_auth/orders/index.tsx:112
#: src/routes/_auth/users/index.tsx:124
#: src/routes/_auth/users/index.tsx:125
msgid "Are you absolutely sure?"
msgstr "Are you absolutely sure?"
@@ -60,103 +54,83 @@ msgstr "Are you absolutely sure?"
msgid "Auto login"
msgstr "Auto login"
#: src/components/NotFound/index.tsx:59
#: src/components/NotFound/index.tsx:57
#: src/routes/_auth/403/index.tsx:35
msgid "Back to Home"
msgstr "Back to Home"
#: src/routes/_auth/orders/index.tsx:116
#: src/routes/_auth/users/-FormModal.tsx:31
#: src/routes/_auth/users/index.tsx:128
#: src/routes/_auth/users/index.tsx:129
msgid "Cancel"
msgstr "Cancel"
#: src/routes/_auth/dashboard/index.tsx:250
#: src/routes/_auth/dashboard/index.tsx:247
msgid "Chart Placeholder"
msgstr "Chart Placeholder"
#: src/hooks/useCrudToasts.ts:43
#: src/hooks/useCrudToasts.ts:40
msgid "Create failed"
msgstr "Create failed"
#: src/routes/_auth/users/-Toolbar.tsx:89
#: src/routes/_auth/users/-Toolbar.tsx:88
msgid "Create User"
msgstr "Create User"
#: src/hooks/useCrudToasts.ts:40
#: src/hooks/useCrudToasts.ts:37
msgid "Created successfully"
msgstr "Created successfully"
#: src/components/Layout/Header/index.tsx:79
#: src/components/Layout/Header/index.tsx:75
#: src/components/Layout/Header/index.tsx:88
#: src/components/Layout/Header/index.tsx:92
#: src/components/Layout/Header/index.tsx:96
#: src/components/Layout/Sidebar/index.tsx:34
#: src/components/Layout/Sidebar/index.tsx:32
msgid "Dashboard"
msgstr "Dashboard"
#: src/routes/_auth/orders/index.tsx:114
#: src/routes/_auth/orders/index.tsx:152
#: src/routes/_auth/users/index.tsx:126
#: src/routes/_auth/users/index.tsx:223
#: src/routes/_auth/users/index.tsx:127
#: src/routes/_auth/users/index.tsx:224
msgid "Delete"
msgstr "Delete"
#: src/hooks/useCrudToasts.ts:65
#: src/hooks/useCrudToasts.ts:62
msgid "Delete failed"
msgstr "Delete failed"
#: src/hooks/useCrudToasts.ts:62
#: src/hooks/useCrudToasts.ts:59
msgid "Deleted successfully"
msgstr "Deleted successfully"
#: src/hooks/useCrudToasts.ts:59
#: src/hooks/useCrudToasts.ts:56
msgid "Deleting…"
msgstr "Deleting…"
#: src/components/Layout/Sidebar/index.tsx:37
#: src/components/Layout/Sidebar/index.tsx:34
msgid "Design Engineering"
msgstr "Design Engineering"
#: src/routes/_auth/orders/-FormModal.tsx:36
msgid "Done"
msgstr "Done"
#: src/routes/_auth/orders/-FormModal.tsx:34
msgid "Draft"
msgstr "Draft"
#: src/routes/_auth/orders/index.tsx:142
#: src/routes/_auth/users/index.tsx:213
#: src/routes/_auth/users/index.tsx:214
msgid "Edit"
msgstr "Edit"
#: src/routes/_auth/orders/-FormModal.tsx:19
msgid "Edit Order"
msgstr "Edit Order"
#: src/routes/_auth/users/-FormModal.tsx:29
msgid "Edit User"
msgstr "Edit User"
#: src/routes/_auth/users/-FormModal.tsx:53
#: src/routes/_auth/users/-Toolbar.tsx:65
#: src/routes/_auth/users/-Toolbar.tsx:64
msgid "Editor"
msgstr "Editor"
#: src/routes/_auth/users/-FormModal.tsx:57
#: src/routes/_auth/users/index.tsx:168
#: src/routes/_auth/users/index.tsx:169
msgid "Email"
msgstr "Email"
#: src/routes/_auth/users.tsx:353
#~ msgid "Filter by role"
#~ msgstr "Filter by role"
#: src/routes/login/index.tsx:188
msgid "Forgot password?"
msgstr "Forgot password?"
#: src/components/NotFound/index.tsx:62
#: src/components/NotFound/index.tsx:60
msgid "Go back"
msgstr "Go back"
@@ -168,27 +142,20 @@ msgstr "Login failed"
msgid "Login successful"
msgstr "Login successful"
#: src/routes/_auth/orders/-Toolbar.tsx:54
#: src/routes/_auth/orders/-Toolbar.tsx:55
#: src/routes/_auth/users/-Toolbar.tsx:91
#: src/routes/_auth/users/-Toolbar.tsx:92
#: src/routes/_auth/users/-Toolbar.tsx:93
msgid "More filters"
msgstr "More filters"
#: src/routes/_auth/orders/-FormModal.tsx:19
#: src/routes/_auth/orders/-Toolbar.tsx:51
msgid "New Order"
msgstr "New Order"
#: src/routes/_auth/users/-FormModal.tsx:29
msgid "New User"
msgstr "New User"
#: src/components/DataTable/DataTableEmpty.tsx:49
#: src/components/DataTable/DataTableEmpty.tsx:46
msgid "No data"
msgstr "No data"
#: src/components/DataTable/DataTableEmpty.tsx:59
#: src/components/DataTable/DataTableEmpty.tsx:56
msgid "Nothing to show in this list yet"
msgstr "Nothing to show in this list yet"
@@ -196,32 +163,20 @@ msgstr "Nothing to show in this list yet"
msgid "OK"
msgstr "OK"
#: src/routes/_auth/orders/-FormModal.tsx:35
msgid "Open"
msgstr "Open"
#: src/components/Layout/Sidebar/index.tsx:358
#: src/components/Layout/Sidebar/index.tsx:350
msgid "Open account menu"
msgstr "Open account menu"
#: src/components/Layout/Sidebar/index.tsx:36
msgid "Orders"
msgstr "Orders"
#: src/routes/_auth/dashboard/index.tsx:233
#: src/routes/_auth/dashboard/index.tsx:230
msgid "Overview"
msgstr "Overview"
#: src/routes/404/index.tsx:87
#~ msgid "Page not found"
#~ msgstr "Page not found"
#: src/routes/login/index.tsx:162
#: src/routes/login/index.tsx:168
msgid "Password"
msgstr "Password"
#: src/components/Layout/Sidebar/index.tsx:32
#: src/components/Layout/Sidebar/index.tsx:30
msgid "Platform"
msgstr "Platform"
@@ -246,11 +201,11 @@ msgstr "Please try again or return to the dashboard."
msgid "Powered by"
msgstr "Powered by"
#: src/components/Layout/Sidebar/index.tsx:33
#: src/components/Layout/Sidebar/index.tsx:31
msgid "Projects"
msgstr "Projects"
#: src/routes/_auth/dashboard/index.tsx:259
#: src/routes/_auth/dashboard/index.tsx:256
msgid "Recent Sales"
msgstr "Recent Sales"
@@ -258,49 +213,36 @@ msgstr "Recent Sales"
msgid "Retry"
msgstr "Retry"
#: src/routes/_auth/users/-Toolbar.tsx:58
#: src/routes/_auth/users/-Toolbar.tsx:57
msgid "Role"
msgstr "Role"
#: src/routes/_auth/users/-FormModal.tsx:46
#: src/routes/_auth/users/index.tsx:175
#: src/routes/_auth/users/index.tsx:176
msgid "Roles"
msgstr "Roles"
#: src/routes/_auth/orders/index.tsx:160
#: src/routes/_auth/users/index.tsx:234
#: src/routes/_auth/users/index.tsx:235
msgid "Row actions"
msgstr "Row actions"
#: src/routes/_auth/dashboard/index.tsx:148
#: src/routes/_auth/dashboard/index.tsx:145
msgid "Sales"
msgstr "Sales"
#: src/components/Layout/Sidebar/index.tsx:38
#: src/components/Layout/Sidebar/index.tsx:35
msgid "Sales & Marketing"
msgstr "Sales & Marketing"
#: src/routes/_auth/orders/-Toolbar.tsx:32
msgid "Search Order"
msgstr "Search Order"
#: src/routes/_auth/users/-Toolbar.tsx:43
#: src/routes/_auth/users/-Toolbar.tsx:42
msgid "Search User"
msgstr "Search User"
#: src/routes/_auth/users.tsx:341
#~ msgid "Search username or email"
#~ msgstr "Search username or email"
#: src/routes/login/index.tsx:200
msgid "Sign In"
msgstr "Sign In"
#: src/routes/login.tsx:100
#~ msgid "Sign in with your credentials."
#~ msgstr "Sign in with your credentials."
#: src/components/Layout/Sidebar/index.tsx:197
#: src/components/Layout/Sidebar/index.tsx:191
msgid "Sign Out"
msgstr "Sign Out"
@@ -308,7 +250,7 @@ msgstr "Sign Out"
msgid "Something went wrong"
msgstr "Something went wrong"
#: src/components/NotFound/index.tsx:55
#: src/components/NotFound/index.tsx:53
msgid "Sorry, the page you visited does not exist."
msgstr "Sorry, the page you visited does not exist."
@@ -316,76 +258,54 @@ msgstr "Sorry, the page you visited does not exist."
msgid "Sorry, you don't have permission to access this page."
msgstr "Sorry, you don't have permission to access this page."
#: src/routes/_auth/orders/-FormModal.tsx:31
#: src/routes/_auth/orders/index.tsx:125
msgid "Status"
msgstr "Status"
#: src/routes/_auth/dashboard/index.tsx:142
#: src/routes/_auth/dashboard/index.tsx:139
msgid "Subscriptions"
msgstr "Subscriptions"
#: src/components/Layout/Header/index.tsx:153
#: src/components/Layout/Header/index.tsx:149
#: src/routes/login/index.tsx:221
msgid "Switch language"
msgstr "Switch language"
#: src/routes/_auth/orders/index.tsx:113
msgid "This action cannot be undone."
msgstr "This action cannot be undone."
#: src/routes/_auth/users/index.tsx:125
#: src/routes/_auth/users/index.tsx:126
msgid "This action cannot be undone. This will permanently delete the user."
msgstr "This action cannot be undone. This will permanently delete the user."
#: src/routes/_auth/orders/-FormModal.tsx:28
#: src/routes/_auth/orders/index.tsx:123
msgid "Title"
msgstr "Title"
#: src/components/Layout/Header/index.tsx:138
#: src/components/Layout/Sidebar/index.tsx:271
#: src/components/Layout/Sidebar/index.tsx:319
#: src/components/Layout/Header/index.tsx:134
#: src/components/Layout/Sidebar/index.tsx:263
#: src/components/Layout/Sidebar/index.tsx:311
msgid "Toggle sidebar"
msgstr "Toggle sidebar"
#: src/components/Layout/Header/index.tsx:159
#: src/components/Layout/Header/index.tsx:155
#: src/routes/login/index.tsx:228
msgid "Toggle Theme"
msgstr "Toggle Theme"
#: src/routes/_auth/dashboard/index.tsx:136
#: src/routes/_auth/dashboard/index.tsx:133
msgid "Total Revenue"
msgstr "Total Revenue"
#: src/hooks/useCrudToasts.ts:54
#: src/hooks/useCrudToasts.ts:51
msgid "Update failed"
msgstr "Update failed"
#: src/hooks/useCrudToasts.ts:51
#: src/hooks/useCrudToasts.ts:48
msgid "Updated successfully"
msgstr "Updated successfully"
#: src/hooks/useCrudToasts.ts:48
#: src/hooks/useCrudToasts.ts:45
msgid "Updating…"
msgstr "Updating…"
#: src/components/Layout/Sidebar/index.tsx:34
#~ msgid "User List"
#~ msgstr "User List"
#: src/routes/_auth/users/-FormModal.tsx:39
#: src/routes/_auth/users/index.tsx:142
#: src/routes/_auth/users/index.tsx:143
#: src/routes/login/index.tsx:149
#: src/routes/login/index.tsx:154
msgid "Username"
msgstr "Username"
#: src/components/Layout/Header/index.tsx:81
#: src/components/Layout/Sidebar/index.tsx:35
#: src/components/Layout/Header/index.tsx:77
#: src/components/Layout/Sidebar/index.tsx:33
msgid "Users"
msgstr "Users"
#: src/routes/login.tsx:101
#~ msgid "Welcome back"
#~ msgstr "Welcome back"
+58 -138
View File
@@ -13,46 +13,40 @@ msgstr ""
"Language-Team: \n"
"Plural-Forms: \n"
#: src/routes/_auth/users/index.tsx:464
#~ msgid "{n} rows"
#~ msgstr "{n} 行"
#: src/routes/_auth/orders/index.tsx:186
#: src/routes/_auth/users/index.tsx:262
#: src/routes/_auth/users/index.tsx:263
msgid "{total} rows"
msgstr "共 {total} 条"
#: src/routes/_auth/dashboard/index.tsx:144
#: src/routes/_auth/dashboard/index.tsx:141
msgid "+180.1% from last month"
msgstr "较上月 +180.1%"
#: src/routes/_auth/dashboard/index.tsx:150
#: src/routes/_auth/dashboard/index.tsx:147
msgid "+19% from last month"
msgstr "较上月 +19%"
#: src/routes/_auth/dashboard/index.tsx:138
#: src/routes/_auth/dashboard/index.tsx:135
msgid "+20.1% from last month"
msgstr "较上月 +20.1%"
#: src/routes/_auth/dashboard/index.tsx:156
#: src/routes/_auth/dashboard/index.tsx:153
msgid "+201 since last hour"
msgstr "较上一小时 +201"
#: src/components/Layout/Header/index.tsx:83
#: src/components/Layout/Header/index.tsx:79
msgid "403"
msgstr "403"
#: src/routes/_auth/dashboard/index.tsx:154
#: src/routes/_auth/dashboard/index.tsx:151
msgid "Active Now"
msgstr "当前在线"
#: src/routes/_auth/users/-FormModal.tsx:52
#: src/routes/_auth/users/-Toolbar.tsx:64
#: src/routes/_auth/users/-Toolbar.tsx:63
msgid "Admin"
msgstr "管理员"
#: src/routes/_auth/orders/index.tsx:112
#: src/routes/_auth/users/index.tsx:124
#: src/routes/_auth/users/index.tsx:125
msgid "Are you absolutely sure?"
msgstr "确定要执行此操作吗?"
@@ -60,103 +54,83 @@ msgstr "确定要执行此操作吗?"
msgid "Auto login"
msgstr "自动登录"
#: src/components/NotFound/index.tsx:59
#: src/components/NotFound/index.tsx:57
#: src/routes/_auth/403/index.tsx:35
msgid "Back to Home"
msgstr "返回首页"
#: src/routes/_auth/orders/index.tsx:116
#: src/routes/_auth/users/-FormModal.tsx:31
#: src/routes/_auth/users/index.tsx:128
#: src/routes/_auth/users/index.tsx:129
msgid "Cancel"
msgstr "取消"
#: src/routes/_auth/dashboard/index.tsx:250
#: src/routes/_auth/dashboard/index.tsx:247
msgid "Chart Placeholder"
msgstr "图表占位"
#: src/hooks/useCrudToasts.ts:43
#: src/hooks/useCrudToasts.ts:40
msgid "Create failed"
msgstr "创建失败"
#: src/routes/_auth/users/-Toolbar.tsx:89
#: src/routes/_auth/users/-Toolbar.tsx:88
msgid "Create User"
msgstr "创建用户"
#: src/hooks/useCrudToasts.ts:40
#: src/hooks/useCrudToasts.ts:37
msgid "Created successfully"
msgstr "创建成功"
#: src/components/Layout/Header/index.tsx:79
#: src/components/Layout/Header/index.tsx:75
#: src/components/Layout/Header/index.tsx:88
#: src/components/Layout/Header/index.tsx:92
#: src/components/Layout/Header/index.tsx:96
#: src/components/Layout/Sidebar/index.tsx:34
#: src/components/Layout/Sidebar/index.tsx:32
msgid "Dashboard"
msgstr "仪表盘"
#: src/routes/_auth/orders/index.tsx:114
#: src/routes/_auth/orders/index.tsx:152
#: src/routes/_auth/users/index.tsx:126
#: src/routes/_auth/users/index.tsx:223
#: src/routes/_auth/users/index.tsx:127
#: src/routes/_auth/users/index.tsx:224
msgid "Delete"
msgstr "删除"
#: src/hooks/useCrudToasts.ts:65
#: src/hooks/useCrudToasts.ts:62
msgid "Delete failed"
msgstr "删除失败"
#: src/hooks/useCrudToasts.ts:62
#: src/hooks/useCrudToasts.ts:59
msgid "Deleted successfully"
msgstr "删除成功"
#: src/hooks/useCrudToasts.ts:59
#: src/hooks/useCrudToasts.ts:56
msgid "Deleting…"
msgstr "正在删除…"
#: src/components/Layout/Sidebar/index.tsx:37
#: src/components/Layout/Sidebar/index.tsx:34
msgid "Design Engineering"
msgstr "设计工程"
#: src/routes/_auth/orders/-FormModal.tsx:36
msgid "Done"
msgstr "已完成"
#: src/routes/_auth/orders/-FormModal.tsx:34
msgid "Draft"
msgstr "草稿"
#: src/routes/_auth/orders/index.tsx:142
#: src/routes/_auth/users/index.tsx:213
#: src/routes/_auth/users/index.tsx:214
msgid "Edit"
msgstr "编辑"
#: src/routes/_auth/orders/-FormModal.tsx:19
msgid "Edit Order"
msgstr "编辑订单"
#: src/routes/_auth/users/-FormModal.tsx:29
msgid "Edit User"
msgstr "编辑用户"
#: src/routes/_auth/users/-FormModal.tsx:53
#: src/routes/_auth/users/-Toolbar.tsx:65
#: src/routes/_auth/users/-Toolbar.tsx:64
msgid "Editor"
msgstr "编辑者"
#: src/routes/_auth/users/-FormModal.tsx:57
#: src/routes/_auth/users/index.tsx:168
#: src/routes/_auth/users/index.tsx:169
msgid "Email"
msgstr "邮箱"
#: src/routes/_auth/users.tsx:353
#~ msgid "Filter by role"
#~ msgstr "按角色筛选"
#: src/routes/login/index.tsx:188
msgid "Forgot password?"
msgstr "忘记密码?"
#: src/components/NotFound/index.tsx:62
#: src/components/NotFound/index.tsx:60
msgid "Go back"
msgstr "返回上一页"
@@ -168,27 +142,20 @@ msgstr "登录失败"
msgid "Login successful"
msgstr "登录成功"
#: src/routes/_auth/orders/-Toolbar.tsx:54
#: src/routes/_auth/orders/-Toolbar.tsx:55
#: src/routes/_auth/users/-Toolbar.tsx:91
#: src/routes/_auth/users/-Toolbar.tsx:92
#: src/routes/_auth/users/-Toolbar.tsx:93
msgid "More filters"
msgstr "更多筛选"
#: src/routes/_auth/orders/-FormModal.tsx:19
#: src/routes/_auth/orders/-Toolbar.tsx:51
msgid "New Order"
msgstr "新建订单"
#: src/routes/_auth/users/-FormModal.tsx:29
msgid "New User"
msgstr "新建用户"
#: src/components/DataTable/DataTableEmpty.tsx:49
#: src/components/DataTable/DataTableEmpty.tsx:46
msgid "No data"
msgstr "暂无数据"
#: src/components/DataTable/DataTableEmpty.tsx:59
#: src/components/DataTable/DataTableEmpty.tsx:56
msgid "Nothing to show in this list yet"
msgstr "当前列表为空,暂无可展示的记录"
@@ -196,32 +163,20 @@ msgstr "当前列表为空,暂无可展示的记录"
msgid "OK"
msgstr "确定"
#: src/routes/_auth/orders/-FormModal.tsx:35
msgid "Open"
msgstr "进行中"
#: src/components/Layout/Sidebar/index.tsx:358
#: src/components/Layout/Sidebar/index.tsx:350
msgid "Open account menu"
msgstr "打开账户菜单"
#: src/components/Layout/Sidebar/index.tsx:36
msgid "Orders"
msgstr "订单"
#: src/routes/_auth/dashboard/index.tsx:233
#: src/routes/_auth/dashboard/index.tsx:230
msgid "Overview"
msgstr "概览"
#: src/routes/404/index.tsx:87
#~ msgid "Page not found"
#~ msgstr "页面不存在"
#: src/routes/login/index.tsx:162
#: src/routes/login/index.tsx:168
msgid "Password"
msgstr "密码"
#: src/components/Layout/Sidebar/index.tsx:32
#: src/components/Layout/Sidebar/index.tsx:30
msgid "Platform"
msgstr "平台"
@@ -246,11 +201,11 @@ msgstr "请重试或返回控制台。"
msgid "Powered by"
msgstr "由"
#: src/components/Layout/Sidebar/index.tsx:33
#: src/components/Layout/Sidebar/index.tsx:31
msgid "Projects"
msgstr "项目"
#: src/routes/_auth/dashboard/index.tsx:259
#: src/routes/_auth/dashboard/index.tsx:256
msgid "Recent Sales"
msgstr "近期销售"
@@ -258,49 +213,36 @@ msgstr "近期销售"
msgid "Retry"
msgstr "重试"
#: src/routes/_auth/users/-Toolbar.tsx:58
#: src/routes/_auth/users/-Toolbar.tsx:57
msgid "Role"
msgstr "角色"
#: src/routes/_auth/users/-FormModal.tsx:46
#: src/routes/_auth/users/index.tsx:175
#: src/routes/_auth/users/index.tsx:176
msgid "Roles"
msgstr "角色"
#: src/routes/_auth/orders/index.tsx:160
#: src/routes/_auth/users/index.tsx:234
#: src/routes/_auth/users/index.tsx:235
msgid "Row actions"
msgstr "行操作"
#: src/routes/_auth/dashboard/index.tsx:148
#: src/routes/_auth/dashboard/index.tsx:145
msgid "Sales"
msgstr "销售额"
#: src/components/Layout/Sidebar/index.tsx:38
#: src/components/Layout/Sidebar/index.tsx:35
msgid "Sales & Marketing"
msgstr "销售与市场"
#: src/routes/_auth/orders/-Toolbar.tsx:32
msgid "Search Order"
msgstr "搜索订单"
#: src/routes/_auth/users/-Toolbar.tsx:43
#: src/routes/_auth/users/-Toolbar.tsx:42
msgid "Search User"
msgstr "搜索用户"
#: src/routes/_auth/users.tsx:341
#~ msgid "Search username or email"
#~ msgstr "搜索用户名或邮箱"
#: src/routes/login/index.tsx:200
msgid "Sign In"
msgstr "登录"
#: src/routes/login.tsx:100
#~ msgid "Sign in with your credentials."
#~ msgstr "凭用户名与密码登录"
#: src/components/Layout/Sidebar/index.tsx:197
#: src/components/Layout/Sidebar/index.tsx:191
msgid "Sign Out"
msgstr "退出登录"
@@ -308,7 +250,7 @@ msgstr "退出登录"
msgid "Something went wrong"
msgstr "出了点问题"
#: src/components/NotFound/index.tsx:55
#: src/components/NotFound/index.tsx:53
msgid "Sorry, the page you visited does not exist."
msgstr "抱歉,您访问的页面不存在。"
@@ -316,76 +258,54 @@ msgstr "抱歉,您访问的页面不存在。"
msgid "Sorry, you don't have permission to access this page."
msgstr "抱歉,您没有权限访问此页面。"
#: src/routes/_auth/orders/-FormModal.tsx:31
#: src/routes/_auth/orders/index.tsx:125
msgid "Status"
msgstr "状态"
#: src/routes/_auth/dashboard/index.tsx:142
#: src/routes/_auth/dashboard/index.tsx:139
msgid "Subscriptions"
msgstr "订阅"
#: src/components/Layout/Header/index.tsx:153
#: src/components/Layout/Header/index.tsx:149
#: src/routes/login/index.tsx:221
msgid "Switch language"
msgstr "切换语言"
#: src/routes/_auth/orders/index.tsx:113
msgid "This action cannot be undone."
msgstr "此操作无法撤销。"
#: src/routes/_auth/users/index.tsx:125
#: src/routes/_auth/users/index.tsx:126
msgid "This action cannot be undone. This will permanently delete the user."
msgstr "此操作无法撤销,将永久删除该用户。"
#: src/routes/_auth/orders/-FormModal.tsx:28
#: src/routes/_auth/orders/index.tsx:123
msgid "Title"
msgstr "标题"
#: src/components/Layout/Header/index.tsx:138
#: src/components/Layout/Sidebar/index.tsx:271
#: src/components/Layout/Sidebar/index.tsx:319
#: src/components/Layout/Header/index.tsx:134
#: src/components/Layout/Sidebar/index.tsx:263
#: src/components/Layout/Sidebar/index.tsx:311
msgid "Toggle sidebar"
msgstr "折叠侧栏"
#: src/components/Layout/Header/index.tsx:159
#: src/components/Layout/Header/index.tsx:155
#: src/routes/login/index.tsx:228
msgid "Toggle Theme"
msgstr "切换主题"
#: src/routes/_auth/dashboard/index.tsx:136
#: src/routes/_auth/dashboard/index.tsx:133
msgid "Total Revenue"
msgstr "总收入"
#: src/hooks/useCrudToasts.ts:54
#: src/hooks/useCrudToasts.ts:51
msgid "Update failed"
msgstr "更新失败"
#: src/hooks/useCrudToasts.ts:51
#: src/hooks/useCrudToasts.ts:48
msgid "Updated successfully"
msgstr "更新成功"
#: src/hooks/useCrudToasts.ts:48
#: src/hooks/useCrudToasts.ts:45
msgid "Updating…"
msgstr "正在更新…"
#: src/components/Layout/Sidebar/index.tsx:34
#~ msgid "User List"
#~ msgstr "用户列表"
#: src/routes/_auth/users/-FormModal.tsx:39
#: src/routes/_auth/users/index.tsx:142
#: src/routes/_auth/users/index.tsx:143
#: src/routes/login/index.tsx:149
#: src/routes/login/index.tsx:154
msgid "Username"
msgstr "用户名"
#: src/components/Layout/Header/index.tsx:81
#: src/components/Layout/Sidebar/index.tsx:35
#: src/components/Layout/Header/index.tsx:77
#: src/components/Layout/Sidebar/index.tsx:33
msgid "Users"
msgstr "用户"
#: src/routes/login.tsx:101
#~ msgid "Welcome back"
#~ msgstr "欢迎回来"
+2 -8
View File
@@ -1,4 +1,4 @@
import type { User, Order } from "@/api/schemas";
import type { User } from "@/api/schemas";
import { vercelAvatarUrl } from "./utils";
const MOCK_IDENTITIES: ReadonlyArray<[string, string]> = [
@@ -29,11 +29,5 @@ export const MOCK_USERS: User[] = MOCK_IDENTITIES.map(([username, email], i) =>
avatar: vercelAvatarUrl(username),
email,
roles: i === 0 ? ["admin"] : ["editor"],
permissions: i === 0 ? ["orders:view","orders:create","orders:edit","orders:delete","user:view", "user:create", "user:edit", "user:delete"] : ["user:view"],
permissions: i === 0 ? ["user:view", "user:create", "user:edit", "user:delete"] : ["user:view"],
}));
export const MOCK_ORDERS: Order[] = [
{ id: "1", title: "Alpha", status: "open" },
{ id: "2", title: "Beta", status: "draft" },
{ id: "3", title: "Gamma", status: "done" },
];
+1 -2
View File
@@ -1,5 +1,4 @@
import { authHandlers } from "./auth";
import { userHandlers } from "./user";
import { ordersHandlers } from "./orders";
export const handlers = [...authHandlers, ...userHandlers, ...ordersHandlers];
export const handlers = [...authHandlers, ...userHandlers];
@@ -1,61 +0,0 @@
import { http } from "msw";
import { OrderSchema } from "@/api/schemas";
import { MOCK_ORDERS } from "../data";
import { paginateList, parsePaginationParams } from "../utils";
import {
withDelay,
errorResponse,
ERROR_CODES,
paginatedWithSchema,
successWithSchema,
successWithNullBody,
} from "../createHandler";
let rows = [...MOCK_ORDERS];
function filterRows(items: typeof rows, keyword: string) {
if (!keyword.trim()) return items;
const k = keyword.toLowerCase();
return items.filter((r) => r.title.toLowerCase().includes(k));
}
export const ordersHandlers = [
http.get("/api/orders", async ({ request }) => {
await withDelay(150);
const url = new URL(request.url);
const { limit, offset } = parsePaginationParams(url.searchParams);
const keyword = url.searchParams.get("keyword") ?? "";
const filtered = filterRows(rows, keyword);
const list = paginateList(filtered, limit, offset);
return paginatedWithSchema(OrderSchema, { list, total: filtered.length });
}),
http.post("/api/orders", async ({ request }) => {
await withDelay(150);
const body = (await request.json()) as Record<string, unknown>;
const next: (typeof rows)[number] = {
id: String(rows.length + 1),
title: typeof body.title === "string" ? body.title : "",
status: (body.status as (typeof rows)[number]["status"]) ?? "draft",
};
rows.push(next);
return successWithSchema(OrderSchema, next);
}),
http.put("/api/orders/:id", async ({ params, request }) => {
await withDelay(150);
const body = (await request.json()) as Record<string, unknown>;
const idx = rows.findIndex((r) => r.id === params.id);
if (idx === -1) {
return errorResponse(ERROR_CODES.NOT_FOUND, "Not found");
}
rows[idx] = { ...rows[idx], ...body } as (typeof rows)[number];
return successWithSchema(OrderSchema, rows[idx]);
}),
http.delete("/api/orders/:id", async ({ params }) => {
await withDelay(150);
rows = rows.filter((r) => r.id !== params.id);
return successWithNullBody();
}),
];
+122 -148
View File
@@ -8,199 +8,173 @@
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as AuthRouteImport } from './routes/_auth'
import { Route as IndexRouteImport } from './routes/index'
import { Route as LoginIndexRouteImport } from './routes/login/index'
import { Route as R404IndexRouteImport } from './routes/404/index'
import { Route as AuthUsersIndexRouteImport } from './routes/_auth/users/index'
import { Route as AuthOrdersIndexRouteImport } from './routes/_auth/orders/index'
import { Route as AuthDashboardIndexRouteImport } from './routes/_auth/dashboard/index'
import { Route as Auth403IndexRouteImport } from './routes/_auth/403/index'
import { Route as rootRouteImport } from "./routes/__root";
import { Route as AuthRouteImport } from "./routes/_auth";
import { Route as IndexRouteImport } from "./routes/index";
import { Route as LoginIndexRouteImport } from "./routes/login/index";
import { Route as R404IndexRouteImport } from "./routes/404/index";
import { Route as AuthUsersIndexRouteImport } from "./routes/_auth/users/index";
import { Route as AuthDashboardIndexRouteImport } from "./routes/_auth/dashboard/index";
import { Route as Auth403IndexRouteImport } from "./routes/_auth/403/index";
const AuthRoute = AuthRouteImport.update({
id: '/_auth',
id: "/_auth",
getParentRoute: () => rootRouteImport,
} as any)
} as any);
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
id: "/",
path: "/",
getParentRoute: () => rootRouteImport,
} as any)
} as any);
const LoginIndexRoute = LoginIndexRouteImport.update({
id: '/login/',
path: '/login/',
id: "/login/",
path: "/login/",
getParentRoute: () => rootRouteImport,
} as any)
} as any);
const R404IndexRoute = R404IndexRouteImport.update({
id: '/404/',
path: '/404/',
id: "/404/",
path: "/404/",
getParentRoute: () => rootRouteImport,
} as any)
} as any);
const AuthUsersIndexRoute = AuthUsersIndexRouteImport.update({
id: '/users/',
path: '/users/',
id: "/users/",
path: "/users/",
getParentRoute: () => AuthRoute,
} as any)
const AuthOrdersIndexRoute = AuthOrdersIndexRouteImport.update({
id: '/orders/',
path: '/orders/',
getParentRoute: () => AuthRoute,
} as any)
} as any);
const AuthDashboardIndexRoute = AuthDashboardIndexRouteImport.update({
id: '/dashboard/',
path: '/dashboard/',
id: "/dashboard/",
path: "/dashboard/",
getParentRoute: () => AuthRoute,
} as any)
} as any);
const Auth403IndexRoute = Auth403IndexRouteImport.update({
id: '/403/',
path: '/403/',
id: "/403/",
path: "/403/",
getParentRoute: () => AuthRoute,
} as any)
} as any);
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/404/': typeof R404IndexRoute
'/login/': typeof LoginIndexRoute
'/403/': typeof Auth403IndexRoute
'/dashboard/': typeof AuthDashboardIndexRoute
'/orders/': typeof AuthOrdersIndexRoute
'/users/': typeof AuthUsersIndexRoute
"/": typeof IndexRoute;
"/404/": typeof R404IndexRoute;
"/login/": typeof LoginIndexRoute;
"/403/": typeof Auth403IndexRoute;
"/dashboard/": typeof AuthDashboardIndexRoute;
"/users/": typeof AuthUsersIndexRoute;
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/404': typeof R404IndexRoute
'/login': typeof LoginIndexRoute
'/403': typeof Auth403IndexRoute
'/dashboard': typeof AuthDashboardIndexRoute
'/orders': typeof AuthOrdersIndexRoute
'/users': typeof AuthUsersIndexRoute
"/": typeof IndexRoute;
"/404": typeof R404IndexRoute;
"/login": typeof LoginIndexRoute;
"/403": typeof Auth403IndexRoute;
"/dashboard": typeof AuthDashboardIndexRoute;
"/users": typeof AuthUsersIndexRoute;
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/_auth': typeof AuthRouteWithChildren
'/404/': typeof R404IndexRoute
'/login/': typeof LoginIndexRoute
'/_auth/403/': typeof Auth403IndexRoute
'/_auth/dashboard/': typeof AuthDashboardIndexRoute
'/_auth/orders/': typeof AuthOrdersIndexRoute
'/_auth/users/': typeof AuthUsersIndexRoute
__root__: typeof rootRouteImport;
"/": typeof IndexRoute;
"/_auth": typeof AuthRouteWithChildren;
"/404/": typeof R404IndexRoute;
"/login/": typeof LoginIndexRoute;
"/_auth/403/": typeof Auth403IndexRoute;
"/_auth/dashboard/": typeof AuthDashboardIndexRoute;
"/_auth/users/": typeof AuthUsersIndexRoute;
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
| '/404/'
| '/login/'
| '/403/'
| '/dashboard/'
| '/orders/'
| '/users/'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/404' | '/login' | '/403' | '/dashboard' | '/orders' | '/users'
fileRoutesByFullPath: FileRoutesByFullPath;
fullPaths: "/" | "/404/" | "/login/" | "/403/" | "/dashboard/" | "/users/";
fileRoutesByTo: FileRoutesByTo;
to: "/" | "/404" | "/login" | "/403" | "/dashboard" | "/users";
id:
| '__root__'
| '/'
| '/_auth'
| '/404/'
| '/login/'
| '/_auth/403/'
| '/_auth/dashboard/'
| '/_auth/orders/'
| '/_auth/users/'
fileRoutesById: FileRoutesById
| "__root__"
| "/"
| "/_auth"
| "/404/"
| "/login/"
| "/_auth/403/"
| "/_auth/dashboard/"
| "/_auth/users/";
fileRoutesById: FileRoutesById;
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
AuthRoute: typeof AuthRouteWithChildren
R404IndexRoute: typeof R404IndexRoute
LoginIndexRoute: typeof LoginIndexRoute
IndexRoute: typeof IndexRoute;
AuthRoute: typeof AuthRouteWithChildren;
R404IndexRoute: typeof R404IndexRoute;
LoginIndexRoute: typeof LoginIndexRoute;
}
declare module '@tanstack/react-router' {
declare module "@tanstack/react-router" {
interface FileRoutesByPath {
'/_auth': {
id: '/_auth'
path: ''
fullPath: '/'
preLoaderRoute: typeof AuthRouteImport
parentRoute: typeof rootRouteImport
}
'/': {
id: '/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/login/': {
id: '/login/'
path: '/login'
fullPath: '/login/'
preLoaderRoute: typeof LoginIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/404/': {
id: '/404/'
path: '/404'
fullPath: '/404/'
preLoaderRoute: typeof R404IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/_auth/users/': {
id: '/_auth/users/'
path: '/users'
fullPath: '/users/'
preLoaderRoute: typeof AuthUsersIndexRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/orders/': {
id: '/_auth/orders/'
path: '/orders'
fullPath: '/orders/'
preLoaderRoute: typeof AuthOrdersIndexRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/dashboard/': {
id: '/_auth/dashboard/'
path: '/dashboard'
fullPath: '/dashboard/'
preLoaderRoute: typeof AuthDashboardIndexRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/403/': {
id: '/_auth/403/'
path: '/403'
fullPath: '/403/'
preLoaderRoute: typeof Auth403IndexRouteImport
parentRoute: typeof AuthRoute
}
"/_auth": {
id: "/_auth";
path: "";
fullPath: "/";
preLoaderRoute: typeof AuthRouteImport;
parentRoute: typeof rootRouteImport;
};
"/": {
id: "/";
path: "/";
fullPath: "/";
preLoaderRoute: typeof IndexRouteImport;
parentRoute: typeof rootRouteImport;
};
"/login/": {
id: "/login/";
path: "/login";
fullPath: "/login/";
preLoaderRoute: typeof LoginIndexRouteImport;
parentRoute: typeof rootRouteImport;
};
"/404/": {
id: "/404/";
path: "/404";
fullPath: "/404/";
preLoaderRoute: typeof R404IndexRouteImport;
parentRoute: typeof rootRouteImport;
};
"/_auth/users/": {
id: "/_auth/users/";
path: "/users";
fullPath: "/users/";
preLoaderRoute: typeof AuthUsersIndexRouteImport;
parentRoute: typeof AuthRoute;
};
"/_auth/dashboard/": {
id: "/_auth/dashboard/";
path: "/dashboard";
fullPath: "/dashboard/";
preLoaderRoute: typeof AuthDashboardIndexRouteImport;
parentRoute: typeof AuthRoute;
};
"/_auth/403/": {
id: "/_auth/403/";
path: "/403";
fullPath: "/403/";
preLoaderRoute: typeof Auth403IndexRouteImport;
parentRoute: typeof AuthRoute;
};
}
}
interface AuthRouteChildren {
Auth403IndexRoute: typeof Auth403IndexRoute
AuthDashboardIndexRoute: typeof AuthDashboardIndexRoute
AuthOrdersIndexRoute: typeof AuthOrdersIndexRoute
AuthUsersIndexRoute: typeof AuthUsersIndexRoute
Auth403IndexRoute: typeof Auth403IndexRoute;
AuthDashboardIndexRoute: typeof AuthDashboardIndexRoute;
AuthUsersIndexRoute: typeof AuthUsersIndexRoute;
}
const AuthRouteChildren: AuthRouteChildren = {
Auth403IndexRoute: Auth403IndexRoute,
AuthDashboardIndexRoute: AuthDashboardIndexRoute,
AuthOrdersIndexRoute: AuthOrdersIndexRoute,
AuthUsersIndexRoute: AuthUsersIndexRoute,
}
};
const AuthRouteWithChildren = AuthRoute._addFileChildren(AuthRouteChildren)
const AuthRouteWithChildren = AuthRoute._addFileChildren(AuthRouteChildren);
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
AuthRoute: AuthRouteWithChildren,
R404IndexRoute: R404IndexRoute,
LoginIndexRoute: LoginIndexRoute,
}
};
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()
._addFileTypes<FileRouteTypes>();
@@ -1,43 +0,0 @@
import { Form, Input, Modal, Select } from "antd";
import { useLingui } from "@lingui/react/macro";
import type { Order, CreateOrderRequest } from "@/api/schemas";
export type FormModalProps = {
open: boolean;
editing: Order | null;
form: ReturnType<typeof Form.useForm<CreateOrderRequest>>[0];
confirmLoading: boolean;
onCancel: () => void;
onFinish: (values: CreateOrderRequest) => void;
};
export function FormModal({ open, editing, form, confirmLoading, onCancel, onFinish }: FormModalProps) {
const { t } = useLingui();
return (
<Modal
open={open}
title={editing ? t`Edit Order` : t`New Order`}
onCancel={onCancel}
onOk={() => {
form.submit();
}}
confirmLoading={confirmLoading}
destroyOnHidden
>
<Form layout="vertical" form={form} onFinish={onFinish} requiredMark={false}>
<Form.Item name="title" label={t`Title`} rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="status" label={t`Status`} rules={[{ required: true }]}>
<Select
options={[
{ value: "draft", label: t`Draft` },
{ value: "open", label: t`Open` },
{ value: "done", label: t`Done` },
]}
/>
</Form.Item>
</Form>
</Modal>
);
}
@@ -1,58 +0,0 @@
import { Button, Input, theme } from "antd";
import { useLingui } from "@lingui/react/macro";
import { Plus } from "lucide-react";
import { forwardRef, useMemo } from "react";
import { FilterToolbar } from "@/components/FilterToolbar";
const FILTER_CONTROL_WIDTH = 220;
export type ToolbarProps = {
keywordInput: string;
onKeywordChange: (value: string) => void;
onSearch: (keyword: string) => void;
onClearSearch: () => void;
onCreateClick: () => void;
};
export const Toolbar = forwardRef<HTMLDivElement, ToolbarProps>(function Toolbar(
{ keywordInput, onKeywordChange, onSearch, onClearSearch, onCreateClick },
ref,
) {
const { t } = useLingui();
const { token } = theme.useToken();
const slots = useMemo(
() => [
{
key: "keyword",
minWidth: FILTER_CONTROL_WIDTH,
children: (
<Input.Search
allowClear
placeholder={t`Search Order`}
style={{ width: FILTER_CONTROL_WIDTH }}
value={keywordInput}
onChange={(e) => onKeywordChange(e.target.value)}
onSearch={(v) => onSearch(v)}
onClear={onClearSearch}
/>
),
},
],
[keywordInput, onClearSearch, onKeywordChange, onSearch, t],
);
return (
<FilterToolbar
ref={ref}
slots={slots}
actions={
<Button type="primary" icon={<Plus size={token.fontSize} />} onClick={onCreateClick}>
{t`New Order`}
</Button>
}
moreFiltersLabel={t`More filters`}
moreFiltersTitle={t`More filters`}
/>
);
});
@@ -1,262 +0,0 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { Button, Form, App, Dropdown, theme, Tag, Flex } from "antd";
import type { TablePaginationConfig } from "antd/es/table/interface";
import { useLingui } from "@lingui/react/macro";
import { useMemo, useRef, useState } from "react";
import { httpClient } from "@/utils/http";
import { ORDER_ENDPOINTS } from "@/api/orders";
import { PaginatedResponseSchema, OrderSchema, CreateOrderRequestSchema } from "@/api/schemas";
import type { Order, CreateOrderRequest } from "@/api/schemas";
import { z } from "zod/v4";
import { MoreVertical, Pencil, Trash2 } from "lucide-react";
import { DataTable } from "@/components/DataTable";
import { useResourceCRUD } from "@/hooks/useResourceCRUD";
import { useTableFitHeight } from "@/hooks/useTableFitHeight";
import { useCrudToasts } from "@/hooks/useCrudToasts";
import { useUrlSearchState } from "@/hooks/useUrlSearchState";
import { Toolbar } from "./-Toolbar";
import { FormModal } from "./-FormModal";
const SearchParamsSchema = z.object({
limit: z.number().int().positive().catch(100),
offset: z.number().int().nonnegative().catch(0),
sortField: z.string().nullable().catch(null),
sortOrder: z.enum(["ascend", "descend"]).nullable().catch(null),
keyword: z.string().catch(""),
});
type RowSearch = z.infer<typeof SearchParamsSchema>;
export const Route = createFileRoute("/_auth/orders/")({
validateSearch: (search) => SearchParamsSchema.parse(search),
component: OrderListPage,
});
const paginatedSchema = PaginatedResponseSchema(OrderSchema);
function OrderListPage() {
const search = Route.useSearch();
const navigate = useNavigate({ from: Route.fullPath });
const { message, modal } = App.useApp();
const { t } = useLingui();
const { token } = theme.useToken();
const [modalOpen, setModalOpen] = useState(false);
const pageShellRef = useRef<HTMLDivElement>(null);
const toolbarRowRef = useRef<HTMLDivElement>(null);
const middleSectionRef = useRef<HTMLDivElement>(null);
const tableFrameRef = useRef<HTMLDivElement>(null);
const [editing, setEditing] = useState<Order | null>(null);
const [form] = Form.useForm<CreateOrderRequest>();
const currentPage = Math.floor(search.offset / search.limit) + 1;
const setSearch = (next: RowSearch) => {
void navigate({ search: next });
};
const { keywordInput, setKeywordInput, applyKeyword } = useUrlSearchState({
search,
setSearch,
});
const crudToasts = useCrudToasts<CreateOrderRequest, CreateOrderRequest & { id: string }>({
message,
resourceKey: "orders",
});
const { data, isLoading, createMutation, updateMutation, deleteMutation } = useResourceCRUD<
{ list: Order[]; total: number },
CreateOrderRequest,
CreateOrderRequest & { id: string }
>({
queryKey: ["orders", search.limit, search.offset, search.keyword, search.sortField, search.sortOrder],
queryFn: () =>
httpClient.get(ORDER_ENDPOINTS.list, {
params: {
limit: search.limit,
offset: search.offset,
keyword: search.keyword || undefined,
sortField: search.sortField ?? undefined,
sortOrder: search.sortOrder ?? undefined,
},
}),
select: (raw) => paginatedSchema.shape.data.parse(raw),
createFn: (values) => httpClient.post(ORDER_ENDPOINTS.create, CreateOrderRequestSchema.parse(values)),
updateFn: ({ id, ...values }) => httpClient.put(ORDER_ENDPOINTS.update(id), values),
deleteFn: (id) => httpClient.delete(ORDER_ENDPOINTS.delete(id)),
optimistic: { update: true, delete: true },
createLifecycle: {
onSuccess: (values) => {
crudToasts.createLifecycle?.onSuccess?.(values);
setModalOpen(false);
form.resetFields();
},
onError: crudToasts.createLifecycle?.onError,
},
updateLifecycle: {
onMutate: crudToasts.updateLifecycle?.onMutate,
onSuccess: (values) => {
crudToasts.updateLifecycle?.onSuccess?.(values);
setModalOpen(false);
setEditing(null);
form.resetFields();
},
onError: crudToasts.updateLifecycle?.onError,
},
deleteLifecycle: {
onMutate: crudToasts.deleteLifecycle?.onMutate,
onSuccess: crudToasts.deleteLifecycle?.onSuccess,
onError: crudToasts.deleteLifecycle?.onError,
},
});
const confirmDelete = (record: Order) => {
modal.confirm({
title: t`Are you absolutely sure?`,
content: t`This action cannot be undone.`,
okText: t`Delete`,
okType: "danger",
cancelText: t`Cancel`,
onOk: () => deleteMutation.mutate(record.id),
});
};
const columns = [
{ title: "ID", dataIndex: "id", key: "id" },
{ title: t`Title`, dataIndex: "title", key: "title" },
{
title: t`Status`,
dataIndex: "status",
key: "status",
render: (s: Order["status"]) => <Tag>{s}</Tag>,
},
{
title: "Actions",
key: "actions",
width: 60,
align: "right" as const,
render: (_: unknown, record: Order) => (
<Dropdown
menu={{
items: [
{
key: "edit",
icon: <Pencil size={token.fontSize} />,
label: t`Edit`,
onClick: () => {
setEditing(record);
form.setFieldsValue(record);
setModalOpen(true);
},
},
{
key: "delete",
icon: <Trash2 size={token.fontSize} />,
label: t`Delete`,
danger: true,
onClick: () => confirmDelete(record),
},
],
}}
placement="bottomRight"
>
<Button type="text" icon={<MoreVertical size={token.fontSize} />} aria-label={t`Row actions`} />
</Dropdown>
),
},
];
const showPagination = (data?.total ?? 0) > search.limit;
const { tableAreaMaxHeight, tableScrollY, lockScrollHeight } = useTableFitHeight({
pageShellRef,
toolbarRef: toolbarRowRef,
middleRef: middleSectionRef,
tableFrameRef,
marginLG: token.marginLG,
rowCount: data?.list?.length ?? 0,
isLoading,
showPagination,
});
const tablePagination: false | TablePaginationConfig = useMemo(
() =>
showPagination
? {
total: data?.total ?? 0,
current: currentPage,
pageSize: search.limit,
showSizeChanger: true,
showTotal: (total) => t`${total} rows`,
onChange: (page, pageSize) => {
void navigate({
search: {
...search,
limit: pageSize ?? search.limit,
offset: (page - 1) * (pageSize ?? search.limit),
},
});
},
}
: false,
[showPagination, data?.total, currentPage, search, navigate, t],
);
return (
<Flex
ref={pageShellRef}
vertical
gap={token.marginMD}
style={{ flex: "1 1 0%", minHeight: 0, overflow: "hidden" }}
>
<Toolbar
ref={toolbarRowRef}
keywordInput={keywordInput}
onKeywordChange={setKeywordInput}
onSearch={applyKeyword}
onClearSearch={() => {
setKeywordInput("");
setSearch({ ...search, keyword: "", offset: 0 });
}}
onCreateClick={() => {
setEditing(null);
form.resetFields();
setModalOpen(true);
}}
/>
<DataTable<Order>
layoutRef={middleSectionRef}
frameRef={tableFrameRef}
lockScrollHeight={lockScrollHeight}
maxHeight={tableAreaMaxHeight}
frameHeight={tableScrollY != null && tableAreaMaxHeight != null ? tableAreaMaxHeight : undefined}
rowKey="id"
size="small"
columns={columns}
dataSource={data?.list ?? []}
loading={isLoading}
pagination={tablePagination}
style={{ flex: 1, minHeight: 0 }}
scroll={tableScrollY != null ? { x: "max-content", y: tableScrollY } : { x: "max-content" }}
/>
<FormModal
open={modalOpen}
editing={editing}
form={form}
confirmLoading={createMutation.isPending || updateMutation.isPending}
onCancel={() => {
setModalOpen(false);
setEditing(null);
form.resetFields();
}}
onFinish={(values) => {
if (editing) {
updateMutation.mutate({ ...values, id: editing.id });
} else {
createMutation.mutate(values);
}
}}
/>
</Flex>
);
}
-11
View File
@@ -33,17 +33,6 @@ export const APP_MENU_TREE: MenuItem[] = [
sort: 1,
hidden: false,
},
{
id: "orders-menu",
kind: "item",
name: "Orders",
path: "/orders",
icon: "IconLucidePackage",
children: null,
permissions: ["orders:view"],
sort: 2,
hidden: false,
},
],
},
{
@@ -1,8 +1,8 @@
# apps/with-lingui 模板优化计划
> 状态:待评审
> 日期:2026-04-24
> 范围:`apps/with-lingui`(`apps/basic` 与主模板抽象稳定后再考虑同步,**当前不做回填**)
> 状态:已部分落地(更新于 2026-04-27)
> 计划日期:2026-04-24
> 范围:以 `apps/with-lingui` 为主模板(`apps/basic` 为英文、无 Lingui 的对照应用;**CRUD 页面级示例仅以 `users` 为样板**,不设第二示例资源)
> 面向:人类贡献者 + AI Agent(结合 `.github/instructions/` 指令体系执行)
## 0. 背景
@@ -18,12 +18,12 @@
- **安全性**:闭合 JWT 刷新流、移除 store/http 跨层耦合。
- **可维护性**:将 `routes/_auth/users/index.tsx`(491 行)中的通用能力下沉为 hook,使新增资源页 <200 行、<30 分钟。
- **可观测性**:RBAC / URL 状态等进入核心 E2E;体积以 `vp build` 日志与 Code review 把关(**不**在 CI 中跑自动化 bundle 上限工具)。
- **AI 协作**:补齐"加资源食谱"与 Cursor 规则镜像;**不**提供 codegen 脚本,以 **`add-resource.instructions.md`** 为唯一执行清单(对照 `users` / `orders`)。
- **AI 协作**:补齐"加资源食谱"与 Cursor 规则镜像;**不**提供 codegen 脚本,以 `**add-resource.instructions.md`** 为唯一执行清单(对照 `users`)。
### 非目标
- 不替换现有技术栈(Vite+ / Lingui / AntD 6 / TanStack 保持不变)。
- **`apps/basic` 回填**:抽象未稳定前**不做**;不与 `with-lingui` 强行双轨同步。
- `**apps/basic` 与主模板同步**:以工程模式对齐为主(HTTP、hooks、E2E 等),不要求为演示而维护两套 CRUD 样板页。
- 不引入新的 UI 或样式库(遵循 `frontend.instructions.md`)。
## 2. 执行项(按优先级)
@@ -82,12 +82,12 @@
- 导出 `CrudResult<T>` 类型别名。
- 可选 `optimistic: { update?: boolean; delete?: boolean }`,内部 `onMutate` 里做乐观 `setQueryData`;失败自动回滚。
- 验收:用户列表删除 / 编辑在慢网(Playwright `route` 延时)下立即反馈;回滚路径有单测覆盖。
- **落地**:`optimistic: { update, delete }` + `cancelQueries` / 失败时 `setQueryData` 恢复快照;`pnpm run test:unit`(`src/hooks/useResourceCRUD.test.ts`)覆盖 merge/delete 与 rollback 契约;`users` / `orders` 已开启乐观更新。
- **落地**:`optimistic: { update, delete }` + `cancelQueries` / 失败时 `setQueryData` 恢复快照;`pnpm run test:unit`(`src/hooks/useResourceCRUD.test.ts`)覆盖 merge/delete 与 rollback 契约;`users` 列表已开启乐观更新。
- **P1-5 MSW handler 端也跑一次 schema 解析**
- 现状:mock 与契约漂移只能在前端 `Zod.parse` 抛错时发现,堆栈离源头远。
- 方案:
- `src/mocks/createHandler.ts`:`successWithSchema`;`paginatedListSchema` + `paginatedWithSchema(itemSchema, payload)`;`successWithNullBody` 用于 `data: null` 响应。
- `handlers/auth.ts` / `user.ts` / `orders.ts` 全部经 schema 出包;新增 handler 须沿用同一模式。
- `handlers/auth.ts`、`handlers/user.ts` 等全部经 schema 出包;新增 handler 须沿用同一模式。
- 验收:故意改错 mock 字段名,dev 控制台首条报错来自 mock 层;`vp test` 下 handler 单元测试覆盖。
- **落地**:`src/mocks/createHandler.test.ts`(`pnpm run test:unit`)覆盖 `successWithSchema` / `paginatedWithSchema` / `successWithNullBody` 的校验与 Zod 抛错路径。
@@ -101,7 +101,7 @@
- 允许通过 `VITE_BRAND_PRIMARY` 环境变量覆盖,便于二次分发。
- **P2-3 Bundle 体积(已决议:不再采用自动化 CI 预算)**
- **不再采用** `size-limit` / `@size-limit/file`(及同类在 CI 中卡阈值的方案);已从 `apps/with-lingui` 与 `with-lingui` workflow 移除。
- 仍通过 **`manualChunks` + `vp build` 输出** 观察各 chunk;重大依赖或分片变更在 PR 描述中注明体积影响即可。
- 仍通过 `**manualChunks` + `vp build` 输出** 观察各 chunk;重大依赖或分片变更在 PR 描述中注明体积影响即可。
- `refactor.instructions.md` 仅保留「分包与避免无谓大依赖」的叙述性建议,不写死 CI 阈值命令。
- **P2-4 i18n 预热**
- `__root.tsx` 在 `requestIdleCallback` 内预拉另一语言 catalog;切换瞬时完成。
@@ -117,7 +117,7 @@
- 仓库根 `.cursor/rules/with-lingui-*.mdc` 五份:仅 `description` + `globs` + 指向上述路径;**不**重复粘贴长文;`.gitignore` 放行 `.cursor/rules/` 以便进库。
- `apps/with-lingui/README.md` 与 `instructions/README.md` 说明用法(必要时 `@` 引用 `.instructions.md`)。
- **P3-3 加资源(无脚本 codegen)**
- **实现形态**:仅 `add-resource.instructions.md` 食谱 + `users` / `orders` 示例;由贡献者或 Agent 按步骤手工落地。
- **实现形态**:仅 `add-resource.instructions.md` 食谱 + `users` 示例;由贡献者或 Agent 按步骤手工落地。
- 产出与原先 codegen 相当:`api/<name>.ts`、`schemas` 片段、mock、路由、`e2e`、菜单等。
- 验收:按食谱新增一资源后 `pnpm exec vp check --no-fmt && pnpm test:e2e:core` 通过(可将新 spec 纳入 core 或单跑)。
@@ -125,7 +125,7 @@
| 阶段 | 包含项 | 预期工时 |
| -------- | --------------------------------------- | ----- |
| -------- | -------------------------------- | ----- |
| Sprint 1 | P0-1 / P0-2 / P0-3 / P1-1 | 2–3 天 |
| Sprint 2 | P1-2 / P1-3 / P1-4 / P1-5 / P3-1 | 2 天 |
| Sprint 3 | P2-1 ~ P2-4 + P3-2 + P3-3(食谱) | 2 天 |
@@ -138,11 +138,11 @@
| 风险 | 影响 | 对策 |
| -------------------------------------- | ----- | ------------------------------------------------------- |
| -------------------------------------- | ----- | ------------------------------------------------------------ |
| refresh token 并发去重实现错误,风暴请求 | 线上故障 | 用 `inflightRefresh`(单 Promise)串行化刷新,避免并发风暴 |
| 乐观更新回滚遗漏 | 数据不一致 | `useResourceCRUD` 回滚路径单测覆盖(`optimistic: true` 下 4xx 用例) |
| 手工加资源时覆盖已有文件 | 代码丢失 | 改前 grep / diff;食谱「注意」节强调不覆盖手写代码 |
| `apps/basic` 与 `apps/with-lingui` 抽象漂移 | 双倍维护 | **回填暂缓**;待 hook/契约稳定后再设固定回填节点;尽量参数化而非分叉 |
| `apps/basic` 与 `apps/with-lingui` 抽象漂移 | 双倍维护 | 两应用共享同一套 hook/HTTP/MSW 模式时尽量同构;Lingui 与文案差异保留在 with-lingui 侧 |
## 5. 验收标准总览
@@ -162,7 +162,7 @@
```md
---
applyTo: "src/api/**/*.ts,src/mocks/handlers/**/*.ts,src/routes/_auth/**/*.tsx"
description: "Use when adding a new CRUD resource (orders, roles, tenants, etc.). Keywords: add resource, new resource, scaffold, CRUD page, 新资源."
description: "Use when adding a new CRUD resource (roles, tenants, etc.). Keywords: add resource, new resource, scaffold, CRUD page, 新资源."
---
# Add Resource Recipe
@@ -188,7 +188,7 @@ description: "Use when adding a new CRUD resource (orders, roles, tenants, etc.)
| 文件 | 类型 |
| -------------------------------------------------------------------- | ------------------ |
| -------------------------------------------------------------------- | ------------------------ |
| `apps/with-lingui/src/utils/http.ts` | 重构 |
| `apps/with-lingui/src/stores/auth.ts` | 增加导出 |
| `apps/with-lingui/src/main.tsx` | 简化 |
@@ -205,3 +205,4 @@ description: "Use when adding a new CRUD resource (orders, roles, tenants, etc.)
| `e2e/{auth-refresh,rbac,url-state,...}.spec.ts` | 新增 / 演进 |
| `.github/workflows/with-lingui-ci.yml` | 质量 + build(无 size-limit) |
+3
View File
@@ -72,6 +72,9 @@ importers:
vite-plus:
specifier: latest
version: 0.1.16(@voidzero-dev/vite-plus-core@0.1.16)(typescript@5.9.3)
vitest:
specifier: 3.0.5
version: 3.0.5(msw@2.13.3)
apps/docs:
dependencies: