263 lines
7.7 KiB
TypeScript
263 lines
7.7 KiB
TypeScript
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();
|
||
}
|
||
|