refactor: api.ts
This commit is contained in:
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user