From cc38bb1575ae39ef4b2c3d069fdb94308a3c1b31 Mon Sep 17 00:00:00 2001 From: xiaozhu <2395895331@qq.com> Date: Mon, 31 Aug 2026 11:10:49 +0800 Subject: [PATCH 01/13] =?UTF-8?q?=E9=83=A8=E5=88=86=E6=A0=B7=E5=BC=8F?= =?UTF-8?q?=E8=B0=83=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/app/components/common/Topbar.tsx | 13 ++---- frontend/app/routes/platform.navigation.tsx | 36 +++++++++++++++++ frontend/app/routes/platform.tsx | 45 +++++++++++++++------ 3 files changed, 71 insertions(+), 23 deletions(-) diff --git a/frontend/app/components/common/Topbar.tsx b/frontend/app/components/common/Topbar.tsx index e04338a..04ac5d5 100644 --- a/frontend/app/components/common/Topbar.tsx +++ b/frontend/app/components/common/Topbar.tsx @@ -2,7 +2,7 @@ import { ChevronRight, LayoutGrid } from "lucide-react"; import type { AuthUser, AuthWorkspace } from "../../context/AuthContext"; type TopbarProps = { - activePage: string; + pageTitle: string; apiOnline: boolean; user: AuthUser | null; currentWorkspace: AuthWorkspace; @@ -14,7 +14,7 @@ type TopbarProps = { }; export function Topbar({ - activePage, + pageTitle, apiOnline, user, currentWorkspace, @@ -24,13 +24,6 @@ export function Topbar({ onSetCurrentWorkspace, onLogout, }: TopbarProps) { - const pageTitles: Record = { - home: "工作台", - scripts: "构建脚本", - schedules: "调度配置", - system: "系统管理", - }; - return (
@@ -45,7 +38,7 @@ export function Topbar({ 开发工作区

- {pageTitles[activePage] || "工作台"} + {pageTitle}

diff --git a/frontend/app/routes/platform.navigation.tsx b/frontend/app/routes/platform.navigation.tsx index 7b856c6..a01731f 100644 --- a/frontend/app/routes/platform.navigation.tsx +++ b/frontend/app/routes/platform.navigation.tsx @@ -21,6 +21,42 @@ export type NavigationItem = { children?: NavigationItem[]; }; +export function pathForPage(page: ActivePage): string { + if (page === "home") return "/workbench"; + return `/${page}`; +} + +function normalizePath(pathname: string): string { + const trimmed = pathname.replace(/\/+$/, ""); + return trimmed || "/"; +} + +/** 根据当前 pathname 解析顶栏标题:二级菜单显示子项名称,一级菜单显示自身名称。 */ +export function pageTitleFromPath(pathname: string): string { + const path = normalizePath(pathname); + + for (const item of navigation) { + if (item.children) { + for (const child of item.children) { + const childPath = + child.activePath ?? `${pathForPage(child.page)}/${child.sub}`; + if (path === childPath) { + return child.label; + } + } + } + } + + for (const item of navigation) { + const itemPath = item.activePath ?? pathForPage(item.page); + if (path === itemPath) { + return item.label; + } + } + + return "工作台"; +} + export const navigation: NavigationItem[] = [ { label: "工作台", icon: Home, page: "home", permission: "dashboard:view" }, { label: "构建脚本", icon: Code, page: "scripts", permission: "script:view" }, diff --git a/frontend/app/routes/platform.tsx b/frontend/app/routes/platform.tsx index 9222340..19b4d47 100644 --- a/frontend/app/routes/platform.tsx +++ b/frontend/app/routes/platform.tsx @@ -1,8 +1,12 @@ import { useEffect, useState } from "react"; import { Outlet, useLocation, useNavigate } from "react-router"; -import { PanelLeft, ChevronRight } from "lucide-react"; -import { navigation } from "./platform.navigation"; +import { ChevronRight, Menu } from "lucide-react"; +import { + navigation, + pathForPage, + pageTitleFromPath, +} from "./platform.navigation"; import type { ActivePage, NavigationItem, SubPage } from "./platform.navigation"; import { Collapsible } from "@base-ui/react/collapsible"; @@ -21,9 +25,10 @@ import { SidebarMenuSub, SidebarMenuSubItem, SidebarMenuSubButton, - SidebarTrigger, SidebarInset, + useSidebar, } from "~/components/ui/sidebar"; +import { cn } from "~/lib/utils"; import { useApi, useAuth, usePermission } from "~/context/AuthContext"; import { useEditSessionLifecycle } from "~/features/platform/hooks/useEditSessionLifecycle"; import { @@ -46,11 +51,6 @@ function pageFromPath(pathname: string): ActivePage { return "home"; } -function pathForPage(page: ActivePage): string { - if (page === "home") return "/workbench"; - return `/${page}`; -} - export function meta({}: Route.MetaArgs) { return [ { title: "模型实验开发平台" }, @@ -132,6 +132,26 @@ function NavRow({ ); } +function SidebarCollapseButton() { + const { toggleSidebar, state } = useSidebar(); + const collapsed = state === "collapsed"; + + return ( + + ); +} + export default function PlatformLayout() { const { currentWorkspace } = useAuth(); if (!currentWorkspace) { @@ -151,6 +171,7 @@ function AuthenticatedLayout() { const location = useLocation(); const navigate = useNavigate(); const activePage = pageFromPath(location.pathname); + const pageTitle = pageTitleFromPath(location.pathname); const auth = useAuth(); const { user, @@ -246,16 +267,14 @@ function AuthenticatedLayout() { ))} - - - {!sidebarCollapsed && 收起菜单} - + + Date: Mon, 31 Aug 2026 15:41:06 +0800 Subject: [PATCH 02/13] =?UTF-8?q?update:=E6=B7=BB=E5=8A=A0=E5=88=86?= =?UTF-8?q?=E9=A1=B5=E3=80=81=E6=88=90=E5=91=98=E6=B7=BB=E5=8A=A0=E4=BC=98?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- API.md | 32 +- backend/src/backend/api/platform/__init__.py | 2 + .../src/backend/api/platform/_pagination.py | 60 +++ backend/src/backend/api/platform/employees.py | 60 ++- backend/src/backend/api/platform/members.py | 298 +++++++++++++++ .../src/backend/api/platform/workspaces.py | 347 +++-------------- backend/tests/test_platform_pagination.py | 57 +++ frontend/app/components/ui/sonner.tsx | 1 + frontend/app/context/AuthContext.tsx | 4 +- .../app/features/admin/AdminPagination.tsx | 117 ++++++ .../app/features/admin/ImportMemberDialog.tsx | 81 ---- .../app/features/admin/MemberAddPanel.tsx | 359 ++++++++++++++++++ .../features/admin/ProjectManagementPage.tsx | 357 +++++++++-------- .../features/admin/ProjectMembersDrawer.tsx | 285 ++++++++------ .../app/features/admin/UserManagementPage.tsx | 188 +++++---- .../app/features/admin/UserMultiSelect.tsx | 173 --------- .../app/features/admin/state/adminStore.ts | 4 +- frontend/app/features/admin/useCursorPage.ts | 128 +++++++ .../app/features/admin/useDebouncedValue.ts | 12 + frontend/app/services/api.ts | 84 +++- 20 files changed, 1729 insertions(+), 920 deletions(-) create mode 100644 backend/src/backend/api/platform/_pagination.py create mode 100644 backend/src/backend/api/platform/members.py create mode 100644 backend/tests/test_platform_pagination.py create mode 100644 frontend/app/features/admin/AdminPagination.tsx delete mode 100644 frontend/app/features/admin/ImportMemberDialog.tsx create mode 100644 frontend/app/features/admin/MemberAddPanel.tsx delete mode 100644 frontend/app/features/admin/UserMultiSelect.tsx create mode 100644 frontend/app/features/admin/useCursorPage.ts create mode 100644 frontend/app/features/admin/useDebouncedValue.ts diff --git a/API.md b/API.md index 9ec2bfb..fadd101 100644 --- a/API.md +++ b/API.md @@ -630,11 +630,11 @@ Base 前缀 `/api/v1/admin`。 | 方法 | 路径 | 说明 | |---|---|---| -| `GET` | `/api/v1/platform/employees` | 列全平台未软删员工;包含停用、锁定及无平台角色用户 | +| `GET` | `/api/v1/platform/employees` | 列全平台未软删员工(cursor 分页 + `q` 搜索,见 §7.0) | | `POST` | `/api/v1/platform/employees` | 创建平台员工账号(返回 201);不自动加入任何 workspace | | `PATCH` | `/api/v1/platform/employees/{user_id}` | 改员工资料/状态/平台角色(仅系统管理员) | | `DELETE` | `/api/v1/platform/employees/{user_id}` | 软删员工;级联软删其 workspace 成员关系(仅系统管理员) | -| `GET` | `/api/v1/platform/workspaces` | 列 workspace(`active`/`archived`);已软删的过滤掉 | +| `GET` | `/api/v1/platform/workspaces` | 列 workspace(`active`/`archived`)(cursor 分页 + `q` 搜索,见 §7.0) | | `POST` | `/api/v1/platform/workspaces` | 创建 workspace(返回 201);创建者自动成为 admin 成员 | | `GET` | `/api/v1/platform/workspaces/{workspace_id}` | 单个 workspace(含已 disabled 的,用于恢复) | | `PATCH` | `/api/v1/platform/workspaces/{workspace_id}` | 改 workspace 字段;`status` 仅允许 `active`/`archived` | @@ -651,9 +651,35 @@ Base 前缀 `/api/v1/admin`。 > - 每个 workspace 必须始终保留至少一个 `admin` 角色的活跃成员;对最后 admin 做降级 / 停用 / 删除 → 409。 > - `PATCH /employees/{user_id}` 降级 admin → developer 时同样触发 workspace last-admin 守卫(因为 workspace 角色继承自 platform 角色,降级会级联到所有活跃 membership);platform 必须始终保留至少一个 `active` 系统管理员;对最后系统管理员做降级 / 停用 / 删除 → 409。 > - 系统管理员不能通过 `DELETE .../members/{self}` 把自己移除(403)。唯一退出方式是 `DELETE /workspaces/{id}` 软删整个 workspace,后者会级联软删所有成员。 -> - workspace 与成员列表接口静默 `pageSize=100` 上限,无客户端分页参数(YAGNI);`GET /employees` 按契约返回全部未软删员工,不设隐藏上限。 +> - `GET /employees` 与 `GET /workspaces` 支持 cursor 分页与关键字搜索(见 §7.0);成员列表仍静默 `pageSize=100` 上限。 > - 跨 workspace 操作**不**需要 `?workspace_id=` query 参数,与 `/api/v1/admin/...`(workspace 内成员管理)不要混淆。 +### 7.0 列表分页约定(employees / workspaces) + +`GET /api/v1/platform/employees` 与 `GET /api/v1/platform/workspaces` 使用 **keyset cursor** 分页(无 `offset` / `page`)。 + +| Query | 类型 | 默认 | 说明 | +|---|---|---|---| +| `limit` | int | `10` | 每页条数,范围 1~200 | +| `cursor` | string | 无 | 上一页返回的 `meta.next_cursor`;缺省为第一页;非法值 → 400 | +| `q` | string | 无 | 关键字搜索。employees 匹配 `display_name`/`username`/`email`;workspaces 匹配 `workspace_name`/`workspace_code`/`description` | + +响应 `meta`: + +```json +{ + "limit": 10, + "page_count": 10, + "total_count": 156, + "has_more": true, + "next_cursor": "..." +} +``` + +- `total_count`:当前筛选条件下的总条数(用于页码展示)。 +- `next_cursor`:无下一页时为 `null`。 +- 排序键:`(created_at ASC, id ASC)`。前端用 cursor 栈实现「上一页 / 下一页 + 已访问页码」;不支持任意跳到未访问过的深页。 + ### 7.1 `POST /api/v1/platform/workspaces` 创建 workspace;创建者(当前系统管理员)自动成为该 workspace 的 `admin` 成员。 diff --git a/backend/src/backend/api/platform/__init__.py b/backend/src/backend/api/platform/__init__.py index f9c8ea2..24f7bd8 100644 --- a/backend/src/backend/api/platform/__init__.py +++ b/backend/src/backend/api/platform/__init__.py @@ -73,6 +73,7 @@ from backend.api.platform._deps import ( system_admin_context, ) from backend.api.platform.employees import router as employees_router +from backend.api.platform.members import router as members_router from backend.api.platform.roles import router as roles_router from backend.api.platform.workspaces import router as workspaces_router @@ -81,6 +82,7 @@ from backend.api.platform.workspaces import router as workspaces_router router = APIRouter() router.include_router(employees_router) router.include_router(workspaces_router) +router.include_router(members_router) router.include_router(roles_router) # 保留 system_admin_context 的 re-export,供其他文件使用 diff --git a/backend/src/backend/api/platform/_pagination.py b/backend/src/backend/api/platform/_pagination.py new file mode 100644 index 0000000..c336032 --- /dev/null +++ b/backend/src/backend/api/platform/_pagination.py @@ -0,0 +1,60 @@ +"""Cursor (keyset) pagination helpers for platform list endpoints. + +Cursor encodes the sort key ``(created_at, id)`` as a URL-safe base64 +string. Clients pass it back via ``?cursor=`` to fetch the next page. +Invalid cursors raise HTTP 400 — never silently treated as page 1. +""" + +from __future__ import annotations + +import base64 +import datetime +from typing import Any + +from fastapi import HTTPException, status + +DEFAULT_PAGE_LIMIT = 10 +MAX_PAGE_LIMIT = 200 + + +def encode_cursor(created_at: datetime.datetime, row_id: str) -> str: + """Encode ``(created_at, id)`` into an opaque cursor string.""" + if created_at.tzinfo is not None: + created_at = created_at.replace(tzinfo=None) + raw = f"{created_at.isoformat()}|{row_id}".encode("utf-8") + return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=") + + +def decode_cursor(cursor: str) -> tuple[datetime.datetime, str]: + """Decode a cursor; raise 400 on malformed input.""" + try: + padded = cursor + "=" * (-len(cursor) % 4) + raw = base64.urlsafe_b64decode(padded.encode("ascii")).decode("utf-8") + ts_part, _, row_id = raw.partition("|") + if not ts_part or not row_id: + raise ValueError("missing parts") + created_at = datetime.datetime.fromisoformat(ts_part) + if created_at.tzinfo is not None: + created_at = created_at.replace(tzinfo=None) + return created_at, row_id + except (ValueError, TypeError, UnicodeDecodeError) as exc: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + "无效的分页 cursor", + ) from exc + + +def page_meta( + *, + limit: int, + page_count: int, + total_count: int, + next_cursor: str | None, +) -> dict[str, Any]: + return { + "limit": limit, + "page_count": page_count, + "total_count": total_count, + "has_more": next_cursor is not None, + "next_cursor": next_cursor, + } diff --git a/backend/src/backend/api/platform/employees.py b/backend/src/backend/api/platform/employees.py index 398cc06..0e2db9a 100644 --- a/backend/src/backend/api/platform/employees.py +++ b/backend/src/backend/api/platform/employees.py @@ -11,9 +11,9 @@ from typing import Any, Literal from common.auth.passwords import hash_password from common.db.models import Roles, Users, WorkspaceMembers, Workspaces from common.ids import new_ulid -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, Query, status from pydantic import BaseModel, ConfigDict, Field -from sqlalchemy import or_, select, update +from sqlalchemy import func, or_, select, tuple_, update from sqlalchemy.ext.asyncio import AsyncSession from backend.api.dependencies import database_session @@ -25,6 +25,13 @@ from backend.api.platform._deps import ( _load_role_by_code, system_admin_context, ) +from backend.api.platform._pagination import ( + DEFAULT_PAGE_LIMIT, + MAX_PAGE_LIMIT, + decode_cursor, + encode_cursor, + page_meta, +) # 平台角色 code 的字符串约束:与 RoleCreate.role_code 一致。 # 之所以从 Literal["admin","developer"] 放宽为 str,是因为 ``listPlatformRoles`` @@ -91,22 +98,63 @@ def platform_employee_payload( # 列出整个平台的非删除用户;不局限于某一个工作区。 @router.get("/employees") async def list_platform_employees( + limit: int = Query(default=DEFAULT_PAGE_LIMIT, ge=1, le=MAX_PAGE_LIMIT), + cursor: str | None = Query(default=None), + q: str | None = Query(default=None, max_length=100), context: SystemAdminContext = Depends(system_admin_context), session: AsyncSession = Depends(database_session), ) -> dict[str, Any]: - """List every non-soft-deleted platform user.""" + """List non-soft-deleted platform users with cursor pagination + search.""" + base_filters = [Users.is_deleted == 0] + keyword = (q or "").strip() + if keyword: + like = f"%{keyword}%" + base_filters.append( + or_( + Users.display_name.like(like), + Users.username.like(like), + Users.email.like(like), + ) + ) + + total_count = int( + await session.scalar( + select(func.count()).select_from(Users).where(*base_filters) + ) + or 0 + ) + + page_filters = list(base_filters) + if cursor is not None: + cursor_ts, cursor_id = decode_cursor(cursor) + page_filters.append( + tuple_(Users.created_at, Users.user_id) > (cursor_ts, cursor_id) + ) + rows = ( await session.execute( select(Users, Roles) .outerjoin(Roles, Roles.role_id == Users.platform_role_id) - .where(Users.is_deleted == 0) + .where(*page_filters) .order_by(Users.created_at, Users.user_id) + .limit(limit + 1) ) ).all() + has_more = len(rows) > limit + page_rows = rows[:limit] + next_cursor = None + if has_more and page_rows: + last_user = page_rows[-1][0] + next_cursor = encode_cursor(last_user.created_at, last_user.user_id) return _envelope( context.request_id, - [platform_employee_payload(user, role) for user, role in rows], - {"count": len(rows)}, + [platform_employee_payload(user, role) for user, role in page_rows], + page_meta( + limit=limit, + page_count=len(page_rows), + total_count=total_count, + next_cursor=next_cursor, + ), ) diff --git a/backend/src/backend/api/platform/members.py b/backend/src/backend/api/platform/members.py new file mode 100644 index 0000000..7024e9e --- /dev/null +++ b/backend/src/backend/api/platform/members.py @@ -0,0 +1,298 @@ +"""Workspace membership CRUD endpoints. + +``GET .../members`` admits system admins or active workspace members; +write endpoints require ``system_admin_context``. +""" + +from __future__ import annotations + +import datetime +from typing import Any, Literal + +from common.db.models import Roles, Users, WorkspaceMembers +from common.ids import new_ulid +from fastapi import APIRouter, Depends, HTTPException, Request, status +from pydantic import BaseModel, ConfigDict, Field +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from backend.api.dependencies import current_user, database_session +from backend.api.platform._deps import ( + SystemAdminContext, + _count_active_admins, + _envelope, + _is_system_admin, + system_admin_context, +) +from backend.api.platform.workspaces import ( + _load_workspace, + member_payload, +) + +router = APIRouter(prefix="/api/v1/platform", tags=["platform"]) + +LIST_PAGE_SIZE = 100 + + +class MemberCreate(BaseModel): + """Add a user to a workspace. Role is inherited from the user's + platform role (Users.platform_role_id) — not set here.""" + + model_config = ConfigDict(extra="forbid") + + user_id: str = Field(min_length=26, max_length=26) + + +class MemberUpdate(BaseModel): + """Update a workspace membership's status. Role cannot be changed + via this endpoint — workspace role is always inherited from the + user's platform role. To change a member's role, PATCH + /platform/employees/{user_id} instead.""" + + model_config = ConfigDict(extra="forbid") + + member_status: Literal["active", "disabled", "locked"] | None = None + + +@router.get("/workspaces/{workspace_id}/members") +async def list_members( + workspace_id: str, + request: Request, + session: AsyncSession = Depends(database_session), +) -> dict[str, Any]: + """List active and historical (non-soft-deleted) members of a workspace. + + Accessible to system admins (any workspace) and to active members of the + workspace itself. The script explorer calls this to seed the per-owner + directory-tree groups for non-admin users; visibility filters on the + scripts/data-resources endpoints still keep each peer's private content + hidden, so this only exposes membership (names), not private files. + """ + user = await current_user(request, session) + is_system_admin = await _is_system_admin(session, user) + if not is_system_admin: + membership = await session.scalar( + select(WorkspaceMembers).where( + WorkspaceMembers.workspace_id == workspace_id, + WorkspaceMembers.user_id == user.user_id, + WorkspaceMembers.is_deleted == 0, + WorkspaceMembers.member_status == "active", + ) + ) + if membership is None: + raise HTTPException( + status.HTTP_403_FORBIDDEN, + "需要系统管理员或该工作区成员权限", + ) + await _load_workspace(session, workspace_id) + rows = ( + await session.execute( + select(Users, Roles, WorkspaceMembers) + .join( + WorkspaceMembers, + WorkspaceMembers.user_id == Users.user_id, + ) + .join(Roles, Roles.role_id == WorkspaceMembers.role_id) + .where( + WorkspaceMembers.workspace_id == workspace_id, + WorkspaceMembers.is_deleted == 0, + ) + .order_by(WorkspaceMembers.joined_at, Users.user_id) + .limit(LIST_PAGE_SIZE) + ) + ).all() + request_id = request.headers.get("X-Request-ID") or new_ulid() + return _envelope( + request_id, + [member_payload(u, r, m) for u, r, m in rows], + {"count": len(rows), "page_size": LIST_PAGE_SIZE}, + ) + +@router.post( + "/workspaces/{workspace_id}/members", + status_code=status.HTTP_201_CREATED, +) +async def add_member( + workspace_id: str, + payload: MemberCreate, + context: SystemAdminContext = Depends(system_admin_context), + session: AsyncSession = Depends(database_session), +) -> dict[str, Any]: + """Add a user to a workspace. The new row starts with member_status='active'. + + The role is inherited from the target user's ``platform_role_id``; + the request body does NOT take a ``role_code``. To change a member's + role, PATCH ``/api/v1/platform/employees/{user_id}`` instead. + """ + await _load_workspace(session, workspace_id) + user = await session.get(Users, payload.user_id) + if user is None or user.is_deleted != 0: + raise HTTPException(status.HTTP_404_NOT_FOUND, "用户不存在") + if user.status != "active": + raise HTTPException( + status.HTTP_409_CONFLICT, + f"用户状态为 {user.status},无法加入 workspace", + ) + if user.platform_role_id is None: + raise HTTPException( + status.HTTP_409_CONFLICT, + "目标用户尚未分配平台角色,无法加入 workspace;" + "请先 PATCH /api/v1/platform/employees/{user_id} 设置 role_code", + ) + role = await session.scalar( + select(Roles).where( + Roles.role_id == user.platform_role_id, + Roles.is_deleted == 0, + ) + ) + if role is None: + raise HTTPException( + status.HTTP_422_UNPROCESSABLE_ENTITY, + "用户的平台角色行不存在或已被删除", + ) + # ``WorkspaceMembers`` 的主键是 ``(workspace_id, user_id)`` 复合 PK, + # 而 ``remove_member`` / ``delete_platform_employee`` 都是软删除 (保留行, + # 仅置 ``is_deleted=1``). 因此这里必须按主键查整行,而不是只看活跃行: + # 否则软删行会被 active-duplicate 检查漏过,然后 INSERT 直接撞 PK. + existing = await session.scalar( + select(WorkspaceMembers).where( + WorkspaceMembers.workspace_id == workspace_id, + WorkspaceMembers.user_id == payload.user_id, + ) + ) + if existing is not None: + if existing.is_deleted == 0: + raise HTTPException( + status.HTTP_409_CONFLICT, + "用户已是该 workspace 成员;workspace 角色继承自平台角色," + "要变更请 PATCH /api/v1/platform/employees/{user_id} 修改 role_code", + ) + # 复活软删除行. 保留 ``joined_at`` 作为历史记录;``role_id`` 重新继承 + # 当前用户的平台角色 (用户在中间可能改过 platform_role);清掉 + # ``deleted_at`` 标记本轮已不在软删状态. + existing.is_deleted = 0 + existing.deleted_at = None + existing.role_id = role.role_id + existing.member_status = "active" + await session.flush() + await session.refresh(existing) + return _envelope( + context.request_id, member_payload(user, role, existing), + ) + membership = WorkspaceMembers( + workspace_id=workspace_id, + user_id=payload.user_id, + role_id=role.role_id, + member_status="active", + ) + session.add(membership) + await session.flush() + await session.refresh(membership) + return _envelope(context.request_id, member_payload(user, role, membership)) + +@router.patch("/workspaces/{workspace_id}/members/{user_id}") +async def update_member( + workspace_id: str, + user_id: str, + payload: MemberUpdate, + context: SystemAdminContext = Depends(system_admin_context), + session: AsyncSession = Depends(database_session), +) -> dict[str, Any]: + """Update a workspace membership's status. Role is not editable here. + + Workspace role is always inherited from the user's platform role + (``Users.platform_role_id``). To change role, PATCH + ``/api/v1/platform/employees/{user_id}`` instead. + + Last-admin guard still applies to ``member_status`` changes: setting + the only active admin to ``disabled``/``locked`` would leave the + workspace without admin coverage. + """ + await _load_workspace(session, workspace_id) + row = ( + await session.execute( + select(Users, Roles, WorkspaceMembers) + .join( + WorkspaceMembers, + WorkspaceMembers.user_id == Users.user_id, + ) + .join(Roles, Roles.role_id == WorkspaceMembers.role_id) + .where( + WorkspaceMembers.workspace_id == workspace_id, + WorkspaceMembers.user_id == user_id, + WorkspaceMembers.is_deleted == 0, + ) + ) + ).first() + if row is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "成员不存在") + user, role, membership = row + + if payload.member_status is not None and payload.member_status != membership.member_status: + if ( + role.role_code == "admin" + and payload.member_status != "active" + ): + remaining = await _count_active_admins( + session, workspace_id, exclude_user_id=user_id, + ) + if remaining == 0: + raise HTTPException( + status.HTTP_409_CONFLICT, + "workspace 必须保留至少一个 admin", + ) + membership.member_status = payload.member_status + + await session.flush() + await session.refresh(membership) + return _envelope(context.request_id, member_payload(user, role, membership)) + +@router.delete("/workspaces/{workspace_id}/members/{user_id}") +async def remove_member( + workspace_id: str, + user_id: str, + context: SystemAdminContext = Depends(system_admin_context), + session: AsyncSession = Depends(database_session), +) -> dict[str, Any]: + """Soft-delete a workspace membership. + + System admins cannot remove themselves — the only escape is to delete + the entire workspace, which cascades membership soft-deletion. + """ + await _load_workspace(session, workspace_id) + if user_id == context.user.user_id: + raise HTTPException( + status.HTTP_403_FORBIDDEN, + "系统管理员不能把自己从 workspace 移除;如需退出,请删除整个 workspace", + ) + row = ( + await session.execute( + select(Roles, WorkspaceMembers) + .join(Roles, Roles.role_id == WorkspaceMembers.role_id) + .where( + WorkspaceMembers.workspace_id == workspace_id, + WorkspaceMembers.user_id == user_id, + WorkspaceMembers.is_deleted == 0, + ) + ) + ).first() + if row is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "成员不存在") + role, membership = row + if role.role_code == "admin" and membership.member_status == "active": + remaining = await _count_active_admins( + session, workspace_id, exclude_user_id=user_id, + ) + if remaining == 0: + raise HTTPException( + status.HTTP_409_CONFLICT, + "workspace 必须保留至少一个 admin", + ) + membership.is_deleted = 1 + membership.deleted_at = datetime.datetime.utcnow() + await session.flush() + return _envelope( + context.request_id, + {"workspace_id": workspace_id, "user_id": user_id, "removed": True}, + ) + diff --git a/backend/src/backend/api/platform/workspaces.py b/backend/src/backend/api/platform/workspaces.py index 5cda1d3..ce0ebc0 100644 --- a/backend/src/backend/api/platform/workspaces.py +++ b/backend/src/backend/api/platform/workspaces.py @@ -1,7 +1,7 @@ -"""Workspace & membership CRUD endpoints. -Five workspace endpoints plus five membership endpoints, gated by -``system_admin_context`` (except ``GET .../members``, which also admits workspace -members). Last-admin guards and soft-delete cascades live here. +"""Workspace CRUD endpoints. + +Membership endpoints live in ``members.py``. Soft-delete cascades for +workspace DELETE still live here. """ from __future__ import annotations @@ -12,29 +12,29 @@ from typing import Any, Literal from common.db.models import Roles, Users, WorkspaceMembers, Workspaces from common.ids import new_ulid -from fastapi import APIRouter, Depends, HTTPException, Request, status +from fastapi import APIRouter, Depends, HTTPException, Query, status from pydantic import BaseModel, ConfigDict, Field -from sqlalchemy import select, update +from sqlalchemy import func, or_, select, tuple_, update from sqlalchemy.ext.asyncio import AsyncSession -from backend.api.dependencies import current_user, database_session +from backend.api.dependencies import database_session from backend.api.platform._deps import ( SystemAdminContext, - _count_active_admins, _envelope, - _is_system_admin, _load_role_by_code, system_admin_context, ) +from backend.api.platform._pagination import ( + DEFAULT_PAGE_LIMIT, + MAX_PAGE_LIMIT, + decode_cursor, + encode_cursor, + page_meta, +) router = APIRouter(prefix="/api/v1/platform", tags=["platform"]) WORKSPACE_CODE_PATTERN = re.compile(r"^[a-z0-9-]{3,32}$") -LIST_PAGE_SIZE = 100 - -WORKSPACE_EDITABLE_STATUS = ("active", "archived") -MEMBER_ROLE_CODES = ("admin", "developer") -MEMBER_STATUS_VALUES = ("active", "disabled", "locked") # 创建工作区时前端提交的请求体;禁止未声明字段。 class WorkspaceCreate(BaseModel): @@ -55,24 +55,6 @@ class WorkspaceUpdate(BaseModel): # 'disabled' is rejected here on purpose — soft delete must go through DELETE. status: Literal["active", "archived"] | None = None -class MemberCreate(BaseModel): - """Add a user to a workspace. Role is inherited from the user's - platform role (Users.platform_role_id) — not set here.""" - - model_config = ConfigDict(extra="forbid") - - user_id: str = Field(min_length=26, max_length=26) - -class MemberUpdate(BaseModel): - """Update a workspace membership's status. Role cannot be changed - via this endpoint — workspace role is always inherited from the - user's platform role. To change a member's role, PATCH - /platform/employees/{user_id} instead.""" - - model_config = ConfigDict(extra="forbid") - - member_status: Literal["active", "disabled", "locked"] | None = None - def workspace_payload(workspace: Workspaces) -> dict[str, Any]: return { "workspace_id": workspace.workspace_id, @@ -114,28 +96,66 @@ async def _load_workspace(session: AsyncSession, workspace_id: str) -> Workspace @router.get("/workspaces") async def list_workspaces( + limit: int = Query(default=DEFAULT_PAGE_LIMIT, ge=1, le=MAX_PAGE_LIMIT), + cursor: str | None = Query(default=None), + q: str | None = Query(default=None, max_length=100), context: SystemAdminContext = Depends(system_admin_context), session: AsyncSession = Depends(database_session), ) -> dict[str, Any]: - """List active/archived workspaces. Soft-deleted rows are filtered out. + """List active/archived workspaces with cursor pagination + search.""" + base_filters = [ + Workspaces.status != "disabled", + Workspaces.is_deleted == 0, + ] + keyword = (q or "").strip() + if keyword: + like = f"%{keyword}%" + base_filters.append( + or_( + Workspaces.workspace_name.like(like), + Workspaces.workspace_code.like(like), + Workspaces.description.like(like), + ) + ) + + total_count = int( + await session.scalar( + select(func.count()).select_from(Workspaces).where(*base_filters) + ) + or 0 + ) + + page_filters = list(base_filters) + if cursor is not None: + cursor_ts, cursor_id = decode_cursor(cursor) + page_filters.append( + tuple_(Workspaces.created_at, Workspaces.workspace_id) + > (cursor_ts, cursor_id) + ) - Silent ``pageSize=100`` cap — YAGNI on real pagination until needed. - """ rows = ( await session.execute( select(Workspaces) - .where( - Workspaces.status != "disabled", - Workspaces.is_deleted == 0, - ) + .where(*page_filters) .order_by(Workspaces.created_at, Workspaces.workspace_id) - .limit(LIST_PAGE_SIZE) + .limit(limit + 1) ) ).scalars().all() + has_more = len(rows) > limit + page_rows = list(rows[:limit]) + next_cursor = None + if has_more and page_rows: + last = page_rows[-1] + next_cursor = encode_cursor(last.created_at, last.workspace_id) return _envelope( context.request_id, - [workspace_payload(w) for w in rows], - {"count": len(rows), "page_size": LIST_PAGE_SIZE}, + [workspace_payload(w) for w in page_rows], + page_meta( + limit=limit, + page_count=len(page_rows), + total_count=total_count, + next_cursor=next_cursor, + ), ) @router.post("/workspaces", status_code=status.HTTP_201_CREATED) @@ -252,246 +272,3 @@ async def delete_workspace( await session.flush() await session.refresh(workspace) return _envelope(context.request_id, workspace_payload(workspace)) - -@router.get("/workspaces/{workspace_id}/members") -async def list_members( - workspace_id: str, - request: Request, - session: AsyncSession = Depends(database_session), -) -> dict[str, Any]: - """List active and historical (non-soft-deleted) members of a workspace. - - Accessible to system admins (any workspace) and to active members of the - workspace itself. The script explorer calls this to seed the per-owner - directory-tree groups for non-admin users; visibility filters on the - scripts/data-resources endpoints still keep each peer's private content - hidden, so this only exposes membership (names), not private files. - """ - user = await current_user(request, session) - is_system_admin = await _is_system_admin(session, user) - if not is_system_admin: - membership = await session.scalar( - select(WorkspaceMembers).where( - WorkspaceMembers.workspace_id == workspace_id, - WorkspaceMembers.user_id == user.user_id, - WorkspaceMembers.is_deleted == 0, - WorkspaceMembers.member_status == "active", - ) - ) - if membership is None: - raise HTTPException( - status.HTTP_403_FORBIDDEN, - "需要系统管理员或该工作区成员权限", - ) - await _load_workspace(session, workspace_id) - rows = ( - await session.execute( - select(Users, Roles, WorkspaceMembers) - .join( - WorkspaceMembers, - WorkspaceMembers.user_id == Users.user_id, - ) - .join(Roles, Roles.role_id == WorkspaceMembers.role_id) - .where( - WorkspaceMembers.workspace_id == workspace_id, - WorkspaceMembers.is_deleted == 0, - ) - .order_by(WorkspaceMembers.joined_at, Users.user_id) - .limit(LIST_PAGE_SIZE) - ) - ).all() - request_id = request.headers.get("X-Request-ID") or new_ulid() - return _envelope( - request_id, - [member_payload(u, r, m) for u, r, m in rows], - {"count": len(rows), "page_size": LIST_PAGE_SIZE}, - ) - -@router.post( - "/workspaces/{workspace_id}/members", - status_code=status.HTTP_201_CREATED, -) -async def add_member( - workspace_id: str, - payload: MemberCreate, - context: SystemAdminContext = Depends(system_admin_context), - session: AsyncSession = Depends(database_session), -) -> dict[str, Any]: - """Add a user to a workspace. The new row starts with member_status='active'. - - The role is inherited from the target user's ``platform_role_id``; - the request body does NOT take a ``role_code``. To change a member's - role, PATCH ``/api/v1/platform/employees/{user_id}`` instead. - """ - await _load_workspace(session, workspace_id) - user = await session.get(Users, payload.user_id) - if user is None or user.is_deleted != 0: - raise HTTPException(status.HTTP_404_NOT_FOUND, "用户不存在") - if user.status != "active": - raise HTTPException( - status.HTTP_409_CONFLICT, - f"用户状态为 {user.status},无法加入 workspace", - ) - if user.platform_role_id is None: - raise HTTPException( - status.HTTP_409_CONFLICT, - "目标用户尚未分配平台角色,无法加入 workspace;" - "请先 PATCH /api/v1/platform/employees/{user_id} 设置 role_code", - ) - role = await session.scalar( - select(Roles).where( - Roles.role_id == user.platform_role_id, - Roles.is_deleted == 0, - ) - ) - if role is None: - raise HTTPException( - status.HTTP_422_UNPROCESSABLE_ENTITY, - "用户的平台角色行不存在或已被删除", - ) - # ``WorkspaceMembers`` 的主键是 ``(workspace_id, user_id)`` 复合 PK, - # 而 ``remove_member`` / ``delete_platform_employee`` 都是软删除 (保留行, - # 仅置 ``is_deleted=1``). 因此这里必须按主键查整行,而不是只看活跃行: - # 否则软删行会被 active-duplicate 检查漏过,然后 INSERT 直接撞 PK. - existing = await session.scalar( - select(WorkspaceMembers).where( - WorkspaceMembers.workspace_id == workspace_id, - WorkspaceMembers.user_id == payload.user_id, - ) - ) - if existing is not None: - if existing.is_deleted == 0: - raise HTTPException( - status.HTTP_409_CONFLICT, - "用户已是该 workspace 成员;workspace 角色继承自平台角色," - "要变更请 PATCH /api/v1/platform/employees/{user_id} 修改 role_code", - ) - # 复活软删除行. 保留 ``joined_at`` 作为历史记录;``role_id`` 重新继承 - # 当前用户的平台角色 (用户在中间可能改过 platform_role);清掉 - # ``deleted_at`` 标记本轮已不在软删状态. - existing.is_deleted = 0 - existing.deleted_at = None - existing.role_id = role.role_id - existing.member_status = "active" - await session.flush() - await session.refresh(existing) - return _envelope( - context.request_id, member_payload(user, role, existing), - ) - membership = WorkspaceMembers( - workspace_id=workspace_id, - user_id=payload.user_id, - role_id=role.role_id, - member_status="active", - ) - session.add(membership) - await session.flush() - await session.refresh(membership) - return _envelope(context.request_id, member_payload(user, role, membership)) - -@router.patch("/workspaces/{workspace_id}/members/{user_id}") -async def update_member( - workspace_id: str, - user_id: str, - payload: MemberUpdate, - context: SystemAdminContext = Depends(system_admin_context), - session: AsyncSession = Depends(database_session), -) -> dict[str, Any]: - """Update a workspace membership's status. Role is not editable here. - - Workspace role is always inherited from the user's platform role - (``Users.platform_role_id``). To change role, PATCH - ``/api/v1/platform/employees/{user_id}`` instead. - - Last-admin guard still applies to ``member_status`` changes: setting - the only active admin to ``disabled``/``locked`` would leave the - workspace without admin coverage. - """ - await _load_workspace(session, workspace_id) - row = ( - await session.execute( - select(Users, Roles, WorkspaceMembers) - .join( - WorkspaceMembers, - WorkspaceMembers.user_id == Users.user_id, - ) - .join(Roles, Roles.role_id == WorkspaceMembers.role_id) - .where( - WorkspaceMembers.workspace_id == workspace_id, - WorkspaceMembers.user_id == user_id, - WorkspaceMembers.is_deleted == 0, - ) - ) - ).first() - if row is None: - raise HTTPException(status.HTTP_404_NOT_FOUND, "成员不存在") - user, role, membership = row - - if payload.member_status is not None and payload.member_status != membership.member_status: - if ( - role.role_code == "admin" - and payload.member_status != "active" - ): - remaining = await _count_active_admins( - session, workspace_id, exclude_user_id=user_id, - ) - if remaining == 0: - raise HTTPException( - status.HTTP_409_CONFLICT, - "workspace 必须保留至少一个 admin", - ) - membership.member_status = payload.member_status - - await session.flush() - await session.refresh(membership) - return _envelope(context.request_id, member_payload(user, role, membership)) - -@router.delete("/workspaces/{workspace_id}/members/{user_id}") -async def remove_member( - workspace_id: str, - user_id: str, - context: SystemAdminContext = Depends(system_admin_context), - session: AsyncSession = Depends(database_session), -) -> dict[str, Any]: - """Soft-delete a workspace membership. - - System admins cannot remove themselves — the only escape is to delete - the entire workspace, which cascades membership soft-deletion. - """ - await _load_workspace(session, workspace_id) - if user_id == context.user.user_id: - raise HTTPException( - status.HTTP_403_FORBIDDEN, - "系统管理员不能把自己从 workspace 移除;如需退出,请删除整个 workspace", - ) - row = ( - await session.execute( - select(Roles, WorkspaceMembers) - .join(Roles, Roles.role_id == WorkspaceMembers.role_id) - .where( - WorkspaceMembers.workspace_id == workspace_id, - WorkspaceMembers.user_id == user_id, - WorkspaceMembers.is_deleted == 0, - ) - ) - ).first() - if row is None: - raise HTTPException(status.HTTP_404_NOT_FOUND, "成员不存在") - role, membership = row - if role.role_code == "admin" and membership.member_status == "active": - remaining = await _count_active_admins( - session, workspace_id, exclude_user_id=user_id, - ) - if remaining == 0: - raise HTTPException( - status.HTTP_409_CONFLICT, - "workspace 必须保留至少一个 admin", - ) - membership.is_deleted = 1 - membership.deleted_at = datetime.datetime.utcnow() - await session.flush() - return _envelope( - context.request_id, - {"workspace_id": workspace_id, "user_id": user_id, "removed": True}, - ) - diff --git a/backend/tests/test_platform_pagination.py b/backend/tests/test_platform_pagination.py new file mode 100644 index 0000000..eb380cf --- /dev/null +++ b/backend/tests/test_platform_pagination.py @@ -0,0 +1,57 @@ +"""Unit tests for platform cursor pagination helpers.""" + +from __future__ import annotations + +import datetime + +import pytest +from fastapi import HTTPException + +from backend.api.platform._pagination import ( + decode_cursor, + encode_cursor, + page_meta, +) + + +def test_encode_decode_roundtrip() -> None: + created_at = datetime.datetime(2026, 3, 15, 12, 30, 45, 123000) + row_id = "01HXY9C5B8N3K4P7Q6RT2V0J8D" + cursor = encode_cursor(created_at, row_id) + decoded_ts, decoded_id = decode_cursor(cursor) + assert decoded_ts == created_at + assert decoded_id == row_id + + +def test_decode_strips_timezone() -> None: + created_at = datetime.datetime(2026, 1, 1, 0, 0, 0, tzinfo=datetime.timezone.utc) + cursor = encode_cursor(created_at, "abc") + decoded_ts, decoded_id = decode_cursor(cursor) + assert decoded_ts.tzinfo is None + assert decoded_id == "abc" + + +def test_decode_invalid_cursor_raises_400() -> None: + with pytest.raises(HTTPException) as exc_info: + decode_cursor("not-a-valid-cursor!!!") + assert exc_info.value.status_code == 400 + + +def test_page_meta_has_more() -> None: + meta = page_meta( + limit=10, + page_count=10, + total_count=25, + next_cursor="abc", + ) + assert meta["has_more"] is True + assert meta["next_cursor"] == "abc" + assert meta["total_count"] == 25 + + meta_end = page_meta( + limit=10, + page_count=5, + total_count=25, + next_cursor=None, + ) + assert meta_end["has_more"] is False diff --git a/frontend/app/components/ui/sonner.tsx b/frontend/app/components/ui/sonner.tsx index 1919f52..ee0335c 100644 --- a/frontend/app/components/ui/sonner.tsx +++ b/frontend/app/components/ui/sonner.tsx @@ -5,6 +5,7 @@ const Toaster = ({ ...props }: ToasterProps) => { return ( rawApi.hideScheduleArtifact(workspaceId, versionsId), listEmployees: () => rawApi.listEmployees(workspaceId), - listPlatformEmployees: () => rawApi.listPlatformEmployees(), + listPlatformEmployees: (input) => rawApi.listPlatformEmployees(input), createEmployee: (input) => rawApi.createEmployee(workspaceId, input), createPlatformEmployee: (input) => rawApi.createPlatformEmployee(input), updateEmployee: (userId, input) => @@ -312,7 +312,7 @@ export function useApi(): WorkspaceBoundApi { getScheduleNodeRunArtifacts: (runId, nodeRunId) => rawApi.getScheduleNodeRunArtifacts(workspaceId, runId, nodeRunId), // Workspace (Project) Management - 系统管理接口(跨 workspace,不需要传入 workspaceId) - listWorkspaces: () => rawApi.listWorkspaces(), + listWorkspaces: (input) => rawApi.listWorkspaces(input), createWorkspace: (input) => rawApi.createWorkspace(input), updateWorkspace: (workspaceId, input) => rawApi.updateWorkspace(workspaceId, input), deleteWorkspace: (workspaceId) => rawApi.deleteWorkspace(workspaceId), diff --git a/frontend/app/features/admin/AdminPagination.tsx b/frontend/app/features/admin/AdminPagination.tsx new file mode 100644 index 0000000..41702b6 --- /dev/null +++ b/frontend/app/features/admin/AdminPagination.tsx @@ -0,0 +1,117 @@ +import { ChevronLeft, ChevronRight } from "lucide-react"; + +import { Button } from "~/components/ui/button"; + +/** Build a compact page number list with ellipsis, e.g. 1 … 4 5 6 … 20 */ +function visiblePages(current: number, total: number): Array { + if (total <= 7) { + return Array.from({ length: total }, (_, index) => index + 1); + } + const pages = new Set([1, total, current, current - 1, current + 1]); + if (current <= 3) { + pages.add(2); + pages.add(3); + pages.add(4); + } + if (current >= total - 2) { + pages.add(total - 1); + pages.add(total - 2); + pages.add(total - 3); + } + const sorted = [...pages].filter((p) => p >= 1 && p <= total).sort((a, b) => a - b); + const result: Array = []; + for (const page of sorted) { + const prev = result[result.length - 1]; + if (typeof prev === "number" && page - prev > 1) { + result.push("ellipsis"); + } + result.push(page); + } + return result; +} + +export function AdminPagination({ + page, + totalPages, + totalCount, + loading, + hasMore, + canGoToPage, + onPrev, + onNext, + onGoToPage, +}: { + page: number; + totalPages: number; + totalCount: number; + loading?: boolean; + hasMore: boolean; + canGoToPage: (page: number) => boolean; + onPrev: () => void; + onNext: () => void; + onGoToPage: (page: number) => void; +}) { + if (totalCount === 0) return null; + + const pages = visiblePages(page, totalPages); + + return ( +
+ + 共 {totalCount} 条,第 {page}/{totalPages} 页 + +
+ + {pages.map((item, index) => + item === "ellipsis" ? ( + + … + + ) : ( + + ), + )} + +
+
+ ); +} diff --git a/frontend/app/features/admin/ImportMemberDialog.tsx b/frontend/app/features/admin/ImportMemberDialog.tsx deleted file mode 100644 index 8ada20d..0000000 --- a/frontend/app/features/admin/ImportMemberDialog.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { type Employee, type Workspace } from "../../services/api"; -import { - AppFormDialog, - dialogPrimaryButtonClass, - dialogSecondaryButtonClass, -} from "~/components/common/AppFormDialog"; -import { Button } from "~/components/ui/button"; -import { - formFieldClass, - modalFormClass, -} from "../platform/modalUi"; -import { UserMultiSelect } from "./UserMultiSelect"; - -export function ImportMemberDialog({ - open, - selectedProject, - availableUsers, - selectedUserIds, - existingMemberIds, - saving, - onChangeSelectedUserIds, - onSubmit, - onClose, -}: { - open: boolean; - selectedProject: Workspace | null; - availableUsers: Employee[]; - selectedUserIds: string[]; - existingMemberIds: string[]; - saving: boolean; - onNotify: (notice: { tone: "success" | "error" | "info"; message: string }) => void; - onChangeSelectedUserIds: (ids: string[]) => void; - onSubmit: () => Promise | void; - onClose: () => void; -}) { - return ( - { - if (!nextOpen) onClose(); - }} - eyebrow="IMPORT MEMBER" - title={`导入成员到 ${selectedProject?.workspace_name ?? "项目"}`} - footer={ - <> - - - - } - > -
- -
-
- ); -} diff --git a/frontend/app/features/admin/MemberAddPanel.tsx b/frontend/app/features/admin/MemberAddPanel.tsx new file mode 100644 index 0000000..3af80de --- /dev/null +++ b/frontend/app/features/admin/MemberAddPanel.tsx @@ -0,0 +1,359 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +import { ArrowLeft, Search } from "lucide-react"; +import { type Employee } from "../../services/api"; +import { Button } from "~/components/ui/button"; +import { dialogSecondaryButtonClass } from "~/components/common/AppFormDialog"; +import { primaryGradientButtonClass } from "~/components/common/buttonClasses"; +import { useDebouncedValue } from "./useDebouncedValue"; + +export type LoadUsersFn = (input: { + q: string; + cursor: string | null; + limit: number; +}) => Promise<{ + items: Employee[]; + hasMore: boolean; + nextCursor: string | null; +}>; + +export function MemberAddPanel({ + existingMemberIds, + saving, + loadUsers, + onBack, + onAddMembers, +}: { + existingMemberIds: string[]; + saving: boolean; + loadUsers: LoadUsersFn; + onBack: () => void; + onAddMembers: (userIds: string[]) => Promise | void; +}) { + const [keyword, setKeyword] = useState(""); + const debouncedKeyword = useDebouncedValue(keyword, 300); + const [users, setUsers] = useState([]); + const [selectedIds, setSelectedIds] = useState([]); + const [selectedCache, setSelectedCache] = useState>({}); + const [loading, setLoading] = useState(false); + const [loadingMore, setLoadingMore] = useState(false); + const [hasMore, setHasMore] = useState(false); + const nextCursorRef = useRef(null); + const requestIdRef = useRef(0); + + const fetchPage = useCallback( + async (cursor: string | null, append: boolean) => { + const requestId = ++requestIdRef.current; + if (append) setLoadingMore(true); + else setLoading(true); + try { + const result = await loadUsers({ + q: debouncedKeyword, + cursor, + limit: 10, + }); + if (requestId !== requestIdRef.current) return; + setUsers((current) => (append ? [...current, ...result.items] : result.items)); + setHasMore(result.hasMore); + nextCursorRef.current = result.nextCursor; + } finally { + if (requestId === requestIdRef.current) { + setLoading(false); + setLoadingMore(false); + } + } + }, + [debouncedKeyword, loadUsers], + ); + + useEffect(() => { + nextCursorRef.current = null; + void fetchPage(null, false); + }, [debouncedKeyword, fetchPage]); + + const toggleUser = (user: Employee) => { + if (existingMemberIds.includes(user.user_id)) return; + setSelectedCache((cache) => ({ ...cache, [user.user_id]: user })); + setSelectedIds((ids) => + ids.includes(user.user_id) + ? ids.filter((id) => id !== user.user_id) + : [...ids, user.user_id], + ); + }; + + const removeSelected = (userId: string) => { + setSelectedIds((ids) => ids.filter((id) => id !== userId)); + }; + + const loadMore = () => { + if (loadingMore || loading || !hasMore || !nextCursorRef.current) return; + void fetchPage(nextCursorRef.current, true); + }; + + const submitAdd = async () => { + if (selectedIds.length === 0) return; + try { + await onAddMembers(selectedIds); + onBack(); + } catch { + // stay on add panel; page already toasts + } + }; + + const selectedUsers = selectedIds + .map((id) => selectedCache[id] ?? users.find((u) => u.user_id === id)) + .filter((u): u is Employee => Boolean(u)); + + return ( + <> +
+ +
+ + setKeyword(event.target.value)} + placeholder="搜索姓名 / 账号" + style={{ + flex: 1, + border: 0, + outline: "none", + background: "transparent", + fontSize: 12, + color: "#20364c", + }} + /> +
+ {selectedUsers.length > 0 && ( +
+ {selectedUsers.map((user) => ( + + {user.display_name} + + + ))} +
+ )} +
+ +
+ {loading && users.length === 0 ? ( +

+ 加载中… +

+ ) : users.length === 0 ? ( +

+ {debouncedKeyword.trim() ? "未找到匹配用户" : "暂无可选用户"} +

+ ) : ( + users.map((user) => { + const isExisting = existingMemberIds.includes(user.user_id); + const isSelected = selectedIds.includes(user.user_id); + return ( + + ); + }) + )} + {hasMore && ( +
+ +
+ )} +
+ +
+ + +
+ + ); +} diff --git a/frontend/app/features/admin/ProjectManagementPage.tsx b/frontend/app/features/admin/ProjectManagementPage.tsx index 15e6b1d..2df0459 100644 --- a/frontend/app/features/admin/ProjectManagementPage.tsx +++ b/frontend/app/features/admin/ProjectManagementPage.tsx @@ -1,7 +1,7 @@ -import { useEffect, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { Plus, Search } from "lucide-react"; -import { ApiRequestError, type Employee, type Workspace, type WorkspaceMember } from "../../services/api"; +import { ApiRequestError, type Workspace, type WorkspaceMember } from "../../services/api"; import { useApi, useAuth } from "../../context/AuthContext"; import { Button } from "~/components/ui/button"; import { ConfirmDialog } from "~/components/common/ConfirmDialog"; @@ -14,9 +14,11 @@ import { TableRow, } from "~/components/ui/table"; import { AdminColgroup, USER_PROJECT_COL_WIDTHS } from "./AdminTable"; +import { AdminPagination } from "./AdminPagination"; import { ProjectEditDialog } from "./ProjectEditDialog"; -import { ImportMemberDialog } from "./ImportMemberDialog"; import { ProjectMembersDrawer } from "./ProjectMembersDrawer"; +import { useCursorPage } from "./useCursorPage"; +import { useDebouncedValue } from "./useDebouncedValue"; import { adminEmptyClass, adminPageClass, @@ -50,44 +52,53 @@ export function ProjectManagementPage({ }) { const api = useApi(); const { user, refreshWorkspaces } = useAuth(); - const [projects, setProjects] = useState([]); - const [projectLoading, setProjectLoading] = useState(true); const [projectDialogOpen, setProjectDialogOpen] = useState(false); const [projectForm, setProjectForm] = useState(EMPTY_PROJECT_FORM); const [editingProject, setEditingProject] = useState(null); - const [importMemberDialogOpen, setImportMemberDialogOpen] = useState(false); const [selectedProject, setSelectedProject] = useState(null); - const [availableUsers, setAvailableUsers] = useState([]); - const [selectedUserIds, setSelectedUserIds] = useState([]); const [projectSearchTerm, setProjectSearchTerm] = useState(""); const [saving, setSaving] = useState(false); const [membersDrawerOpen, setMembersDrawerOpen] = useState(false); const [currentProjectMembers, setCurrentProjectMembers] = useState([]); const [membersLoading, setMembersLoading] = useState(false); const [deleteTarget, setDeleteTarget] = useState(null); + const [removeMemberTarget, setRemoveMemberTarget] = useState(null); + const debouncedSearch = useDebouncedValue(projectSearchTerm, 300); const canManage = user?.role_code === "admin"; - const loadProjects = async (): Promise => { - setProjectLoading(true); - try { - const workspaceList = await api.listWorkspaces(); - setProjects(workspaceList); - onConnectionChange(true); - } catch (error) { - onConnectionChange(false); - onNotify({ - tone: "error", - message: error instanceof Error ? error.message : "项目列表加载失败", - }); - } finally { - setProjectLoading(false); - } - }; + const fetchProjects = useCallback( + async (input: { limit: number; cursor: string | null; q: string }) => { + try { + const page = await api.listWorkspaces(input); + onConnectionChange(true); + return page; + } catch (error) { + onConnectionChange(false); + onNotify({ + tone: "error", + message: error instanceof Error ? error.message : "项目列表加载失败", + }); + throw error; + } + }, + [api, onConnectionChange, onNotify], + ); - useEffect(() => { - void loadProjects(); - }, []); + const { + items: projects, + setItems: setProjects, + page, + loading: projectLoading, + meta, + totalPages, + goNext, + goPrev, + goToPage, + canGoToPage, + reload, + refreshFromStart, + } = useCursorPage(fetchProjects, debouncedSearch); const openCreateProject = (): void => { setEditingProject(null); @@ -125,26 +136,36 @@ export function ProjectManagementPage({ description: projectForm.description.trim() || undefined, }); setProjects((current) => - current.map((p) => (p.workspace_id === updated.workspace_id ? updated : p)) + current.map((p) => (p.workspace_id === updated.workspace_id ? updated : p)), ); onNotify({ tone: "success", message: "项目信息已更新" }); } else { - const generatedCode = projectForm.workspace_code.trim() || projectForm.workspace_name.trim().toLowerCase().replace(/[^a-z0-9-]/g, "-").slice(0, 32); - const created = await api.createWorkspace({ + const generatedCode = + projectForm.workspace_code.trim() || + projectForm.workspace_name + .trim() + .toLowerCase() + .replace(/[^a-z0-9-]/g, "-") + .slice(0, 32); + await api.createWorkspace({ workspace_code: generatedCode, workspace_name: projectForm.workspace_name.trim(), quota_bytes: projectForm.quota_bytes, description: projectForm.description.trim() || undefined, }); - setProjects((current) => [...current, created]); void refreshWorkspaces(); onNotify({ tone: "success", message: "项目已创建" }); + await refreshFromStart(); } setProjectDialogOpen(false); } catch (error) { onNotify({ tone: "error", - message: error instanceof ApiRequestError ? error.message : (editingProject ? "更新项目失败" : "创建项目失败"), + message: error instanceof ApiRequestError + ? error.message + : editingProject + ? "更新项目失败" + : "创建项目失败", }); } finally { setSaving(false); @@ -154,8 +175,12 @@ export function ProjectManagementPage({ const executeDeleteProject = async (project: Workspace): Promise => { try { await api.deleteWorkspace(project.workspace_id); - setProjects((current) => current.filter((p) => p.workspace_id !== project.workspace_id)); onNotify({ tone: "success", message: "项目已删除" }); + if (projects.length <= 1 && page > 1) { + goPrev(); + } else { + reload(); + } } catch (error) { onNotify({ tone: "error", @@ -164,26 +189,21 @@ export function ProjectManagementPage({ } }; - const openImportMemberDialog = async (project: Workspace): Promise => { - setSelectedProject(project); - setSelectedUserIds([]); - setImportMemberDialogOpen(true); - - // 加载可用用户和项目成员 - try { - const [allUsers, currentMembers] = await Promise.all([ - api.listPlatformEmployees(), - api.listWorkspaceMembers(project.workspace_id), - ]); - setAvailableUsers(allUsers); - setCurrentProjectMembers(currentMembers); - } catch (error) { - // 如果加载失败,仍显示所有用户 - const allUsers = await api.listPlatformEmployees(); - setAvailableUsers(allUsers); - setCurrentProjectMembers([]); - } - }; + const loadImportUsers = useCallback( + async (input: { q: string; cursor: string | null; limit: number }) => { + const page = await api.listPlatformEmployees({ + limit: input.limit, + cursor: input.cursor, + q: input.q, + }); + return { + items: page.items, + hasMore: page.meta.has_more, + nextCursor: page.meta.next_cursor, + }; + }, + [api], + ); const openMembersDrawer = (project: Workspace): void => { setSelectedProject(project); @@ -206,17 +226,23 @@ export function ProjectManagementPage({ } }; - const removeMember = async (userId: string): Promise => { - if (!selectedProject) return; - // 管理员不能被移除 + const requestRemoveMember = (userId: string): void => { const targetMember = currentProjectMembers.find((m) => m.user_id === userId); - if (targetMember?.role_code === "admin") { + if (!targetMember) return; + if (targetMember.role_code === "admin") { onNotify({ tone: "error", message: "管理员不能被移除" }); return; } + setRemoveMemberTarget(targetMember); + }; + + const executeRemoveMember = async (member: WorkspaceMember): Promise => { + if (!selectedProject) return; try { - await api.deleteWorkspaceMember(selectedProject.workspace_id, userId); - setCurrentProjectMembers((current) => current.filter((m) => m.user_id !== userId)); + await api.deleteWorkspaceMember(selectedProject.workspace_id, member.user_id); + setCurrentProjectMembers((current) => + current.filter((m) => m.user_id !== member.user_id), + ); onNotify({ tone: "success", message: "成员已移除" }); } catch (error) { onNotify({ @@ -226,32 +252,39 @@ export function ProjectManagementPage({ } }; - const importMember = async (): Promise => { - if (!selectedProject || selectedUserIds.length === 0) { + const importMembers = async (userIds: string[]): Promise => { + if (!selectedProject || userIds.length === 0) { onNotify({ tone: "error", message: "请选择要添加的用户" }); return; } setSaving(true); try { await Promise.all( - selectedUserIds.map((userId) => + userIds.map((userId) => api.addWorkspaceMember(selectedProject.workspace_id, { user_id: userId, - }) - ) + }), + ), ); - setImportMemberDialogOpen(false); - onNotify({ tone: "success", message: `已添加 ${selectedUserIds.length} 名成员` }); + onNotify({ tone: "success", message: `已添加 ${userIds.length} 名成员` }); + await loadProjectMembers(selectedProject.workspace_id); } catch (error) { onNotify({ tone: "error", message: error instanceof ApiRequestError ? error.message : "添加成员失败", }); + throw error; } finally { setSaving(false); } }; + const emptyMessage = useMemo(() => { + if (projectLoading) return "正在加载项目…"; + if (debouncedSearch.trim()) return "未找到匹配的项目"; + return "暂无项目"; + }, [projectLoading, debouncedSearch]); + return (
@@ -291,90 +324,90 @@ export function ProjectManagementPage({ - {projectLoading ? ( - + {projectLoading || projects.length === 0 ? ( + -

正在加载项目…

-
-
- ) : projects.length === 0 ? ( - - -

暂无项目

+

{emptyMessage}

) : ( - projects - .filter((project) => { - const term = projectSearchTerm.toLowerCase().trim(); - if (!term) return true; - return ( - project.workspace_name.toLowerCase().includes(term) || - project.workspace_code.toLowerCase().includes(term) || - (project.description && project.description.toLowerCase().includes(term)) - ); - }) - .map((project) => ( - - - - {project.workspace_name.slice(0, 1)} - - {project.workspace_name} - {project.description ?? "无描述"} - - - - {project.workspace_code} - + projects.map((project) => ( + + + + {project.workspace_name.slice(0, 1)} - - {project.status === "active" ? "正常" : project.status === "archived" ? "已归档" : "已删除"} - + {project.workspace_name} + {project.description ?? "无描述"} - - {project.quota_bytes > 0 ? `${(project.quota_bytes / 1024 / 1024 / 1024).toFixed(1)} GB` : "无限制"} - - - - - - - - - )) + + + {project.workspace_code} + + + {project.status === "active" ? "正常" : project.status === "archived" ? "已归档" : "已删除"} + + + + + {project.quota_bytes > 0 + ? `${(project.quota_bytes / 1024 / 1024 / 1024).toFixed(1)} GB` + : "无限制"} + + + + + + + + + + + )) )}
+ + setProjectDialogOpen(false)} /> - m.user_id)} - saving={saving} - onNotify={onNotify} - onChangeSelectedUserIds={setSelectedUserIds} - onSubmit={() => void importMember()} - onClose={() => setImportMemberDialogOpen(false)} - /> - setMembersDrawerOpen(false)} - onAddMembers={() => { + saving={saving} + onClose={() => { setMembersDrawerOpen(false); - if (selectedProject) void openImportMemberDialog(selectedProject); + setRemoveMemberTarget(null); }} - onRemoveMember={(userId) => void removeMember(userId)} - onNotify={onNotify} + onRemoveMember={requestRemoveMember} + onAddMembers={importMembers} + loadUsers={loadImportUsers} /> + + { + if (!nextOpen) setRemoveMemberTarget(null); + }} + title="确定移除成员?" + description={ + removeMemberTarget + ? `确定将"${removeMemberTarget.display_name}"从项目中移除吗?` + : "" + } + confirmLabel="移除" + destructive + onConfirm={async () => { + if (!removeMemberTarget) return; + await executeRemoveMember(removeMemberTarget); + setRemoveMemberTarget(null); + }} + />
); } diff --git a/frontend/app/features/admin/ProjectMembersDrawer.tsx b/frontend/app/features/admin/ProjectMembersDrawer.tsx index 84687d5..26cb605 100644 --- a/frontend/app/features/admin/ProjectMembersDrawer.tsx +++ b/frontend/app/features/admin/ProjectMembersDrawer.tsx @@ -1,6 +1,12 @@ -import { type Workspace, type WorkspaceMember } from "../../services/api"; +import { useEffect, useState } from "react"; + import { Plus, X } from "lucide-react"; +import { type Workspace, type WorkspaceMember } from "../../services/api"; +import { Button } from "~/components/ui/button"; import { primaryGradientButtonClass } from "~/components/common/buttonClasses"; +import { MemberAddPanel, type LoadUsersFn } from "./MemberAddPanel"; + +type DrawerPanel = "members" | "add"; export function ProjectMembersDrawer({ open, @@ -8,151 +14,186 @@ export function ProjectMembersDrawer({ members, membersLoading, canManage, + saving, onClose, - onAddMembers, onRemoveMember, - onNotify, + onAddMembers, + loadUsers, }: { open: boolean; selectedProject: Workspace | null; members: WorkspaceMember[]; membersLoading: boolean; canManage: boolean; + saving: boolean; onClose: () => void; - onAddMembers: () => void; onRemoveMember: (userId: string) => void; - onNotify: (notice: { tone: "success" | "error" | "info"; message: string }) => void; + onAddMembers: (userIds: string[]) => Promise | void; + loadUsers: LoadUsersFn; }) { + const [panel, setPanel] = useState("members"); + + useEffect(() => { + if (!open) setPanel("members"); + }, [open]); + if (!open || !selectedProject) return null; return ( <> -
onClose()} style={{ - position: "fixed", - top: 0, - left: 0, - right: 0, - bottom: 0, - backgroundColor: "rgba(0,0,0,0.3)", - zIndex: 99, - }} /> -
diff --git a/frontend/app/features/platform/dataResourcePreview.ts b/frontend/app/features/platform/dataResourcePreview.ts new file mode 100644 index 0000000..1fffba5 --- /dev/null +++ b/frontend/app/features/platform/dataResourcePreview.ts @@ -0,0 +1,77 @@ +/** 数据资源预览:扩展名分流与展示名。 */ + +export type DataResourcePreviewKind = "excel" | "text" | "table"; + +export const EXCEL_EXTENSIONS = [".xlsx", ".xls"] as const; +export const TEXT_EXTENSIONS = [".txt", ".json"] as const; +export const TABLE_EXTENSIONS = [".csv", ".tsv"] as const; + +/** Excel 整文件拉取上限。 */ +export const EXCEL_PREVIEW_MAX_BYTES = 80 * 1024 * 1024; +/** 文本预览拉取上限。 */ +export const TEXT_PREVIEW_MAX_BYTES = 5 * 1024 * 1024; + +export type DataResourcePreviewTarget = { + resourceId: string; + resourceName: string; + fileExtension: string | null; + sizeBytes: number; + kind: DataResourcePreviewKind; +}; + +function lowerName(name: string | null | undefined): string { + return (name ?? "").toLowerCase(); +} + +function matchesExt(name: string, exts: readonly string[]): boolean { + return exts.some((ext) => name.endsWith(ext)); +} + +export function isExcelFileName(name: string | null | undefined): boolean { + return matchesExt(lowerName(name), EXCEL_EXTENSIONS); +} + +export function isTextPreviewFileName(name: string | null | undefined): boolean { + return matchesExt(lowerName(name), TEXT_EXTENSIONS); +} + +export function isTablePreviewFileName(name: string | null | undefined): boolean { + return matchesExt(lowerName(name), TABLE_EXTENSIONS); +} + +export function previewKindFromFileName( + name: string | null | undefined, +): DataResourcePreviewKind | null { + const lower = lowerName(name); + if (matchesExt(lower, EXCEL_EXTENSIONS)) return "excel"; + if (matchesExt(lower, TEXT_EXTENSIONS)) return "text"; + if (matchesExt(lower, TABLE_EXTENSIONS)) return "table"; + return null; +} + +export function canPreviewDataResource(name: string | null | undefined): boolean { + return previewKindFromFileName(name) !== null; +} + +export function resourceDisplayName( + resourceName: string, + fileExtension: string | null | undefined, +): string { + if (!fileExtension) return resourceName; + const ext = fileExtension.startsWith(".") + ? fileExtension + : `.${fileExtension}`; + if (resourceName.toLowerCase().endsWith(ext.toLowerCase())) { + return resourceName; + } + return `${resourceName}${ext}`; +} + +/** @deprecated use resourceDisplayName */ +export const excelDisplayName = resourceDisplayName; + +export function monacoLanguageFromFileName(name: string): string { + const lower = lowerName(name); + if (lower.endsWith(".json")) return "json"; + return "plaintext"; +} diff --git a/frontend/app/services/api.ts b/frontend/app/services/api.ts index f998bfe..b7b4568 100644 --- a/frontend/app/services/api.ts +++ b/frontend/app/services/api.ts @@ -556,6 +556,109 @@ export async function deleteResource( ); } +/** 同源流式下载数据资源字节,供 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: { @@ -1667,10 +1770,20 @@ export type WorkspaceBoundApi = { uploadId: string, body: Parameters[2], ) => Promise; - deleteResource: ( - resourceId: string, - ) => Promise<{ resource_id: string; status: string }>; - updateScript: ( + 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; diff --git a/frontend/package.json b/frontend/package.json index 50fa110..1d7056e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,6 +13,8 @@ }, "dependencies": { "@base-ui/react": "^1.7.0", + "@file-viewer/preset-office": "^3.0.0", + "@file-viewer/react": "^3.0.0", "@fontsource-variable/inter": "^5.3.0", "@monaco-editor/react": "^4.7.0", "@react-router/node": "^8", @@ -34,6 +36,7 @@ "zustand": "^5.0.14" }, "devDependencies": { + "@file-viewer/vite-plugin": "^3.0.0", "@react-router/dev": "^8", "@tailwindcss/vite": "^4.2.2", "@types/node": "^22", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index af650d4..ad74369 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -11,6 +11,12 @@ importers: '@base-ui/react': specifier: ^1.7.0 version: 1.7.0(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@file-viewer/preset-office': + specifier: ^3.0.0 + version: 3.0.0 + '@file-viewer/react': + specifier: ^3.0.0 + version: 3.0.0(react@19.2.8) '@fontsource-variable/inter': specifier: ^5.3.0 version: 5.3.0 @@ -69,6 +75,9 @@ importers: specifier: ^5.0.14 version: 5.0.14(@types/react@19.2.17)(react@19.2.8)(use-sync-external-store@1.6.0(react@19.2.8)) devDependencies: + '@file-viewer/vite-plugin': + specifier: ^3.0.0 + version: 3.0.0(vite@8.1.5(@types/node@22.20.1)(jiti@2.7.0)) '@react-router/dev': specifier: ^8 version: 8.3.0(@react-router/serve@8.3.0(react-router@8.3.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(typescript@5.9.3))(react-router@8.3.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(typescript@5.9.3)(vite@8.1.5(@types/node@22.20.1)(jiti@2.7.0)) @@ -264,13 +273,77 @@ packages: resolution: {integrity: sha512-VYJy0uhFm9zTJ1TxBaW/pA8bjbOM/OttaNMwZ1RHG4JKyRG7DhSdiqD1ipQoAyoD22olUtxbP78W9xY3Wd11bg==} '@emnapi/core@1.11.1': - resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} + resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==, tarball: https://registry.npmmirror.com/@emnapi/core/-/core-1.11.1.tgz} '@emnapi/runtime@1.11.1': - resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} + resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==, tarball: https://registry.npmmirror.com/@emnapi/runtime/-/runtime-1.11.1.tgz} '@emnapi/wasi-threads@1.2.2': - resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==, tarball: https://registry.npmmirror.com/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz} + + '@file-viewer/core@3.0.0': + resolution: {integrity: sha512-2yf2KAjrCAFQtzcX+O8kKPLQcNxuacsKHkZNllwRky9xegsHgj10EO1jPVJaH0TF9D44LSzS4AoC6Brg4wCX8g==, tarball: https://registry.npmmirror.com/@file-viewer/core/-/core-3.0.0.tgz} + + '@file-viewer/doc@3.0.0': + resolution: {integrity: sha512-vuGQS7CwyVNpTy5lkOeH6rBwq29Eyl5BSsqoF9aXhWN+sCt/oKpS6U5MQjsbVV6dE5YvM+woHQJj7TklK63VuQ==, tarball: https://registry.npmmirror.com/@file-viewer/doc/-/doc-3.0.0.tgz} + + '@file-viewer/docx@0.3.28': + resolution: {integrity: sha512-EKi7TnQpHGkZX285ACHwLeu7pGKRSO4TXmIBQ0BNSLtBJRf0IoffLEut981qGgyvLfuXvJLoi6JoCh2xB6qMQA==, tarball: https://registry.npmmirror.com/@file-viewer/docx/-/docx-0.3.28.tgz} + + '@file-viewer/ppt@0.3.3': + resolution: {integrity: sha512-Px2OpBUWlp74Fyt6Vm+V8IU7+xOt1Zp9PWzWo7zUCdCr+I5YYCJ9v0Nt2kriYyrusbWm8E5ri+wNy6ZkR7v3qQ==, tarball: https://registry.npmmirror.com/@file-viewer/ppt/-/ppt-0.3.3.tgz} + engines: {node: '>=18'} + + '@file-viewer/pptx@3.0.0': + resolution: {integrity: sha512-6ZpVkC8wRFA7fkGK8I3ohfxh7/c6nl2Im+SM7a9jlVgoPA5fEA3SMB8f5StK/HbJzNPlRH/O+lExOJY8cZrpww==, tarball: https://registry.npmmirror.com/@file-viewer/pptx/-/pptx-3.0.0.tgz} + + '@file-viewer/preset-office@3.0.0': + resolution: {integrity: sha512-tsICQpPzR5aHuyL1sA9623TY8Ok+vgGpZE7vXc5EBncb5OZ99Y8OHZ5APx7k2/JyIZ1ptOekx34jE3eaMU7/FQ==, tarball: https://registry.npmmirror.com/@file-viewer/preset-office/-/preset-office-3.0.0.tgz} + + '@file-viewer/react@3.0.0': + resolution: {integrity: sha512-483TZMmYbf67FiXJnsk742/5UI5e0y3kX9245rruydznwV7+lkJ3LdcMX1L46geCa7Q6THd/HWm9ccvt1PkX/g==, tarball: https://registry.npmmirror.com/@file-viewer/react/-/react-3.0.0.tgz} + peerDependencies: + react: '>=17 <20' + + '@file-viewer/renderer-hangul@3.0.0': + resolution: {integrity: sha512-kG4Efj68jOc2MVXmwzufwbt1KayftbSALhR41X7HoN3Y/UcEkiePgy1XU9SDE3CaYXJ3l8de1MzDWBtpqDKmGg==, tarball: https://registry.npmmirror.com/@file-viewer/renderer-hangul/-/renderer-hangul-3.0.0.tgz} + + '@file-viewer/renderer-iwork@3.0.0': + resolution: {integrity: sha512-SBc4exrcciNdqTRHryo6cpY6X0Ti+i6BxawHzRj7WXO2zpZuc/54ZAgH1YCDOXOdqfwdLVO574Y0+DUTBRaD6A==, tarball: https://registry.npmmirror.com/@file-viewer/renderer-iwork/-/renderer-iwork-3.0.0.tgz} + + '@file-viewer/renderer-ofd@3.0.0': + resolution: {integrity: sha512-CuG3oIvFCM7NBQbXo3V0D508VpCtcUP5JfeXAUTrGTcrlNxjRhKexpHzk4AyvQTfJOduAIXyxg7eGKi2MZ+8Sg==, tarball: https://registry.npmmirror.com/@file-viewer/renderer-ofd/-/renderer-ofd-3.0.0.tgz} + + '@file-viewer/renderer-pdf@3.0.0': + resolution: {integrity: sha512-MA6caHidGkpVnkJzRpkqqWLspi5FwdBVUF3aKlF4/n0N7xqyFt7rGNay+VEyd+cmFS6bsDQkDfPzhhbrfdCsGQ==, tarball: https://registry.npmmirror.com/@file-viewer/renderer-pdf/-/renderer-pdf-3.0.0.tgz} + peerDependencies: + pdf-lib: 1.17.1 + peerDependenciesMeta: + pdf-lib: + optional: true + + '@file-viewer/renderer-ppt@3.0.0': + resolution: {integrity: sha512-i76uuauBhH4RuDNsFQxkgMEOK4ykin3dAGhdVyNTNica1IdRtYv37ELksEys1OXE6DN7ZbgrAJxUGYOGvpJCkw==, tarball: https://registry.npmmirror.com/@file-viewer/renderer-ppt/-/renderer-ppt-3.0.0.tgz} + + '@file-viewer/renderer-pptx@3.0.0': + resolution: {integrity: sha512-crzBqog20MKhPIPpw/OetS9NmUTTfKt7MJG/cvL7rdJWpoHsyTojd2SqhrP3hvdoQx+IVKp6+qypmVW8FpgCtw==, tarball: https://registry.npmmirror.com/@file-viewer/renderer-pptx/-/renderer-pptx-3.0.0.tgz} + + '@file-viewer/renderer-presentation@3.0.0': + resolution: {integrity: sha512-oXfPi1F0KI+mIEso17GFeMaYaXlPxps+jcv8yCHykYeXKJ07B2Ekr8FZlyb15ZR3qjFTtwvTi7iaTRRNHNNYLA==, tarball: https://registry.npmmirror.com/@file-viewer/renderer-presentation/-/renderer-presentation-3.0.0.tgz} + + '@file-viewer/renderer-spreadsheet@3.0.0': + resolution: {integrity: sha512-uEx/+F8T2NybC9q5hnZcMtzRGSgvyAy/dcVSdzhlUEvrI78za+QGwmGjjXt3DOr7z+kSlxakNrLrwlzqeTVAgg==, tarball: https://registry.npmmirror.com/@file-viewer/renderer-spreadsheet/-/renderer-spreadsheet-3.0.0.tgz} + + '@file-viewer/renderer-word@3.0.0': + resolution: {integrity: sha512-1udx8S6IfMbc6LBAxg3u1eKQV6qJVLqU6vsv5QczjElCgTEU/M1ON5OqFyiymw7/PHtQSnRNZ6tUr+9rNV79DA==, tarball: https://registry.npmmirror.com/@file-viewer/renderer-word/-/renderer-word-3.0.0.tgz} + + '@file-viewer/renderer-wordperfect@3.0.0': + resolution: {integrity: sha512-ehMVOo8YJ2KAq3w+nR93fM4NZhnPmLF/LmnFIinDS0pUWtXyNk5yDJnqpkz38ahnqgYYhV29IPvXdcfAhGIIdA==, tarball: https://registry.npmmirror.com/@file-viewer/renderer-wordperfect/-/renderer-wordperfect-3.0.0.tgz} + + '@file-viewer/vite-plugin@3.0.0': + resolution: {integrity: sha512-Nh/yNhhHhIr/FqzodmJRKqLOEzoKJcpC6Kv+9TddayPoxC84R5Kra22RqDBWzn0vybxU126zpWmmBPzyZkC2wA==, tarball: https://registry.npmmirror.com/@file-viewer/vite-plugin/-/vite-plugin-3.0.0.tgz} + peerDependencies: + vite: '>=5 <9' '@floating-ui/core@1.8.0': resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==} @@ -350,12 +423,15 @@ packages: react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 '@napi-rs/wasm-runtime@1.2.0': - resolution: {integrity: sha512-kDoONqMa+VnZ4vvvu/ZUurpJ4gkZU57e7g69qpNgWhYcZFPUHZM2CEMKm+cG6ufDVALbjMvfmMjFVqaK7uEMnA==} + resolution: {integrity: sha512-kDoONqMa+VnZ4vvvu/ZUurpJ4gkZU57e7g69qpNgWhYcZFPUHZM2CEMKm+cG6ufDVALbjMvfmMjFVqaK7uEMnA==, tarball: https://registry.npmmirror.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.0.tgz} engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} peerDependencies: '@emnapi/core': ^2.0.0-alpha.3 '@emnapi/runtime': ^2.0.0-alpha.3 + '@nodable/entities@3.0.0': + resolution: {integrity: sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==, tarball: https://registry.npmmirror.com/@nodable/entities/-/entities-3.0.0.tgz} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -371,6 +447,9 @@ packages: '@oxc-project/types@0.139.0': resolution: {integrity: sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==} + '@protobuf-ts/runtime@2.11.1': + resolution: {integrity: sha512-KuDaT1IfHkugM2pyz+FwiY80ejWrkH1pAtOBOZFuR6SXEFTsnb/jiQWQ1rCIrcKx2BtyxnxW6BWwsVSA/Ie+WQ==, tarball: https://registry.npmmirror.com/@protobuf-ts/runtime/-/runtime-2.11.1.tgz} + '@react-router/dev@8.3.0': resolution: {integrity: sha512-XR+N2fEFOPjczYo2efc3/AOtosbSICCroLF/IxnZ6ErGBeBGRG6SqAso0SYoff0e18OA05qOyhJHFhXKMGIPRw==} engines: {node: '>=22.22.0'} @@ -427,96 +506,96 @@ packages: resolution: {integrity: sha512-UfjOXed/DQteaM5VyTfqTeGpHwyL2J5aoRGY6cydip4tt1ehNNeSwuXCC7AEGE0RWBs/7bgKxYkL/B/+UDe4AA==} '@rolldown/binding-android-arm64@1.1.5': - resolution: {integrity: sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==} + resolution: {integrity: sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==, tarball: https://registry.npmmirror.com/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] '@rolldown/binding-darwin-arm64@1.1.5': - resolution: {integrity: sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==} + resolution: {integrity: sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==, tarball: https://registry.npmmirror.com/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] '@rolldown/binding-darwin-x64@1.1.5': - resolution: {integrity: sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==} + resolution: {integrity: sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==, tarball: https://registry.npmmirror.com/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] '@rolldown/binding-freebsd-x64@1.1.5': - resolution: {integrity: sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==} + resolution: {integrity: sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==, tarball: https://registry.npmmirror.com/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] '@rolldown/binding-linux-arm-gnueabihf@1.1.5': - resolution: {integrity: sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==} + resolution: {integrity: sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==, tarball: https://registry.npmmirror.com/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] '@rolldown/binding-linux-arm64-gnu@1.1.5': - resolution: {integrity: sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==} + resolution: {integrity: sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==, tarball: https://registry.npmmirror.com/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.1.5': - resolution: {integrity: sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==} + resolution: {integrity: sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==, tarball: https://registry.npmmirror.com/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] '@rolldown/binding-linux-ppc64-gnu@1.1.5': - resolution: {integrity: sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==} + resolution: {integrity: sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==, tarball: https://registry.npmmirror.com/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] '@rolldown/binding-linux-s390x-gnu@1.1.5': - resolution: {integrity: sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==} + resolution: {integrity: sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==, tarball: https://registry.npmmirror.com/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] '@rolldown/binding-linux-x64-gnu@1.1.5': - resolution: {integrity: sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==} + resolution: {integrity: sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==, tarball: https://registry.npmmirror.com/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] '@rolldown/binding-linux-x64-musl@1.1.5': - resolution: {integrity: sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==} + resolution: {integrity: sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==, tarball: https://registry.npmmirror.com/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] '@rolldown/binding-openharmony-arm64@1.1.5': - resolution: {integrity: sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==} + resolution: {integrity: sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==, tarball: https://registry.npmmirror.com/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] '@rolldown/binding-wasm32-wasi@1.1.5': - resolution: {integrity: sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==} + resolution: {integrity: sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==, tarball: https://registry.npmmirror.com/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz} engines: {node: ^20.19.0 || >=22.12.0} cpu: [wasm32] '@rolldown/binding-win32-arm64-msvc@1.1.5': - resolution: {integrity: sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==} + resolution: {integrity: sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==, tarball: https://registry.npmmirror.com/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] '@rolldown/binding-win32-x64-msvc@1.1.5': - resolution: {integrity: sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==} + resolution: {integrity: sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==, tarball: https://registry.npmmirror.com/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -531,69 +610,74 @@ packages: resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} engines: {node: '>=18'} + '@ssabrojs/hwpxjs@0.4.0': + resolution: {integrity: sha512-tJ42NS2ywHOnhnFQ/i73hQb9xZRQGRItr4P6qJEN3C4UeSDwGLgZsa8AeSpyL+2pK8ZDKuzLWAXGjGVvVPOUuA==, tarball: https://registry.npmmirror.com/@ssabrojs/hwpxjs/-/hwpxjs-0.4.0.tgz} + engines: {node: '>=18'} + hasBin: true + '@tailwindcss/node@4.3.3': resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==} '@tailwindcss/oxide-android-arm64@4.3.3': - resolution: {integrity: sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==} + resolution: {integrity: sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==, tarball: https://registry.npmmirror.com/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz} engines: {node: '>= 20'} cpu: [arm64] os: [android] '@tailwindcss/oxide-darwin-arm64@4.3.3': - resolution: {integrity: sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==} + resolution: {integrity: sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==, tarball: https://registry.npmmirror.com/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz} engines: {node: '>= 20'} cpu: [arm64] os: [darwin] '@tailwindcss/oxide-darwin-x64@4.3.3': - resolution: {integrity: sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==} + resolution: {integrity: sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==, tarball: https://registry.npmmirror.com/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz} engines: {node: '>= 20'} cpu: [x64] os: [darwin] '@tailwindcss/oxide-freebsd-x64@4.3.3': - resolution: {integrity: sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==} + resolution: {integrity: sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==, tarball: https://registry.npmmirror.com/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz} engines: {node: '>= 20'} cpu: [x64] os: [freebsd] '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3': - resolution: {integrity: sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==} + resolution: {integrity: sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==, tarball: https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz} engines: {node: '>= 20'} cpu: [arm] os: [linux] '@tailwindcss/oxide-linux-arm64-gnu@4.3.3': - resolution: {integrity: sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==} + resolution: {integrity: sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==, tarball: https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz} engines: {node: '>= 20'} cpu: [arm64] os: [linux] libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.3.3': - resolution: {integrity: sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==} + resolution: {integrity: sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==, tarball: https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz} engines: {node: '>= 20'} cpu: [arm64] os: [linux] libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.3.3': - resolution: {integrity: sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==} + resolution: {integrity: sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==, tarball: https://registry.npmmirror.com/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz} engines: {node: '>= 20'} cpu: [x64] os: [linux] libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.3.3': - resolution: {integrity: sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==} + resolution: {integrity: sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==, tarball: https://registry.npmmirror.com/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz} engines: {node: '>= 20'} cpu: [x64] os: [linux] libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.3.3': - resolution: {integrity: sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==} + resolution: {integrity: sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==, tarball: https://registry.npmmirror.com/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz} engines: {node: '>=14.0.0'} cpu: [wasm32] bundledDependencies: @@ -605,13 +689,13 @@ packages: - tslib '@tailwindcss/oxide-win32-arm64-msvc@4.3.3': - resolution: {integrity: sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==} + resolution: {integrity: sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==, tarball: https://registry.npmmirror.com/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz} engines: {node: '>= 20'} cpu: [arm64] os: [win32] '@tailwindcss/oxide-win32-x64-msvc@4.3.3': - resolution: {integrity: sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==} + resolution: {integrity: sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==, tarball: https://registry.npmmirror.com/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz} engines: {node: '>= 20'} cpu: [x64] os: [win32] @@ -648,7 +732,7 @@ packages: resolution: {integrity: sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==} '@tybys/wasm-util@0.10.3': - resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==, tarball: https://registry.npmmirror.com/@tybys/wasm-util/-/wasm-util-0.10.3.tgz} '@types/node@22.20.1': resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==} @@ -662,15 +746,23 @@ packages: resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} '@types/trusted-types@2.0.7': - resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==, tarball: https://registry.npmmirror.com/@types/trusted-types/-/trusted-types-2.0.7.tgz} '@types/validate-npm-package-name@4.0.2': resolution: {integrity: sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==} + '@xmldom/xmldom@0.9.12': + resolution: {integrity: sha512-5AXjrcMClTryPe9LgZrygpB1lj7s0S9E0+W+AHaVKAVyHanafK86iPSvG5xHVSp/jC+VH1UXu0TAEmY279xH7A==, tarball: https://registry.npmmirror.com/@xmldom/xmldom/-/xmldom-0.9.12.tgz} + engines: {node: '>=14.6'} + accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} + adler-32@1.3.1: + resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==, tarball: https://registry.npmmirror.com/adler-32/-/adler-32-1.3.1.tgz} + engines: {node: '>=0.8'} + ajv-formats@2.1.1: resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} peerDependencies: @@ -702,6 +794,9 @@ packages: resolution: {integrity: sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==} engines: {node: '>=12'} + anynum@1.0.1: + resolution: {integrity: sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==, tarball: https://registry.npmmirror.com/anynum/-/anynum-1.0.1.tgz} + argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -772,6 +867,10 @@ packages: caniuse-lite@1.0.30001806: resolution: {integrity: sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==} + cfb@1.2.2: + resolution: {integrity: sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==, tarball: https://registry.npmmirror.com/cfb/-/cfb-1.2.2.tgz} + engines: {node: '>=0.8'} + chalk@5.3.0: resolution: {integrity: sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} @@ -847,6 +946,9 @@ packages: resolution: {integrity: sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==} engines: {node: '>= 0.6'} + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==, tarball: https://registry.npmmirror.com/core-util-is/-/core-util-is-1.0.3.tgz} + cors@2.8.6: resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} engines: {node: '>= 0.10'} @@ -860,6 +962,11 @@ packages: typescript: optional: true + crc-32@1.2.2: + resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==, tarball: https://registry.npmmirror.com/crc-32/-/crc-32-1.2.2.tgz} + engines: {node: '>=0.8'} + hasBin: true + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -933,9 +1040,34 @@ packages: resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} engines: {node: '>=0.3.1'} + dingbat-to-unicode@1.0.1: + resolution: {integrity: sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==, tarball: https://registry.npmmirror.com/dingbat-to-unicode/-/dingbat-to-unicode-1.0.1.tgz} + + dom-serializer@3.1.1: + resolution: {integrity: sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==, tarball: https://registry.npmmirror.com/dom-serializer/-/dom-serializer-3.1.1.tgz} + engines: {node: '>=20.19.0'} + + domelementtype@3.0.0: + resolution: {integrity: sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==, tarball: https://registry.npmmirror.com/domelementtype/-/domelementtype-3.0.0.tgz} + engines: {node: '>=20.19.0'} + + domhandler@6.0.1: + resolution: {integrity: sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==, tarball: https://registry.npmmirror.com/domhandler/-/domhandler-6.0.1.tgz} + engines: {node: '>=20.19.0'} + + dompurify@3.4.13: + resolution: {integrity: sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==, tarball: https://registry.npmmirror.com/dompurify/-/dompurify-3.4.13.tgz} + + dompurify@3.4.14: + resolution: {integrity: sha512-dVoH9z+MY+C9IilgGCk3YfFqjLi3fChm2OiKJMzh6axrJ5qwxqWaZamgmHrpv22CN/KdbZJuGEGgfQoL00LTdg==, tarball: https://registry.npmmirror.com/dompurify/-/dompurify-3.4.14.tgz} + dompurify@3.4.8: resolution: {integrity: sha512-yb1cEmaOum7wFvOCSQxyfgVlv5D47Rc30iZWoMpbDIWTnJ6grDDQyu2KFJzB2k7u0pMuJcQ1zphH//fFnw2tjQ==} + domutils@4.0.2: + resolution: {integrity: sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==, tarball: https://registry.npmmirror.com/domutils/-/domutils-4.0.2.tgz} + engines: {node: '>=20.19.0'} + dot-prop@6.0.1: resolution: {integrity: sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA==} engines: {node: '>=10'} @@ -948,6 +1080,9 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + e-virt-table@1.3.26: + resolution: {integrity: sha512-STKVxs/nGfmhilrJ0HDWSUHmSYmJPWurheolZ9Lgm8NIK/pZSv9muYR0F/C0UdkaWIixOGuS3Ye/seZ/buttCA==, tarball: https://registry.npmmirror.com/e-virt-table/-/e-virt-table-1.3.26.tgz} + ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} @@ -969,6 +1104,10 @@ packages: resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} engines: {node: '>=8.6'} + entities@8.0.0: + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==, tarball: https://registry.npmmirror.com/entities/-/entities-8.0.0.tgz} + engines: {node: '>=20.19.0'} + env-paths@2.2.1: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} @@ -1050,6 +1189,13 @@ packages: fast-uri@3.1.6: resolution: {integrity: sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==} + fast-xml-builder@1.3.1: + resolution: {integrity: sha512-pIM/1n3ntFXKYrUZwW7QCK0gAW7XY+wzj1YMIV3tLDvPj/V+zTGJK5e3/4WJfwj0qWw2ElNXiTixda/R+3YSug==, tarball: https://registry.npmmirror.com/fast-xml-builder/-/fast-xml-builder-1.3.1.tgz} + + fast-xml-parser@5.11.1: + resolution: {integrity: sha512-TBw6K/fxoQGGjCmZDw9w/ZwP3uDcnTM4YH/g+PFRWr8sbe5idXtxNN6vITh4+1ruCZaho6uBFurElsA7F0zzgw==, tarball: https://registry.npmmirror.com/fast-xml-parser/-/fast-xml-parser-5.11.1.tgz} + hasBin: true + fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -1091,7 +1237,7 @@ packages: engines: {node: '>=14.14'} fsevents@2.3.3: - resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==, tarball: https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] @@ -1152,6 +1298,10 @@ packages: resolution: {integrity: sha512-r8AO2mYHoLxSHkgafNeC/BXyb2vWRxD3jem4Ts+ptav8oTG5FIRifAjuJEmZI4bSvvc2ns0GxmIYiZnHqN3mMw==} engines: {node: '>=16.9.0'} + htmlparser2@12.0.0: + resolution: {integrity: sha512-Tz7u1i95/g2x2jz81+x0FBVhBhY5aRTvD3tXXdFaljuNdzDLJ8UGNRrTcj2cgQvAg3iW/h77Fz15nLW0L0CrZw==, tarball: https://registry.npmmirror.com/htmlparser2/-/htmlparser2-12.0.0.tgz} + engines: {node: '>=20.19.0'} + http-errors@2.0.1: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} @@ -1172,6 +1322,9 @@ packages: resolution: {integrity: sha512-g7dmpshy+gD7mh88OC9NwSGTKoc3kyLAZQRU1mt53Aw/vnvfXnbC+F/7F7QoYVKbV+KNvJx8wArewKy1vXMtlg==} engines: {node: '>= 4'} + immediate@3.0.6: + resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==, tarball: https://registry.npmmirror.com/immediate/-/immediate-3.0.6.tgz} + import-fresh@3.3.0: resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==} engines: {node: '>=6'} @@ -1260,6 +1413,9 @@ packages: resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} engines: {node: '>=18'} + is-unsafe@2.0.2: + resolution: {integrity: sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ==, tarball: https://registry.npmmirror.com/is-unsafe/-/is-unsafe-2.0.2.tgz} + is-wsl@2.2.0: resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} engines: {node: '>=8'} @@ -1268,6 +1424,9 @@ packages: resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} engines: {node: '>=16'} + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==, tarball: https://registry.npmmirror.com/isarray/-/isarray-1.0.0.tgz} + isbot@5.2.1: resolution: {integrity: sha512-dJ+LpKyClQZ7NG+j3OensC/mAZkGpukE9YUrgPYvAZj2doVL0edfDgywTUh5CXa0o+nW9a1V9e5+CJTX8+SxRw==} engines: {node: '>=18'} @@ -1318,6 +1477,12 @@ packages: jsonfile@6.2.1: resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} + jszip@3.10.1: + resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==, tarball: https://registry.npmmirror.com/jszip/-/jszip-3.10.1.tgz} + + keynote-archives@2.0.1: + resolution: {integrity: sha512-c2rEuhDRPPmwa/BGuPwk19gHgTr1a7p22tvysujR+87DeS2lbguYaWcGm0r03Aq6Yo1hjje+LMPG/7GgvVUTlg==, tarball: https://registry.npmmirror.com/keynote-archives/-/keynote-archives-2.0.1.tgz} + kleur@3.0.3: resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} engines: {node: '>=6'} @@ -1326,142 +1491,145 @@ packages: resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} engines: {node: '>=6'} + lie@3.3.0: + resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==, tarball: https://registry.npmmirror.com/lie/-/lie-3.3.0.tgz} + lightningcss-android-arm64@1.32.0: - resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==, tarball: https://registry.npmmirror.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [android] lightningcss-android-arm64@1.33.0: - resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==, tarball: https://registry.npmmirror.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [android] lightningcss-darwin-arm64@1.32.0: - resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==, tarball: https://registry.npmmirror.com/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [darwin] lightningcss-darwin-arm64@1.33.0: - resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==, tarball: https://registry.npmmirror.com/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [darwin] lightningcss-darwin-x64@1.32.0: - resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==, tarball: https://registry.npmmirror.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz} engines: {node: '>= 12.0.0'} cpu: [x64] os: [darwin] lightningcss-darwin-x64@1.33.0: - resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==, tarball: https://registry.npmmirror.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz} engines: {node: '>= 12.0.0'} cpu: [x64] os: [darwin] lightningcss-freebsd-x64@1.32.0: - resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==, tarball: https://registry.npmmirror.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz} engines: {node: '>= 12.0.0'} cpu: [x64] os: [freebsd] lightningcss-freebsd-x64@1.33.0: - resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==, tarball: https://registry.npmmirror.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz} engines: {node: '>= 12.0.0'} cpu: [x64] os: [freebsd] lightningcss-linux-arm-gnueabihf@1.32.0: - resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==, tarball: https://registry.npmmirror.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz} engines: {node: '>= 12.0.0'} cpu: [arm] os: [linux] lightningcss-linux-arm-gnueabihf@1.33.0: - resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==, tarball: https://registry.npmmirror.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz} engines: {node: '>= 12.0.0'} cpu: [arm] os: [linux] lightningcss-linux-arm64-gnu@1.32.0: - resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==, tarball: https://registry.npmmirror.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] libc: [glibc] lightningcss-linux-arm64-gnu@1.33.0: - resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==, tarball: https://registry.npmmirror.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: - resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==, tarball: https://registry.npmmirror.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] libc: [musl] lightningcss-linux-arm64-musl@1.33.0: - resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==, tarball: https://registry.npmmirror.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] libc: [musl] lightningcss-linux-x64-gnu@1.32.0: - resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==, tarball: https://registry.npmmirror.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] libc: [glibc] lightningcss-linux-x64-gnu@1.33.0: - resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==, tarball: https://registry.npmmirror.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] libc: [glibc] lightningcss-linux-x64-musl@1.32.0: - resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==, tarball: https://registry.npmmirror.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] libc: [musl] lightningcss-linux-x64-musl@1.33.0: - resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==, tarball: https://registry.npmmirror.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: - resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==, tarball: https://registry.npmmirror.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [win32] lightningcss-win32-arm64-msvc@1.33.0: - resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==, tarball: https://registry.npmmirror.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [win32] lightningcss-win32-x64-msvc@1.32.0: - resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==, tarball: https://registry.npmmirror.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz} engines: {node: '>= 12.0.0'} cpu: [x64] os: [win32] lightningcss-win32-x64-msvc@1.33.0: - resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==, tarball: https://registry.npmmirror.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz} engines: {node: '>= 12.0.0'} cpu: [x64] os: [win32] @@ -1504,6 +1672,11 @@ packages: engines: {node: '>= 18'} hasBin: true + marked@18.0.11: + resolution: {integrity: sha512-HnslJfsZkRPBDJRHvVtAaWlZHEpSu7u8LgQuJCELjRKuWR+hpq4A7sLq3p8HaI9ypVoXDXxV34CsQJEe1+J5Aw==, tarball: https://registry.npmmirror.com/marked/-/marked-18.0.11.tgz} + engines: {node: '>= 20'} + hasBin: true + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -1657,6 +1830,12 @@ packages: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==, tarball: https://registry.npmmirror.com/pako/-/pako-1.0.11.tgz} + + pako@2.2.0: + resolution: {integrity: sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==, tarball: https://registry.npmmirror.com/pako/-/pako-2.2.0.tgz} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -1680,6 +1859,10 @@ packages: resolution: {integrity: sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==} engines: {node: '>=4'} + path-expression-matcher@1.6.2: + resolution: {integrity: sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==, tarball: https://registry.npmmirror.com/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz} + engines: {node: '>=14.0.0'} + path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} @@ -1741,6 +1924,9 @@ packages: resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} engines: {node: '>=18'} + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==, tarball: https://registry.npmmirror.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz} + prompts@2.4.2: resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} engines: {node: '>= 6'} @@ -1787,6 +1973,9 @@ packages: resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} engines: {node: '>=0.10.0'} + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==, tarball: https://registry.npmmirror.com/readable-stream/-/readable-stream-2.3.8.tgz} + readdirp@5.0.0: resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} engines: {node: '>= 20.19.0'} @@ -1859,6 +2048,9 @@ packages: resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} engines: {node: '>= 18'} + setimmediate@1.0.5: + resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==, tarball: https://registry.npmmirror.com/setimmediate/-/setimmediate-1.0.5.tgz} + setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} @@ -1905,6 +2097,9 @@ packages: resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} + snappyjs@0.7.0: + resolution: {integrity: sha512-u5iEEXkMe2EInQio6Wv9LWHOQYRDbD2O9hzS27GpT/lwfIQhTCnHCTqedqHIHe9ZcvQo+9au6vngQayipz1NYw==, tarball: https://registry.npmmirror.com/snappyjs/-/snappyjs-0.7.0.tgz} + socks@2.8.9: resolution: {integrity: sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==} engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} @@ -1949,6 +2144,9 @@ packages: resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} engines: {node: '>=18'} + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==, tarball: https://registry.npmmirror.com/string_decoder/-/string_decoder-1.1.1.tgz} + stringify-object@5.0.0: resolution: {integrity: sha512-zaJYxz2FtcMb4f+g60KsRNFOpVMUyuJgA51Zi5Z1DOTC3S59+OQiVOzE9GZt0x72uBGWKsQIuBKeF9iusmKFsg==} engines: {node: '>=14.16'} @@ -1973,6 +2171,14 @@ packages: resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==} engines: {node: '>=18'} + strnum@2.4.2: + resolution: {integrity: sha512-rDG3Ah4TV0k1hWvLSzkZtMmLN9+eS+h3knq4MP6A42Y3Yh5qGNnOUs1jJkoSr8FG5dsL28c7KgkIBzSEykqtuw==, tarball: https://registry.npmmirror.com/strnum/-/strnum-2.4.2.tgz} + + styled-exceljs@0.21.4: + resolution: {integrity: sha512-oUoAJZmTG6+tyIKb7or9fOL/RI2lYtQj0++MboozfhaXErNkIhYTM0O+0zAJ+ZRavk9f2n+FARepUd08CDY9XA==, tarball: https://registry.npmmirror.com/styled-exceljs/-/styled-exceljs-0.21.4.tgz} + engines: {node: '>=0.8'} + hasBin: true + systeminformation@5.33.1: resolution: {integrity: sha512-DEN6ICHk3Tk0Uf/hrAHh7xlt7iL5CJFBtPZinA0H62DrGG/KPKqq/Nzj6lCXPS4Ay/sf/14zNnk9LpqKzBIc+w==} engines: {node: '>=10.0.0'} @@ -1992,6 +2198,9 @@ packages: tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + tinycolor2@1.6.0: + resolution: {integrity: sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==, tarball: https://registry.npmmirror.com/tinycolor2/-/tinycolor2-1.6.0.tgz} + tinyglobby@0.2.17: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} @@ -2012,7 +2221,7 @@ packages: engines: {node: '>=6'} tslib@2.8.1: - resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==, tarball: https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz} tw-animate-css@1.4.0: resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==} @@ -2056,6 +2265,9 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + utif@3.1.0: + resolution: {integrity: sha512-WEo4D/xOvFW53K5f5QTaTbbiORcm2/pCL9P6qmJnup+17eYfKaEhDeX9PeQkuyEoIxlbGklDuGl8xwuXYMrrXQ==, tarball: https://registry.npmmirror.com/utif/-/utif-3.1.0.tgz} + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -2135,6 +2347,10 @@ packages: resolution: {integrity: sha512-Hl0ZOAs672vg+06kfujwRhoS6/jehvULrlFkuF2dRu6pHgA8U06h3xqNIqNNU1LTXPcedxByAR4GS6pwQK0mgA==} engines: {node: '>=20'} + xml-naming@0.3.0: + resolution: {integrity: sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==, tarball: https://registry.npmmirror.com/xml-naming/-/xml-naming-0.3.0.tgz} + engines: {node: '>=16.0.0'} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -2422,6 +2638,116 @@ snapshots: tslib: 2.8.1 optional: true + '@file-viewer/core@3.0.0': + dependencies: + dompurify: 3.4.13 + + '@file-viewer/doc@3.0.0': + dependencies: + dompurify: 3.4.14 + + '@file-viewer/docx@0.3.28': + dependencies: + jszip: 3.10.1 + + '@file-viewer/ppt@0.3.3': {} + + '@file-viewer/pptx@3.0.0': + dependencies: + dingbat-to-unicode: 1.0.1 + dompurify: 3.4.14 + jszip: 3.10.1 + tinycolor2: 1.6.0 + utif: 3.1.0 + + '@file-viewer/preset-office@3.0.0': + dependencies: + '@file-viewer/core': 3.0.0 + '@file-viewer/renderer-hangul': 3.0.0 + '@file-viewer/renderer-iwork': 3.0.0 + '@file-viewer/renderer-ofd': 3.0.0 + '@file-viewer/renderer-pdf': 3.0.0 + '@file-viewer/renderer-presentation': 3.0.0 + '@file-viewer/renderer-spreadsheet': 3.0.0 + '@file-viewer/renderer-word': 3.0.0 + '@file-viewer/renderer-wordperfect': 3.0.0 + transitivePeerDependencies: + - pdf-lib + + '@file-viewer/react@3.0.0(react@19.2.8)': + dependencies: + '@file-viewer/core': 3.0.0 + react: 19.2.8 + + '@file-viewer/renderer-hangul@3.0.0': + dependencies: + '@file-viewer/core': 3.0.0 + '@ssabrojs/hwpxjs': 0.4.0 + '@xmldom/xmldom': 0.9.12 + cfb: 1.2.2 + jszip: 3.10.1 + pako: 2.2.0 + + '@file-viewer/renderer-iwork@3.0.0': + dependencies: + '@file-viewer/core': 3.0.0 + '@xmldom/xmldom': 0.9.12 + jszip: 3.10.1 + keynote-archives: 2.0.1 + pako: 2.2.0 + styled-exceljs: 0.21.4 + tslib: 2.8.1 + + '@file-viewer/renderer-ofd@3.0.0': + dependencies: + '@file-viewer/core': 3.0.0 + jszip: 3.10.1 + + '@file-viewer/renderer-pdf@3.0.0': + dependencies: + '@file-viewer/core': 3.0.0 + + '@file-viewer/renderer-ppt@3.0.0': + dependencies: + '@file-viewer/core': 3.0.0 + '@file-viewer/ppt': 0.3.3 + + '@file-viewer/renderer-pptx@3.0.0': + dependencies: + '@file-viewer/core': 3.0.0 + '@file-viewer/pptx': 3.0.0 + + '@file-viewer/renderer-presentation@3.0.0': + dependencies: + '@file-viewer/core': 3.0.0 + '@file-viewer/renderer-ppt': 3.0.0 + '@file-viewer/renderer-pptx': 3.0.0 + + '@file-viewer/renderer-spreadsheet@3.0.0': + dependencies: + '@file-viewer/core': 3.0.0 + '@xmldom/xmldom': 0.9.12 + e-virt-table: 1.3.26 + jszip: 3.10.1 + styled-exceljs: 0.21.4 + tinycolor2: 1.6.0 + utif: 3.1.0 + + '@file-viewer/renderer-word@3.0.0': + dependencies: + '@file-viewer/core': 3.0.0 + '@file-viewer/doc': 3.0.0 + '@file-viewer/docx': 0.3.28 + jszip: 3.10.1 + + '@file-viewer/renderer-wordperfect@3.0.0': + dependencies: + '@file-viewer/core': 3.0.0 + + '@file-viewer/vite-plugin@3.0.0(vite@8.1.5(@types/node@22.20.1)(jiti@2.7.0))': + dependencies: + vite: 8.1.5(@types/node@22.20.1)(jiti@2.7.0) + '@floating-ui/core@1.8.0': dependencies: '@floating-ui/utils': 0.2.12 @@ -2518,6 +2844,8 @@ snapshots: '@tybys/wasm-util': 0.10.3 optional: true + '@nodable/entities@3.0.0': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -2532,6 +2860,8 @@ snapshots: '@oxc-project/types@0.139.0': {} + '@protobuf-ts/runtime@2.11.1': {} + '@react-router/dev@8.3.0(@react-router/serve@8.3.0(react-router@8.3.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(typescript@5.9.3))(react-router@8.3.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(typescript@5.9.3)(vite@8.1.5(@types/node@22.20.1)(jiti@2.7.0))': dependencies: '@babel/core': 7.29.7 @@ -2655,6 +2985,15 @@ snapshots: '@sindresorhus/merge-streams@4.0.0': {} + '@ssabrojs/hwpxjs@0.4.0': + dependencies: + cfb: 1.2.2 + fast-xml-parser: 5.11.1 + htmlparser2: 12.0.0 + jszip: 3.10.1 + marked: 18.0.11 + pako: 2.2.0 + '@tailwindcss/node@4.3.3': dependencies: '@jridgewell/remapping': 2.3.5 @@ -2772,11 +3111,15 @@ snapshots: '@types/validate-npm-package-name@4.0.2': {} + '@xmldom/xmldom@0.9.12': {} + accepts@2.0.0: dependencies: mime-types: 3.0.2 negotiator: 1.0.0 + adler-32@1.3.1: {} + ajv-formats@2.1.1(ajv@8.20.0): optionalDependencies: ajv: 8.20.0 @@ -2798,6 +3141,8 @@ snapshots: ansi-regex@6.3.0: {} + anynum@1.0.1: {} + argparse@2.0.1: {} ast-types@0.16.1: @@ -2875,6 +3220,11 @@ snapshots: caniuse-lite@1.0.30001806: {} + cfb@1.2.2: + dependencies: + adler-32: 1.3.1 + crc-32: 1.2.2 + chalk@5.3.0: {} chokidar@5.0.0: @@ -2944,6 +3294,8 @@ snapshots: cookie@0.7.1: {} + core-util-is@1.0.3: {} + cors@2.8.6: dependencies: object-assign: 4.1.1 @@ -2958,6 +3310,8 @@ snapshots: optionalDependencies: typescript: 5.9.3 + crc-32@1.2.2: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -3001,10 +3355,38 @@ snapshots: diff@8.0.4: {} + dingbat-to-unicode@1.0.1: {} + + dom-serializer@3.1.1: + dependencies: + domelementtype: 3.0.0 + domhandler: 6.0.1 + entities: 8.0.0 + + domelementtype@3.0.0: {} + + domhandler@6.0.1: + dependencies: + domelementtype: 3.0.0 + + dompurify@3.4.13: + optionalDependencies: + '@types/trusted-types': 2.0.7 + + dompurify@3.4.14: + optionalDependencies: + '@types/trusted-types': 2.0.7 + dompurify@3.4.8: optionalDependencies: '@types/trusted-types': 2.0.7 + domutils@4.0.2: + dependencies: + dom-serializer: 3.1.1 + domelementtype: 3.0.0 + domhandler: 6.0.1 + dot-prop@6.0.1: dependencies: is-obj: 2.0.0 @@ -3017,6 +3399,10 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + e-virt-table@1.3.26: + dependencies: + '@floating-ui/dom': 1.8.0 + ee-first@1.1.1: {} electron-to-chromium@1.5.398: {} @@ -3035,6 +3421,8 @@ snapshots: ansi-colors: 4.1.3 strip-ansi: 6.0.1 + entities@8.0.0: {} + env-paths@2.2.1: {} error-ex@1.3.4: @@ -3149,6 +3537,20 @@ snapshots: fast-uri@3.1.6: {} + fast-xml-builder@1.3.1: + dependencies: + path-expression-matcher: 1.6.2 + xml-naming: 0.3.0 + + fast-xml-parser@5.11.1: + dependencies: + '@nodable/entities': 3.0.0 + fast-xml-builder: 1.3.1 + is-unsafe: 2.0.2 + path-expression-matcher: 1.6.2 + strnum: 2.4.2 + xml-naming: 0.3.0 + fastq@1.20.1: dependencies: reusify: 1.1.0 @@ -3244,6 +3646,13 @@ snapshots: hono@4.13.3: {} + htmlparser2@12.0.0: + dependencies: + domelementtype: 3.0.0 + domhandler: 6.0.1 + domutils: 4.0.2 + entities: 8.0.0 + http-errors@2.0.1: dependencies: depd: 2.0.0 @@ -3262,6 +3671,8 @@ snapshots: ignore@5.3.0: {} + immediate@3.0.6: {} + import-fresh@3.3.0: dependencies: parent-module: 1.0.1 @@ -3313,6 +3724,8 @@ snapshots: is-unicode-supported@2.1.0: {} + is-unsafe@2.0.2: {} + is-wsl@2.2.0: dependencies: is-docker: 2.2.1 @@ -3321,6 +3734,8 @@ snapshots: dependencies: is-inside-container: 1.0.0 + isarray@1.0.0: {} + isbot@5.2.1: {} isexe@2.0.0: {} @@ -3355,10 +3770,27 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 + jszip@3.10.1: + dependencies: + lie: 3.3.0 + pako: 1.0.11 + readable-stream: 2.3.8 + setimmediate: 1.0.5 + + keynote-archives@2.0.1: + dependencies: + '@protobuf-ts/runtime': 2.11.1 + jszip: 3.10.1 + snappyjs: 0.7.0 + kleur@3.0.3: {} kleur@4.1.5: {} + lie@3.3.0: + dependencies: + immediate: 3.0.6 + lightningcss-android-arm64@1.32.0: optional: true @@ -3485,6 +3917,8 @@ snapshots: marked@14.0.0: {} + marked@18.0.11: {} + math-intrinsics@1.1.0: {} media-typer@1.1.1: {} @@ -3622,6 +4056,10 @@ snapshots: p-try@2.2.0: {} + pako@1.0.11: {} + + pako@2.2.0: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -3641,6 +4079,8 @@ snapshots: path-exists@3.0.0: {} + path-expression-matcher@1.6.2: {} + path-key@3.1.1: {} path-key@4.0.0: {} @@ -3688,6 +4128,8 @@ snapshots: dependencies: parse-ms: 4.0.0 + process-nextick-args@2.0.1: {} + prompts@2.4.2: dependencies: kleur: 3.0.3 @@ -3730,6 +4172,16 @@ snapshots: react@19.2.8: {} + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + readdirp@5.0.0: {} recast@0.23.21: @@ -3827,6 +4279,8 @@ snapshots: transitivePeerDependencies: - supports-color + setimmediate@1.0.5: {} + setprototypeof@1.2.0: {} shadcn@4.19.0(typescript@5.9.3): @@ -3912,6 +4366,8 @@ snapshots: smart-buffer@4.2.0: {} + snappyjs@0.7.0: {} + socks@2.8.9: dependencies: ip-address: 10.5.0 @@ -3947,6 +4403,10 @@ snapshots: get-east-asian-width: 1.6.0 strip-ansi: 7.2.0 + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + stringify-object@5.0.0: dependencies: get-own-enumerable-keys: 1.0.0 @@ -3967,6 +4427,12 @@ snapshots: strip-final-newline@4.0.0: {} + strnum@2.4.2: + dependencies: + anynum: 1.0.1 + + styled-exceljs@0.21.4: {} + systeminformation@5.33.1: {} tailwind-merge@3.6.0: {} @@ -3977,6 +4443,8 @@ snapshots: tiny-invariant@1.3.3: {} + tinycolor2@1.6.0: {} + tinyglobby@0.2.17: dependencies: fdir: 6.5.0(picomatch@4.0.5) @@ -4031,6 +4499,10 @@ snapshots: dependencies: react: 19.2.8 + utif@3.1.0: + dependencies: + pako: 1.0.11 + util-deprecate@1.0.2: {} valibot@1.4.2(typescript@5.9.3): @@ -4068,6 +4540,8 @@ snapshots: is-wsl: 3.1.1 powershell-utils: 0.1.0 + xml-naming@0.3.0: {} + yallist@3.1.1: {} yocto-spinner@1.2.2: diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index f63484d..8b89453 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,11 +1,19 @@ import { reactRouter } from "@react-router/dev/vite"; +import { fileViewerRenderers } from "@file-viewer/vite-plugin"; import tailwindcss from "@tailwindcss/vite"; import { defineConfig } from "vite"; import path from "node:path"; export default defineConfig({ base: '/', - plugins: [reactRouter(), tailwindcss()], + plugins: [ + reactRouter(), + tailwindcss(), + fileViewerRenderers({ + preset: "office", + copyAssets: { mode: "both", baseDir: "file-viewer" }, + }), + ], resolve: { alias: { "~": path.resolve(__dirname, "./app"), From d23bc881bfa339660f718f205f68a1b2347bea58 Mon Sep 17 00:00:00 2001 From: xiaozhu <2395895331@qq.com> Date: Mon, 31 Aug 2026 18:51:17 +0800 Subject: [PATCH 06/13] =?UTF-8?q?feat:=E8=84=9A=E6=9C=AC=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E9=A2=84=E8=A7=88-backend?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- API.md | 2 + backend/src/backend/api/_resources_common.py | 130 +++++++++++ backend/src/backend/api/resources.py | 176 +------------- backend/src/backend/api/resources_content.py | 231 +++++++++++++++++++ backend/src/backend/main.py | 2 + 5 files changed, 374 insertions(+), 167 deletions(-) create mode 100644 backend/src/backend/api/_resources_common.py create mode 100644 backend/src/backend/api/resources_content.py diff --git a/API.md b/API.md index fadd101..efae065 100644 --- a/API.md +++ b/API.md @@ -508,6 +508,8 @@ queued ──→ running ──┬─→ succeeded | `POST` | `/api/v1/data-resources/uploads/{upload_id}/bind` | 绑定已完成上传为数据资源 | | `GET` | `/api/v1/data-resources` | 列表(owner 作用域 + visibility 过滤) | | `GET` | `/api/v1/data-resources/{id}` | 详情 | +| `GET` | `/api/v1/data-resources/{id}/content` | 同源流式读取文件字节(预览/下载) | +| `GET` | `/api/v1/data-resources/{id}/preview` | 表格抽样预览(csv/tsv, `limit` 默认 100) | | `POST` | `/api/v1/data-resources/{id}/download-url` | 生成 presigned GET URL | | `DELETE` | `/api/v1/data-resources/{id}` | 软删 | diff --git a/backend/src/backend/api/_resources_common.py b/backend/src/backend/api/_resources_common.py new file mode 100644 index 0000000..e35d2a3 --- /dev/null +++ b/backend/src/backend/api/_resources_common.py @@ -0,0 +1,130 @@ +"""数据资源路由共享的 payload / 可见性辅助。""" + +from __future__ import annotations + +import os +from pathlib import Path, PurePosixPath +from typing import Any + +from common.db.models import DataResources, StorageObjects +from common.storage import workspaces_root +from fastapi import HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from backend.api.dependencies import RequestContext +from backend.api.scripts import _escape_like_pattern, normalize_user_path + + +def build_list_resources_descendant_prefix(parent_path: str) -> str: + """Return the escaped materialized-path prefix for direct children.""" + normalized = normalize_user_path(parent_path) + escaped = _escape_like_pattern(normalized) + return f"{escaped}/" if escaped else "" + + +def compute_jupyter_relative_path(script_path: str, resource_relative: str) -> str: + """从当前脚本所在目录算到资源文件的 Jupyter 相对路径。""" + script_dir = PurePosixPath(script_path).parent.as_posix() + if not script_dir or script_dir == ".": + return resource_relative + return os.path.relpath(resource_relative, start=script_dir) + + +def resource_directory( + object_key: str, + workspace_id: str, + owner_user_id: str, +) -> str: + """从 object_key 解析资源所在目录(相对于用户根目录,根目录返回 "")。""" + prefix = f"{workspace_id}/{owner_user_id}/" + if not object_key.startswith(prefix): + return "" + tail = object_key[len(prefix):] + directory, _, _ = tail.rpartition("/") + return directory + + +def resource_payload( + resource: DataResources, + storage_object: StorageObjects, + owner_display_name: str | None = None, +) -> dict[str, Any]: + workspace_prefix = f"{resource.workspace_id}/" + user_prefix = f"{resource.owner_user_id}/" + jupyter_accessible_path = "" + absolute_path = "" + if storage_object.object_key and storage_object.object_key.startswith( + workspace_prefix + ): + remainder = storage_object.object_key[len(workspace_prefix):] + if remainder.startswith(user_prefix): + tail = remainder[len(user_prefix):] + jupyter_accessible_path = tail + absolute_path = ( + workspaces_root() + / resource.workspace_id + / resource.owner_user_id + / Path(tail) + ).as_posix() + else: + jupyter_accessible_path = remainder + elif storage_object.object_key: + jupyter_accessible_path = storage_object.object_key + return { + "resource_id": resource.resource_id, + "workspace_id": resource.workspace_id, + "storage_object_id": resource.storage_object_id, + "owner_user_id": resource.owner_user_id, + "owner_display_name": owner_display_name, + "resource_name": resource.resource_name, + "description": resource.description, + "visibility": resource.visibility, + "status": resource.status, + "created_at": resource.created_at.isoformat(), + "updated_at": resource.updated_at.isoformat(), + "file": { + "file_name": storage_object.file_name, + "file_extension": storage_object.file_extension, + "mime_type": storage_object.mime_type, + "size_bytes": storage_object.size_bytes, + "content_hash": storage_object.content_hash, + "object_status": storage_object.object_status, + }, + "jupyter_accessible_path": jupyter_accessible_path, + "absolute_path": absolute_path, + } + + +def can_view(resource: DataResources, context: RequestContext) -> bool: + if resource.owner_user_id == context.user.user_id: + return True + if resource.visibility in {"workspace", "public"}: + return True + return context.is_admin + + +async def get_visible_resource( + resource_id: str, + context: RequestContext, + session: AsyncSession, +) -> tuple[DataResources, StorageObjects]: + row = ( + await session.execute( + select(DataResources, StorageObjects) + .join( + StorageObjects, + StorageObjects.storage_object_id + == DataResources.storage_object_id, + ) + .where( + DataResources.resource_id == resource_id, + DataResources.workspace_id + == context.workspace.workspace_id, + DataResources.status == "active", + ) + ) + ).one_or_none() + if row is None or not can_view(row[0], context): + raise HTTPException(status.HTTP_404_NOT_FOUND, "resource not found") + return row diff --git a/backend/src/backend/api/resources.py b/backend/src/backend/api/resources.py index 8a16e6e..a1fef77 100644 --- a/backend/src/backend/api/resources.py +++ b/backend/src/backend/api/resources.py @@ -7,17 +7,13 @@ from __future__ import annotations -import os from datetime import UTC, datetime -from pathlib import Path, PurePosixPath from typing import Any from common.db.models import DataResources, StorageObjects, Users from common.ids import new_ulid -from common.storage import workspaces_root from common.storage.schemas import ( CreateUploadRequest, - DownloadUrlRequest, ) from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request, status from sqlalchemy import func, or_, select @@ -28,8 +24,15 @@ from backend.api.dependencies import ( database_session, request_context, ) -from backend.api.scripts import _escape_like_pattern, normalize_user_path -from backend.schemas.common import DownloadUrlRequest +from backend.api._resources_common import ( + build_list_resources_descendant_prefix as _build_list_resources_descendant_prefix, + can_view, + compute_jupyter_relative_path, + get_visible_resource, + resource_directory, + resource_payload, +) +from backend.api.scripts import _escape_like_pattern from backend.schemas.resources import ( CompleteResourceUploadRequest, CreateResourceUploadRequest, @@ -37,7 +40,6 @@ from backend.schemas.resources import ( ) from backend.services.storage import ( acquire_named_lock, - create_download_url_payload, create_upload_record, release_named_lock, soft_delete_object, @@ -47,119 +49,6 @@ from backend.services.storage import ( router = APIRouter(prefix="/api/v1/data-resources", tags=["data-resources"]) -def _build_list_resources_descendant_prefix(parent_path: str) -> str: - """Return the escaped materialized-path prefix for direct children - of ``parent_path`` against ``StorageObjects.object_key``. - - The full object_key is ``{ws_id}/{owner_user_id}/{jupyter_path}``. - ``list_resources`` prepends ``{ws_id}/{owner_user_id}`` (the requester - by default, or the ``owner_user_id`` query param) to this prefix and - applies ``LIKE '{ws_id}/{owner}/{prefix}%' AND NOT LIKE '...%/%'`` so - only that owner's direct children under ``parent_path`` match. LIKE - wildcards in parent_path are escaped so folder names containing ``_`` - or ``%`` do not act as wildcards. - """ - normalized = normalize_user_path(parent_path) - escaped = _escape_like_pattern(normalized) - return f"{escaped}/" if escaped else "" - - -def compute_jupyter_relative_path(script_path: str, resource_relative: str) -> str: - """从当前脚本所在目录算到资源文件的 Jupyter 相对路径。 - - script_path / resource_relative 都是相对于 user root_dir 的 POSIX 路径。 - """ - script_dir = PurePosixPath(script_path).parent.as_posix() - if not script_dir or script_dir == ".": - return resource_relative - return os.path.relpath(resource_relative, start=script_dir) - - -def resource_directory( - object_key: str, - workspace_id: str, - owner_user_id: str, -) -> str: - """从 object_key 解析资源所在目录(相对于用户根目录,根目录返回 "")。 - - object_key 形如 ``{ws_id}/{user_id}/{target_path}/{file_name}``; - 不匹配该前缀的键(如无 ws/user 前缀的旧数据)统一视为根目录。 - """ - prefix = f"{workspace_id}/{owner_user_id}/" - if not object_key.startswith(prefix): - return "" - tail = object_key[len(prefix):] - directory, _, _ = tail.rpartition("/") - return directory - - -def resource_payload( - resource: DataResources, - storage_object: StorageObjects, - owner_display_name: str | None = None, -) -> dict[str, Any]: - # ``object_key`` now follows ``{ws_id}/{user_id}/{target_path}/{file_name}`` - # (target_path may be empty). Legacy objects still live under - # ``.resources/{file_name}`` and must remain readable. Both shapes share - # the same derivation: strip the workspace/user prefix and use the rest - # as the Jupyter-relative path. - workspace_prefix = f"{resource.workspace_id}/" - user_prefix = f"{resource.owner_user_id}/" - jupyter_accessible_path = "" - absolute_path = "" - if storage_object.object_key and storage_object.object_key.startswith( - workspace_prefix - ): - remainder = storage_object.object_key[len(workspace_prefix):] - if remainder.startswith(user_prefix): - tail = remainder[len(user_prefix):] - jupyter_accessible_path = tail - absolute_path = ( - workspaces_root() - / resource.workspace_id - / resource.owner_user_id - / Path(tail) - ).as_posix() - else: - jupyter_accessible_path = remainder - elif storage_object.object_key: - jupyter_accessible_path = storage_object.object_key - return { - "resource_id": resource.resource_id, - "workspace_id": resource.workspace_id, - "storage_object_id": resource.storage_object_id, - "owner_user_id": resource.owner_user_id, - "owner_display_name": owner_display_name, - "resource_name": resource.resource_name, - "description": resource.description, - "visibility": resource.visibility, - "status": resource.status, - "created_at": resource.created_at.isoformat(), - "updated_at": resource.updated_at.isoformat(), - "file": { - "file_name": storage_object.file_name, - "file_extension": storage_object.file_extension, - "mime_type": storage_object.mime_type, - "size_bytes": storage_object.size_bytes, - "content_hash": storage_object.content_hash, - "object_status": storage_object.object_status, - }, - "jupyter_accessible_path": jupyter_accessible_path, - "absolute_path": absolute_path, - } - - -def can_view(resource: DataResources, context: RequestContext) -> bool: - # 同一 workspace 内:owner 永远可见自己的资源(含 private); - # 其他成员只见 visibility in {workspace, public} 的资源; - # admin 全部可见。 - if resource.owner_user_id == context.user.user_id: - return True - if resource.visibility in {"workspace", "public"}: - return True - return context.is_admin - - # 根据当前脚本位置计算资源的相对路径,便于 Notebook 中用相对路径读取文件。 @router.post("/{resource_id}/jupyter-relative-path") async def resource_jupyter_relative_path( @@ -465,31 +354,6 @@ async def list_resources( } -async def get_visible_resource( - resource_id: str, - context: RequestContext, - session: AsyncSession, -) -> tuple[DataResources, StorageObjects]: - row = ( - await session.execute( - select(DataResources, StorageObjects) - .join( - StorageObjects, - StorageObjects.storage_object_id - == DataResources.storage_object_id, - ) - .where( - DataResources.resource_id == resource_id, - DataResources.workspace_id - == context.workspace.workspace_id, - DataResources.status == "active", - ) - ) - ).one_or_none() - if row is None or not can_view(row[0], context): - raise HTTPException(status.HTTP_404_NOT_FOUND, "resource not found") - return row - # 查询单个数据资源的元数据与其关联文件信息。 @router.get("/{resource_id}") @@ -510,28 +374,6 @@ async def get_resource( } -# 为资源文件生成带时效的下载链接。 -@router.post("/{resource_id}/download-url") -async def resource_download_url( - resource_id: str, - payload: DownloadUrlRequest, - request: Request, - context: RequestContext = Depends(request_context), - session: AsyncSession = Depends(database_session), -) -> dict[str, Any]: - resource, _ = await get_visible_resource( - resource_id, - context, - session, - ) - data = await create_download_url_payload( - await session.get(StorageObjects, resource.storage_object_id), - DownloadUrlRequest(expires_seconds=payload.expires_seconds), - request, - ) - return {"request_id": context.request_id, "data": data["data"], "meta": {}} - - # 软删除数据资源及其关联对象,遵循存储层的回收站策略。 @router.delete("/{resource_id}") async def delete_resource( diff --git a/backend/src/backend/api/resources_content.py b/backend/src/backend/api/resources_content.py new file mode 100644 index 0000000..7789a28 --- /dev/null +++ b/backend/src/backend/api/resources_content.py @@ -0,0 +1,231 @@ +"""数据资源下载、同源内容流与表格抽样预览接口。 + +与 ``resources.py`` 共用前缀 ``/api/v1/data-resources``,在 ``main`` 中并列挂载。 +""" + +from __future__ import annotations + +import csv +import io +from typing import Any +from urllib.parse import quote + +from common.db.models import StorageObjects +from fastapi import APIRouter, Depends, HTTPException, Query, Request, status +from fastapi.responses import StreamingResponse +from sqlalchemy.ext.asyncio import AsyncSession + +from backend.api.dependencies import ( + RequestContext, + database_session, + request_context, +) +from backend.api._resources_common import get_visible_resource +from backend.schemas.common import DownloadUrlRequest +from backend.services.storage import create_download_url_payload + +router = APIRouter(prefix="/api/v1/data-resources", tags=["data-resources"]) + +_PREVIEW_MAX_BYTES = 2 * 1024 * 1024 +_PREVIEW_DEFAULT_LIMIT = 100 +_PREVIEW_MAX_LIMIT = 500 +_TABLE_EXTENSIONS = {".csv", ".tsv"} + + +def _extension_of(file_name: str | None, resource_name: str | None) -> str: + for candidate in (file_name, resource_name): + if not candidate: + continue + lower = candidate.lower() + for ext in _TABLE_EXTENSIONS: + if lower.endswith(ext): + return ext + return "" + + +def _decode_preview_text(raw: bytes) -> str: + for encoding in ("utf-8-sig", "utf-8", "gb18030"): + try: + return raw.decode(encoding) + except UnicodeDecodeError: + continue + return raw.decode("utf-8", errors="replace") + + +async def _read_prefix_bytes(store: Any, object_key: str, max_bytes: int) -> bytes: + chunks: list[bytes] = [] + total = 0 + async for chunk in store.get_stream(object_key): + if not chunk: + continue + chunks.append(chunk) + total += len(chunk) + if total >= max_bytes: + break + data = b"".join(chunks) + return data[:max_bytes] + + +def _parse_table_preview( + text: str, + *, + delimiter: str, + limit: int, + byte_truncated: bool, +) -> dict[str, Any]: + reader = csv.reader(io.StringIO(text), delimiter=delimiter) + try: + header = next(reader) + except StopIteration: + return { + "kind": "table", + "columns": [], + "rows": [], + "row_count": 0, + "truncated": byte_truncated, + "delimiter": delimiter, + } + + columns = [str(cell) if cell is not None else "" for cell in header] + if not any(columns): + columns = [f"col_{index + 1}" for index in range(max(len(header), 1))] + + rows: list[list[str]] = [] + truncated = byte_truncated + for row in reader: + if len(rows) >= limit: + truncated = True + break + cells = [str(cell) if cell is not None else "" for cell in row] + if len(cells) < len(columns): + cells.extend([""] * (len(columns) - len(cells))) + elif len(cells) > len(columns): + cells = cells[: len(columns)] + rows.append(cells) + + return { + "kind": "table", + "columns": columns, + "rows": rows, + "row_count": len(rows), + "truncated": truncated, + "delimiter": delimiter, + } + + +@router.post("/{resource_id}/download-url") +async def resource_download_url( + resource_id: str, + payload: DownloadUrlRequest, + request: Request, + context: RequestContext = Depends(request_context), + session: AsyncSession = Depends(database_session), +) -> dict[str, Any]: + resource, _ = await get_visible_resource( + resource_id, + context, + session, + ) + data = await create_download_url_payload( + await session.get(StorageObjects, resource.storage_object_id), + DownloadUrlRequest(expires_seconds=payload.expires_seconds), + request, + ) + return {"request_id": context.request_id, "data": data["data"], "meta": {}} + + +@router.get("/{resource_id}/content") +async def resource_content( + resource_id: str, + request: Request, + context: RequestContext = Depends(request_context), + session: AsyncSession = Depends(database_session), +) -> StreamingResponse: + """同源流式读取资源字节,供前端预览器加载。""" + resource, storage_object = await get_visible_resource( + resource_id, + context, + session, + ) + if storage_object.object_status != "available": + raise HTTPException(status.HTTP_404_NOT_FOUND, "object not found") + if ( + not storage_object.bucket_name + or not storage_object.object_key + or storage_object.bucket_name not in request.app.state.object_stores + ): + raise HTTPException( + status.HTTP_409_CONFLICT, + "object does not support content download", + ) + + file_name = storage_object.file_name or resource.resource_name or "file" + media_type = storage_object.mime_type or "application/octet-stream" + store = request.app.state.object_stores[storage_object.bucket_name] + stream = store.get_stream(storage_object.object_key) + headers = { + "Content-Disposition": f"inline; filename*=UTF-8''{quote(file_name)}", + "Cache-Control": "private, no-store", + } + if storage_object.size_bytes is not None: + headers["Content-Length"] = str(storage_object.size_bytes) + + return StreamingResponse( + stream, + media_type=media_type, + headers=headers, + ) + + +@router.get("/{resource_id}/preview") +async def resource_preview( + resource_id: str, + request: Request, + context: RequestContext = Depends(request_context), + session: AsyncSession = Depends(database_session), + limit: int = Query(default=_PREVIEW_DEFAULT_LIMIT, ge=1, le=_PREVIEW_MAX_LIMIT), +) -> dict[str, Any]: + """表格类数据资源抽样预览(csv / tsv)。""" + resource, storage_object = await get_visible_resource( + resource_id, + context, + session, + ) + if storage_object.object_status != "available": + raise HTTPException(status.HTTP_404_NOT_FOUND, "object not found") + if ( + not storage_object.bucket_name + or not storage_object.object_key + or storage_object.bucket_name not in request.app.state.object_stores + ): + raise HTTPException( + status.HTTP_409_CONFLICT, + "object does not support preview", + ) + + extension = _extension_of(storage_object.file_name, resource.resource_name) + if extension not in _TABLE_EXTENSIONS: + raise HTTPException( + status.HTTP_422_UNPROCESSABLE_ENTITY, + "仅支持预览 .csv / .tsv 表格文件", + ) + + store = request.app.state.object_stores[storage_object.bucket_name] + raw = await _read_prefix_bytes( + store, + storage_object.object_key, + _PREVIEW_MAX_BYTES, + ) + byte_truncated = ( + storage_object.size_bytes is not None + and storage_object.size_bytes > len(raw) + ) or len(raw) >= _PREVIEW_MAX_BYTES + text = _decode_preview_text(raw) + delimiter = "\t" if extension == ".tsv" else "," + payload = _parse_table_preview( + text, + delimiter=delimiter, + limit=limit, + byte_truncated=byte_truncated, + ) + return {"request_id": context.request_id, "data": payload, "meta": {}} diff --git a/backend/src/backend/main.py b/backend/src/backend/main.py index cd8eab5..6c6ad3a 100644 --- a/backend/src/backend/main.py +++ b/backend/src/backend/main.py @@ -38,6 +38,7 @@ from backend.api.auth import router as auth_router from backend.api.jupyter import router as jupyter_router from backend.api.platform import router as platform_router from backend.api.resources import router as resources_router +from backend.api.resources_content import router as resources_content_router from backend.api.schedules.runs import router as schedule_runs_router from backend.api.schedules.schedules import router as schedules_router from backend.api.scripts import router as scripts_router @@ -107,6 +108,7 @@ app = create_service_app( app.include_router(auth_router) app.include_router(jupyter_router) app.include_router(resources_router) +app.include_router(resources_content_router) app.include_router(schedule_runs_router) app.include_router(schedules_router) app.include_router(scripts_router) From 4bfd4ac4c22029f6c99346cd757f6164344c09d8 Mon Sep 17 00:00:00 2001 From: xiaozhu <2395895331@qq.com> Date: Tue, 1 Sep 2026 10:00:41 +0800 Subject: [PATCH 07/13] =?UTF-8?q?fix:=E8=A1=A5=E9=BD=90file-viewer?= =?UTF-8?q?=E9=A2=84=E8=A7=88=E8=B5=84=E6=BA=90=E5=8C=85=E4=BE=9D=E8=B5=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/package.json | 5 +++ frontend/pnpm-lock.yaml | 67 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/frontend/package.json b/frontend/package.json index 1d7056e..c8b52c5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -36,6 +36,11 @@ "zustand": "^5.0.14" }, "devDependencies": { + "@file-viewer/assets-hangul": "3.0.0", + "@file-viewer/assets-iwork": "3.0.0", + "@file-viewer/assets-ppt": "3.0.0", + "@file-viewer/assets-standard": "3.0.0", + "@file-viewer/assets-wordperfect": "3.0.0", "@file-viewer/vite-plugin": "^3.0.0", "@react-router/dev": "^8", "@tailwindcss/vite": "^4.2.2", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index ad74369..b30601e 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -75,6 +75,21 @@ importers: specifier: ^5.0.14 version: 5.0.14(@types/react@19.2.17)(react@19.2.8)(use-sync-external-store@1.6.0(react@19.2.8)) devDependencies: + '@file-viewer/assets-hangul': + specifier: 3.0.0 + version: 3.0.0 + '@file-viewer/assets-iwork': + specifier: 3.0.0 + version: 3.0.0 + '@file-viewer/assets-ppt': + specifier: 3.0.0 + version: 3.0.0 + '@file-viewer/assets-standard': + specifier: 3.0.0 + version: 3.0.0 + '@file-viewer/assets-wordperfect': + specifier: 3.0.0 + version: 3.0.0 '@file-viewer/vite-plugin': specifier: ^3.0.0 version: 3.0.0(vite@8.1.5(@types/node@22.20.1)(jiti@2.7.0)) @@ -281,6 +296,36 @@ packages: '@emnapi/wasi-threads@1.2.2': resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==, tarball: https://registry.npmmirror.com/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz} + '@file-viewer/asset-installer@3.0.0': + resolution: {integrity: sha512-EgpTyYzBxnJ5AzN/dPwePoOFv20MMTgBonKg8fMaZ6CtPzUpl3Sfsnt+2x1P5f3n4wlY7pWOmuryj/Bq4IVNBQ==, tarball: https://registry.npmmirror.com/@file-viewer/asset-installer/-/asset-installer-3.0.0.tgz} + engines: {node: '>=20'} + hasBin: true + + '@file-viewer/assets-hangul@3.0.0': + resolution: {integrity: sha512-L3JozLDGuvnZRg4bajo8A6g1qQUSQJBx2CxOBRP88rzaNsNHC3KhtB9XsyXirjROMSKbzbNWdKE2kU037tt71A==, tarball: https://registry.npmjs.org/@file-viewer/assets-hangul/-/assets-hangul-3.0.0.tgz} + engines: {node: '>=20'} + hasBin: true + + '@file-viewer/assets-iwork@3.0.0': + resolution: {integrity: sha512-wOBnLNL4j0qzI7MVMEi41X6tNf8fhyy1x59peidKCC/7XcIZFkH3jeBl9TSjOqlP4duBT9vt9awFLeK94yGMzA==, tarball: https://registry.npmjs.org/@file-viewer/assets-iwork/-/assets-iwork-3.0.0.tgz} + engines: {node: '>=20'} + hasBin: true + + '@file-viewer/assets-ppt@3.0.0': + resolution: {integrity: sha512-A/YTknr1DFGwfnCL1z9DRaGdNbUqQsq4iNUwTORkqcTu53WVJ6j/CojU8mPowsucaMOjMqEVfqEByUOey+XvZg==, tarball: https://registry.npmmirror.com/@file-viewer/assets-ppt/-/assets-ppt-3.0.0.tgz} + engines: {node: '>=20'} + hasBin: true + + '@file-viewer/assets-standard@3.0.0': + resolution: {integrity: sha512-YIB1S7gPcfwWgRA66nWGu3fMOHhOWfSO/v2JSRIbazDf6CSwu0XeGneeKPHYdC4Khr4sDY/hFzUDEr3hXhWa0Q==, tarball: https://registry.npmmirror.com/@file-viewer/assets-standard/-/assets-standard-3.0.0.tgz} + engines: {node: '>=20'} + hasBin: true + + '@file-viewer/assets-wordperfect@3.0.0': + resolution: {integrity: sha512-Co4hc76izTClDseAEsPtiJBtuAMUX6ciz1X20KewG+KX2RVfWc/J1QINtbdQchlCSYsfYcqUFdYRJNvKGb0u+g==, tarball: https://registry.npmjs.org/@file-viewer/assets-wordperfect/-/assets-wordperfect-3.0.0.tgz} + engines: {node: '>=20'} + hasBin: true + '@file-viewer/core@3.0.0': resolution: {integrity: sha512-2yf2KAjrCAFQtzcX+O8kKPLQcNxuacsKHkZNllwRky9xegsHgj10EO1jPVJaH0TF9D44LSzS4AoC6Brg4wCX8g==, tarball: https://registry.npmmirror.com/@file-viewer/core/-/core-3.0.0.tgz} @@ -2638,6 +2683,28 @@ snapshots: tslib: 2.8.1 optional: true + '@file-viewer/asset-installer@3.0.0': {} + + '@file-viewer/assets-hangul@3.0.0': + dependencies: + '@file-viewer/asset-installer': 3.0.0 + + '@file-viewer/assets-iwork@3.0.0': + dependencies: + '@file-viewer/asset-installer': 3.0.0 + + '@file-viewer/assets-ppt@3.0.0': + dependencies: + '@file-viewer/asset-installer': 3.0.0 + + '@file-viewer/assets-standard@3.0.0': + dependencies: + '@file-viewer/asset-installer': 3.0.0 + + '@file-viewer/assets-wordperfect@3.0.0': + dependencies: + '@file-viewer/asset-installer': 3.0.0 + '@file-viewer/core@3.0.0': dependencies: dompurify: 3.4.13 From 0deb2d205bae6e13f339b62fc1258a2c41c93910 Mon Sep 17 00:00:00 2001 From: "tao.chen" <93983997+taochen-ct@users.noreply.github.com> Date: Tue, 1 Sep 2026 11:10:39 +0800 Subject: [PATCH 08/13] refactor: api.ts --- frontend/app/services/api.ts | 1976 +---------------- frontend/app/services/api/_shared.ts | 262 +++ frontend/app/services/api/boundApi.ts | 348 +++ frontend/app/services/api/employees.ts | 55 + frontend/app/services/api/fileLocks.ts | 140 ++ .../app/services/api/platformEmployees.ts | 60 + frontend/app/services/api/platformRoles.ts | 81 + frontend/app/services/api/resources.ts | 289 +++ frontend/app/services/api/scheduleGraph.ts | 271 +++ frontend/app/services/api/schedules.ts | 197 ++ frontend/app/services/api/scripts.ts | 299 +++ frontend/app/services/api/workspaceMembers.ts | 60 + frontend/app/services/api/workspaces.ts | 83 + 13 files changed, 2157 insertions(+), 1964 deletions(-) create mode 100644 frontend/app/services/api/_shared.ts create mode 100644 frontend/app/services/api/boundApi.ts create mode 100644 frontend/app/services/api/employees.ts create mode 100644 frontend/app/services/api/fileLocks.ts create mode 100644 frontend/app/services/api/platformEmployees.ts create mode 100644 frontend/app/services/api/platformRoles.ts create mode 100644 frontend/app/services/api/resources.ts create mode 100644 frontend/app/services/api/scheduleGraph.ts create mode 100644 frontend/app/services/api/schedules.ts create mode 100644 frontend/app/services/api/scripts.ts create mode 100644 frontend/app/services/api/workspaceMembers.ts create mode 100644 frontend/app/services/api/workspaces.ts 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" }, + ); +} + From 64d33fb1d088d0262f0bb10e5d1c6f82ef5b6e4f Mon Sep 17 00:00:00 2001 From: "tao.chen" <93983997+taochen-ct@users.noreply.github.com> Date: Tue, 1 Sep 2026 11:29:07 +0800 Subject: [PATCH 09/13] fix: loadChildren + loadScripts + loadDataResources --- .../features/platform/state/scriptsSlice.ts | 42 ++++++++++++++----- .../app/features/platform/state/treeSlice.ts | 26 ++++++++---- frontend/app/features/platform/state/types.ts | 4 ++ .../platform/state/useScriptWorkspaceStore.ts | 1 + 4 files changed, 56 insertions(+), 17 deletions(-) diff --git a/frontend/app/features/platform/state/scriptsSlice.ts b/frontend/app/features/platform/state/scriptsSlice.ts index 08555a8..d0f9ff5 100644 --- a/frontend/app/features/platform/state/scriptsSlice.ts +++ b/frontend/app/features/platform/state/scriptsSlice.ts @@ -283,28 +283,41 @@ export const createScriptsSlice: StateCreator< loadDataResources: async (parentPath = "", ownerUserId) => { const api = requireApi(); + const cacheKey = ownerCacheKey(ownerUserId, parentPath); + // 命中缓存:listResources 是非递归的,同一 (owner, parent_path) 拉过的 + // 内容不会自己变化;省去 toggleExpanded 重复展开同一目录时的网络往返。 + if (get().loadedDataResourcePaths.has(cacheKey)) return; set({ dataResourcesLoading: true }); try { const list = await api.listResources(parentPath, { ownerUserId }); const fresh = Array.isArray(list) ? list : []; set((state) => { - // 按 owner 范围合并:丢弃该 owner 的旧资源再并入 fresh(fresh 覆盖 - // 同 id)。owner 缺省=我,故根加载/刷新我的数据时替换我的一级资源。 + // 按 (owner, parent_path) 局部替换:丢掉该 owner 在 parentPath 下的 + // 旧条目,保留该 owner 在其它路径下的条目,再并入 fresh。 + // 这样 toggleExpanded 在子目录展开时按需拉取不会把根已加载的数据 + // 资源擦掉(修"根加载后子目录展开丢数据 / 子目录数据本来不显示")。 const targetOwner = ownerUserId ?? getCurrentUserId() ?? null; - const kept = state.dataResources.filter( - (r) => r.owner_user_id !== targetOwner, - ); + const kept = state.dataResources.filter((r) => { + if (r.owner_user_id !== targetOwner) return true; + return parentPathOf(r.jupyter_accessible_path) !== parentPath; + }); const byId = new Map(kept.map((r) => [r.resource_id, r])); for (const item of fresh) byId.set(item.resource_id, item); - return { dataResources: Array.from(byId.values()) }; + const nextLoaded = new Set(state.loadedDataResourcePaths); + nextLoaded.add(cacheKey); + return { + dataResources: Array.from(byId.values()), + loadedDataResourcePaths: nextLoaded, + }; }); } catch { set((state) => { const targetOwner = ownerUserId ?? getCurrentUserId() ?? null; return { - dataResources: state.dataResources.filter( - (r) => r.owner_user_id !== targetOwner, - ), + dataResources: state.dataResources.filter((r) => { + if (r.owner_user_id !== targetOwner) return true; + return parentPathOf(r.jupyter_accessible_path) !== parentPath; + }), }; }); } finally { @@ -336,4 +349,13 @@ export const createScriptsSlice: StateCreator< } }, }; -}; \ No newline at end of file +}; + +// 提取 jupyter-accessible 路径的父目录;用于 `loadDataResources` 局部替换时 +// 判断一条缓存资源是否落在目标 parent_path 下(list-resources 按 parent_path +// 精确匹配,非递归)。 +function parentPathOf(path: string): string { + const parts = path.split("/"); + parts.pop(); + return parts.join("/"); +} \ No newline at end of file diff --git a/frontend/app/features/platform/state/treeSlice.ts b/frontend/app/features/platform/state/treeSlice.ts index 2b88c9c..79a5c3a 100644 --- a/frontend/app/features/platform/state/treeSlice.ts +++ b/frontend/app/features/platform/state/treeSlice.ts @@ -1,14 +1,16 @@ // ---- treeSlice ---- // // 拥有 expandedPaths / loadingChildrenPaths / loadedChildPaths / -// loadedScriptPaths / loadingScriptPaths (5 个 directory-tree 缓存集合)。 -// 负责 toggleExpanded 和 loadChildren。 +// loadedScriptPaths / loadingScriptPaths / loadedDataResourcePaths +// (6 个 directory-tree 缓存集合)。负责 toggleExpanded 和 loadChildren。 // // 注意: // - loadedScriptPaths/loadingScriptPaths 也由 scriptsSlice 写 (loadScripts / // loadOwnerGroup),但 ownership 在 treeSlice 里 (因为是 cache set,不是数据) -// - scriptsSlice.load 也会写这俩,所以这里只保留这两个 setter (toggleExpanded -// 也要写 expandedPaths)。 +// - loadedDataResourcePaths 由 scriptsSlice.loadDataResources 写(同样的 cache +// 不放数据原则),toggleExpanded 在真实目录分支按需触发。 +// - scriptsSlice.load 也会写 cached script paths,所以这里只保留 toggleExpanded +// (写 expandedPaths) 和 loadChildren (写目录缓存)。 import type { StateCreator } from "zustand"; @@ -34,6 +36,7 @@ export const createTreeSlice: StateCreator< loadedChildPaths: new Set(), loadedScriptPaths: new Set(), loadingScriptPaths: new Set(), + loadedDataResourcePaths: new Set(), }; return { @@ -110,15 +113,24 @@ export const createTreeSlice: StateCreator< } } else { // 真实目录展开:loadChildren(owner 限定的显式目录行)+ loadScripts - // 并行。两者都 idempotent + 缓存;ownerUserId 缺省=我。他人目录同样 - // 调 loadChildren(owner) 拉取其目录结构,否则嵌套子目录无法被发现 - // (list_scripts 非递归,只能看到直接子脚本)。 + // + loadDataResources 并行。三者都 idempotent + 缓存;ownerUserId + // 缺省=我。他人目录同样调 loadChildren(owner) 拉取其目录结构,否则 + // 嵌套子目录无法被发现(list_scripts 非递归,只能看到直接子脚本)。 + // 数据资源也是非递归的——不按需拉取,子目录里的 csv/xlsx/json 等 + // 都不会出现(修"目录树子目录里的数据文件不显示")。 if (!state.loadedChildPaths.has(ownerCacheKey(ownerUserId, loadPath))) { void get().loadChildren(loadPath, ownerUserId); } if (!state.loadedScriptPaths.has(ownerCacheKey(ownerUserId, loadPath))) { void get().loadScripts(loadPath, ownerUserId); } + if ( + !state.loadedDataResourcePaths.has( + ownerCacheKey(ownerUserId, loadPath), + ) + ) { + void get().loadDataResources(loadPath, ownerUserId); + } } } set({ expandedPaths: next }); diff --git a/frontend/app/features/platform/state/types.ts b/frontend/app/features/platform/state/types.ts index a4fb073..df21320 100644 --- a/frontend/app/features/platform/state/types.ts +++ b/frontend/app/features/platform/state/types.ts @@ -62,6 +62,10 @@ export type TreeSliceState = { // `${owner_user_id}:${parent_path}` (see ownerCacheKey). loadedScriptPaths: Set; loadingScriptPaths: Set; + // 与 loadedScriptPaths 同形:按 (owner, parent_path) 缓存已拉取的数据资源, + // 让 toggleExpanded 在子目录展开时也按需请求 listDataResources,而不是 + // 只在根加载一次(修"子目录下的数据文件不展示")。 + loadedDataResourcePaths: Set; }; export type TreeSliceActions = { diff --git a/frontend/app/features/platform/state/useScriptWorkspaceStore.ts b/frontend/app/features/platform/state/useScriptWorkspaceStore.ts index fc5534b..9dd91ed 100644 --- a/frontend/app/features/platform/state/useScriptWorkspaceStore.ts +++ b/frontend/app/features/platform/state/useScriptWorkspaceStore.ts @@ -112,6 +112,7 @@ const INITIAL: ScriptWorkspaceState = { loadedChildPaths: new Set(), loadedScriptPaths: new Set(), loadingScriptPaths: new Set(), + loadedDataResourcePaths: new Set(), // selectionSlice selectedId: null, openTabIds: [], From 1532feedde9c6d538fc2f89eedbcf40545f7b869 Mon Sep 17 00:00:00 2001 From: xiaozhu <2395895331@qq.com> Date: Tue, 1 Sep 2026 15:03:45 +0800 Subject: [PATCH 10/13] =?UTF-8?q?feat:=E8=B4=A6=E5=8F=B7=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?+=E5=AF=86=E7=A0=81=E9=87=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- API.md | 50 ++++ backend/src/backend/api/auth.py | 126 ++++++-- backend/src/backend/api/platform/employees.py | 27 ++ backend/tests/test_auth_profile_password.py | 39 +++ frontend/.gitignore | 3 + .../common/AccountSettingsDialog.tsx | 269 ++++++++++++++++++ frontend/app/components/common/Topbar.tsx | 86 +++++- frontend/app/context/AuthContext.tsx | 46 ++- .../features/admin/ResetPasswordDialog.tsx | 127 +++++++++ .../app/features/admin/UserFormDialog.tsx | 20 ++ .../app/features/admin/UserManagementPage.tsx | 37 +++ frontend/app/services/api/boundApi.ts | 4 + .../app/services/api/platformEmployees.ts | 10 + 13 files changed, 815 insertions(+), 29 deletions(-) create mode 100644 backend/tests/test_auth_profile_password.py create mode 100644 frontend/app/components/common/AccountSettingsDialog.tsx create mode 100644 frontend/app/features/admin/ResetPasswordDialog.tsx diff --git a/API.md b/API.md index efae065..3bb71d7 100644 --- a/API.md +++ b/API.md @@ -50,6 +50,42 @@ > - `is_system_admin: bool` —— 派生自 `users.platform_role_id` 指向的角色 `role_code == 'admin'` 且用户状态为 `active`。前端据此决定是否渲染"系统管理"入口。 > - `permissions: string[]` —— 当前用户通过其平台角色(`platform_role_id`)间接持有的菜单权限码列表(`permission_code`),按字典序排列;未分配平台角色时为空数组。前端据此过滤菜单与 `` 路由守卫。**仅控制前端展示,不参与后端 endpoint 鉴权**——后端鉴权继续由 `system_admin_context`(`role_code == 'admin'`)与 workspace membership 负责。详见 §7.12-7.14。 +### 1.1 登录 / 会话 + +| 方法 | 路径 | 说明 | +|---|---|---| +| `POST` | `/api/v1/auth/login` | 校验用户名密码,种 `access_token` HttpOnly Cookie,返回 `user` + `workspaces` | +| `POST` | `/api/v1/auth/logout` | 清除 Cookie(幂等) | +| `GET` | `/api/v1/auth/me` | 返回当前登录用户与可访问 workspace 列表 | + +### 1.2 `PATCH /api/v1/auth/me` — 修改本人资料 + +当前登录用户修改自己的显示名 / 邮箱。不需要 `workspace_id`。 + +- **请求体**(至少提供一个字段;多余字段 → 422): + +| 字段 | 类型 | 限制 | 说明 | +|---|---|---|---| +| `display_name` | string | 1~100 | trim 后写入 | +| `email` | string \| null | ≤255 | trim 后写入;空字符串归一为 `null`;与其他未软删用户冲突 → 409 "邮箱已存在" | + +- 不可通过本端点修改:`username`、`password`、`status`、`platform_role_id`。 +- **响应 200**:`data.user` 与 `GET /me` 中的 `user` 形状一致(含 `role_code` / `is_system_admin` / `permissions`)。 + +### 1.3 `POST /api/v1/auth/password` — 本人修改密码 + +- **请求体**: + +| 字段 | 类型 | 限制 | 说明 | +|---|---|---|---| +| `current_password` | string | 1~72 | 当前密码 | +| `new_password` | string | 8~72 | 新密码 | + +- 当前密码错误 → 400 "当前密码不正确"。 +- 新密码与当前密码相同 → 400 "新密码不能与当前密码相同"。 +- **成功后清除会话 Cookie**,客户端须引导用户重新登录。 +- **响应 200**:`data.password_changed = true`。 + --- ## 二、统一约定 @@ -635,6 +671,7 @@ Base 前缀 `/api/v1/admin`。 | `GET` | `/api/v1/platform/employees` | 列全平台未软删员工(cursor 分页 + `q` 搜索,见 §7.0) | | `POST` | `/api/v1/platform/employees` | 创建平台员工账号(返回 201);不自动加入任何 workspace | | `PATCH` | `/api/v1/platform/employees/{user_id}` | 改员工资料/状态/平台角色(仅系统管理员) | +| `POST` | `/api/v1/platform/employees/{user_id}/reset-password` | 重置员工密码(仅系统管理员;不需要旧密码) | | `DELETE` | `/api/v1/platform/employees/{user_id}` | 软删员工;级联软删其 workspace 成员关系(仅系统管理员) | | `GET` | `/api/v1/platform/workspaces` | 列 workspace(`active`/`archived`)(cursor 分页 + `q` 搜索,见 §7.0) | | `POST` | `/api/v1/platform/workspaces` | 创建 workspace(返回 201);创建者自动成为 admin 成员 | @@ -864,6 +901,19 @@ Base 前缀 `/api/v1/admin`。 - **响应 200**:返回更新后的 `PlatformEmployeePayload`,`role_code` / `role_name` 反映最新的 `platform_role_id`。 +### 7.10.1 `POST /api/v1/platform/employees/{user_id}/reset-password` + +系统管理员为指定员工设置新密码(不校验旧密码)。JWT 无服务端吊销列表,已有会话在过期前仍可用;对方下次登录须用新密码。 + +- **请求体**: + +| 字段 | 类型 | 限制 | 说明 | +|---|---|---|---| +| `new_password` | string | 8~72 | 新密码;bcrypt 哈希后写入 `password_hash` | + +- 目标不存在或已软删 → 404 "用户不存在"。 +- **响应 200**:`data = { "user_id": "...", "password_reset": true }`。 + ### 7.11 `DELETE /api/v1/platform/employees/{user_id}` 软删除平台员工;级联软删其所有 `workspace_members` 行。调用者必须是系统管理员。 diff --git a/backend/src/backend/api/auth.py b/backend/src/backend/api/auth.py index 4870385..74ebf8e 100644 --- a/backend/src/backend/api/auth.py +++ b/backend/src/backend/api/auth.py @@ -12,6 +12,8 @@ The user-facing flow is: ``backend.api.dependencies.request_context`` 3. POST /api/v1/auth/logout — clear the cookie 4. GET /api/v1/auth/me — return the current user + 5. PATCH /api/v1/auth/me — update display_name / email + 6. POST /api/v1/auth/password — change password (clears session cookie) Service-to-service calls do not use these endpoints — they live on the shared Docker network and have no application-layer auth. See @@ -24,15 +26,16 @@ from typing import Any from common.auth.jwt import JwtError, issue_jwt, verify_jwt_token from common.auth.membership import resolve_is_system_admin -from common.auth.passwords import verify_password +from common.auth.passwords import hash_password, verify_password from common.config import settings from common.db.models import Roles, Users, WorkspaceMembers, Workspaces from common.ids import new_ulid from fastapi import APIRouter, Depends, HTTPException, Request, Response, status +from pydantic import BaseModel, ConfigDict, Field from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from backend.api.dependencies import database_session, load_user_permissions +from backend.api.dependencies import current_user, database_session, load_user_permissions router = APIRouter(prefix="/api/v1/auth", tags=["auth"]) @@ -95,6 +98,29 @@ def _workspace_payload( } +class ProfileUpdate(BaseModel): + model_config = ConfigDict(extra="forbid") + + display_name: str | None = Field(default=None, min_length=1, max_length=100) + email: str | None = Field(default=None, max_length=255) + + +class PasswordChange(BaseModel): + model_config = ConfigDict(extra="forbid") + + current_password: str = Field(min_length=1, max_length=72) + new_password: str = Field(min_length=8, max_length=72) + + +async def _platform_role_code(session: AsyncSession, user: Users) -> str | None: + if user.platform_role_id is None: + return None + platform_role_row = await session.scalar( + select(Roles).where(Roles.role_id == user.platform_role_id) + ) + return platform_role_row.role_code if platform_role_row is not None else None + + # 校验账号密码,设置登录 Cookie,并返回用户可进入的工作区列表。 @router.post("/login") async def login( @@ -167,13 +193,7 @@ async def login( # (``Users.platform_role_id``); the iteration over rows above was # a legacy way to find the "highest" workspace role and is no # longer correct now that admin/developer are platform-only. - user_role_code: str | None = None - if user.platform_role_id is not None: - platform_role_row = await session.scalar( - select(Roles).where(Roles.role_id == user.platform_role_id) - ) - if platform_role_row is not None: - user_role_code = platform_role_row.role_code + user_role_code = await _platform_role_code(session, user) token = issue_jwt(user.user_id, ttl_seconds=COOKIE_TTL_SECONDS) _set_session_cookie(request, response, token) @@ -257,13 +277,7 @@ async def me( workspaces = [_workspace_payload(ws, role) for ws, role, _ in rows] default_workspace_id = workspaces[0]["workspace_id"] if workspaces else None - user_role_code: str | None = None - if user.platform_role_id is not None: - platform_role_row = await session.scalar( - select(Roles).where(Roles.role_id == user.platform_role_id) - ) - if platform_role_row is not None: - user_role_code = platform_role_row.role_code + user_role_code = await _platform_role_code(session, user) is_system_admin = await resolve_is_system_admin(session, user) permissions = await load_user_permissions(session, user) @@ -284,6 +298,86 @@ async def me( } +# 当前登录用户修改显示名 / 邮箱(不可改 username、角色、密码)。 +@router.patch("/me") +async def update_me( + payload: ProfileUpdate, + user: Users = Depends(current_user), + session: AsyncSession = Depends(database_session), +) -> dict[str, Any]: + """Update the authenticated user's display_name and/or email.""" + if payload.display_name is None and payload.email is None: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + "至少提供一个可修改字段", + ) + + if payload.display_name is not None: + user.display_name = payload.display_name.strip() + + if payload.email is not None: + normalized = payload.email.strip() or None + if normalized is not None: + duplicate = await session.scalar( + select(Users.user_id).where( + Users.email == normalized, + Users.user_id != user.user_id, + Users.is_deleted == 0, + ) + ) + if duplicate is not None: + raise HTTPException(status.HTTP_409_CONFLICT, "邮箱已存在") + user.email = normalized + + await session.flush() + await session.refresh(user) + + user_role_code = await _platform_role_code(session, user) + is_system_admin = await resolve_is_system_admin(session, user) + permissions = await load_user_permissions(session, user) + + return { + "request_id": new_ulid(), + "data": { + "user": _user_payload( + user, + user_role_code, + is_system_admin=is_system_admin, + permissions=permissions, + ), + }, + "meta": {}, + } + + +# 当前登录用户修改密码;成功后清除 Cookie,需重新登录。 +@router.post("/password") +async def change_password( + payload: PasswordChange, + response: Response, + user: Users = Depends(current_user), + session: AsyncSession = Depends(database_session), +) -> dict[str, Any]: + """Change password after verifying the current one; clears the session cookie.""" + if not verify_password(payload.current_password, user.password_hash): + raise HTTPException(status.HTTP_400_BAD_REQUEST, "当前密码不正确") + if payload.current_password == payload.new_password: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + "新密码不能与当前密码相同", + ) + + user.password_hash = hash_password(payload.new_password) + await session.flush() + _clear_session_cookie(response) + + return { + "request_id": new_ulid(), + "data": {"password_changed": True}, + "meta": {}, + } + + __all__ = [ "COOKIE_NAME", "COOKIE_TTL_SECONDS", diff --git a/backend/src/backend/api/platform/employees.py b/backend/src/backend/api/platform/employees.py index 0e2db9a..6be9e69 100644 --- a/backend/src/backend/api/platform/employees.py +++ b/backend/src/backend/api/platform/employees.py @@ -396,3 +396,30 @@ async def delete_platform_employee( ) +class PlatformEmployeePasswordReset(BaseModel): + model_config = ConfigDict(extra="forbid") + + new_password: str = Field(min_length=8, max_length=72) + + +# 系统管理员重置指定员工密码(不需要旧密码)。 +@router.post("/employees/{user_id}/reset-password") +async def reset_platform_employee_password( + user_id: str, + payload: PlatformEmployeePasswordReset, + context: SystemAdminContext = Depends(system_admin_context), + session: AsyncSession = Depends(database_session), +) -> dict[str, Any]: + """Reset a platform employee's password. Caller must be a system admin.""" + user = await session.get(Users, user_id) + if user is None or user.is_deleted != 0: + raise HTTPException(status.HTTP_404_NOT_FOUND, "用户不存在") + + user.password_hash = hash_password(payload.new_password) + await session.flush() + return _envelope( + context.request_id, + {"user_id": user_id, "password_reset": True}, + ) + + diff --git a/backend/tests/test_auth_profile_password.py b/backend/tests/test_auth_profile_password.py new file mode 100644 index 0000000..fa69bb1 --- /dev/null +++ b/backend/tests/test_auth_profile_password.py @@ -0,0 +1,39 @@ +"""Unit tests for auth profile / password request schemas.""" + +from __future__ import annotations + +import pytest +from pydantic import ValidationError + +from backend.api.auth import PasswordChange, ProfileUpdate +from backend.api.platform.employees import PlatformEmployeePasswordReset + + +def test_profile_update_allows_partial_fields() -> None: + only_name = ProfileUpdate(display_name="张三") + assert only_name.display_name == "张三" + assert only_name.email is None + + only_email = ProfileUpdate(email="a@example.com") + assert only_email.email == "a@example.com" + + +def test_profile_update_forbids_unknown_fields() -> None: + with pytest.raises(ValidationError): + ProfileUpdate(display_name="张三", username="hacked") # type: ignore[call-arg] + + +def test_password_change_enforces_new_password_length() -> None: + with pytest.raises(ValidationError): + PasswordChange(current_password="old-pass", new_password="short") + + ok = PasswordChange(current_password="old-pass-1", new_password="new-pass-12") + assert ok.new_password == "new-pass-12" + + +def test_admin_password_reset_schema() -> None: + with pytest.raises(ValidationError): + PlatformEmployeePasswordReset(new_password="1234567") + + payload = PlatformEmployeePasswordReset(new_password="reset-pass-9") + assert payload.new_password == "reset-pass-9" diff --git a/frontend/.gitignore b/frontend/.gitignore index b4ac379..2f75b43 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -6,6 +6,9 @@ /.react-router/ /build/ +# Vite / file-viewer 构建产物,不要提交 +/dist/ + # monaco-editor 预构建 min/vs(由 scripts/copy-monaco.mjs 在 postinstall 时 # 从 node_modules/monaco-editor/min/vs 复制生成,不要提交) /public/monaco/vs/ diff --git a/frontend/app/components/common/AccountSettingsDialog.tsx b/frontend/app/components/common/AccountSettingsDialog.tsx new file mode 100644 index 0000000..4b3f81a --- /dev/null +++ b/frontend/app/components/common/AccountSettingsDialog.tsx @@ -0,0 +1,269 @@ +import { useEffect, useState } from "react"; +import { toast } from "sonner"; + +import { useAuth } from "~/context/AuthContext"; +import { Button } from "~/components/ui/button"; +import { Input } from "~/components/ui/input"; +import { + formFieldClass, + formHintClass, + formInputClass, + modalFormClass, +} from "~/features/platform/modalUi"; +import { cn } from "~/lib/utils"; + +import { + AppFormDialog, + dialogPrimaryButtonClass, + dialogSecondaryButtonClass, +} from "./AppFormDialog"; + +type AccountSettingsDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; +}; + +type SettingsTab = "profile" | "password"; + +const TAB_ITEMS: Array<{ id: SettingsTab; label: string }> = [ + { id: "profile", label: "基本信息" }, + { id: "password", label: "修改密码" }, +]; + +export function AccountSettingsDialog({ + open, + onOpenChange, +}: AccountSettingsDialogProps) { + const { user, updateProfile, changePassword, logout } = useAuth(); + const [tab, setTab] = useState("profile"); + const [displayName, setDisplayName] = useState(""); + const [email, setEmail] = useState(""); + const [savingProfile, setSavingProfile] = useState(false); + const [currentPassword, setCurrentPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [savingPassword, setSavingPassword] = useState(false); + + useEffect(() => { + if (!open || !user) return; + setTab("profile"); + setDisplayName(user.display_name); + setEmail(user.email ?? ""); + setCurrentPassword(""); + setNewPassword(""); + setConfirmPassword(""); + }, [open, user]); + + const handleSaveProfile = async (event: React.FormEvent) => { + event.preventDefault(); + const trimmedName = displayName.trim(); + if (!trimmedName) { + toast.error("显示名称不能为空"); + return; + } + setSavingProfile(true); + try { + await updateProfile({ + display_name: trimmedName, + email: email.trim(), + }); + toast.success("资料已更新"); + onOpenChange(false); + } catch (error) { + toast.error(error instanceof Error ? error.message : "资料更新失败"); + } finally { + setSavingProfile(false); + } + }; + + const handleChangePassword = async (event: React.FormEvent) => { + event.preventDefault(); + if (!currentPassword) { + toast.error("请输入当前密码"); + return; + } + if (newPassword.length < 8 || newPassword.length > 72) { + toast.error("新密码长度需为 8~72 字符"); + return; + } + if (newPassword !== confirmPassword) { + toast.error("两次输入的新密码不一致"); + return; + } + if (newPassword === currentPassword) { + toast.error("新密码不能与当前密码相同"); + return; + } + setSavingPassword(true); + try { + await changePassword({ + current_password: currentPassword, + new_password: newPassword, + }); + toast.success("密码已修改,请重新登录"); + onOpenChange(false); + await logout(); + } catch (error) { + toast.error(error instanceof Error ? error.message : "密码修改失败"); + } finally { + setSavingPassword(false); + } + }; + + const formId = + tab === "profile" ? "account-profile-form" : "account-password-form"; + const saving = tab === "profile" ? savingProfile : savingPassword; + const primaryLabel = + tab === "profile" + ? savingProfile + ? "保存中…" + : "保存资料" + : savingPassword + ? "修改中…" + : "修改密码"; + + return ( + + + + + } + > +
+
+ {TAB_ITEMS.map((item) => { + const active = tab === item.id; + return ( + + ); + })} +
+
+ +
+ {tab === "profile" ? ( +
void handleSaveProfile(event)} + > + + + +
+ ) : ( +
void handleChangePassword(event)} + > + + + +
+ )} +
+
+ ); +} diff --git a/frontend/app/components/common/Topbar.tsx b/frontend/app/components/common/Topbar.tsx index 04ac5d5..7f3ee73 100644 --- a/frontend/app/components/common/Topbar.tsx +++ b/frontend/app/components/common/Topbar.tsx @@ -1,5 +1,7 @@ -import { ChevronRight, LayoutGrid } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { ChevronRight, LayoutGrid, LogOut, Settings } from "lucide-react"; import type { AuthUser, AuthWorkspace } from "../../context/AuthContext"; +import { AccountSettingsDialog } from "./AccountSettingsDialog"; type TopbarProps = { pageTitle: string; @@ -24,6 +26,26 @@ export function Topbar({ onSetCurrentWorkspace, onLogout, }: TopbarProps) { + const [userMenuOpen, setUserMenuOpen] = useState(false); + const [settingsOpen, setSettingsOpen] = useState(false); + const userMenuRef = useRef(null); + + useEffect(() => { + if (!userMenuOpen) return; + const onPointerDown = (event: MouseEvent) => { + if (!userMenuRef.current?.contains(event.target as Node)) { + setUserMenuOpen(false); + } + }; + document.addEventListener("mousedown", onPointerDown); + return () => document.removeEventListener("mousedown", onPointerDown); + }, [userMenuOpen]); + + const openUserMenu = () => { + onSetWorkspaceMenuOpen(false); + setUserMenuOpen((open) => !open); + }; + return (
@@ -57,7 +79,10 @@ export function Topbar({
)} -
+
- + {userMenuOpen && ( +
+
+ + {user?.display_name ?? "未知用户"} + + + {user?.username} + {user?.role_code === "admin" ? " · 管理员" : " · 开发人员"} + +
+ + +
+ )}
+ +
); } diff --git a/frontend/app/context/AuthContext.tsx b/frontend/app/context/AuthContext.tsx index c79a2dd..39b71a8 100644 --- a/frontend/app/context/AuthContext.tsx +++ b/frontend/app/context/AuthContext.tsx @@ -48,6 +48,14 @@ type AuthContextValue = { loading: boolean; login: (username: string, password: string) => Promise; logout: () => Promise; + updateProfile: (input: { + display_name?: string; + email?: string; + }) => Promise; + changePassword: (input: { + current_password: string; + new_password: string; + }) => Promise; setCurrentWorkspace: (workspaceId: string) => void; refreshWorkspaces: () => Promise; }; @@ -162,6 +170,27 @@ export function AuthProvider({ children }: { children: ReactNode }) { } }, [navigate]); + const updateProfile = useCallback(async (input: { + display_name?: string; + email?: string; + }) => { + const result = await authRequest<{ user: AuthUser }>("/api/v1/auth/me", { + method: "PATCH", + body: JSON.stringify(input), + }); + setUser(result.user); + }, []); + + const changePassword = useCallback(async (input: { + current_password: string; + new_password: string; + }) => { + await authRequest<{ password_changed: boolean }>("/api/v1/auth/password", { + method: "POST", + body: JSON.stringify(input), + }); + }, []); + const setCurrentWorkspace = useCallback((workspaceId: string) => { const workspace = workspaces.find((item) => item.workspace_id === workspaceId); if (!workspace) return; @@ -193,9 +222,22 @@ export function AuthProvider({ children }: { children: ReactNode }) { loading, login, logout, + updateProfile, + changePassword, setCurrentWorkspace, refreshWorkspaces, - }), [currentWorkspace, loading, login, logout, setCurrentWorkspace, user, workspaces, refreshWorkspaces]); + }), [ + changePassword, + currentWorkspace, + loading, + login, + logout, + refreshWorkspaces, + setCurrentWorkspace, + updateProfile, + user, + workspaces, + ]); if (loading) { return ( @@ -283,6 +325,8 @@ export function useApi(): WorkspaceBoundApi { rawApi.updatePlatformEmployee(userId, input), deleteEmployee: (userId) => rawApi.deleteEmployee(workspaceId, userId), deletePlatformEmployee: (userId) => rawApi.deletePlatformEmployee(userId), + resetPlatformEmployeePassword: (userId, newPassword) => + rawApi.resetPlatformEmployeePassword(userId, newPassword), // Role Management - 角色管理接口(跨 workspace,不需要传入 workspaceId) listPlatformRoles: () => rawApi.listPlatformRoles(), getPlatformRole: (roleCode) => rawApi.getPlatformRole(roleCode), diff --git a/frontend/app/features/admin/ResetPasswordDialog.tsx b/frontend/app/features/admin/ResetPasswordDialog.tsx new file mode 100644 index 0000000..261a1ee --- /dev/null +++ b/frontend/app/features/admin/ResetPasswordDialog.tsx @@ -0,0 +1,127 @@ +import { useEffect, useState } from "react"; + +import { + AppFormDialog, + dialogPrimaryButtonClass, + dialogSecondaryButtonClass, +} from "~/components/common/AppFormDialog"; +import { Button } from "~/components/ui/button"; +import { Input } from "~/components/ui/input"; +import { + formFieldClass, + formHintClass, + formInputClass, + modalFormClass, +} from "../platform/modalUi"; + +type ResetPasswordDialogProps = { + open: boolean; + displayName: string; + username: string; + saving: boolean; + onSubmit: (newPassword: string) => Promise; + onClose: () => void; +}; + +export function ResetPasswordDialog({ + open, + displayName, + username, + saving, + onSubmit, + onClose, +}: ResetPasswordDialogProps) { + const [password, setPassword] = useState(""); + const [confirm, setConfirm] = useState(""); + const [error, setError] = useState(null); + + useEffect(() => { + if (!open) return; + setPassword(""); + setConfirm(""); + setError(null); + }, [open]); + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + if (password.length < 8 || password.length > 72) { + setError("密码长度需为 8~72 字符"); + return; + } + if (password !== confirm) { + setError("两次输入的密码不一致"); + return; + } + setError(null); + await onSubmit(password); + }; + + return ( + { + if (!nextOpen) onClose(); + }} + eyebrow="SECURITY" + title="重置密码" + > +
void handleSubmit(event)} + > +

+ 正在为 {displayName} + ({username})设置新密码。对方需使用新密码重新登录。 +

+ + + {error ? ( +

{error}

+ ) : null} +
+ + +
+
+
+ ); +} diff --git a/frontend/app/features/admin/UserFormDialog.tsx b/frontend/app/features/admin/UserFormDialog.tsx index 3fac2de..12ad2c3 100644 --- a/frontend/app/features/admin/UserFormDialog.tsx +++ b/frontend/app/features/admin/UserFormDialog.tsx @@ -9,6 +9,7 @@ import { Input } from "~/components/ui/input"; import { CreateUserProjectField } from "./CreateUserProjectField"; import { formFieldClass, + formHintClass, formInputClass, modalFormClass, } from "../platform/modalUi"; @@ -40,6 +41,7 @@ export function UserFormDialog({ onToggleWorkspace, onSubmit, onClose, + onResetPassword, }: { open: boolean; editing: Employee | null; @@ -55,6 +57,7 @@ export function UserFormDialog({ onToggleWorkspace: (workspaceId: string) => void; onSubmit: (event: React.FormEvent) => void; onClose: () => void; + onResetPassword?: () => void; }) { return ( )} + {editing && onResetPassword ? ( +
+
安全
+

+ 该用户忘记密码时,可为其设置临时密码。对方需使用新密码重新登录。 +

+ +
+ ) : null}
@@ -93,10 +93,10 @@ export function Topbar({ {currentWorkspace.workspace_name} - + {workspaceMenuOpen && ( -
+
{workspaces.map((workspace) => { const isSelected = workspace.workspace_id === currentWorkspace.workspace_id; @@ -131,7 +131,7 @@ export function Topbar({
{userMenuOpen && (
diff --git a/frontend/app/features/schedules/SchedulePage.tsx b/frontend/app/features/schedules/SchedulePage.tsx index a378aa0..bee3ac3 100644 --- a/frontend/app/features/schedules/SchedulePage.tsx +++ b/frontend/app/features/schedules/SchedulePage.tsx @@ -449,37 +449,38 @@ export default function SchedulePage({ linkSourceId={linkSourceId} onCancelLink={() => setLinkSourceId(null)} /> -
{ - event.preventDefault(); - event.dataTransfer.dropEffect = "copy"; - }} - onDrop={onCanvasDrop} - onClick={(event) => { - // 画布实际内容位于 surface/SVG 子元素中,不能只比较 currentTarget; - // 点击节点或连线时保留选择,点击其余空白位置则返回调度方案基本信息。 - const target = event.target as Element; - if ( - target.closest(".schedule-node") || - target.matches(".schedule-edge-line, .schedule-edge-hit") - ) return; - setSelectedNodeId(null); - setSelectedEdgeId(null); - }} - > +
{ + event.preventDefault(); + event.dataTransfer.dropEffect = "copy"; + }} + onDrop={onCanvasDrop} + onClick={(event) => { + // 画布实际内容位于 surface/SVG 子元素中,不能只比较 currentTarget; + // 点击节点或连线时保留选择,点击其余空白位置则返回调度方案基本信息。 + const target = event.target as Element; + if ( + target.closest(".schedule-node") || + target.matches(".schedule-edge-line, .schedule-edge-hit") + ) return; + setSelectedNodeId(null); + setSelectedEdgeId(null); + }} > +
{schedule && (
))} - - {!schedule ? ( -
+
+
+ {!schedule ? ( +
+
还没有选中调度方案

点击"新建"创建一个调度,然后拖入稳定版本脚本。

@@ -616,14 +619,16 @@ export default function SchedulePage({ 新建调度
- ) : schedule.nodes.length === 0 ? ( -
+
+ ) : schedule.nodes.length === 0 ? ( +
+
从稳定版本开始编排

把左侧脚本卡片拖到这里,或双击卡片快速加入。

- ) : null} -
+
+ ) : null}
Date: Tue, 1 Sep 2026 16:30:19 +0800 Subject: [PATCH 12/13] =?UTF-8?q?fix:=E4=B8=8A=E4=BC=A0=E5=85=B6=E4=BB=96?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E6=9C=AA=E8=87=AA=E5=8A=A8=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/features/platform/ScriptsPage.tsx | 2 -- .../features/platform/state/mutationsSlice.ts | 30 +++++++++++++++++-- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/frontend/app/features/platform/ScriptsPage.tsx b/frontend/app/features/platform/ScriptsPage.tsx index 3d8166c..ed95983 100644 --- a/frontend/app/features/platform/ScriptsPage.tsx +++ b/frontend/app/features/platform/ScriptsPage.tsx @@ -264,8 +264,6 @@ export default function ScriptsPage() { visibility, description: description.trim(), targetPath: targetPath.trim(), - }).then((resource) => { - if (resource) void loadDataResources(); }); }; diff --git a/frontend/app/features/platform/state/mutationsSlice.ts b/frontend/app/features/platform/state/mutationsSlice.ts index b0d0998..a4b1d54 100644 --- a/frontend/app/features/platform/state/mutationsSlice.ts +++ b/frontend/app/features/platform/state/mutationsSlice.ts @@ -7,7 +7,7 @@ // - createScript 写 scripts (scriptsSlice) + 调 openTab (selectionSlice) // - uploadScripts 写 scripts (scriptsSlice) + 调 openTab (selectionSlice) + // 调 load (scriptsSlice) -// - uploadDataResource 不写 store state (只走 uiStore) +// - uploadDataResource 写 dataResources (scriptsSlice) + 失效对应路径缓存 // - createFolder 写 loadedChildPaths/directories/expandedPaths (treeSlice) + // 调 loadChildren / load (treeSlice/scriptsSlice) // - deleteScript 写 openTabIds/selectedId (selectionSlice) + 调 @@ -175,6 +175,26 @@ export const createMutationsSlice: StateCreator< description: meta.description, visibility: meta.visibility, }); + // 与 uploadScripts 一致:直接写入 store。loadDataResources 有路径缓存, + // 上传后再调会命中已加载路径直接 return,列表不会更新。 + const parentPath = parentPathOfResource(resource.jupyter_accessible_path); + set((state) => { + const nextLoaded = new Set(state.loadedDataResourcePaths); + nextLoaded.delete(ownerCacheKey(resource.owner_user_id, parentPath)); + // targetPath 与 jupyter 父路径不一致时一并失效(例如带前缀差异)。 + if (meta.targetPath !== parentPath) { + nextLoaded.delete( + ownerCacheKey(resource.owner_user_id, meta.targetPath), + ); + } + const withoutDup = state.dataResources.filter( + (r) => r.resource_id !== resource.resource_id, + ); + return { + dataResources: [resource, ...withoutDup], + loadedDataResourcePaths: nextLoaded, + }; + }); ui.closeDataResourceDialog(); pushToast( "success", @@ -426,4 +446,10 @@ export const createMutationsSlice: StateCreator< } }, }; -}; \ No newline at end of file +}; + +function parentPathOfResource(path: string): string { + const parts = path.split("/"); + parts.pop(); + return parts.join("/"); +} \ No newline at end of file From 026cd085c6497e9404384d4b3b979fa0dbe67c25 Mon Sep 17 00:00:00 2001 From: xiaozhu <2395895331@qq.com> Date: Tue, 1 Sep 2026 17:23:31 +0800 Subject: [PATCH 13/13] =?UTF-8?q?update:=E5=8E=9F=E7=94=9F=E7=A1=AE?= =?UTF-8?q?=E8=AE=A4=E6=A1=86=E6=9B=BF=E6=8D=A2=E4=B8=BA=E8=87=AA=E5=AE=9A?= =?UTF-8?q?=E4=B9=89=E5=BC=B9=E7=AA=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/components/common/ConfirmDialog.tsx | 2 +- .../app/features/platform/ScriptsPage.tsx | 65 ++++++++------ .../platform/ScriptsPendingConfirm.tsx | 66 ++++++++++++++ .../app/features/platform/TreeContextMenu.tsx | 10 ++- .../platform/state/editSessionSlice.ts | 4 +- .../features/platform/state/mutationsSlice.ts | 13 --- .../features/platform/state/selectionSlice.ts | 10 +-- frontend/app/features/platform/state/types.ts | 3 +- .../platform/useScriptsPendingConfirm.ts | 54 ++++++++++++ .../app/features/schedules/SchedulePage.tsx | 80 +++++++++++++++-- .../schedules/SchedulePendingConfirm.tsx | 67 ++++++++++++++ .../schedules/ScheduleRenameDialog.tsx | 87 +++++++++++++++++++ .../features/schedules/state/dialogSlice.ts | 29 +++++-- .../app/features/schedules/state/listSlice.ts | 43 ++++----- .../schedules/state/useSchedulesStore.ts | 2 + 15 files changed, 452 insertions(+), 83 deletions(-) create mode 100644 frontend/app/features/platform/ScriptsPendingConfirm.tsx create mode 100644 frontend/app/features/platform/useScriptsPendingConfirm.ts create mode 100644 frontend/app/features/schedules/SchedulePendingConfirm.tsx create mode 100644 frontend/app/features/schedules/ScheduleRenameDialog.tsx diff --git a/frontend/app/components/common/ConfirmDialog.tsx b/frontend/app/components/common/ConfirmDialog.tsx index b568603..a1653eb 100644 --- a/frontend/app/components/common/ConfirmDialog.tsx +++ b/frontend/app/components/common/ConfirmDialog.tsx @@ -39,7 +39,7 @@ function ConfirmDialog({ }: ConfirmDialogProps) { return ( - + {title} diff --git a/frontend/app/features/platform/ScriptsPage.tsx b/frontend/app/features/platform/ScriptsPage.tsx index ed95983..d33655b 100644 --- a/frontend/app/features/platform/ScriptsPage.tsx +++ b/frontend/app/features/platform/ScriptsPage.tsx @@ -13,9 +13,10 @@ import { import { WelcomePanel } from "./WelcomePanel"; import { PublishModal } from "./PublishModal"; import { ScriptExplorer } from "./ScriptExplorer"; +import { ScriptsPendingConfirmDialog } from "./ScriptsPendingConfirm"; import { TreeContextMenu } from "./TreeContextMenu"; import { VersionReceiptModal } from "./VersionReceiptModal"; - +import { useScriptsPendingConfirm } from "./useScriptsPendingConfirm"; import { getSessionCache, useScriptWorkspaceStore } from "./state/scriptWorkspaceStore"; import { useUiStore } from "./state/uiStore"; import { toast } from "sonner"; @@ -75,6 +76,18 @@ export default function ScriptsPage() { const submitPublish = useScriptWorkspaceStore((s) => s.submitPublish); const refreshReadOnlyContent = useScriptWorkspaceStore((s) => s.refreshReadOnlyContent); + const { + pendingConfirm, + setPendingConfirm, + requestCloseTab, + handleConfirm, + } = useScriptsPendingConfirm(scripts, { + deleteScript, + deleteDataResource, + deleteDirectory, + closeTab, + }); + // ui store const pushToast = (notice: { tone: "success" | "error" | "info"; message: string }) => { if (notice.tone === "error") toast.error(notice.message); @@ -206,27 +219,11 @@ export default function ScriptsPage() { }; }, [contextMenu, closeContextMenu]); - // python editor handlers with scriptId forwarding - const handleSetPythonEditorContent = (scriptId: string, value: string) => { - setPythonEditorContent(scriptId, value); - }; - const handleSavePythonEditor = (scriptId: string) => { - void savePythonEditor(scriptId); - }; - const handleExitPythonEditor = (scriptId: string) => { - exitPythonEditor(scriptId); - }; - const handleClosePythonTab = (scriptId: string) => { - void closeTab(scriptId); - }; - - // 5) handlers + // 5) handlers const SCRIPT_EXTS = [".py", ".ipynb"]; const DATA_EXTS = [".csv", ".xlsx", ".xls", ".tsv", ".json", ".parquet", ".txt"]; - const matchesExt = (name: string, exts: string[]) => exts.some((ext) => name.toLowerCase().endsWith(ext)); - const handleUpload = (event: React.ChangeEvent) => { const files = Array.from(event.target.files ?? []); event.target.value = ""; @@ -381,17 +378,17 @@ export default function ScriptsPage() { void endEditing(); } }} - onClose={(scriptId, event) => void closeTab(scriptId, event)} + onClose={(scriptId, event) => void requestCloseTab(scriptId, event)} onSwitchTab={switchTab} onNewTab={() => openCreateDialog("")} onPublish={() => openPublishDialog(selected)} scripts={scripts} pythonEditorBuffers={pythonEditorBuffers} onOpenPythonEditor={() => void openPythonEditor(selected)} - onSetPythonEditorContent={handleSetPythonEditorContent} - onSavePythonEditor={handleSavePythonEditor} - onExitPythonEditor={handleExitPythonEditor} - onClosePythonTab={handleClosePythonTab} + onSetPythonEditorContent={setPythonEditorContent} + onSavePythonEditor={(scriptId) => void savePythonEditor(scriptId)} + onExitPythonEditor={exitPythonEditor} + onClosePythonTab={(scriptId) => void requestCloseTab(scriptId)} onInfo={(t) => pushToast(t)} /> ) : ( @@ -429,19 +426,35 @@ export default function ScriptsPage() { selectScript(scriptId); closeContextMenu(); }} - onRemoveScript={(s) => void deleteScript(s)} + onRemoveScript={(s) => setPendingConfirm({ kind: "script", script: s })} onToggleLock={(s) => void toggleScriptLock(s)} onOpenCreateDialog={(parentPath, scriptType) => openCreateDialog(parentPath, scriptType)} onOpenFolderDialog={(parentPath) => openFolderDialog(parentPath)} onChooseUpload={(parentPath) => triggerUpload(parentPath ?? "")} - onRemoveDirectory={(p) => void deleteDirectory(p)} + onRemoveDirectory={(p) => + setPendingConfirm({ kind: "directory", path: p })} onCopyResourcePath={(p) => void handleCopyResourcePath(p)} onPreviewResource={openResourcePreview} - onRemoveResource={(id) => void deleteDataResource(id)} + onRemoveResource={(id) => { + const resource = dataResources.find((item) => item.resource_id === id); + setPendingConfirm({ + kind: "resource", + id, + name: resource?.resource_name ?? id, + }); + }} onClose={closeContextMenu} /> + { + if (!open) setPendingConfirm(null); + }} + onConfirm={() => void handleConfirm()} + /> + setResourcePreview(null)} diff --git a/frontend/app/features/platform/ScriptsPendingConfirm.tsx b/frontend/app/features/platform/ScriptsPendingConfirm.tsx new file mode 100644 index 0000000..a873eaa --- /dev/null +++ b/frontend/app/features/platform/ScriptsPendingConfirm.tsx @@ -0,0 +1,66 @@ +import { ConfirmDialog } from "~/components/common/ConfirmDialog"; +import type { ScriptItem } from "~/services/api"; + +export type ScriptsPendingConfirm = + | { kind: "script"; script: ScriptItem } + | { kind: "resource"; id: string; name: string } + | { kind: "directory"; path: string } + | { kind: "close-tab"; id: string; name: string }; + +type ScriptsPendingConfirmDialogProps = { + pending: ScriptsPendingConfirm | null; + onOpenChange: (open: boolean) => void; + onConfirm: () => void | Promise; +}; + +function copyFor(pending: ScriptsPendingConfirm) { + switch (pending.kind) { + case "script": + return { + title: "确定删除文件?", + description: `确定删除文件"${pending.script.script_name}"吗?稳定版本会保留。`, + confirmLabel: "删除", + destructive: true, + }; + case "resource": + return { + title: "确定删除数据资源?", + description: `确定删除数据资源"${pending.name}"吗?稳定版本会保留。`, + confirmLabel: "删除", + destructive: true, + }; + case "directory": + return { + title: "确定删除文件夹?", + description: `确定递归删除文件夹"${pending.path}"及其内容吗?稳定版本会保留。`, + confirmLabel: "删除", + destructive: true, + }; + case "close-tab": + return { + title: "确定关闭标签?", + description: `当前脚本有未保存修改,确定关闭 "${pending.name}" 吗?`, + confirmLabel: "关闭", + destructive: false, + }; + } +} + +export function ScriptsPendingConfirmDialog({ + pending, + onOpenChange, + onConfirm, +}: ScriptsPendingConfirmDialogProps) { + const copy = pending ? copyFor(pending) : null; + return ( + + ); +} diff --git a/frontend/app/features/platform/TreeContextMenu.tsx b/frontend/app/features/platform/TreeContextMenu.tsx index 8f96c7f..7e4fe66 100644 --- a/frontend/app/features/platform/TreeContextMenu.tsx +++ b/frontend/app/features/platform/TreeContextMenu.tsx @@ -151,7 +151,10 @@ export function TreeContextMenu({ className={dangerItemClass} type="button" role="menuitem" - onClick={() => onRemoveScript(contextMenu.script!)} + onClick={() => { + onRemoveScript(contextMenu.script!); + onClose(); + }} > 删除文件 @@ -204,7 +207,10 @@ export function TreeContextMenu({ className={dangerItemClass} type="button" role="menuitem" - onClick={() => onRemoveDirectory(contextMenu.path)} + onClick={() => { + onRemoveDirectory(contextMenu.path); + onClose(); + }} > 删除文件夹 diff --git a/frontend/app/features/platform/state/editSessionSlice.ts b/frontend/app/features/platform/state/editSessionSlice.ts index 376c80d..58c48db 100644 --- a/frontend/app/features/platform/state/editSessionSlice.ts +++ b/frontend/app/features/platform/state/editSessionSlice.ts @@ -164,7 +164,7 @@ export const createEditSessionSlice: StateCreator< const scriptId = active?.script_id; if (!active) { if (closeTabFlag && scriptId) { - await get().closeTab(scriptId); + await get().closeTab(scriptId, undefined, { discardDirty: true }); } return; } @@ -174,7 +174,7 @@ export const createEditSessionSlice: StateCreator< applyEditSessionState((p) => set(p), null, null); if (scriptId) sessionCache.delete(scriptId); if (closeTabFlag && scriptId) { - await get().closeTab(scriptId); + await get().closeTab(scriptId, undefined, { discardDirty: true }); } if (showToast) { pushToast("success", `${active.script_name} 的编辑锁已释放`); diff --git a/frontend/app/features/platform/state/mutationsSlice.ts b/frontend/app/features/platform/state/mutationsSlice.ts index a4b1d54..d47ca27 100644 --- a/frontend/app/features/platform/state/mutationsSlice.ts +++ b/frontend/app/features/platform/state/mutationsSlice.ts @@ -215,9 +215,6 @@ export const createMutationsSlice: StateCreator< deleteDataResource: async (resourceId) => { const api = requireApi(); useUiStore.getState().closeContextMenu(); - if (!window.confirm(`确定删除数据资源吗?稳定版本会保留。`)) { - return; - } try { await api.deleteResource(resourceId); set((state) => ({ @@ -287,11 +284,6 @@ export const createMutationsSlice: StateCreator< deleteScript: async (script: ScriptItem) => { const api = requireApi(); useUiStore.getState().closeContextMenu(); - if ( - !window.confirm(`确定删除文件"${script.script_name}"吗?稳定版本会保留。`) - ) { - return; - } const editSession = getEditSession(); if (editSession?.script_id === script.script_id) { await get().endEditing(false, false); @@ -323,11 +315,6 @@ export const createMutationsSlice: StateCreator< deleteDirectory: async (path) => { const api = requireApi(); useUiStore.getState().closeContextMenu(); - if ( - !window.confirm(`确定递归删除文件夹"${path}"及其内容吗?稳定版本会保留。`) - ) { - return; - } const activeScript = get().scripts.find( (item) => item.script_id === getEditSession()?.script_id, ); diff --git a/frontend/app/features/platform/state/selectionSlice.ts b/frontend/app/features/platform/state/selectionSlice.ts index 8cf83af..2bbfa56 100644 --- a/frontend/app/features/platform/state/selectionSlice.ts +++ b/frontend/app/features/platform/state/selectionSlice.ts @@ -62,14 +62,11 @@ export const createSelectionSlice: StateCreator< })); }, - closeTab: async (id, event) => { + closeTab: async (id, event, options) => { event?.stopPropagation(); const buffer = get().pythonEditorBuffers[id]; - if (buffer?.dirty && !buffer.saving) { - const name = - get().scripts.find((s) => s.script_id === id)?.script_name ?? "该脚本"; - const ok = window.confirm(`当前脚本有未保存修改,确定关闭 "${name}" 吗?`); - if (!ok) return; + if (buffer?.dirty && !buffer.saving && !options?.discardDirty) { + return false; } if (buffer) { get().exitPythonEditor(id); @@ -91,6 +88,7 @@ export const createSelectionSlice: StateCreator< } return { openTabIds: newTabs, selectedId: nextSelected }; }); + return true; }, switchTab: (id) => { diff --git a/frontend/app/features/platform/state/types.ts b/frontend/app/features/platform/state/types.ts index df21320..1036eb3 100644 --- a/frontend/app/features/platform/state/types.ts +++ b/frontend/app/features/platform/state/types.ts @@ -92,7 +92,8 @@ export type SelectionSliceActions = { closeTab: ( id: string, event?: { stopPropagation: () => void }, - ) => Promise; + options?: { discardDirty?: boolean }, + ) => Promise; switchTab: (id: string) => void; openPublishDialog: (script: ScriptItem) => void; }; diff --git a/frontend/app/features/platform/useScriptsPendingConfirm.ts b/frontend/app/features/platform/useScriptsPendingConfirm.ts new file mode 100644 index 0000000..89ed4af --- /dev/null +++ b/frontend/app/features/platform/useScriptsPendingConfirm.ts @@ -0,0 +1,54 @@ +import { useState } from "react"; +import type { ScriptItem } from "~/services/api"; +import type { ScriptsPendingConfirm } from "./ScriptsPendingConfirm"; + +type DeleteActions = { + deleteScript: (script: ScriptItem) => Promise; + deleteDataResource: (resourceId: string) => Promise; + deleteDirectory: (path: string) => Promise; + closeTab: ( + id: string, + event?: { stopPropagation: () => void }, + options?: { discardDirty?: boolean }, + ) => Promise; +}; + +export function useScriptsPendingConfirm( + scripts: ScriptItem[], + actions: DeleteActions, +) { + const [pendingConfirm, setPendingConfirm] = + useState(null); + + const requestCloseTab = async ( + scriptId: string, + event?: { stopPropagation: () => void }, + ) => { + const closed = await actions.closeTab(scriptId, event); + if (closed) return; + const name = + scripts.find((s) => s.script_id === scriptId)?.script_name ?? "该脚本"; + setPendingConfirm({ kind: "close-tab", id: scriptId, name }); + }; + + const handleConfirm = async () => { + if (!pendingConfirm) return; + const target = pendingConfirm; + setPendingConfirm(null); + if (target.kind === "script") await actions.deleteScript(target.script); + else if (target.kind === "resource") { + await actions.deleteDataResource(target.id); + } else if (target.kind === "directory") { + await actions.deleteDirectory(target.path); + } else { + await actions.closeTab(target.id, undefined, { discardDirty: true }); + } + }; + + return { + pendingConfirm, + setPendingConfirm, + requestCloseTab, + handleConfirm, + }; +} diff --git a/frontend/app/features/schedules/SchedulePage.tsx b/frontend/app/features/schedules/SchedulePage.tsx index bee3ac3..d492957 100644 --- a/frontend/app/features/schedules/SchedulePage.tsx +++ b/frontend/app/features/schedules/SchedulePage.tsx @@ -28,6 +28,8 @@ import { type ScheduleNode, } from "~/services/api"; +import { SchedulePendingConfirmDialog, type SchedulePendingConfirm } from "./SchedulePendingConfirm"; +import { ScheduleRenameDialog } from "./ScheduleRenameDialog"; import { useApi, useAuth } from "~/context/AuthContext"; import { formFieldClass, @@ -236,6 +238,8 @@ export default function SchedulePage({ const setRuns = useSchedulesStore((s) => s.setRuns); const setRunsLoading = useSchedulesStore((s) => s.setRunsLoading); const canvasRef = useRef(null); + const [pendingConfirm, setPendingConfirm] = + useState(null); const selectedNode = schedule?.nodes.find( (item) => item.node_id === selectedNodeId, @@ -366,6 +370,30 @@ export default function SchedulePage({ [], ); + const requestRemoveNode = async (node: ScheduleNode) => { + const result = await useSchedulesStore.getState().removeNode(node); + if (result === "needs_history_confirm") { + setPendingConfirm({ kind: "node-history", node }); + } + }; + + const handleConfirm = async () => { + if (!pendingConfirm) return; + const target = pendingConfirm; + setPendingConfirm(null); + if (target.kind === "schedule") { + await useSchedulesStore.getState().removeSchedule(target.schedule); + } else if (target.kind === "artifact") { + await useSchedulesStore.getState().removeArtifact(target.artifact); + } else if (target.kind === "node") { + await requestRemoveNode(target.node); + } else { + await useSchedulesStore + .getState() + .removeNode(target.node, { deleteExecutionHistory: true }); + } + }; + return (
@@ -416,7 +444,9 @@ export default function SchedulePage({ variant="destructive" size="sm" disabled={!schedule || Boolean(busy)} - onClick={() => useSchedulesStore.getState().removeSchedule(schedule ?? undefined)} + onClick={() => { + if (schedule) setPendingConfirm({ kind: "schedule", schedule }); + }} > 删除 @@ -662,7 +692,8 @@ export default function SchedulePage({ busy={Boolean(busy)} onChange={setNodeForm} onSave={() => useSchedulesStore.getState().saveNode(selectedNode)} - onDelete={() => useSchedulesStore.getState().removeNode(selectedNode)} + onDelete={() => + setPendingConfirm({ kind: "node", node: selectedNode })} /> ) : ( useSchedulesStore.getState().renameSchedule(contextMenu.schedule)} + onClick={() => + useSchedulesStore.getState().openRenameDialog(contextMenu.schedule)} > 改名 @@ -732,21 +764,47 @@ export default function SchedulePage({ type="button" variant="destructive" role="menuitem" - onClick={() => useSchedulesStore.getState().removeSchedule(contextMenu.schedule)} + onClick={() => { + useSchedulesStore.getState().setContextMenu(null); + setPendingConfirm({ + kind: "schedule", + schedule: contextMenu.schedule, + }); + }} > 删除调度方案 )} - {contextMenu.kind === "artifact" && null} + {contextMenu.kind === "artifact" && ( + + )} {contextMenu.kind === "node" && ( + +
+ + + ); +} diff --git a/frontend/app/features/schedules/state/dialogSlice.ts b/frontend/app/features/schedules/state/dialogSlice.ts index 8b899ac..eaecac8 100644 --- a/frontend/app/features/schedules/state/dialogSlice.ts +++ b/frontend/app/features/schedules/state/dialogSlice.ts @@ -1,7 +1,4 @@ -// Dialog slice: create-schedule dialog state + cron preview. -// Pure setters + small mutations that read/write only dialog-owned fields. - -import type { CronPreview } from "../../../services/api"; +import type { CronPreview, Schedule } from "../../../services/api"; import type { StateCreator } from "zustand"; import { handleError, notify, requireApi } from "./helpers"; @@ -12,6 +9,8 @@ import type { SchedulesStore } from "./useSchedulesStore"; export type DialogSliceState = { createDialogOpen: boolean; newScheduleName: string; + renameTarget: Schedule | null; + renameName: string; cronResult: CronPreview | null; }; @@ -21,10 +20,13 @@ export type DialogSliceActions = { // pure setters setCreateDialogOpen: (open: boolean) => void; setNewScheduleName: (name: string) => void; + setRenameName: (name: string) => void; setCronResult: (result: CronPreview | null) => void; // mutations openCreateDialog: () => void; + openRenameDialog: (target: Schedule) => void; + closeRenameDialog: () => void; runCronPreview: () => Promise; }; @@ -35,12 +37,15 @@ export const createDialogSlice: StateCreator set({ createDialogOpen: open }), setNewScheduleName: (name) => set({ newScheduleName: name }), + setRenameName: (name) => set({ renameName: name }), setCronResult: (result) => set({ cronResult: result }), // ---- mutations ---- @@ -54,6 +59,20 @@ export const createDialogSlice: StateCreator { + if (get().busy) return; + set({ + contextMenu: null, + renameTarget: target, + renameName: target.schedule_name, + }); + }, + + closeRenameDialog: () => { + if (get().busy === "rename-schedule") return; + set({ renameTarget: null, renameName: "" }); + }, + runCronPreview: async () => { const api = requireApi(); const { scheduleForm } = get(); @@ -75,4 +94,4 @@ export const createDialogSlice: StateCreator Promise; removeSchedule: (target?: Schedule) => Promise; - renameSchedule: (target: Schedule) => Promise; + renameSchedule: (target: Schedule, scheduleName: string) => Promise; removeArtifact: (artifact: ScheduleArtifact) => Promise; saveSchedule: () => Promise; runNow: () => Promise; @@ -70,7 +70,10 @@ export type ListSliceActions = { positionY: number, ) => Promise; saveNode: (selectedNode: ScheduleNode | null) => Promise; - removeNode: (target?: ScheduleNode) => Promise; + removeNode: ( + target?: ScheduleNode, + options?: { deleteExecutionHistory?: boolean }, + ) => Promise<"ok" | "needs_history_confirm" | "noop">; removeEdge: (target?: ScheduleEdge) => Promise; checkDag: () => Promise; connectTo: (targetNodeId: string) => Promise; @@ -234,7 +237,6 @@ export const createListSlice: StateCreator { + renameSchedule: async (target, scheduleName) => { const api = requireApi(); - if (get().busy) return; - set({ contextMenu: null }); - const scheduleName = window - .prompt("请输入新的调度方案名称", target.schedule_name) - ?.trim(); - if (!scheduleName || scheduleName === target.schedule_name) return; + const trimmed = scheduleName.trim(); + if (get().busy || !trimmed || trimmed === target.schedule_name) return; set({ busy: "rename-schedule" }); try { const updated = await api.updateSchedule(target.schedule_id, { workflow_version: target.workflow_version, - schedule_name: scheduleName, + schedule_name: trimmed, }); set((s: any) => ({ schedules: s.schedules.map((item: Schedule) => @@ -287,6 +285,8 @@ export const createListSlice: StateCreator { + removeNode: async (target, options) => { const api = requireApi(); const state_ = get(); const { schedule, selectedNodeId } = state_; const node = target ?? schedule?.nodes.find((item) => item.node_id === selectedNodeId) ?? null; - if (!schedule || !node || state_.busy) return; + if (!schedule || !node || state_.busy) return "noop"; set({ contextMenu: null }); - if (!window.confirm(`确定删除节点"${node.node_name}"吗?`)) return; set({ busy: "delete-node" }); try { let updated: Schedule; @@ -550,6 +543,9 @@ export const createListSlice: StateCreator = { // Dialog createDialogOpen: false, newScheduleName: "", + renameTarget: null, + renameName: "", cronResult: null, // Runs runs: [],