chore: scaffold apps/basic from with-lingui

Made-with: Cursor
This commit is contained in:
zuiidea
2026-04-14 19:21:30 +08:00
parent 5913cd2f43
commit b08775bad8
82 changed files with 11062 additions and 0 deletions
+143
View File
@@ -0,0 +1,143 @@
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",
kind: "group",
name: "Platform",
path: null,
icon: "IconLucideSparkles",
permissions: null,
sort: 0,
hidden: false,
children: [
{
id: "1",
kind: "item",
name: "Dashboard",
path: "/dashboard",
icon: "IconLucideLayoutDashboard",
children: null,
permissions: null,
sort: 0,
hidden: false,
},
{
id: "2",
kind: "item",
name: "Users",
path: "/users",
icon: "IconLucideUsers",
children: null,
permissions: ["user:view"],
sort: 1,
hidden: false,
},
],
},
{
id: "g-projects",
kind: "group",
name: "Projects",
path: null,
icon: "IconLucideFolderKanban",
children: [
{
id: "3",
kind: "item",
name: "Design Engineering",
path: "/design-engineering",
icon: "IconLucideBookOpen",
children: null,
permissions: null,
sort: 0,
hidden: false,
},
{
id: "4",
kind: "item",
name: "Sales & Marketing",
path: "/sales-marketing",
icon: "IconLucideBriefcase",
children: null,
permissions: ["user:view"],
sort: 1,
hidden: false,
},
],
permissions: null,
sort: 1,
hidden: false,
},
];
function hasRequiredPermissions(
required: string[] | null | undefined,
granted: Set<string>,
): boolean {
if (!required || required.length === 0) return true;
return required.every((p) => granted.has(p));
}
export function filterMenuTreeByPermissions(
nodes: MenuItem[],
permissionList: string[],
): MenuItem[] {
const granted = new Set(permissionList);
const walk = (list: MenuItem[]): MenuItem[] =>
list
.map((node) => {
if (!hasRequiredPermissions(node.permissions ?? null, granted)) return null;
if (node.kind === "group") {
const children = walk(node.children);
if (children.length === 0) return null;
return { ...node, children };
}
if (node.children?.length) {
const children = walk(node.children);
if (children.length === 0) return null;
return { ...node, children };
}
return node;
})
.filter((n): n is MenuItem => n != null);
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> = {
"/dashboard": null,
"/users": "user:view",
"/design-engineering": null,
"/sales-marketing": "user:view",
"/403": null,
};
return map[p] ?? null;
}
export function canAccessPath(pathname: string, permissions: string[] | undefined): boolean {
const required = requiredPermissionForPath(pathname);
if (required == null) return true;
if (!permissions?.length) return false;
return permissions.includes(required);
}
+7
View File
@@ -0,0 +1,7 @@
export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "";
/** Favicon path under `public/` (Vite serves as site root). */
export const APP_FAVICON_SRC = "/favicon.svg";
/** Product / brand name (login header, sidebar logo text, etc.). */
export const APP_BRAND_NAME = "Antd Admin";
+89
View File
@@ -0,0 +1,89 @@
import { API_BASE_URL } from "./constants";
export class ApiError extends Error {
code: number;
constructor(code: number, message: string) {
super(message);
this.name = "ApiError";
this.code = code;
}
}
export class HttpError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.name = "HttpError";
this.status = status;
}
}
type RequestOptions = Omit<RequestInit, "method" | "body"> & {
params?: Record<string, string | number | null | undefined>;
};
function buildUrl(path: string, params?: RequestOptions["params"]): string {
const base = path.startsWith("http") ? path : `${API_BASE_URL}${path}`;
if (!params) return base;
const url = new URL(base, window.location.origin);
for (const [key, value] of Object.entries(params)) {
if (value != null) url.searchParams.set(key, String(value));
}
return url.toString();
}
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
}
return {};
}
async function request<T>(
method: string,
path: string,
body?: unknown,
options?: RequestOptions,
): Promise<T> {
const url = buildUrl(path, options?.params);
const headers: Record<string, string> = {
"Content-Type": "application/json",
...getAuthHeaders(),
...(options?.headers as Record<string, string>),
};
const res = await fetch(url, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
...options,
});
if (!res.ok) {
throw new HttpError(res.status, `HTTP ${res.status}: ${res.statusText}`);
}
const json = await res.json();
if (json.code !== undefined && json.code !== 0) {
throw new ApiError(json.code, json.message ?? "Unknown error");
}
return json.data !== undefined ? json.data : json;
}
export const httpClient = {
get: <T>(path: string, options?: RequestOptions) => request<T>("GET", path, undefined, options),
post: <T>(path: string, body?: unknown, options?: RequestOptions) =>
request<T>("POST", path, body, options),
put: <T>(path: string, body?: unknown, options?: RequestOptions) =>
request<T>("PUT", path, body, options),
delete: <T>(path: string, options?: RequestOptions) =>
request<T>("DELETE", path, undefined, options),
};
+18
View File
@@ -0,0 +1,18 @@
import { httpClient } from "@/utils/http";
import { AUTH_ENDPOINTS } from "@/api/auth";
import { AuthUserResponseSchema, PermissionsListSchema, UserSchema } from "@/api/schemas";
import { APP_MENU_TREE, filterMenuTreeByPermissions } from "@/utils/appMenu";
import { useAuthStore } from "@/stores/auth";
/** Fetches `/api/auth/user` and `/api/auth/permissions`, then updates `user` and sidebar `menus`. */
export async function fetchSessionAndApplyToStore(): Promise<void> {
const [userBase, permissions] = await Promise.all([
httpClient.get(AUTH_ENDPOINTS.user).then((d) => AuthUserResponseSchema.parse(d)),
httpClient.get(AUTH_ENDPOINTS.permissions).then((d) => PermissionsListSchema.parse(d)),
]);
const user = UserSchema.parse({ ...userBase, permissions });
const menus = filterMenuTreeByPermissions(APP_MENU_TREE, permissions);
const { setUser, setMenus } = useAuthStore.getState();
setUser(user);
setMenus(menus);
}