From b7b8e28b5207f368dd62ce24baa329cfee388b76 Mon Sep 17 00:00:00 2001 From: xiaozhu <2395895331@qq.com> Date: Mon, 31 Aug 2026 15:41:06 +0800 Subject: [PATCH] =?UTF-8?q?update:=E6=B7=BB=E5=8A=A0=E5=88=86=E9=A1=B5?= =?UTF-8?q?=E3=80=81=E6=88=90=E5=91=98=E6=B7=BB=E5=8A=A0=E4=BC=98=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, - }} /> -