diff --git a/frontend/app/services/api.ts b/frontend/app/services/api.ts index b7b4568..17f9657 100644 --- a/frontend/app/services/api.ts +++ b/frontend/app/services/api.ts @@ -1,1964 +1,12 @@ -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 ScriptType = "python" | "notebook"; -export type Visibility = "private" | "workspace" | "public"; - -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; -}; - -// Role (Platform Role) types - 角色管理接口 -export type Role = { - role_id: string; - role_code: string; - role_name: string; - is_builtin: boolean; - description: string | null; - permission_codes: string[]; -}; - -export type PlatformPermission = { - permission_code: string; - permission_name: string; - module_code: string; - description: string | null; -}; - -export type RoleCreatePayload = { - role_code: string; - role_name: string; - description?: string | null; - permission_codes?: string[]; -}; - -export type RoleUpdatePayload = { - role_name?: string; - description?: string | null; // null = clear -}; - -// Workspace (Project) types - 对应 API.md 第七部分系统管理接口 -export type Workspace = { - workspace_id: string; - workspace_code: string; - workspace_name: string; - active_root_uri: string; - quota_bytes: number; - status: "active" | "archived" | "disabled"; - description: string | null; - created_by: string; - created_at: string; - updated_at: string | null; -}; - -export type WorkspaceMember = { - user_id: string; - username: string; - display_name: string; - email: string | null; - role_code: "admin" | "developer"; - role_name: string; - member_status: "active" | "disabled" | "locked"; - joined_at: string; -}; - -export type WorkspaceCreatePayload = { - workspace_code: string; - workspace_name: string; - quota_bytes?: number; - description?: string; -}; - -export type WorkspaceUpdatePayload = { - workspace_name?: string; - quota_bytes?: number; - description?: string; - status?: "active" | "archived"; -}; - -/** 加入工作区;角色继承自用户的 platform_role,请求体不能带 role_code。 */ -export type WorkspaceMemberAddPayload = { - user_id: string; -}; - -/** 仅可改成员状态;改角色请 PATCH /platform/employees/{user_id}。 */ -export type WorkspaceMemberUpdatePayload = { - member_status?: "active" | "disabled" | "locked"; -}; - -export type ScriptItem = { - script_id: string; - workspace_id: string; - current_object_id: string; - owner_user_id: string; - owner_display_name: string | null; - script_name: string; - script_type: ScriptType; - visibility: Visibility; - status: string; - is_locked: boolean; - relative_path: string; - jupyter_path: string; - content_hash: string; - size_bytes: number; - created_at: string; - updated_at: string; -}; - -export type WorkspaceDirectory = { - path: string; - name: string; - parent_path: string; - owner_user_id: string; - has_children?: boolean; -}; - -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; -}; - -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; - } -} - -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)}`; -} - -async function apiRequest( - path: string, - init: RequestInit = {}, - workspaceId?: string, -): Promise { - const result = await apiRequestWithMeta(path, init, workspaceId); - return result.data; -} - -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 ?? {} }; -} - -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, - }; -} - -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(); -} - -export async function listScripts( - workspaceId: string, - parentPath: string = "", - ownerUserId?: string, -): Promise { - // Default (no ownerUserId) scopes to the requester's own subtree; passing - // ownerUserId scopes to that owner's subtree (workspace/public only — the - // backend excludes their private) so the tree can lazily fetch another - // member's content when their group is expanded. - const parameters = new URLSearchParams(); - if (parentPath) parameters.set("parent_path", parentPath); - if (ownerUserId) parameters.set("owner_user_id", ownerUserId); - const query = parameters.toString(); - return apiRequest( - `/api/v1/scripts${query ? `?${query}` : ""}`, - {}, - workspaceId, - ); -} - -export async function countScripts( - workspaceId: string, -): Promise { - // Backend route /api/v1/scripts/count must be declared BEFORE the - // /scripts/{script_id} route on the server side. Returns - // { data: { total: number } } — the dashboard's single source of - // truth for "total active scripts in workspace", independent of the - // lazy-loaded scripts[] in the workspace store. - const envelope = await apiRequest<{ total: number }>( - "/api/v1/scripts/count", - {}, - workspaceId, - ); - return envelope.total; -} - -function initialContent(scriptType: ScriptType): string { - if (scriptType === "python") { - return [ - '"""模型实验开发平台构建脚本。"""', - "", - "", - "def main() -> None:", - ' print("Hello, Model Platform!")', - "", - "", - 'if __name__ == "__main__":', - " main()", - "", - ].join("\n"); - } - - return JSON.stringify( - { - cells: [ - { - id: "intro", - cell_type: "code", - execution_count: null, - metadata: {}, - outputs: [], - source: ["print('Hello, Model Platform!')\n"], - }, - ], - metadata: { - kernelspec: { - display_name: "Python 3", - language: "python", - name: "python3", - }, - language_info: { - name: "python", - version: "3.12", - }, - }, - nbformat: 4, - nbformat_minor: 5, - }, - null, - 2, - ); -} - -export async function createScript( - workspaceId: string, - input: { - name: string; - scriptType: ScriptType; - visibility: Visibility; - parentPath?: string | null; - }, -): Promise { - return apiRequest( - "/api/v1/scripts", - { - method: "POST", - body: JSON.stringify({ - script_name: input.name.trim(), - script_type: input.scriptType, - visibility: input.visibility, - content: initialContent(input.scriptType), - parent_path: input.parentPath, - }), - }, - workspaceId, - ); -} - -export async function uploadScript( - workspaceId: string, - file: File, - parentPath = "", - visibility: Visibility = "workspace", -): Promise { - const parameters = new URLSearchParams({ - file_name: file.name, - parent_path: parentPath, - visibility, - }); - return apiRequest( - `/api/v1/scripts/upload?${parameters.toString()}`, - { - method: "POST", - headers: { "Content-Type": "application/octet-stream" }, - body: file, - }, - workspaceId, - ); -} - -export type ResourceItem = { - resource_id: string; - workspace_id: string; - storage_object_id: string; - owner_user_id: string; - owner_display_name?: string | null; - resource_name: string; - description: string | null; - visibility: "private" | "workspace" | "public"; - status: string; - created_at: string; - updated_at: string; - file: { - file_name: string; - file_extension: string | null; - mime_type: string | null; - size_bytes: number; - content_hash: string | null; - object_status: string; - }; - jupyter_accessible_path: string; - absolute_path: string; -}; - -export async function listResources( - workspaceId: string, - parentPath: string = "", - opts?: { visibility?: string; keyword?: string; ownerUserId?: string }, -): Promise { - // Default (no ownerUserId) scopes to the requester's own object_key - // subtree; passing ownerUserId scopes to that owner's subtree so the tree - // can lazily fetch another member's data resources on group expand. - const parameters = new URLSearchParams(); - if (parentPath) parameters.set("parent_path", parentPath); - if (opts?.visibility) parameters.set("visibility", opts.visibility); - if (opts?.keyword) parameters.set("keyword", opts.keyword); - if (opts?.ownerUserId) parameters.set("owner_user_id", opts.ownerUserId); - const query = parameters.toString(); - // apiRequest already unwraps the envelope's `data` field, so we - // request `ResourceItem[]` directly here (matching listScripts). - return apiRequest( - `/api/v1/data-resources${query ? `?${query}` : ""}`, - {}, - workspaceId, - ); -} - -export async function deleteResource( - workspaceId: string, - resourceId: string, -): Promise<{ resource_id: string; status: string }> { - return apiRequest<{ resource_id: string; status: string }>( - `/api/v1/data-resources/${encodeURIComponent(resourceId)}`, - { method: "DELETE" }, - workspaceId, - ); -} - -/** 同源流式下载数据资源字节,供 Excel 等预览器使用。 */ -export async function fetchResourceContentFile( - workspaceId: string, - resourceId: string, - fileName: string, - signal?: AbortSignal, -): Promise { - const response = await fetch( - `/api/v1/data-resources/${encodeURIComponent(resourceId)}/content?workspace_id=${encodeURIComponent(workspaceId)}`, - { - credentials: "same-origin", - signal, - headers: { - "X-Request-ID": createUuid().replaceAll("-", ""), - }, - }, - ); - - if (response.status === 401 && typeof window !== "undefined") { - const here = window.location.pathname; - if (here !== "/login") { - window.location.assign("/login"); - } - throw new ApiRequestError("未登录或登录已过期", 401); - } - - if (!response.ok) { - let message = `请求失败(HTTP ${response.status})`; - try { - const payload = (await response.json()) as ApiErrorEnvelope; - const detailMessage = - typeof payload.detail === "string" - ? payload.detail - : payload.detail?.message; - if (detailMessage) message = detailMessage; - } catch { - /* ignore non-JSON error bodies */ - } - throw new ApiRequestError(message, response.status); - } - - const blob = await response.blob(); - return new File([blob], fileName, { - type: blob.type || "application/octet-stream", - }); -} - -export type ResourcePreviewPayload = { - kind: "table"; - columns: string[]; - rows: string[][]; - row_count: number; - truncated: boolean; - delimiter: string; -}; - -/** 表格类数据资源抽样预览(csv / tsv)。 */ -export async function fetchResourcePreview( - workspaceId: string, - resourceId: string, - input: { limit?: number } = {}, - signal?: AbortSignal, -): Promise { - const parameters = new URLSearchParams(); - parameters.set("workspace_id", workspaceId); - if (input.limit != null) parameters.set("limit", String(input.limit)); - const response = await fetch( - `/api/v1/data-resources/${encodeURIComponent(resourceId)}/preview?${parameters.toString()}`, - { - credentials: "same-origin", - signal, - headers: { - "X-Request-ID": createUuid().replaceAll("-", ""), - }, - }, - ); - - 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(); - if (!response.ok) { - const error = payload as ApiErrorEnvelope; - const detailMessage = - typeof error.detail === "string" ? error.detail : error.detail?.message; - throw new ApiRequestError( - detailMessage ?? `请求失败(HTTP ${response.status})`, - response.status, - ); - } - - const data = (payload as { data: ResourcePreviewPayload }).data; - if (!data || data.kind !== "table" || !Array.isArray(data.columns)) { - throw new ApiRequestError("响应数据格式错误", response.status); - } - return data; -} - -export async function createResourceUpload( - workspaceId: string, - body: { - file_name: string; - content_type: string; - expected_size_bytes: number; - expected_hash: string | null; - target_path?: string; - }, -): Promise<{ upload_id: string; upload_path: string }> { - return apiRequest<{ upload_id: string; upload_path: string }>( - "/api/v1/data-resources/uploads", - { - method: "POST", - body: JSON.stringify(body), - headers: { "Idempotency-Key": createUuid().replaceAll("-", "") }, - }, - workspaceId, - ); -} - -export async function uploadResourceBytes( - workspaceId: string, - uploadId: string, - fileBytes: ArrayBuffer | Blob, - contentType: string, -): Promise<{ storage_object_id: string }> { - return apiRequest<{ storage_object_id: string }>( - `/api/v1/data-resources/uploads/${encodeURIComponent(uploadId)}`, - { - method: "PUT", - headers: { "Content-Type": contentType }, - body: fileBytes, - }, - workspaceId, - ); -} - -export async function bindResourceUpload( - workspaceId: string, - uploadId: string, - body: { - resource_name: string; - description: string; - visibility: "private" | "workspace" | "public"; - }, -): Promise { - return apiRequest( - `/api/v1/data-resources/uploads/${encodeURIComponent(uploadId)}/bind`, - { - method: "POST", - body: JSON.stringify(body), - }, - workspaceId, - ); -} - -export async function setScriptLock( - workspaceId: string, - scriptId: string, - isLocked: boolean, -): Promise { - return apiRequest( - `/api/v1/scripts/${encodeURIComponent(scriptId)}/lock`, - { - method: "PATCH", - body: JSON.stringify({ is_locked: isLocked }), - }, - workspaceId, - ); -} - -export async function updateScript( - workspaceId: string, - scriptId: string, - input: { content: string }, -): Promise { - return apiRequest( - `/api/v1/scripts/${scriptId}`, - { method: "PUT", body: JSON.stringify(input) }, - workspaceId, - ); -} - -export async function getScriptContent( - workspaceId: string, - scriptId: string, -): Promise<{ - script_id: string; - script_type: ScriptType; - content: string | object; - format: string; -}> { - const response = await fetch( - `/api/v1/scripts/${scriptId}/content?workspace_id=${encodeURIComponent(workspaceId)}`, - { - credentials: "same-origin", - headers: { - "X-Request-ID": createUuid().replaceAll("-", ""), - }, - }, - ); - - 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(); - if (!response.ok) { - const error = payload as ApiErrorEnvelope; - const detailMessage = typeof error.detail === "string" - ? error.detail - : error.detail?.message; - throw new ApiRequestError( - detailMessage ?? `请求失败(HTTP ${response.status})`, - response.status, - ); - } - - const data = (payload as { data: { script_id: string; script_type: ScriptType; content: string | object; format: string } }).data; - if (!data || !data.script_type) { - throw new ApiRequestError("响应数据格式错误", response.status); - } - return data; -} - -export async function deleteScript( - workspaceId: string, - scriptId: string, -): Promise<{ script_id: string; status: string; versions_preserved: boolean }> { - return apiRequest( - `/api/v1/scripts/${scriptId}`, - { method: "DELETE" }, - workspaceId, - ); -} - -export async function listWorkspaceDirectories( - workspaceId: string, - parentPath: string = "", - ownerUserId?: string, -): Promise { - // Default (no ownerUserId) scopes to the requester's own subtree; passing - // ownerUserId scopes to that owner so the tree can lazily render their - // directory structure on expand. Directories are structural rows; file - // visibility is still enforced by the scripts/data-resources endpoints. - const parameters = new URLSearchParams(); - if (parentPath) parameters.set("parent_path", parentPath); - if (ownerUserId) parameters.set("owner_user_id", ownerUserId); - const query = parameters.toString(); - const data = await apiRequest<{ directories: WorkspaceDirectory[] }>( - `/api/v1/workspace-directories${query ? `?${query}` : ""}`, - {}, - workspaceId, - ); - return data.directories; -} - -export async function createWorkspaceDirectory( - workspaceId: string, - directoryName: string, - parentPath = "", -): Promise { - return apiRequest( - "/api/v1/workspace-directories", - { - method: "POST", - body: JSON.stringify({ - directory_name: directoryName, - parent_path: parentPath, - }), - }, - workspaceId, - ); -} - -export async function deleteWorkspaceDirectory( - workspaceId: string, - path: string, -): Promise<{ - path: string; - status: string; - deleted_scripts: number; - versions_preserved: boolean; -}> { - const parameters = new URLSearchParams({ path }); - return apiRequest( - `/api/v1/workspace-directories?${parameters.toString()}`, - { method: "DELETE" }, - workspaceId, - ); -} - -export type FileLockSession = { - edit_session_id: string; - workspace_id: string; - storage_object_id: string; - user_id: string; - session_status: "active" | "closed" | "expired"; - lease_seconds: number; - heartbeat_interval_seconds: number; - expires_at: string; - runtime_id: string; - jupyter_session_id: string; - jupyter_url?: string; - relative_path?: string; - lock_token?: string; -}; - -export type ActiveEditSession = FileLockSession & { - script_id: string; - script_name: string; - jupyter_path: string; - lock_token: string; - ticket_expires_at?: string; -}; - -export type JupyterAccessTicket = { - edit_session_id: string; - jupyter_url: string; - expires_at: string; -}; - -export type StableVersion = { - versions_id: string; - workspace_id: string; - script_id: string; - source_object_id: string; - artifact_object_id: string; - version_no: number; - version_label: string; - source_path: string; - artifact_path: string; - content_hash: string; - file_size_bytes: number; - visibility: Visibility; - release_note: string | null; - created_by: string; - created_at: string; -}; - -// 本地浏览器级“编辑锁”——后端没有 acquire/heartbeat/release/edit-session 表。 -// 这里的四个函数全部是占位:返回结构是为了让上层 store 的 -// _editSession / sessionCache 继续按“session”接口工作,但锁的实际作用域 -// 仅限当前 tab。关闭 tab、刷新页面、用隐身模式打开、或换浏览器,锁即失效。 -// 不要把这些函数当作鉴权或并发控制用——它们什么都不查、什么都不写。 -// 真实并发控制需要后端 edit_sessions 表 + Nginx auth_request 联动,是后续工单。 - -export async function acquireFileLock( - workspaceId: string, - script: ScriptItem, -): Promise { - const now = Date.now(); - return { - edit_session_id: createUuid().replaceAll("-", ""), - workspace_id: workspaceId, - storage_object_id: script.current_object_id, - user_id: script.owner_user_id, - session_status: "active", - lease_seconds: 3600, - heartbeat_interval_seconds: 300, - expires_at: new Date(now + 3600_000).toISOString(), - runtime_id: workspaceId, - jupyter_session_id: "local", - relative_path: script.relative_path, - lock_token: "local", - script_id: script.script_id, - script_name: script.script_name, - jupyter_path: script.jupyter_path, - }; -} - -export async function heartbeatFileLock( - _workspaceId: string, - session: ActiveEditSession, -): Promise { - // 本地锁不存在过期概念;只是把 expires_at 推后让 UI 看着还活着。 - // 该字段当前没有任何消费者,保留只是为了不破坏契约。 - return session; -} - -export async function releaseFileLock( - _workspaceId: string, - session: ActiveEditSession, -): Promise { - return { ...session, session_status: "closed" }; -} - -export function releaseFileLockOnUnload( - _workspaceId: string, - _session: ActiveEditSession, -): void { - // 本地锁随 tab 生命周期结束。beforeunload 调到这里只是让 store 端 - // 清理模块级引用,避免下一个 tab 复用时看到陈旧 _editSession。 -} - -async function waitForJupyterReady(jupyterUrl: string): Promise { - const retryableStatuses = new Set([502, 503, 504]); - let lastStatus = 0; - - for (let attempt = 0; attempt < 10; attempt += 1) { - const response = await fetch(jupyterUrl, { - credentials: "same-origin", - cache: "no-store", - }); - if (response.ok) return; - - lastStatus = response.status; - if (!retryableStatuses.has(response.status)) { - throw new ApiRequestError( - `Jupyter 打开失败(HTTP ${response.status})`, - response.status, - ); - } - await new Promise((resolve) => window.setTimeout(resolve, 400)); - } - - throw new ApiRequestError( - `Jupyter 服务启动超时${lastStatus ? `(HTTP ${lastStatus})` : ""}`, - lastStatus || 504, - ); -} - -export async function createJupyterAccessTicket( - workspaceId: string, - session: ActiveEditSession, -): Promise { - const editorRoute = session.script_name.toLowerCase().endsWith(".ipynb") - ? "notebooks" - : "edit"; - const encodedPath = session.jupyter_path - .split("/") - .filter(Boolean) - .map(encodeURIComponent) - .join("/"); - if (!encodedPath) { - throw new Error("脚本缺少 Jupyter 存储路径"); - } - - const jupyterUrl = `/jupyter/${encodeURIComponent(workspaceId)}/${editorRoute}/${encodedPath}`; - await waitForJupyterReady(jupyterUrl); - return { - edit_session_id: session.edit_session_id, - jupyter_url: jupyterUrl, - expires_at: new Date(Date.now() + 3600_000).toISOString(), - }; -} - -export type LatestVersion = { - versions_id: string; - version_label: string; -}; - -export async function getLatestScriptVersion( - workspaceId: string, - scriptId: string, -): Promise { - return apiRequest( - `/api/v1/scripts/${scriptId}/latest-version`, - {}, - workspaceId, - ); -} - -export async function publishScriptVersion( - workspaceId: string, - input: { - script: ScriptItem; - releaseNote: string; - visibility: Visibility; - }, -): Promise { - return apiRequest( - `/api/v1/scripts/${input.script.script_id}/versions`, - { - method: "POST", - body: JSON.stringify({ - source_object_id: input.script.current_object_id, - release_note: input.releaseNote.trim() || null, - visibility: input.visibility, - }), - }, - workspaceId, - ); -} - -export type ScheduleArtifact = { - versions_id: string; - version_label: string; - script_id: string; - script_name: string; - script_type: ScriptType; - content_hash: string; - file_size_bytes: number; - visibility: Visibility; - created_by: string; - created_at: string; -}; - -export type PythonVersion = "3.8" | "3.10" | "3.12"; - -export type ScheduleNode = { - node_id: string; - schedule_id: string; - node_key: string; - node_name: string; - versions_id: string; - timeout_seconds: number; - retry_count: number; - retry_interval_sec: number; - position_x: number; - position_y: number; - arguments_json: Record; - env_refs_json: Record; - python_version: PythonVersion; - created_at: string; - updated_at: string; - version: { - versions_id: string; - version_label: string; - script_id: string; - script_name: string; - script_type: ScriptType; - content_hash: string; - created_at: string; - }; -}; - -export type ScheduleEdge = { - edge_id: string; - schedule_id: string; - source_node_id: string; - target_node_id: string; - condition_expr: string | null; - created_at: string; -}; - -export type DagValidation = { - valid: boolean; - node_count: number; - edge_count: number; - root_node_ids: string[]; - leaf_node_ids: string[]; - topological_order: string[]; - errors: Array<{ - code: string; - message: string; - edge_id?: string; - node_ids?: string[]; - }>; -}; - -export type Schedule = { - schedule_id: string; - workspace_id: string; - schedule_name: string; - description: string | null; - trigger_type: "manual" | "cron" | "api"; - cron_expression: string | null; - timezone: string; - enabled: boolean; - workflow_version: number; - max_concurrency: number; - failure_policy: "stop" | "continue"; - last_run_at: string | null; - next_run_at: string | null; - created_by: string; - updated_by: string; - created_at: string; - updated_at: string; - node_count: number; - edge_count: number; - nodes: ScheduleNode[]; - edges: ScheduleEdge[]; - dag_validation: DagValidation; -}; - -export type CronPreview = { - cron_expression: string; - timezone: string; - base_time: string; - occurrences: Array<{ - local_time: string; - utc_time: string; - }>; -}; - -export type ScheduleRunStatus = - | "queued" - | "running" - | "succeeded" - | "failed" - | "cancelled" - | "timed_out"; - -export type ScheduleNodeRunStatus = - | ScheduleRunStatus - | "skipped"; - -export type ScheduleRunSummary = { - run_id: string; - schedule_id: string; - workspace_id: string; - workflow_version: number; - trigger_type: "manual" | "cron" | "api" | "retry"; - run_status: ScheduleRunStatus; - state_version: number; - queued_at: string; - started_at: string | null; - finished_at: string | null; - duration_ms: number | null; - error_code: string | null; - error_message: string | null; - logs_object_id: string | null; - result_object_id: string | null; -}; - -export type ScheduleNodeRun = { - node_run_id: string; - run_id: string; - node_id: string; - versions_id: string; - attempt_no: number; - node_status: ScheduleNodeRunStatus; - state_version: number; - started_at: string | null; - finished_at: string | null; - duration_ms: number | null; - exit_code: number | null; - message: string | null; - logs_object_id: string | null; - result_object_id: string | null; -}; - -export type ScheduleRunDetail = ScheduleRunSummary & { - node_runs: ScheduleNodeRun[]; -}; - -export type ScheduleRunArtifact = { - url: string; - file_name: string; - mime_type: string | null; - size_bytes: number; -}; - -export type ScheduleNodeRunArtifacts = { - run_id: string; - node_run_id: string; - log: ScheduleRunArtifact | null; - result: ScheduleRunArtifact | null; -}; - -export async function listSchedules(workspaceId: string): Promise { - return apiRequest("/api/v1/schedules", {}, workspaceId); -} - -export async function getSchedule( - workspaceId: string, - scheduleId: string, -): Promise { - return apiRequest( - `/api/v1/schedules/${scheduleId}`, - {}, - workspaceId, - ); -} - -export async function createSchedule( - workspaceId: string, - input: { - schedule_name: string; - description?: string | null; - trigger_type?: "manual" | "cron" | "api"; - cron_expression?: string | null; - timezone?: string; - enabled?: boolean; - max_concurrency?: number; - failure_policy?: "stop" | "continue"; - }, -): Promise { - return apiRequest( - "/api/v1/schedules", - { method: "POST", body: JSON.stringify(input) }, - workspaceId, - ); -} - -export async function updateSchedule( - workspaceId: string, - scheduleId: string, - input: { - workflow_version: number; - schedule_name?: string; - description?: string | null; - trigger_type?: "manual" | "cron" | "api"; - cron_expression?: string | null; - timezone?: string; - enabled?: boolean; - max_concurrency?: number; - failure_policy?: "stop" | "continue"; - }, -): Promise { - return apiRequest( - `/api/v1/schedules/${scheduleId}`, - { method: "PATCH", body: JSON.stringify(input) }, - workspaceId, - ); -} - -export async function deleteSchedule( - workspaceId: string, - scheduleId: string, - workflowVersion: number, -): Promise<{ schedule_id: string; deleted: boolean; workflow_version: number }> { - return apiRequest( - `/api/v1/schedules/${scheduleId}`, - { method: "DELETE", body: JSON.stringify({ workflow_version: workflowVersion }) }, - workspaceId, - ); -} - -export async function listScheduleArtifacts( - workspaceId: string, -): Promise { - return apiRequest( - "/api/v1/schedule-artifacts", - {}, - workspaceId, - ); -} - -export async function hideScheduleArtifact( - workspaceId: string, - versionsId: string, -): Promise<{ - versions_id: string; - deleted: boolean; - artifact_preserved: boolean; -}> { - return apiRequest( - `/api/v1/versions/${versionsId}`, - { method: "DELETE" }, - workspaceId, - ); -} - -// 系统管理级别接口 - 不区分 workspace -export async function listPlatformEmployees( - input: CursorListParams = {}, -): Promise> { - const query = buildCursorQuery(input); - const { data, meta } = await apiRequestWithMeta( - `/api/v1/platform/employees?${query}`, - ); - return { items: data, meta: parseCursorPageMeta(meta) }; -} - -export async function createPlatformEmployee( - input: { - username: string; - display_name: string; - email?: string | undefined; - password: string; - role_code?: "admin" | "developer"; - }, -): Promise { - return apiRequest( - "/api/v1/platform/employees", - { method: "POST", body: JSON.stringify({ ...input, role_code: input.role_code ?? "developer" }) }, - ); -} - -export async function updatePlatformEmployee( - userId: string, - input: { - display_name?: string; - email?: string | null; - role_code?: "admin" | "developer"; - status?: "active" | "disabled" | "locked"; - }, -): Promise { - return apiRequest( - `/api/v1/platform/employees/${userId}`, - { method: "PATCH", body: JSON.stringify(input) }, - ); -} - -export async function deletePlatformEmployee( - userId: string, -): Promise<{ user_id: string; deleted: boolean }> { - return apiRequest( - `/api/v1/platform/employees/${userId}`, - { method: "DELETE" }, - ); -} - -export async function listPlatformRoles(): Promise { - return apiRequest("/api/v1/platform/roles"); -} - -export async function getPlatformRole(roleCode: string): Promise { - return apiRequest(`/api/v1/platform/roles/${roleCode}/permissions`); -} - -export async function listPlatformPermissions(): Promise { - return apiRequest("/api/v1/platform/permissions"); -} - -export async function createPlatformRole(input: RoleCreatePayload): Promise { - return apiRequest( - "/api/v1/platform/roles", - { method: "POST", body: JSON.stringify(input) }, - ); -} - -export async function updatePlatformRole( - roleCode: string, - input: RoleUpdatePayload, -): Promise { - return apiRequest( - `/api/v1/platform/roles/${roleCode}`, - { method: "PATCH", body: JSON.stringify(input) }, - ); -} - -export async function deletePlatformRole( - roleCode: string, -): Promise<{ role_code: string; deleted: boolean }> { - return apiRequest( - `/api/v1/platform/roles/${roleCode}`, - { method: "DELETE" }, - ); -} - -export async function updatePlatformRolePermissions( - roleCode: string, - permissionCodes: string[], -): Promise { - return apiRequest( - `/api/v1/platform/roles/${roleCode}/permissions`, - { method: "PATCH", body: JSON.stringify({ permission_codes: permissionCodes }) }, - ); -} - -// 兼容旧的 workspace 级别接口(已废弃,建议使用 system-level 接口) -/** @deprecated 使用 listPlatformEmployees 代替 */ -export async function listEmployees(workspaceId: string): Promise { - return apiRequest("/api/v1/admin/employees", {}, workspaceId); -} - -/** @deprecated 使用 createPlatformEmployee 代替 */ -export async function createEmployee( - workspaceId: string, - input: { - username: string; - display_name: string; - email?: string | null; - role_code: "admin" | "developer"; - password: string; - }, -): Promise { - return apiRequest( - "/api/v1/admin/employees", - { method: "POST", body: JSON.stringify(input) }, - workspaceId, - ); -} - -/** @deprecated 使用 updatePlatformEmployee 代替 */ -export async function updateEmployee( - workspaceId: string, - userId: string, - input: { - display_name?: string; - email?: string | null; - role_code?: "admin" | "developer"; - status?: "active" | "disabled" | "locked"; - }, -): Promise { - return apiRequest( - `/api/v1/admin/employees/${userId}`, - { method: "PATCH", body: JSON.stringify(input) }, - workspaceId, - ); -} - -/** @deprecated 使用 deletePlatformEmployee 代替 */ -export async function deleteEmployee( - workspaceId: string, - userId: string, -): Promise<{ user_id: string; deleted: boolean }> { - return apiRequest( - `/api/v1/admin/employees/${userId}`, - { method: "DELETE" }, - workspaceId, - ); -} - -// ---------------------------------------------------------------------------- -// Workspace (Project) Management APIs - 对应 API.md 第七部分系统管理接口 -// ---------------------------------------------------------------------------- - -export async function listWorkspaces( - input: CursorListParams = {}, -): Promise> { - const query = buildCursorQuery(input); - const { data, meta } = await apiRequestWithMeta( - `/api/v1/platform/workspaces?${query}`, - ); - return { items: data, meta: parseCursorPageMeta(meta) }; -} - -export async function createWorkspace( - input: WorkspaceCreatePayload, -): Promise { - return apiRequest( - "/api/v1/platform/workspaces", - { method: "POST", body: JSON.stringify(input) }, - ); -} - -export async function getWorkspace(workspaceId: string): Promise { - return apiRequest(`/api/v1/platform/workspaces/${workspaceId}`); -} - -export async function updateWorkspace( - workspaceId: string, - input: WorkspaceUpdatePayload, -): Promise { - return apiRequest( - `/api/v1/platform/workspaces/${workspaceId}`, - { method: "PATCH", body: JSON.stringify(input) }, - ); -} - -export async function deleteWorkspace(workspaceId: string): Promise<{ - workspace_id: string; - deleted: boolean; -}> { - return apiRequest( - `/api/v1/platform/workspaces/${workspaceId}`, - { method: "DELETE" }, - ); -} - -export async function listWorkspaceMembers(workspaceId: string): Promise { - return apiRequest( - `/api/v1/platform/workspaces/${workspaceId}/members`, - ); -} - -export async function addWorkspaceMember( - workspaceId: string, - input: WorkspaceMemberAddPayload, -): Promise { - return apiRequest( - `/api/v1/platform/workspaces/${workspaceId}/members`, - { method: "POST", body: JSON.stringify(input) }, - ); -} - -export async function updateWorkspaceMember( - workspaceId: string, - userId: string, - input: WorkspaceMemberUpdatePayload, -): Promise { - return apiRequest( - `/api/v1/platform/workspaces/${workspaceId}/members/${userId}`, - { method: "PATCH", body: JSON.stringify(input) }, - ); -} - -export async function deleteWorkspaceMember( - workspaceId: string, - userId: string, -): Promise<{ user_id: string; deleted: boolean }> { - return apiRequest( - `/api/v1/platform/workspaces/${workspaceId}/members/${userId}`, - { method: "DELETE" }, - ); -} - -export async function createScheduleNode( - workspaceId: string, - scheduleId: string, - input: { - workflow_version: number; - node_key: string; - node_name: string; - versions_id: string; - timeout_seconds?: number; - retry_count?: number; - retry_interval_sec?: number; - position_x?: number; - position_y?: number; - arguments_json?: Record; - env_refs_json?: Record; - python_version?: PythonVersion; - }, -): Promise { - return apiRequest( - `/api/v1/schedules/${scheduleId}/nodes`, - { method: "POST", body: JSON.stringify(input) }, - workspaceId, - ); -} - -export async function updateScheduleNode( - workspaceId: string, - scheduleId: string, - nodeId: string, - input: { - workflow_version: number; - node_name?: string; - versions_id?: string; - timeout_seconds?: number; - retry_count?: number; - retry_interval_sec?: number; - position_x?: number; - position_y?: number; - arguments_json?: Record; - env_refs_json?: Record; - python_version?: PythonVersion; - }, -): Promise { - return apiRequest( - `/api/v1/schedules/${scheduleId}/nodes/${nodeId}`, - { method: "PUT", body: JSON.stringify(input) }, - workspaceId, - ); -} - -export async function deleteScheduleNode( - workspaceId: string, - scheduleId: string, - nodeId: string, - workflowVersion: number, - options: { delete_execution_history?: boolean } = {}, -): Promise { - return apiRequest( - `/api/v1/schedules/${scheduleId}/nodes/${nodeId}`, - { - method: "DELETE", - body: JSON.stringify({ workflow_version: workflowVersion, ...options }), - }, - workspaceId, - ); -} - -export async function createScheduleEdge( - workspaceId: string, - scheduleId: string, - input: { - workflow_version: number; - source_node_id: string; - target_node_id: string; - condition_expr?: string | null; - }, -): Promise { - return apiRequest( - `/api/v1/schedules/${scheduleId}/edges`, - { method: "POST", body: JSON.stringify(input) }, - workspaceId, - ); -} - -export async function deleteScheduleEdge( - workspaceId: string, - scheduleId: string, - edgeId: string, - workflowVersion: number, -): Promise { - return apiRequest( - `/api/v1/schedules/${scheduleId}/edges/${edgeId}`, - { method: "DELETE", body: JSON.stringify({ workflow_version: workflowVersion }) }, - workspaceId, - ); -} - -export async function validateSchedule( - workspaceId: string, - scheduleId: string, -): Promise { - return apiRequest( - `/api/v1/schedules/${scheduleId}/validate`, - { method: "POST" }, - workspaceId, - ); -} - -export async function previewCron( - workspaceId: string, - input: { - cron_expression: string; - timezone: string; - count?: number; - base_time?: string; - }, -): Promise { - return apiRequest( - "/api/v1/cron/preview", - { method: "POST", body: JSON.stringify(input) }, - workspaceId, - ); -} - -export async function runScheduleNow( - workspaceId: string, - scheduleId: string, -): Promise { - return apiRequest( - `/api/v1/schedules/${scheduleId}/run`, - { - method: "POST", - headers: { - "Idempotency-Key": createUuid(), - }, - body: JSON.stringify({ reason: "manual_run" }), - }, - workspaceId, - ); -} - -export async function listScheduleRuns( - workspaceId: string, - input: { - scheduleId?: string; - status?: ScheduleRunStatus; - limit?: number; - } = {}, -): Promise { - const query = new URLSearchParams(); - if (input.scheduleId) query.set("schedule_id", input.scheduleId); - if (input.status) query.set("status", input.status); - query.set("limit", String(input.limit ?? 20)); - return apiRequest( - `/api/v1/schedule-runs?${query.toString()}`, - {}, - workspaceId, - ); -} - -export async function getScheduleRun( - workspaceId: string, - runId: string, -): Promise { - return apiRequest( - `/api/v1/schedule-runs/${runId}`, - {}, - workspaceId, - ); -} - -export async function getScheduleNodeRunArtifacts( - workspaceId: string, - runId: string, - nodeRunId: string, -): Promise { - return apiRequest( - `/api/v1/schedule-runs/${runId}/node-runs/${nodeRunId}/artifacts`, - {}, - workspaceId, - ); -} - -// ---------------------------------------------------------------------------- -// Workspace-bound API surface. -// -// `useApi()` in ~/context/AuthContext returns an object where every -// function has had its first `workspaceId` argument pre-filled. The -// type below lets consumers import the bound type without depending -// on the raw functions. Keep this last in the file so the type -// references all the exports above. -// ---------------------------------------------------------------------------- -export type WorkspaceBoundApi = { - listScripts: ( - parentPath?: Parameters[1], - ownerUserId?: Parameters[2], - ) => Promise; - countScripts: () => Promise; - listResources: ( - parentPath?: Parameters[1], - opts?: Parameters[2], - ) => Promise; - createScript: ( - input: Parameters[1], - ) => Promise; - uploadScript: ( - file: File, - parentPath?: string, - visibility?: Visibility, - ) => Promise; - createResourceUpload: ( - body: Parameters[1], - ) => Promise<{ upload_id: string; upload_path: string }>; - uploadResourceBytes: ( - uploadId: string, - fileBytes: ArrayBuffer | Blob, - contentType: string, - ) => Promise<{ storage_object_id: string }>; - bindResourceUpload: ( - uploadId: string, - body: Parameters[2], - ) => Promise; - deleteResource: ( - resourceId: string, - ) => Promise<{ resource_id: string; status: string }>; - fetchResourceContentFile: ( - resourceId: string, - fileName: string, - signal?: AbortSignal, - ) => Promise; - fetchResourcePreview: ( - resourceId: string, - input?: { limit?: number }, - signal?: AbortSignal, - ) => Promise; - updateScript: ( - scriptId: string, - input: Parameters[2], - ) => Promise; - deleteScript: ( - scriptId: string, - ) => Promise<{ script_id: string; status: string; versions_preserved: boolean }>; - setScriptLock: ( - scriptId: string, - isLocked: boolean, - ) => Promise; - listWorkspaceDirectories: ( - parentPath?: Parameters[1], - ownerUserId?: Parameters[2], - ) => Promise; - createWorkspaceDirectory: ( - directoryName: string, - parentPath?: string, - ) => Promise; - deleteWorkspaceDirectory: ( - path: string, - ) => Promise<{ - path: string; - status: string; - deleted_scripts: number; - versions_preserved: boolean; - }>; - acquireFileLock: ( - script: ScriptItem, - ) => Promise; - heartbeatFileLock: ( - session: ActiveEditSession, - ) => Promise; - releaseFileLock: ( - session: ActiveEditSession, - ) => Promise; - releaseFileLockOnUnload: (session: ActiveEditSession) => void; - createJupyterAccessTicket: ( - session: ActiveEditSession, - ) => Promise; - getLatestScriptVersion: (scriptId: string) => Promise; - publishScriptVersion: ( - input: Parameters[1], - ) => Promise; - listSchedules: () => Promise; - getSchedule: (scheduleId: string) => Promise; - createSchedule: ( - input: Parameters[1], - ) => Promise; - updateSchedule: ( - scheduleId: string, - input: Parameters[2], - ) => Promise; - deleteSchedule: ( - scheduleId: string, - workflowVersion: number, - ) => Promise<{ schedule_id: string; deleted: boolean; workflow_version: number }>; - listScheduleArtifacts: () => Promise; - hideScheduleArtifact: ( - versionsId: string, - ) => Promise<{ - versions_id: string; - deleted: boolean; - artifact_preserved: boolean; - }>; - listEmployees: () => Promise; - listPlatformEmployees: ( - input?: CursorListParams, - ) => Promise>; - createEmployee: ( - input: Parameters[1], - ) => Promise; - createPlatformEmployee: ( - input: { - username: string; - display_name: string; - email?: string | undefined; - password: string; - role_code?: "admin" | "developer"; - }, - ) => Promise; - updateEmployee: ( - userId: string, - input: Parameters[2], - ) => Promise; - updatePlatformEmployee: ( - userId: string, - input: { - display_name?: string; - email?: string | null; - role_code?: "admin" | "developer"; - status?: "active" | "disabled" | "locked"; - }, - ) => Promise; - deleteEmployee: ( - userId: string, - ) => Promise<{ user_id: string; deleted: boolean }>; - deletePlatformEmployee: ( - userId: string, - ) => Promise<{ user_id: string; deleted: boolean }>; - listPlatformRoles: () => Promise; - getPlatformRole: (roleCode: string) => Promise; - listPlatformPermissions: () => Promise; - createPlatformRole: (input: RoleCreatePayload) => Promise; - updatePlatformRole: ( - roleCode: string, - input: RoleUpdatePayload, - ) => Promise; - deletePlatformRole: ( - roleCode: string, - ) => Promise<{ role_code: string; deleted: boolean }>; - updatePlatformRolePermissions: ( - roleCode: string, - permissionCodes: string[], - ) => Promise; - createScheduleNode: ( - scheduleId: string, - input: Parameters[2], - ) => Promise; - updateScheduleNode: ( - scheduleId: string, - nodeId: string, - input: Parameters[3], - ) => Promise; - deleteScheduleNode: ( - scheduleId: string, - nodeId: string, - workflowVersion: number, - options?: { delete_execution_history?: boolean }, - ) => Promise; - createScheduleEdge: ( - scheduleId: string, - input: Parameters[2], - ) => Promise; - deleteScheduleEdge: ( - scheduleId: string, - edgeId: string, - workflowVersion: number, - ) => Promise; - validateSchedule: ( - scheduleId: string, - ) => Promise; - previewCron: ( - input: Parameters[1], - ) => Promise; - runScheduleNow: (scheduleId: string) => Promise; - listScheduleRuns: ( - input?: Parameters[1], - ) => Promise; - getScheduleRun: (runId: string) => Promise; - getScheduleNodeRunArtifacts: ( - runId: string, - nodeRunId: string, - ) => Promise; - // Workspace (Project) Management - 系统管理接口 - listWorkspaces: ( - input?: CursorListParams, - ) => Promise>; - createWorkspace: (input: WorkspaceCreatePayload) => Promise; - updateWorkspace: ( - workspaceId: string, - input: WorkspaceUpdatePayload, - ) => Promise; - deleteWorkspace: (workspaceId: string) => Promise<{ workspace_id: string; deleted: boolean }>; - listWorkspaceMembers: (workspaceId: string) => Promise; - addWorkspaceMember: ( - workspaceId: string, - input: WorkspaceMemberAddPayload, - ) => Promise; - updateWorkspaceMember: ( - workspaceId: string, - userId: string, - input: WorkspaceMemberUpdatePayload, - ) => Promise; - deleteWorkspaceMember: ( - workspaceId: string, - userId: string, - ) => Promise<{ user_id: string; deleted: boolean }>; -}; +export * from "./api/_shared"; +export * from "./api/scripts"; +export * from "./api/resources"; +export * from "./api/fileLocks"; +export * from "./api/schedules"; +export * from "./api/scheduleGraph"; +export * from "./api/platformEmployees"; +export * from "./api/platformRoles"; +export * from "./api/employees"; +export * from "./api/workspaces"; +export * from "./api/workspaceMembers"; +export * from "./api/boundApi"; diff --git a/frontend/app/services/api/_shared.ts b/frontend/app/services/api/_shared.ts new file mode 100644 index 0000000..ef88afb --- /dev/null +++ b/frontend/app/services/api/_shared.ts @@ -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 = { + 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(); +} + diff --git a/frontend/app/services/api/boundApi.ts b/frontend/app/services/api/boundApi.ts new file mode 100644 index 0000000..23f96ae --- /dev/null +++ b/frontend/app/services/api/boundApi.ts @@ -0,0 +1,348 @@ +import type { + CursorListParams, + CursorPage, + Employee, +} from "./_shared"; +import type { + listScripts, + countScripts, + createScript, + uploadScript, + setScriptLock, + updateScript, + deleteScript, + getLatestScriptVersion, + publishScriptVersion, + ScriptItem, + Visibility, + LatestVersion, + StableVersion, +} from "./scripts"; +import type { + listResources, + deleteResource, + fetchResourceContentFile, + fetchResourcePreview, + createResourceUpload, + uploadResourceBytes, + bindResourceUpload, + listWorkspaceDirectories, + createWorkspaceDirectory, + deleteWorkspaceDirectory, + ResourceItem, + ResourcePreviewPayload, + WorkspaceDirectory, +} from "./resources"; +import type { + acquireFileLock, + heartbeatFileLock, + releaseFileLock, + releaseFileLockOnUnload, + createJupyterAccessTicket, + ActiveEditSession, + FileLockSession, + JupyterAccessTicket, +} from "./fileLocks"; +import type { + listSchedules, + getSchedule, + createSchedule, + updateSchedule, + deleteSchedule, + listScheduleArtifacts, + hideScheduleArtifact, + Schedule, + ScheduleArtifact, + CronPreview, + ScheduleRunDetail, + ScheduleRunSummary, + ScheduleNodeRunArtifacts, +} from "./schedules"; +import type { + createScheduleNode, + updateScheduleNode, + deleteScheduleNode, + createScheduleEdge, + deleteScheduleEdge, + validateSchedule, + previewCron, + runScheduleNow, + listScheduleRuns, + getScheduleRun, + getScheduleNodeRunArtifacts, + DagValidation, +} from "./scheduleGraph"; +import type { + listPlatformEmployees, + createPlatformEmployee, + updatePlatformEmployee, + deletePlatformEmployee, +} from "./platformEmployees"; +import type { + listPlatformRoles, + getPlatformRole, + listPlatformPermissions, + createPlatformRole, + updatePlatformRole, + deletePlatformRole, + updatePlatformRolePermissions, + Role, + PlatformPermission, + RoleCreatePayload, + RoleUpdatePayload, +} from "./platformRoles"; +import type { + listEmployees, + createEmployee, + updateEmployee, + deleteEmployee, +} from "./employees"; +import type { + listWorkspaces, + createWorkspace, + updateWorkspace, + deleteWorkspace, + Workspace, + WorkspaceCreatePayload, + WorkspaceUpdatePayload, +} from "./workspaces"; +import type { + listWorkspaceMembers, + addWorkspaceMember, + updateWorkspaceMember, + deleteWorkspaceMember, + WorkspaceMember, + WorkspaceMemberAddPayload, + WorkspaceMemberUpdatePayload, +} from "./workspaceMembers"; + +// Workspace-bound API surface. +// +// `useApi()` in ~/context/AuthContext returns an object where every +// function has had its first `workspaceId` argument pre-filled. The +// type below lets consumers import the bound type without depending +// on the raw functions. Keep this last in the file so the type +// references all the exports above. +// ---------------------------------------------------------------------------- +export type WorkspaceBoundApi = { + listScripts: ( + parentPath?: Parameters[1], + ownerUserId?: Parameters[2], + ) => Promise; + countScripts: () => Promise; + listResources: ( + parentPath?: Parameters[1], + opts?: Parameters[2], + ) => Promise; + createScript: ( + input: Parameters[1], + ) => Promise; + uploadScript: ( + file: File, + parentPath?: string, + visibility?: Visibility, + ) => Promise; + createResourceUpload: ( + body: Parameters[1], + ) => Promise<{ upload_id: string; upload_path: string }>; + uploadResourceBytes: ( + uploadId: string, + fileBytes: ArrayBuffer | Blob, + contentType: string, + ) => Promise<{ storage_object_id: string }>; + bindResourceUpload: ( + uploadId: string, + body: Parameters[2], + ) => Promise; + deleteResource: ( + resourceId: string, + ) => Promise<{ resource_id: string; status: string }>; + fetchResourceContentFile: ( + resourceId: string, + fileName: string, + signal?: AbortSignal, + ) => Promise; + fetchResourcePreview: ( + resourceId: string, + input?: { limit?: number }, + signal?: AbortSignal, + ) => Promise; + updateScript: ( + scriptId: string, + input: Parameters[2], + ) => Promise; + deleteScript: ( + scriptId: string, + ) => Promise<{ script_id: string; status: string; versions_preserved: boolean }>; + setScriptLock: ( + scriptId: string, + isLocked: boolean, + ) => Promise; + listWorkspaceDirectories: ( + parentPath?: Parameters[1], + ownerUserId?: Parameters[2], + ) => Promise; + createWorkspaceDirectory: ( + directoryName: string, + parentPath?: string, + ) => Promise; + deleteWorkspaceDirectory: ( + path: string, + ) => Promise<{ + path: string; + status: string; + deleted_scripts: number; + versions_preserved: boolean; + }>; + acquireFileLock: ( + script: ScriptItem, + ) => Promise; + heartbeatFileLock: ( + session: ActiveEditSession, + ) => Promise; + releaseFileLock: ( + session: ActiveEditSession, + ) => Promise; + releaseFileLockOnUnload: (session: ActiveEditSession) => void; + createJupyterAccessTicket: ( + session: ActiveEditSession, + ) => Promise; + getLatestScriptVersion: (scriptId: string) => Promise; + publishScriptVersion: ( + input: Parameters[1], + ) => Promise; + listSchedules: () => Promise; + getSchedule: (scheduleId: string) => Promise; + createSchedule: ( + input: Parameters[1], + ) => Promise; + updateSchedule: ( + scheduleId: string, + input: Parameters[2], + ) => Promise; + deleteSchedule: ( + scheduleId: string, + workflowVersion: number, + ) => Promise<{ schedule_id: string; deleted: boolean; workflow_version: number }>; + listScheduleArtifacts: () => Promise; + hideScheduleArtifact: ( + versionsId: string, + ) => Promise<{ + versions_id: string; + deleted: boolean; + artifact_preserved: boolean; + }>; + listEmployees: () => Promise; + listPlatformEmployees: ( + input?: CursorListParams, + ) => Promise>; + createEmployee: ( + input: Parameters[1], + ) => Promise; + createPlatformEmployee: ( + input: { + username: string; + display_name: string; + email?: string | undefined; + password: string; + role_code?: "admin" | "developer"; + }, + ) => Promise; + updateEmployee: ( + userId: string, + input: Parameters[2], + ) => Promise; + updatePlatformEmployee: ( + userId: string, + input: { + display_name?: string; + email?: string | null; + role_code?: "admin" | "developer"; + status?: "active" | "disabled" | "locked"; + }, + ) => Promise; + deleteEmployee: ( + userId: string, + ) => Promise<{ user_id: string; deleted: boolean }>; + deletePlatformEmployee: ( + userId: string, + ) => Promise<{ user_id: string; deleted: boolean }>; + listPlatformRoles: () => Promise; + getPlatformRole: (roleCode: string) => Promise; + listPlatformPermissions: () => Promise; + createPlatformRole: (input: RoleCreatePayload) => Promise; + updatePlatformRole: ( + roleCode: string, + input: RoleUpdatePayload, + ) => Promise; + deletePlatformRole: ( + roleCode: string, + ) => Promise<{ role_code: string; deleted: boolean }>; + updatePlatformRolePermissions: ( + roleCode: string, + permissionCodes: string[], + ) => Promise; + createScheduleNode: ( + scheduleId: string, + input: Parameters[2], + ) => Promise; + updateScheduleNode: ( + scheduleId: string, + nodeId: string, + input: Parameters[3], + ) => Promise; + deleteScheduleNode: ( + scheduleId: string, + nodeId: string, + workflowVersion: number, + options?: { delete_execution_history?: boolean }, + ) => Promise; + createScheduleEdge: ( + scheduleId: string, + input: Parameters[2], + ) => Promise; + deleteScheduleEdge: ( + scheduleId: string, + edgeId: string, + workflowVersion: number, + ) => Promise; + validateSchedule: ( + scheduleId: string, + ) => Promise; + previewCron: ( + input: Parameters[1], + ) => Promise; + runScheduleNow: (scheduleId: string) => Promise; + listScheduleRuns: ( + input?: Parameters[1], + ) => Promise; + getScheduleRun: (runId: string) => Promise; + getScheduleNodeRunArtifacts: ( + runId: string, + nodeRunId: string, + ) => Promise; + // Workspace (Project) Management - 系统管理接口 + listWorkspaces: ( + input?: CursorListParams, + ) => Promise>; + createWorkspace: (input: WorkspaceCreatePayload) => Promise; + updateWorkspace: ( + workspaceId: string, + input: WorkspaceUpdatePayload, + ) => Promise; + deleteWorkspace: (workspaceId: string) => Promise<{ workspace_id: string; deleted: boolean }>; + listWorkspaceMembers: (workspaceId: string) => Promise; + addWorkspaceMember: ( + workspaceId: string, + input: WorkspaceMemberAddPayload, + ) => Promise; + updateWorkspaceMember: ( + workspaceId: string, + userId: string, + input: WorkspaceMemberUpdatePayload, + ) => Promise; + deleteWorkspaceMember: ( + workspaceId: string, + userId: string, + ) => Promise<{ user_id: string; deleted: boolean }>; +}; diff --git a/frontend/app/services/api/employees.ts b/frontend/app/services/api/employees.ts new file mode 100644 index 0000000..13272b0 --- /dev/null +++ b/frontend/app/services/api/employees.ts @@ -0,0 +1,55 @@ +import { apiRequest, type Employee } from "./_shared"; + +export async function listEmployees(workspaceId: string): Promise { + return apiRequest("/api/v1/admin/employees", {}, workspaceId); +} + +/** @deprecated 使用 createPlatformEmployee 代替 */ +export async function createEmployee( + workspaceId: string, + input: { + username: string; + display_name: string; + email?: string | null; + role_code: "admin" | "developer"; + password: string; + }, +): Promise { + return apiRequest( + "/api/v1/admin/employees", + { method: "POST", body: JSON.stringify(input) }, + workspaceId, + ); +} + +/** @deprecated 使用 updatePlatformEmployee 代替 */ +export async function updateEmployee( + workspaceId: string, + userId: string, + input: { + display_name?: string; + email?: string | null; + role_code?: "admin" | "developer"; + status?: "active" | "disabled" | "locked"; + }, +): Promise { + return apiRequest( + `/api/v1/admin/employees/${userId}`, + { method: "PATCH", body: JSON.stringify(input) }, + workspaceId, + ); +} + +/** @deprecated 使用 deletePlatformEmployee 代替 */ +export async function deleteEmployee( + workspaceId: string, + userId: string, +): Promise<{ user_id: string; deleted: boolean }> { + return apiRequest( + `/api/v1/admin/employees/${userId}`, + { method: "DELETE" }, + workspaceId, + ); +} + +// ---------------------------------------------------------------------------- diff --git a/frontend/app/services/api/fileLocks.ts b/frontend/app/services/api/fileLocks.ts new file mode 100644 index 0000000..74d43b1 --- /dev/null +++ b/frontend/app/services/api/fileLocks.ts @@ -0,0 +1,140 @@ +import { ApiRequestError, createUuid } from "./_shared"; +import type { ScriptItem } from "./scripts"; + +export type FileLockSession = { + edit_session_id: string; + workspace_id: string; + storage_object_id: string; + user_id: string; + session_status: "active" | "closed" | "expired"; + lease_seconds: number; + heartbeat_interval_seconds: number; + expires_at: string; + runtime_id: string; + jupyter_session_id: string; + jupyter_url?: string; + relative_path?: string; + lock_token?: string; +}; + +export type ActiveEditSession = FileLockSession & { + script_id: string; + script_name: string; + jupyter_path: string; + lock_token: string; + ticket_expires_at?: string; +}; + +export type JupyterAccessTicket = { + edit_session_id: string; + jupyter_url: string; + expires_at: string; +}; + +// 本地浏览器级“编辑锁”——后端没有 acquire/heartbeat/release/edit-session 表。 +// 这里的四个函数全部是占位:返回结构是为了让上层 store 的 +// _editSession / sessionCache 继续按“session”接口工作,但锁的实际作用域 +// 仅限当前 tab。关闭 tab、刷新页面、用隐身模式打开、或换浏览器,锁即失效。 +// 不要把这些函数当作鉴权或并发控制用——它们什么都不查、什么都不写。 +// 真实并发控制需要后端 edit_sessions 表 + Nginx auth_request 联动,是后续工单。 + +export async function acquireFileLock( + workspaceId: string, + script: ScriptItem, +): Promise { + const now = Date.now(); + return { + edit_session_id: createUuid().replaceAll("-", ""), + workspace_id: workspaceId, + storage_object_id: script.current_object_id, + user_id: script.owner_user_id, + session_status: "active", + lease_seconds: 3600, + heartbeat_interval_seconds: 300, + expires_at: new Date(now + 3600_000).toISOString(), + runtime_id: workspaceId, + jupyter_session_id: "local", + relative_path: script.relative_path, + lock_token: "local", + script_id: script.script_id, + script_name: script.script_name, + jupyter_path: script.jupyter_path, + }; +} + +export async function heartbeatFileLock( + _workspaceId: string, + session: ActiveEditSession, +): Promise { + // 本地锁不存在过期概念;只是把 expires_at 推后让 UI 看着还活着。 + // 该字段当前没有任何消费者,保留只是为了不破坏契约。 + return session; +} + +export async function releaseFileLock( + _workspaceId: string, + session: ActiveEditSession, +): Promise { + return { ...session, session_status: "closed" }; +} + +export function releaseFileLockOnUnload( + _workspaceId: string, + _session: ActiveEditSession, +): void { + // 本地锁随 tab 生命周期结束。beforeunload 调到这里只是让 store 端 + // 清理模块级引用,避免下一个 tab 复用时看到陈旧 _editSession。 +} + +async function waitForJupyterReady(jupyterUrl: string): Promise { + const retryableStatuses = new Set([502, 503, 504]); + let lastStatus = 0; + + for (let attempt = 0; attempt < 10; attempt += 1) { + const response = await fetch(jupyterUrl, { + credentials: "same-origin", + cache: "no-store", + }); + if (response.ok) return; + + lastStatus = response.status; + if (!retryableStatuses.has(response.status)) { + throw new ApiRequestError( + `Jupyter 打开失败(HTTP ${response.status})`, + response.status, + ); + } + await new Promise((resolve) => window.setTimeout(resolve, 400)); + } + + throw new ApiRequestError( + `Jupyter 服务启动超时${lastStatus ? `(HTTP ${lastStatus})` : ""}`, + lastStatus || 504, + ); +} + +export async function createJupyterAccessTicket( + workspaceId: string, + session: ActiveEditSession, +): Promise { + const editorRoute = session.script_name.toLowerCase().endsWith(".ipynb") + ? "notebooks" + : "edit"; + const encodedPath = session.jupyter_path + .split("/") + .filter(Boolean) + .map(encodeURIComponent) + .join("/"); + if (!encodedPath) { + throw new Error("脚本缺少 Jupyter 存储路径"); + } + + const jupyterUrl = `/jupyter/${encodeURIComponent(workspaceId)}/${editorRoute}/${encodedPath}`; + await waitForJupyterReady(jupyterUrl); + return { + edit_session_id: session.edit_session_id, + jupyter_url: jupyterUrl, + expires_at: new Date(Date.now() + 3600_000).toISOString(), + }; +} + diff --git a/frontend/app/services/api/platformEmployees.ts b/frontend/app/services/api/platformEmployees.ts new file mode 100644 index 0000000..f2cc630 --- /dev/null +++ b/frontend/app/services/api/platformEmployees.ts @@ -0,0 +1,60 @@ +import { + apiRequest, + apiRequestWithMeta, + buildCursorQuery, + parseCursorPageMeta, + type CursorListParams, + type CursorPage, + type Employee, +} from "./_shared"; + +// 系统管理级别接口 - 不区分 workspace +export async function listPlatformEmployees( + input: CursorListParams = {}, +): Promise> { + const query = buildCursorQuery(input); + const { data, meta } = await apiRequestWithMeta( + `/api/v1/platform/employees?${query}`, + ); + return { items: data, meta: parseCursorPageMeta(meta) }; +} + +export async function createPlatformEmployee( + input: { + username: string; + display_name: string; + email?: string | undefined; + password: string; + role_code?: "admin" | "developer"; + }, +): Promise { + return apiRequest( + "/api/v1/platform/employees", + { method: "POST", body: JSON.stringify({ ...input, role_code: input.role_code ?? "developer" }) }, + ); +} + +export async function updatePlatformEmployee( + userId: string, + input: { + display_name?: string; + email?: string | null; + role_code?: "admin" | "developer"; + status?: "active" | "disabled" | "locked"; + }, +): Promise { + return apiRequest( + `/api/v1/platform/employees/${userId}`, + { method: "PATCH", body: JSON.stringify(input) }, + ); +} + +export async function deletePlatformEmployee( + userId: string, +): Promise<{ user_id: string; deleted: boolean }> { + return apiRequest( + `/api/v1/platform/employees/${userId}`, + { method: "DELETE" }, + ); +} + diff --git a/frontend/app/services/api/platformRoles.ts b/frontend/app/services/api/platformRoles.ts new file mode 100644 index 0000000..1f8f1bf --- /dev/null +++ b/frontend/app/services/api/platformRoles.ts @@ -0,0 +1,81 @@ +import { apiRequest } from "./_shared"; + +// Role (Platform Role) types - 角色管理接口 +export type Role = { + role_id: string; + role_code: string; + role_name: string; + is_builtin: boolean; + description: string | null; + permission_codes: string[]; +}; + +export type PlatformPermission = { + permission_code: string; + permission_name: string; + module_code: string; + description: string | null; +}; + +export type RoleCreatePayload = { + role_code: string; + role_name: string; + description?: string | null; + permission_codes?: string[]; +}; + +export type RoleUpdatePayload = { + role_name?: string; + description?: string | null; // null = clear +}; + +export async function listPlatformRoles(): Promise { + return apiRequest("/api/v1/platform/roles"); +} + +export async function getPlatformRole(roleCode: string): Promise { + return apiRequest(`/api/v1/platform/roles/${roleCode}/permissions`); +} + +export async function listPlatformPermissions(): Promise { + return apiRequest("/api/v1/platform/permissions"); +} + +export async function createPlatformRole(input: RoleCreatePayload): Promise { + return apiRequest( + "/api/v1/platform/roles", + { method: "POST", body: JSON.stringify(input) }, + ); +} + +export async function updatePlatformRole( + roleCode: string, + input: RoleUpdatePayload, +): Promise { + return apiRequest( + `/api/v1/platform/roles/${roleCode}`, + { method: "PATCH", body: JSON.stringify(input) }, + ); +} + +export async function deletePlatformRole( + roleCode: string, +): Promise<{ role_code: string; deleted: boolean }> { + return apiRequest( + `/api/v1/platform/roles/${roleCode}`, + { method: "DELETE" }, + ); +} + +export async function updatePlatformRolePermissions( + roleCode: string, + permissionCodes: string[], +): Promise { + return apiRequest( + `/api/v1/platform/roles/${roleCode}/permissions`, + { method: "PATCH", body: JSON.stringify({ permission_codes: permissionCodes }) }, + ); +} + +// 兼容旧的 workspace 级别接口(已废弃,建议使用 system-level 接口) +/** @deprecated 使用 listPlatformEmployees 代替 */ diff --git a/frontend/app/services/api/resources.ts b/frontend/app/services/api/resources.ts new file mode 100644 index 0000000..fa3ff05 --- /dev/null +++ b/frontend/app/services/api/resources.ts @@ -0,0 +1,289 @@ +import { + apiRequest, + ApiRequestError, + createUuid, + type ApiErrorEnvelope, +} from "./_shared"; + +export type WorkspaceDirectory = { + path: string; + name: string; + parent_path: string; + owner_user_id: string; + has_children?: boolean; +}; + +export type ResourceItem = { + resource_id: string; + workspace_id: string; + storage_object_id: string; + owner_user_id: string; + owner_display_name?: string | null; + resource_name: string; + description: string | null; + visibility: "private" | "workspace" | "public"; + status: string; + created_at: string; + updated_at: string; + file: { + file_name: string; + file_extension: string | null; + mime_type: string | null; + size_bytes: number; + content_hash: string | null; + object_status: string; + }; + jupyter_accessible_path: string; + absolute_path: string; +}; + +export async function listResources( + workspaceId: string, + parentPath: string = "", + opts?: { visibility?: string; keyword?: string; ownerUserId?: string }, +): Promise { + // Default (no ownerUserId) scopes to the requester's own object_key + // subtree; passing ownerUserId scopes to that owner's subtree so the tree + // can lazily fetch another member's data resources on group expand. + const parameters = new URLSearchParams(); + if (parentPath) parameters.set("parent_path", parentPath); + if (opts?.visibility) parameters.set("visibility", opts.visibility); + if (opts?.keyword) parameters.set("keyword", opts.keyword); + if (opts?.ownerUserId) parameters.set("owner_user_id", opts.ownerUserId); + const query = parameters.toString(); + // apiRequest already unwraps the envelope's `data` field, so we + // request `ResourceItem[]` directly here (matching listScripts). + return apiRequest( + `/api/v1/data-resources${query ? `?${query}` : ""}`, + {}, + workspaceId, + ); +} + +export async function deleteResource( + workspaceId: string, + resourceId: string, +): Promise<{ resource_id: string; status: string }> { + return apiRequest<{ resource_id: string; status: string }>( + `/api/v1/data-resources/${encodeURIComponent(resourceId)}`, + { method: "DELETE" }, + workspaceId, + ); +} + +/** 同源流式下载数据资源字节,供 Excel 等预览器使用。 */ +export async function fetchResourceContentFile( + workspaceId: string, + resourceId: string, + fileName: string, + signal?: AbortSignal, +): Promise { + const response = await fetch( + `/api/v1/data-resources/${encodeURIComponent(resourceId)}/content?workspace_id=${encodeURIComponent(workspaceId)}`, + { + credentials: "same-origin", + signal, + headers: { + "X-Request-ID": createUuid().replaceAll("-", ""), + }, + }, + ); + + if (response.status === 401 && typeof window !== "undefined") { + const here = window.location.pathname; + if (here !== "/login") { + window.location.assign("/login"); + } + throw new ApiRequestError("未登录或登录已过期", 401); + } + + if (!response.ok) { + let message = `请求失败(HTTP ${response.status})`; + try { + const payload = (await response.json()) as ApiErrorEnvelope; + const detailMessage = + typeof payload.detail === "string" + ? payload.detail + : payload.detail?.message; + if (detailMessage) message = detailMessage; + } catch { + /* ignore non-JSON error bodies */ + } + throw new ApiRequestError(message, response.status); + } + + const blob = await response.blob(); + return new File([blob], fileName, { + type: blob.type || "application/octet-stream", + }); +} + +export type ResourcePreviewPayload = { + kind: "table"; + columns: string[]; + rows: string[][]; + row_count: number; + truncated: boolean; + delimiter: string; +}; + +/** 表格类数据资源抽样预览(csv / tsv)。 */ +export async function fetchResourcePreview( + workspaceId: string, + resourceId: string, + input: { limit?: number } = {}, + signal?: AbortSignal, +): Promise { + const parameters = new URLSearchParams(); + parameters.set("workspace_id", workspaceId); + if (input.limit != null) parameters.set("limit", String(input.limit)); + const response = await fetch( + `/api/v1/data-resources/${encodeURIComponent(resourceId)}/preview?${parameters.toString()}`, + { + credentials: "same-origin", + signal, + headers: { + "X-Request-ID": createUuid().replaceAll("-", ""), + }, + }, + ); + + 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(); + if (!response.ok) { + const error = payload as ApiErrorEnvelope; + const detailMessage = + typeof error.detail === "string" ? error.detail : error.detail?.message; + throw new ApiRequestError( + detailMessage ?? `请求失败(HTTP ${response.status})`, + response.status, + ); + } + + const data = (payload as { data: ResourcePreviewPayload }).data; + if (!data || data.kind !== "table" || !Array.isArray(data.columns)) { + throw new ApiRequestError("响应数据格式错误", response.status); + } + return data; +} + +export async function createResourceUpload( + workspaceId: string, + body: { + file_name: string; + content_type: string; + expected_size_bytes: number; + expected_hash: string | null; + target_path?: string; + }, +): Promise<{ upload_id: string; upload_path: string }> { + return apiRequest<{ upload_id: string; upload_path: string }>( + "/api/v1/data-resources/uploads", + { + method: "POST", + body: JSON.stringify(body), + headers: { "Idempotency-Key": createUuid().replaceAll("-", "") }, + }, + workspaceId, + ); +} + +export async function uploadResourceBytes( + workspaceId: string, + uploadId: string, + fileBytes: ArrayBuffer | Blob, + contentType: string, +): Promise<{ storage_object_id: string }> { + return apiRequest<{ storage_object_id: string }>( + `/api/v1/data-resources/uploads/${encodeURIComponent(uploadId)}`, + { + method: "PUT", + headers: { "Content-Type": contentType }, + body: fileBytes, + }, + workspaceId, + ); +} + +export async function bindResourceUpload( + workspaceId: string, + uploadId: string, + body: { + resource_name: string; + description: string; + visibility: "private" | "workspace" | "public"; + }, +): Promise { + return apiRequest( + `/api/v1/data-resources/uploads/${encodeURIComponent(uploadId)}/bind`, + { + method: "POST", + body: JSON.stringify(body), + }, + workspaceId, + ); +} + +export async function listWorkspaceDirectories( + workspaceId: string, + parentPath: string = "", + ownerUserId?: string, +): Promise { + // Default (no ownerUserId) scopes to the requester's own subtree; passing + // ownerUserId scopes to that owner so the tree can lazily render their + // directory structure on expand. Directories are structural rows; file + // visibility is still enforced by the scripts/data-resources endpoints. + const parameters = new URLSearchParams(); + if (parentPath) parameters.set("parent_path", parentPath); + if (ownerUserId) parameters.set("owner_user_id", ownerUserId); + const query = parameters.toString(); + const data = await apiRequest<{ directories: WorkspaceDirectory[] }>( + `/api/v1/workspace-directories${query ? `?${query}` : ""}`, + {}, + workspaceId, + ); + return data.directories; +} + +export async function createWorkspaceDirectory( + workspaceId: string, + directoryName: string, + parentPath = "", +): Promise { + return apiRequest( + "/api/v1/workspace-directories", + { + method: "POST", + body: JSON.stringify({ + directory_name: directoryName, + parent_path: parentPath, + }), + }, + workspaceId, + ); +} + +export async function deleteWorkspaceDirectory( + workspaceId: string, + path: string, +): Promise<{ + path: string; + status: string; + deleted_scripts: number; + versions_preserved: boolean; +}> { + const parameters = new URLSearchParams({ path }); + return apiRequest( + `/api/v1/workspace-directories?${parameters.toString()}`, + { method: "DELETE" }, + workspaceId, + ); +} + diff --git a/frontend/app/services/api/scheduleGraph.ts b/frontend/app/services/api/scheduleGraph.ts new file mode 100644 index 0000000..a84eb30 --- /dev/null +++ b/frontend/app/services/api/scheduleGraph.ts @@ -0,0 +1,271 @@ +import { apiRequest, createUuid } from "./_shared"; +import type { ScriptType } from "./scripts"; +import type { + CronPreview, + PythonVersion, + Schedule, + ScheduleNodeRunArtifacts, + ScheduleRunDetail, + ScheduleRunStatus, + ScheduleRunSummary, +} from "./schedules"; + +export type ScheduleNode = { + node_id: string; + schedule_id: string; + node_key: string; + node_name: string; + versions_id: string; + timeout_seconds: number; + retry_count: number; + retry_interval_sec: number; + position_x: number; + position_y: number; + arguments_json: Record; + env_refs_json: Record; + python_version: PythonVersion; + created_at: string; + updated_at: string; + version: { + versions_id: string; + version_label: string; + script_id: string; + script_name: string; + script_type: ScriptType; + content_hash: string; + created_at: string; + }; +}; + +export type ScheduleEdge = { + edge_id: string; + schedule_id: string; + source_node_id: string; + target_node_id: string; + condition_expr: string | null; + created_at: string; +}; + +export type DagValidation = { + valid: boolean; + node_count: number; + edge_count: number; + root_node_ids: string[]; + leaf_node_ids: string[]; + topological_order: string[]; + errors: Array<{ + code: string; + message: string; + edge_id?: string; + node_ids?: string[]; + }>; +}; + +export type ScheduleNodeRunStatus = + | ScheduleRunStatus + | "skipped"; + +export type ScheduleNodeRun = { + node_run_id: string; + run_id: string; + node_id: string; + versions_id: string; + attempt_no: number; + node_status: ScheduleNodeRunStatus; + state_version: number; + started_at: string | null; + finished_at: string | null; + duration_ms: number | null; + exit_code: number | null; + message: string | null; + logs_object_id: string | null; + result_object_id: string | null; +}; + +export async function createScheduleNode( + workspaceId: string, + scheduleId: string, + input: { + workflow_version: number; + node_key: string; + node_name: string; + versions_id: string; + timeout_seconds?: number; + retry_count?: number; + retry_interval_sec?: number; + position_x?: number; + position_y?: number; + arguments_json?: Record; + env_refs_json?: Record; + python_version?: PythonVersion; + }, +): Promise { + return apiRequest( + `/api/v1/schedules/${scheduleId}/nodes`, + { method: "POST", body: JSON.stringify(input) }, + workspaceId, + ); +} + +export async function updateScheduleNode( + workspaceId: string, + scheduleId: string, + nodeId: string, + input: { + workflow_version: number; + node_name?: string; + versions_id?: string; + timeout_seconds?: number; + retry_count?: number; + retry_interval_sec?: number; + position_x?: number; + position_y?: number; + arguments_json?: Record; + env_refs_json?: Record; + python_version?: PythonVersion; + }, +): Promise { + return apiRequest( + `/api/v1/schedules/${scheduleId}/nodes/${nodeId}`, + { method: "PUT", body: JSON.stringify(input) }, + workspaceId, + ); +} + +export async function deleteScheduleNode( + workspaceId: string, + scheduleId: string, + nodeId: string, + workflowVersion: number, + options: { delete_execution_history?: boolean } = {}, +): Promise { + return apiRequest( + `/api/v1/schedules/${scheduleId}/nodes/${nodeId}`, + { + method: "DELETE", + body: JSON.stringify({ workflow_version: workflowVersion, ...options }), + }, + workspaceId, + ); +} + +export async function createScheduleEdge( + workspaceId: string, + scheduleId: string, + input: { + workflow_version: number; + source_node_id: string; + target_node_id: string; + condition_expr?: string | null; + }, +): Promise { + return apiRequest( + `/api/v1/schedules/${scheduleId}/edges`, + { method: "POST", body: JSON.stringify(input) }, + workspaceId, + ); +} + +export async function deleteScheduleEdge( + workspaceId: string, + scheduleId: string, + edgeId: string, + workflowVersion: number, +): Promise { + return apiRequest( + `/api/v1/schedules/${scheduleId}/edges/${edgeId}`, + { method: "DELETE", body: JSON.stringify({ workflow_version: workflowVersion }) }, + workspaceId, + ); +} + +export async function validateSchedule( + workspaceId: string, + scheduleId: string, +): Promise { + return apiRequest( + `/api/v1/schedules/${scheduleId}/validate`, + { method: "POST" }, + workspaceId, + ); +} + +export async function previewCron( + workspaceId: string, + input: { + cron_expression: string; + timezone: string; + count?: number; + base_time?: string; + }, +): Promise { + return apiRequest( + "/api/v1/cron/preview", + { method: "POST", body: JSON.stringify(input) }, + workspaceId, + ); +} + +export async function runScheduleNow( + workspaceId: string, + scheduleId: string, +): Promise { + return apiRequest( + `/api/v1/schedules/${scheduleId}/run`, + { + method: "POST", + headers: { + "Idempotency-Key": createUuid(), + }, + body: JSON.stringify({ reason: "manual_run" }), + }, + workspaceId, + ); +} + +export async function listScheduleRuns( + workspaceId: string, + input: { + scheduleId?: string; + status?: ScheduleRunStatus; + limit?: number; + } = {}, +): Promise { + const query = new URLSearchParams(); + if (input.scheduleId) query.set("schedule_id", input.scheduleId); + if (input.status) query.set("status", input.status); + query.set("limit", String(input.limit ?? 20)); + return apiRequest( + `/api/v1/schedule-runs?${query.toString()}`, + {}, + workspaceId, + ); +} + +export async function getScheduleRun( + workspaceId: string, + runId: string, +): Promise { + return apiRequest( + `/api/v1/schedule-runs/${runId}`, + {}, + workspaceId, + ); +} + +export async function getScheduleNodeRunArtifacts( + workspaceId: string, + runId: string, + nodeRunId: string, +): Promise { + return apiRequest( + `/api/v1/schedule-runs/${runId}/node-runs/${nodeRunId}/artifacts`, + {}, + workspaceId, + ); +} + +// ---------------------------------------------------------------------------- diff --git a/frontend/app/services/api/schedules.ts b/frontend/app/services/api/schedules.ts new file mode 100644 index 0000000..0da6a0d --- /dev/null +++ b/frontend/app/services/api/schedules.ts @@ -0,0 +1,197 @@ +import { apiRequest } from "./_shared"; +import type { ScriptType, Visibility } from "./scripts"; +import type { + DagValidation, + ScheduleEdge, + ScheduleNode, + ScheduleNodeRun, +} from "./scheduleGraph"; + +export type ScheduleArtifact = { + versions_id: string; + version_label: string; + script_id: string; + script_name: string; + script_type: ScriptType; + content_hash: string; + file_size_bytes: number; + visibility: Visibility; + created_by: string; + created_at: string; +}; + +export type PythonVersion = "3.8" | "3.10" | "3.12"; + +export type Schedule = { + schedule_id: string; + workspace_id: string; + schedule_name: string; + description: string | null; + trigger_type: "manual" | "cron" | "api"; + cron_expression: string | null; + timezone: string; + enabled: boolean; + workflow_version: number; + max_concurrency: number; + failure_policy: "stop" | "continue"; + last_run_at: string | null; + next_run_at: string | null; + created_by: string; + updated_by: string; + created_at: string; + updated_at: string; + node_count: number; + edge_count: number; + nodes: ScheduleNode[]; + edges: ScheduleEdge[]; + dag_validation: DagValidation; +}; + +export type CronPreview = { + cron_expression: string; + timezone: string; + base_time: string; + occurrences: Array<{ + local_time: string; + utc_time: string; + }>; +}; + +export type ScheduleRunStatus = + | "queued" + | "running" + | "succeeded" + | "failed" + | "cancelled" + | "timed_out"; + +export type ScheduleRunSummary = { + run_id: string; + schedule_id: string; + workspace_id: string; + workflow_version: number; + trigger_type: "manual" | "cron" | "api" | "retry"; + run_status: ScheduleRunStatus; + state_version: number; + queued_at: string; + started_at: string | null; + finished_at: string | null; + duration_ms: number | null; + error_code: string | null; + error_message: string | null; + logs_object_id: string | null; + result_object_id: string | null; +}; + +export type ScheduleRunDetail = ScheduleRunSummary & { + node_runs: ScheduleNodeRun[]; +}; + +export type ScheduleRunArtifact = { + url: string; + file_name: string; + mime_type: string | null; + size_bytes: number; +}; + +export type ScheduleNodeRunArtifacts = { + run_id: string; + node_run_id: string; + log: ScheduleRunArtifact | null; + result: ScheduleRunArtifact | null; +}; + +export async function listSchedules(workspaceId: string): Promise { + return apiRequest("/api/v1/schedules", {}, workspaceId); +} + +export async function getSchedule( + workspaceId: string, + scheduleId: string, +): Promise { + return apiRequest( + `/api/v1/schedules/${scheduleId}`, + {}, + workspaceId, + ); +} + +export async function createSchedule( + workspaceId: string, + input: { + schedule_name: string; + description?: string | null; + trigger_type?: "manual" | "cron" | "api"; + cron_expression?: string | null; + timezone?: string; + enabled?: boolean; + max_concurrency?: number; + failure_policy?: "stop" | "continue"; + }, +): Promise { + return apiRequest( + "/api/v1/schedules", + { method: "POST", body: JSON.stringify(input) }, + workspaceId, + ); +} + +export async function updateSchedule( + workspaceId: string, + scheduleId: string, + input: { + workflow_version: number; + schedule_name?: string; + description?: string | null; + trigger_type?: "manual" | "cron" | "api"; + cron_expression?: string | null; + timezone?: string; + enabled?: boolean; + max_concurrency?: number; + failure_policy?: "stop" | "continue"; + }, +): Promise { + return apiRequest( + `/api/v1/schedules/${scheduleId}`, + { method: "PATCH", body: JSON.stringify(input) }, + workspaceId, + ); +} + +export async function deleteSchedule( + workspaceId: string, + scheduleId: string, + workflowVersion: number, +): Promise<{ schedule_id: string; deleted: boolean; workflow_version: number }> { + return apiRequest( + `/api/v1/schedules/${scheduleId}`, + { method: "DELETE", body: JSON.stringify({ workflow_version: workflowVersion }) }, + workspaceId, + ); +} + +export async function listScheduleArtifacts( + workspaceId: string, +): Promise { + return apiRequest( + "/api/v1/schedule-artifacts", + {}, + workspaceId, + ); +} + +export async function hideScheduleArtifact( + workspaceId: string, + versionsId: string, +): Promise<{ + versions_id: string; + deleted: boolean; + artifact_preserved: boolean; +}> { + return apiRequest( + `/api/v1/versions/${versionsId}`, + { method: "DELETE" }, + workspaceId, + ); +} + diff --git a/frontend/app/services/api/scripts.ts b/frontend/app/services/api/scripts.ts new file mode 100644 index 0000000..04af6cf --- /dev/null +++ b/frontend/app/services/api/scripts.ts @@ -0,0 +1,299 @@ +import { + apiRequest, + ApiRequestError, + createUuid, + type ApiErrorEnvelope, +} from "./_shared"; + +export type ScriptType = "python" | "notebook"; +export type Visibility = "private" | "workspace" | "public"; + +export type ScriptItem = { + script_id: string; + workspace_id: string; + current_object_id: string; + owner_user_id: string; + owner_display_name: string | null; + script_name: string; + script_type: ScriptType; + visibility: Visibility; + status: string; + is_locked: boolean; + relative_path: string; + jupyter_path: string; + content_hash: string; + size_bytes: number; + created_at: string; + updated_at: string; +}; + +export async function listScripts( + workspaceId: string, + parentPath: string = "", + ownerUserId?: string, +): Promise { + // Default (no ownerUserId) scopes to the requester's own subtree; passing + // ownerUserId scopes to that owner's subtree (workspace/public only — the + // backend excludes their private) so the tree can lazily fetch another + // member's content when their group is expanded. + const parameters = new URLSearchParams(); + if (parentPath) parameters.set("parent_path", parentPath); + if (ownerUserId) parameters.set("owner_user_id", ownerUserId); + const query = parameters.toString(); + return apiRequest( + `/api/v1/scripts${query ? `?${query}` : ""}`, + {}, + workspaceId, + ); +} + +export async function countScripts( + workspaceId: string, +): Promise { + // Backend route /api/v1/scripts/count must be declared BEFORE the + // /scripts/{script_id} route on the server side. Returns + // { data: { total: number } } — the dashboard's single source of + // truth for "total active scripts in workspace", independent of the + // lazy-loaded scripts[] in the workspace store. + const envelope = await apiRequest<{ total: number }>( + "/api/v1/scripts/count", + {}, + workspaceId, + ); + return envelope.total; +} + +function initialContent(scriptType: ScriptType): string { + if (scriptType === "python") { + return [ + '"""模型实验开发平台构建脚本。"""', + "", + "", + "def main() -> None:", + ' print("Hello, Model Platform!")', + "", + "", + 'if __name__ == "__main__":', + " main()", + "", + ].join("\n"); + } + + return JSON.stringify( + { + cells: [ + { + id: "intro", + cell_type: "code", + execution_count: null, + metadata: {}, + outputs: [], + source: ["print('Hello, Model Platform!')\n"], + }, + ], + metadata: { + kernelspec: { + display_name: "Python 3", + language: "python", + name: "python3", + }, + language_info: { + name: "python", + version: "3.12", + }, + }, + nbformat: 4, + nbformat_minor: 5, + }, + null, + 2, + ); +} + +export async function createScript( + workspaceId: string, + input: { + name: string; + scriptType: ScriptType; + visibility: Visibility; + parentPath?: string | null; + }, +): Promise { + return apiRequest( + "/api/v1/scripts", + { + method: "POST", + body: JSON.stringify({ + script_name: input.name.trim(), + script_type: input.scriptType, + visibility: input.visibility, + content: initialContent(input.scriptType), + parent_path: input.parentPath, + }), + }, + workspaceId, + ); +} + +export async function uploadScript( + workspaceId: string, + file: File, + parentPath = "", + visibility: Visibility = "workspace", +): Promise { + const parameters = new URLSearchParams({ + file_name: file.name, + parent_path: parentPath, + visibility, + }); + return apiRequest( + `/api/v1/scripts/upload?${parameters.toString()}`, + { + method: "POST", + headers: { "Content-Type": "application/octet-stream" }, + body: file, + }, + workspaceId, + ); +} + +export async function setScriptLock( + workspaceId: string, + scriptId: string, + isLocked: boolean, +): Promise { + return apiRequest( + `/api/v1/scripts/${encodeURIComponent(scriptId)}/lock`, + { + method: "PATCH", + body: JSON.stringify({ is_locked: isLocked }), + }, + workspaceId, + ); +} + +export async function updateScript( + workspaceId: string, + scriptId: string, + input: { content: string }, +): Promise { + return apiRequest( + `/api/v1/scripts/${scriptId}`, + { method: "PUT", body: JSON.stringify(input) }, + workspaceId, + ); +} + +export async function getScriptContent( + workspaceId: string, + scriptId: string, +): Promise<{ + script_id: string; + script_type: ScriptType; + content: string | object; + format: string; +}> { + const response = await fetch( + `/api/v1/scripts/${scriptId}/content?workspace_id=${encodeURIComponent(workspaceId)}`, + { + credentials: "same-origin", + headers: { + "X-Request-ID": createUuid().replaceAll("-", ""), + }, + }, + ); + + 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(); + if (!response.ok) { + const error = payload as ApiErrorEnvelope; + const detailMessage = typeof error.detail === "string" + ? error.detail + : error.detail?.message; + throw new ApiRequestError( + detailMessage ?? `请求失败(HTTP ${response.status})`, + response.status, + ); + } + + const data = (payload as { data: { script_id: string; script_type: ScriptType; content: string | object; format: string } }).data; + if (!data || !data.script_type) { + throw new ApiRequestError("响应数据格式错误", response.status); + } + return data; +} + +export async function deleteScript( + workspaceId: string, + scriptId: string, +): Promise<{ script_id: string; status: string; versions_preserved: boolean }> { + return apiRequest( + `/api/v1/scripts/${scriptId}`, + { method: "DELETE" }, + workspaceId, + ); +} + +export type StableVersion = { + versions_id: string; + workspace_id: string; + script_id: string; + source_object_id: string; + artifact_object_id: string; + version_no: number; + version_label: string; + source_path: string; + artifact_path: string; + content_hash: string; + file_size_bytes: number; + visibility: Visibility; + release_note: string | null; + created_by: string; + created_at: string; +}; + +export type LatestVersion = { + versions_id: string; + version_label: string; +}; + +export async function getLatestScriptVersion( + workspaceId: string, + scriptId: string, +): Promise { + return apiRequest( + `/api/v1/scripts/${scriptId}/latest-version`, + {}, + workspaceId, + ); +} + +export async function publishScriptVersion( + workspaceId: string, + input: { + script: ScriptItem; + releaseNote: string; + visibility: Visibility; + }, +): Promise { + return apiRequest( + `/api/v1/scripts/${input.script.script_id}/versions`, + { + method: "POST", + body: JSON.stringify({ + source_object_id: input.script.current_object_id, + release_note: input.releaseNote.trim() || null, + visibility: input.visibility, + }), + }, + workspaceId, + ); +} + diff --git a/frontend/app/services/api/workspaceMembers.ts b/frontend/app/services/api/workspaceMembers.ts new file mode 100644 index 0000000..b109cfc --- /dev/null +++ b/frontend/app/services/api/workspaceMembers.ts @@ -0,0 +1,60 @@ +import { apiRequest } from "./_shared"; + +export type WorkspaceMember = { + user_id: string; + username: string; + display_name: string; + email: string | null; + role_code: "admin" | "developer"; + role_name: string; + member_status: "active" | "disabled" | "locked"; + joined_at: string; +}; + +/** 加入工作区;角色继承自用户的 platform_role,请求体不能带 role_code。 */ +export type WorkspaceMemberAddPayload = { + user_id: string; +}; + +/** 仅可改成员状态;改角色请 PATCH /platform/employees/{user_id}。 */ +export type WorkspaceMemberUpdatePayload = { + member_status?: "active" | "disabled" | "locked"; +}; + +export async function listWorkspaceMembers(workspaceId: string): Promise { + return apiRequest( + `/api/v1/platform/workspaces/${workspaceId}/members`, + ); +} + +export async function addWorkspaceMember( + workspaceId: string, + input: WorkspaceMemberAddPayload, +): Promise { + return apiRequest( + `/api/v1/platform/workspaces/${workspaceId}/members`, + { method: "POST", body: JSON.stringify(input) }, + ); +} + +export async function updateWorkspaceMember( + workspaceId: string, + userId: string, + input: WorkspaceMemberUpdatePayload, +): Promise { + return apiRequest( + `/api/v1/platform/workspaces/${workspaceId}/members/${userId}`, + { method: "PATCH", body: JSON.stringify(input) }, + ); +} + +export async function deleteWorkspaceMember( + workspaceId: string, + userId: string, +): Promise<{ user_id: string; deleted: boolean }> { + return apiRequest( + `/api/v1/platform/workspaces/${workspaceId}/members/${userId}`, + { method: "DELETE" }, + ); +} + diff --git a/frontend/app/services/api/workspaces.ts b/frontend/app/services/api/workspaces.ts new file mode 100644 index 0000000..42a302d --- /dev/null +++ b/frontend/app/services/api/workspaces.ts @@ -0,0 +1,83 @@ +import { + apiRequest, + apiRequestWithMeta, + buildCursorQuery, + parseCursorPageMeta, + type CursorListParams, + type CursorPage, +} from "./_shared"; + +// Workspace (Project) types - 对应 API.md 第七部分系统管理接口 +export type Workspace = { + workspace_id: string; + workspace_code: string; + workspace_name: string; + active_root_uri: string; + quota_bytes: number; + status: "active" | "archived" | "disabled"; + description: string | null; + created_by: string; + created_at: string; + updated_at: string | null; +}; + +export type WorkspaceCreatePayload = { + workspace_code: string; + workspace_name: string; + quota_bytes?: number; + description?: string; +}; + +export type WorkspaceUpdatePayload = { + workspace_name?: string; + quota_bytes?: number; + description?: string; + status?: "active" | "archived"; +}; + +// Workspace (Project) Management APIs - 对应 API.md 第七部分系统管理接口 +// ---------------------------------------------------------------------------- + +export async function listWorkspaces( + input: CursorListParams = {}, +): Promise> { + const query = buildCursorQuery(input); + const { data, meta } = await apiRequestWithMeta( + `/api/v1/platform/workspaces?${query}`, + ); + return { items: data, meta: parseCursorPageMeta(meta) }; +} + +export async function createWorkspace( + input: WorkspaceCreatePayload, +): Promise { + return apiRequest( + "/api/v1/platform/workspaces", + { method: "POST", body: JSON.stringify(input) }, + ); +} + +export async function getWorkspace(workspaceId: string): Promise { + return apiRequest(`/api/v1/platform/workspaces/${workspaceId}`); +} + +export async function updateWorkspace( + workspaceId: string, + input: WorkspaceUpdatePayload, +): Promise { + return apiRequest( + `/api/v1/platform/workspaces/${workspaceId}`, + { method: "PATCH", body: JSON.stringify(input) }, + ); +} + +export async function deleteWorkspace(workspaceId: string): Promise<{ + workspace_id: string; + deleted: boolean; +}> { + return apiRequest( + `/api/v1/platform/workspaces/${workspaceId}`, + { method: "DELETE" }, + ); +} +