Files
郑龙捷 74dd97428f feat: 接入运维真实数据与三角色权限链路
- 新增 model_deploy 与 model_operations 双库查询,支持模型列表、模型详情、单月监控结果和运维工作台真实接口。

- 关闭运维模块生产 Mock 数据,补充缺表/空数据降级、工作台空状态和首条纵向链路测试。

- 按业务团队、模型团队、管理员权限矩阵接入菜单、页面、操作按钮和后端接口权限校验,支持 business_team 角色。

- 更新工作台布局、深色欢迎卡片、全宽页面适配、顶部回退,以及权限分组展示。

- 新增架构实现基线、周目标完成情况和角色权限矩阵初始化 SQL 文档。
2026-09-02 15:02:47 +08:00

197 lines
5.6 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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("-");
}
// 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: string;
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();
}