mirror of
https://github.com/zuiidea/antd-admin.git
synced 2026-10-19 08:13:38 +00:00
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:
@@ -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`
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
}
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -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();
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -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({
|
||||
defaultOptions: {
|
||||
queries: { retry: 1, refetchOnWindowFocus: false },
|
||||
},
|
||||
});
|
||||
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>
|
||||
<ConfigProvider {...configProviderProps} locale={enUS}>
|
||||
<App>
|
||||
<AppQueryBridge>
|
||||
<Outlet />
|
||||
</App>
|
||||
</ConfigProvider>
|
||||
</QueryClientProvider>
|
||||
</AppQueryBridge>
|
||||
</App>
|
||||
</ConfigProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export const Route = createRootRoute({
|
||||
component: RootComponent,
|
||||
notFoundComponent: NotFound,
|
||||
errorComponent: RouteError,
|
||||
});
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
measure();
|
||||
const ro = new ResizeObserver(measure);
|
||||
ro.observe(shell);
|
||||
ro.observe(mid);
|
||||
return () => ro.disconnect();
|
||||
}, [showPagination, data?.list?.length, isLoading, token.marginLG]);
|
||||
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(
|
||||
() =>
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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;
|
||||
if (token) return { Authorization: `Bearer ${token}` };
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
const token = getAccessToken();
|
||||
if (token) return { Authorization: `Bearer ${token}` };
|
||||
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 = {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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": {
|
||||
@@ -61,4 +61,4 @@
|
||||
"public"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 "欢迎回来"
|
||||
|
||||
@@ -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,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();
|
||||
}),
|
||||
];
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,19 +117,19 @@
|
||||
- 仓库根 `.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 或单跑)。
|
||||
|
||||
## 3. 落地节奏
|
||||
|
||||
|
||||
| 阶段 | 包含项 | 预期工时 |
|
||||
| -------- | --------------------------------------- | ----- |
|
||||
| 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 天 |
|
||||
| Sprint 4 | (预留)抽象稳定后再评估 `apps/basic` 与其它增强项 | 待定 |
|
||||
| 阶段 | 包含项 | 预期工时 |
|
||||
| -------- | -------------------------------- | ----- |
|
||||
| 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 天 |
|
||||
| Sprint 4 | (预留)抽象稳定后再评估 `apps/basic` 与其它增强项 | 待定 |
|
||||
|
||||
|
||||
每 Sprint 结束:`vp check --no-fmt && pnpm run test:e2e:core && pnpm run build`(在 `apps/with-lingui` 下),并关注构建日志中的 chunk 体积。
|
||||
@@ -137,12 +137,12 @@
|
||||
## 4. 风险与对策
|
||||
|
||||
|
||||
| 风险 | 影响 | 对策 |
|
||||
| -------------------------------------- | ----- | ------------------------------------------------------- |
|
||||
| refresh token 并发去重实现错误,风暴请求 | 线上故障 | 用 `inflightRefresh`(单 Promise)串行化刷新,避免并发风暴 |
|
||||
| 乐观更新回滚遗漏 | 数据不一致 | `useResourceCRUD` 回滚路径单测覆盖(`optimistic: true` 下 4xx 用例) |
|
||||
| 手工加资源时覆盖已有文件 | 代码丢失 | 改前 grep / diff;食谱「注意」节强调不覆盖手写代码 |
|
||||
| `apps/basic` 与 `apps/with-lingui` 抽象漂移 | 双倍维护 | **回填暂缓**;待 hook/契约稳定后再设固定回填节点;尽量参数化而非分叉 |
|
||||
| 风险 | 影响 | 对策 |
|
||||
| -------------------------------------- | ----- | ------------------------------------------------------------ |
|
||||
| refresh token 并发去重实现错误,风暴请求 | 线上故障 | 用 `inflightRefresh`(单 Promise)串行化刷新,避免并发风暴 |
|
||||
| 乐观更新回滚遗漏 | 数据不一致 | `useResourceCRUD` 回滚路径单测覆盖(`optimistic: true` 下 4xx 用例) |
|
||||
| 手工加资源时覆盖已有文件 | 代码丢失 | 改前 grep / diff;食谱「注意」节强调不覆盖手写代码 |
|
||||
| `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
|
||||
@@ -187,21 +187,22 @@ description: "Use when adding a new CRUD resource (orders, roles, tenants, etc.)
|
||||
## 附录 B:文件改动预估
|
||||
|
||||
|
||||
| 文件 | 类型 |
|
||||
| -------------------------------------------------------------------- | ------------------ |
|
||||
| `apps/with-lingui/src/utils/http.ts` | 重构 |
|
||||
| `apps/with-lingui/src/stores/auth.ts` | 增加导出 |
|
||||
| `apps/with-lingui/src/main.tsx` | 简化 |
|
||||
| `apps/with-lingui/src/routes/_auth.tsx` | 新增 `beforeLoad` |
|
||||
| `apps/with-lingui/src/hooks/useTableFitHeight.ts` | 新增 |
|
||||
| `apps/with-lingui/src/hooks/useCrudToasts.ts` | 新增 |
|
||||
| `apps/with-lingui/src/hooks/useUrlSearchState.ts` | 新增 |
|
||||
| `apps/with-lingui/src/hooks/useResourceCRUD.ts` | 增强 |
|
||||
| `apps/with-lingui/src/mocks/createHandler.ts` | 增强 |
|
||||
| `apps/with-lingui/src/mocks/handlers/auth.ts` | 补 refresh |
|
||||
| `apps/with-lingui/src/routes/_auth/users/index.tsx` | 大幅精简 |
|
||||
| `apps/with-lingui/.github/instructions/add-resource.instructions.md` | 新增 |
|
||||
| `.cursor/rules/*.mdc` | 新增(5 份镜像) |
|
||||
| `e2e/{auth-refresh,rbac,url-state,...}.spec.ts` | 新增 / 演进 |
|
||||
| `.github/workflows/with-lingui-ci.yml` | 质量 + build(无 size-limit) |
|
||||
| 文件 | 类型 |
|
||||
| -------------------------------------------------------------------- | ------------------------ |
|
||||
| `apps/with-lingui/src/utils/http.ts` | 重构 |
|
||||
| `apps/with-lingui/src/stores/auth.ts` | 增加导出 |
|
||||
| `apps/with-lingui/src/main.tsx` | 简化 |
|
||||
| `apps/with-lingui/src/routes/_auth.tsx` | 新增 `beforeLoad` |
|
||||
| `apps/with-lingui/src/hooks/useTableFitHeight.ts` | 新增 |
|
||||
| `apps/with-lingui/src/hooks/useCrudToasts.ts` | 新增 |
|
||||
| `apps/with-lingui/src/hooks/useUrlSearchState.ts` | 新增 |
|
||||
| `apps/with-lingui/src/hooks/useResourceCRUD.ts` | 增强 |
|
||||
| `apps/with-lingui/src/mocks/createHandler.ts` | 增强 |
|
||||
| `apps/with-lingui/src/mocks/handlers/auth.ts` | 补 refresh |
|
||||
| `apps/with-lingui/src/routes/_auth/users/index.tsx` | 大幅精简 |
|
||||
| `apps/with-lingui/.github/instructions/add-resource.instructions.md` | 新增 |
|
||||
| `.cursor/rules/*.mdc` | 新增(5 份镜像) |
|
||||
| `e2e/{auth-refresh,rbac,url-state,...}.spec.ts` | 新增 / 演进 |
|
||||
| `.github/workflows/with-lingui-ci.yml` | 质量 + build(无 size-limit) |
|
||||
|
||||
|
||||
|
||||
Generated
+3
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user