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 = { request_id: string; data: T; meta: Record; }; export type CursorPageMeta = { limit: number; page_count: number; total_count: number; has_more: boolean; next_cursor: string | null; }; export type CursorPage = { 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( path: string, init: RequestInit = {}, workspaceId?: string, ): Promise { const result = await apiRequestWithMeta(path, init, workspaceId); return result.data; } export async function apiRequestWithMeta( path: string, init: RequestInit = {}, workspaceId?: string, ): Promise<{ data: T; meta: Record }> { 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 | 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; return { data: envelope.data, meta: envelope.meta ?? {} }; } export function parseCursorPageMeta(meta: Record): 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(); }