refactor: api.ts

This commit is contained in:
tao.chen
2026-09-02 10:10:41 +08:00
committed by tao.chen
parent cc0e2eaec4
commit 9b95357b39
13 changed files with 2157 additions and 1964 deletions
+262
View File
@@ -0,0 +1,262 @@
export type DemoUser = {
userId: string;
userName: string;
username: string;
roleCode: "admin" | "developer";
roleName: string;
};
export type DemoWorkspace = {
workspaceId: string;
workspaceName: string;
};
export function createUuid(): string {
const cryptoApi = globalThis.crypto;
if (typeof cryptoApi?.randomUUID === "function") {
return cryptoApi.randomUUID();
}
const bytes = new Uint8Array(16);
if (typeof cryptoApi?.getRandomValues === "function") {
cryptoApi.getRandomValues(bytes);
} else {
for (let index = 0; index < bytes.length; index += 1) {
bytes[index] = Math.floor(Math.random() * 256);
}
}
bytes[6] = ((bytes[6] ?? 0) & 0x0f) | 0x40;
bytes[8] = ((bytes[8] ?? 0) & 0x3f) | 0x80;
const hex = Array.from(bytes, (value) =>
value.toString(16).padStart(2, "0"),
).join("");
return [
hex.slice(0, 8),
hex.slice(8, 12),
hex.slice(12, 16),
hex.slice(16, 20),
hex.slice(20),
].join("-");
}
export const demoUsers: DemoUser[] = [
{ userId: "0000000000RF6FG1SDBXG59S13", userName: "张三", username: "admin-zhang", roleCode: "admin", roleName: "管理员" },
{ userId: "0000000000H2QYCGPCWQM1JSGS", userName: "李四", username: "admin-li", roleCode: "admin", roleName: "管理员" },
{ userId: "0000000000RWG40ESZPGJT629J", userName: "王五", username: "dev-wang", roleCode: "developer", roleName: "开发人员" },
{ userId: "00000000004CQV7WASJA6N6FW4", userName: "赵六", username: "dev-zhao", roleCode: "developer", roleName: "开发人员" },
];
export const demoWorkspaces: DemoWorkspace[] = [
{ workspaceId: "00000000000BM630VT9ARVFZPC", workspaceName: "模型开发 Workspace" },
{ workspaceId: "0000000000AE0NC0V5T424KK86", workspaceName: "风险验证 Workspace" },
];
function readStoredContext(): Partial<{
userId: string;
workspaceId: string;
}> {
if (typeof window === "undefined") return {};
try {
return JSON.parse(
window.localStorage.getItem("model-platform-demo-context") ?? "{}",
) as Partial<{ userId: string; workspaceId: string }>;
} catch {
return {};
}
}
const storedContext = readStoredContext();
const initialUser = demoUsers.find((item) => item.userId === storedContext.userId)
?? demoUsers[0];
const initialWorkspace = demoWorkspaces.find(
(item) => item.workspaceId === storedContext.workspaceId,
) ?? demoWorkspaces[0];
export const demoContext = {
...initialUser,
...initialWorkspace,
};
export function setDemoContext(input: {
user?: DemoUser;
workspace?: DemoWorkspace;
}): void {
if (input.user) Object.assign(demoContext, input.user);
if (input.workspace) Object.assign(demoContext, input.workspace);
if (typeof window !== "undefined") {
window.localStorage.setItem("model-platform-demo-context", JSON.stringify({
userId: demoContext.userId,
workspaceId: demoContext.workspaceId,
}));
}
}
// API client for the platform backend.
//
// All endpoints that take a workspace context require the caller to
// pass `workspaceId` explicitly. Components read the active workspace
// from `useAuth().currentWorkspace` and thread it through; the cookie
// set by `/api/v1/auth/login` is sent automatically thanks to
// `credentials: "same-origin"`, and the backend reads it via the
// shared `request_context` dependency.
//
// 401 from any endpoint means the session has expired or was never
// established; the global `apiRequest` helper bounces the user to
// `/login` so the platform never tries to render with a stale identity.
export type Employee = {
user_id: string;
username: string;
display_name: string;
email: string | null;
status: "active" | "disabled" | "locked";
role_code: "admin" | "developer";
role_name: string;
created_at: string;
};
export type ApiEnvelope<T> = {
request_id: string;
data: T;
meta: Record<string, unknown>;
};
export type CursorPageMeta = {
limit: number;
page_count: number;
total_count: number;
has_more: boolean;
next_cursor: string | null;
};
export type CursorPage<T> = {
items: T[];
meta: CursorPageMeta;
};
export type CursorListParams = {
limit?: number;
cursor?: string | null;
q?: string;
};
export type ApiErrorEnvelope = {
detail?: string | {
code?: string;
message?: string;
};
error?: {
code?: string;
message?: string;
details?: {
editor_name?: string;
lease_expires_at?: string;
};
};
};
export class ApiRequestError extends Error {
readonly status: number;
readonly code?: string;
constructor(message: string, status: number, code?: string) {
super(message);
this.name = "ApiRequestError";
this.status = status;
this.code = code;
}
}
export function appendWorkspaceId(path: string, workspaceId: string): string {
// `path` may already contain a query string. Use URLSearchParams to
// merge cleanly either way.
const separator = path.includes("?") ? "&" : "?";
return `${path}${separator}workspace_id=${encodeURIComponent(workspaceId)}`;
}
export async function apiRequest<T>(
path: string,
init: RequestInit = {},
workspaceId?: string,
): Promise<T> {
const result = await apiRequestWithMeta<T>(path, init, workspaceId);
return result.data;
}
export async function apiRequestWithMeta<T>(
path: string,
init: RequestInit = {},
workspaceId?: string,
): Promise<{ data: T; meta: Record<string, unknown> }> {
const finalPath = workspaceId ? appendWorkspaceId(path, workspaceId) : path;
const response = await fetch(finalPath, {
...init,
credentials: "same-origin",
headers: {
"X-Request-ID": createUuid().replaceAll("-", ""),
...(init.body ? { "Content-Type": "application/json" } : {}),
...init.headers,
},
});
// Session expired / never authenticated — bounce to login. The
// /login route itself is the only path that must remain reachable
// while anonymous, so the redirect there is safe.
if (response.status === 401 && typeof window !== "undefined") {
const here = window.location.pathname;
if (here !== "/login") {
window.location.assign("/login");
}
throw new ApiRequestError("未登录或登录已过期", 401);
}
const payload = (await response.json().catch(() => ({}))) as
| ApiEnvelope<T>
| ApiErrorEnvelope;
if (!response.ok) {
const error = payload as ApiErrorEnvelope;
const detailMessage = typeof error.detail === "string"
? error.detail
: error.detail?.message;
const editor = error.error?.details?.editor_name;
throw new ApiRequestError(
(editor ? `${error.error?.message ?? "文件正在编辑"}${editor}` : undefined)
?? error.error?.message
?? detailMessage
?? `请求失败(HTTP ${response.status}`,
response.status,
typeof error.detail === "object"
? error.detail?.code
: error.error?.code,
);
}
const envelope = payload as ApiEnvelope<T>;
return { data: envelope.data, meta: envelope.meta ?? {} };
}
export function parseCursorPageMeta(meta: Record<string, unknown>): CursorPageMeta {
const totalFromMeta =
typeof meta.total_count === "number"
? meta.total_count
: typeof meta.count === "number"
? meta.count
: 0;
return {
limit: typeof meta.limit === "number" ? meta.limit : 10,
page_count: typeof meta.page_count === "number" ? meta.page_count : 0,
total_count: totalFromMeta,
has_more: Boolean(meta.has_more),
next_cursor: typeof meta.next_cursor === "string" ? meta.next_cursor : null,
};
}
export function buildCursorQuery(input: CursorListParams = {}): string {
const parameters = new URLSearchParams();
parameters.set("limit", String(input.limit ?? 10));
if (input.cursor) parameters.set("cursor", input.cursor);
const keyword = input.q?.trim();
if (keyword) parameters.set("q", keyword);
return parameters.toString();
}