update:添加分页、成员添加优化

This commit is contained in:
xiaozhu
2026-09-02 10:10:41 +08:00
committed by tao.chen
parent 5a90f7db36
commit b7b8e28b52
20 changed files with 1729 additions and 920 deletions
+29 -3
View File
@@ -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` 成员。
@@ -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,供其他文件使用
@@ -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,
}
+54 -6
View File
@@ -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,
),
)
+298
View File
@@ -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},
)
+62 -285
View File
@@ -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},
)
+57
View File
@@ -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
+1
View File
@@ -5,6 +5,7 @@ const Toaster = ({ ...props }: ToasterProps) => {
return (
<Sonner
theme="light"
position="bottom-right"
className="toaster group"
icons={{
success: (
+2 -2
View File
@@ -270,7 +270,7 @@ export function useApi(): WorkspaceBoundApi {
hideScheduleArtifact: (versionsId) =>
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),
@@ -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<number | "ellipsis"> {
if (total <= 7) {
return Array.from({ length: total }, (_, index) => index + 1);
}
const pages = new Set<number>([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<number | "ellipsis"> = [];
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 (
<div className="mt-3 flex flex-wrap items-center justify-between gap-2 text-[11px] text-[#5d7186]">
<span>
<strong className="text-[#27394d]">{totalCount}</strong> {page}/{totalPages}
</span>
<div className="flex items-center gap-1">
<Button
type="button"
variant="outline"
size="xs"
disabled={loading || page <= 1}
onClick={onPrev}
className="h-7 gap-0.5 rounded-[5px] border-[#d9e3ec] bg-white px-2 text-[11px] text-[#4c6c88]"
>
<ChevronLeft size={14} />
</Button>
{pages.map((item, index) =>
item === "ellipsis" ? (
<span key={`e-${index}`} className="px-1 text-[#94a2b3]">
</span>
) : (
<Button
key={item}
type="button"
variant="outline"
size="xs"
disabled={loading || !canGoToPage(item)}
onClick={() => onGoToPage(item)}
className={`h-7 min-w-7 rounded-[5px] px-2 text-[11px] ${
item === page
? "border-brand bg-brand-soft font-semibold text-brand"
: "border-[#d9e3ec] bg-white text-[#4c6c88] disabled:opacity-40"
}`}
title={
canGoToPage(item)
? undefined
: "请先通过上一页/下一页依次访问该页"
}
>
{item}
</Button>
),
)}
<Button
type="button"
variant="outline"
size="xs"
disabled={loading || !hasMore}
onClick={onNext}
className="h-7 gap-0.5 rounded-[5px] border-[#d9e3ec] bg-white px-2 text-[11px] text-[#4c6c88]"
>
<ChevronRight size={14} />
</Button>
</div>
</div>
);
}
@@ -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> | void;
onClose: () => void;
}) {
return (
<AppFormDialog
open={open && selectedProject !== null}
onOpenChange={(nextOpen) => {
if (!nextOpen) onClose();
}}
eyebrow="IMPORT MEMBER"
title={`导入成员到 ${selectedProject?.workspace_name ?? "项目"}`}
footer={
<>
<Button
variant="outline"
size="sm"
type="button"
onClick={() => onClose()}
className={dialogSecondaryButtonClass}
>
</Button>
<Button
variant="default"
size="sm"
type="button"
disabled={saving || selectedUserIds.length === 0}
onClick={() => void onSubmit()}
className={dialogPrimaryButtonClass}
>
{saving ? "添加中…" : `添加 (${selectedUserIds.length})`}
</Button>
</>
}
>
<div className={modalFormClass}>
<label className={`${formFieldClass} pb-[120px]`}>
<span><span className="text-danger">*</span></span>
<UserMultiSelect
users={availableUsers}
selectedUserIds={selectedUserIds}
onChange={onChangeSelectedUserIds}
existingMemberIds={existingMemberIds}
/>
</label>
</div>
</AppFormDialog>
);
}
@@ -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> | void;
}) {
const [keyword, setKeyword] = useState("");
const debouncedKeyword = useDebouncedValue(keyword, 300);
const [users, setUsers] = useState<Employee[]>([]);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [selectedCache, setSelectedCache] = useState<Record<string, Employee>>({});
const [loading, setLoading] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const [hasMore, setHasMore] = useState(false);
const nextCursorRef = useRef<string | null>(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 (
<>
<div
style={{
padding: "12px 20px",
borderBottom: "1px solid #f0f0f0",
display: "flex",
flexDirection: "column",
gap: 10,
}}
>
<button
type="button"
onClick={onBack}
style={{
display: "inline-flex",
alignItems: "center",
gap: 4,
border: 0,
background: "transparent",
color: "#1474d4",
fontSize: 12,
fontWeight: 600,
cursor: "pointer",
padding: 0,
width: "fit-content",
}}
>
<ArrowLeft size={14} />
</button>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
border: "1px solid #d9e2eb",
borderRadius: 6,
background: "#f8fafb",
padding: "8px 10px",
}}
>
<Search size={14} color="#8a99a8" />
<input
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
placeholder="搜索姓名 / 账号"
style={{
flex: 1,
border: 0,
outline: "none",
background: "transparent",
fontSize: 12,
color: "#20364c",
}}
/>
</div>
{selectedUsers.length > 0 && (
<div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
{selectedUsers.map((user) => (
<span
key={user.user_id}
style={{
display: "inline-flex",
alignItems: "center",
gap: 4,
backgroundColor: "#e6f7ff",
color: "#1890ff",
border: "1px solid #91d5ff",
borderRadius: 4,
padding: "2px 8px",
fontSize: 12,
}}
>
{user.display_name}
<button
type="button"
onClick={() => removeSelected(user.user_id)}
style={{
border: 0,
background: "transparent",
color: "#1890ff",
cursor: "pointer",
fontWeight: 700,
padding: 0,
lineHeight: 1,
}}
>
×
</button>
</span>
))}
</div>
)}
</div>
<div style={{ flex: 1, overflow: "auto", padding: "6px 10px" }}>
{loading && users.length === 0 ? (
<p style={{ textAlign: "center", color: "#999", padding: "24px 0", fontSize: 12 }}>
</p>
) : users.length === 0 ? (
<p style={{ textAlign: "center", color: "#999", padding: "24px 0", fontSize: 12 }}>
{debouncedKeyword.trim() ? "未找到匹配用户" : "暂无可选用户"}
</p>
) : (
users.map((user) => {
const isExisting = existingMemberIds.includes(user.user_id);
const isSelected = selectedIds.includes(user.user_id);
return (
<button
key={user.user_id}
type="button"
disabled={isExisting}
onClick={() => toggleUser(user)}
style={{
display: "flex",
width: "100%",
alignItems: "center",
gap: 8,
padding: "5px 6px",
minHeight: 28,
border: 0,
borderBottom: "1px solid #f3f5f7",
background: isSelected ? "#f0f7ff" : "transparent",
cursor: isExisting ? "not-allowed" : "pointer",
opacity: isExisting ? 0.55 : 1,
textAlign: "left",
}}
>
<span
style={{
width: 14,
height: 14,
borderRadius: 3,
border: `1px solid ${isSelected || isExisting ? "#1890ff" : "#c5d0db"}`,
background: isSelected ? "#1890ff" : "#fff",
color: "#fff",
fontSize: 10,
display: "grid",
placeItems: "center",
flexShrink: 0,
}}
>
{isSelected ? "✓" : ""}
</span>
<span
style={{
flex: 1,
minWidth: 0,
display: "flex",
alignItems: "baseline",
gap: 6,
overflow: "hidden",
}}
>
<strong
style={{
fontSize: 14,
color: "#23384e",
fontWeight: 600,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{user.display_name}
</strong>
<small
style={{
fontSize: 13,
color: "#8896a6",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{user.username}
</small>
</span>
{isExisting && (
<span
style={{
fontSize: 12,
color: "#999",
background: "#f0f0f0",
padding: "0 5px",
borderRadius: 2,
lineHeight: "16px",
flexShrink: 0,
}}
>
</span>
)}
</button>
);
})
)}
{hasMore && (
<div style={{ display: "flex", justifyContent: "center", padding: "10px 0 6px" }}>
<button
type="button"
onClick={loadMore}
disabled={loadingMore || loading}
style={{
border: "1px solid #d9e3ec",
background: "#fff",
color: "#4c6c88",
borderRadius: 4,
padding: "4px 12px",
fontSize: 11,
cursor: loadingMore || loading ? "wait" : "pointer",
opacity: loadingMore || loading ? 0.65 : 1,
}}
>
{loadingMore ? "加载中…" : "加载更多"}
</button>
</div>
)}
</div>
<div
style={{
padding: "12px 20px",
borderTop: "1px solid #e8e8e8",
display: "flex",
justifyContent: "flex-end",
gap: 8,
}}
>
<Button
type="button"
variant="outline"
size="sm"
onClick={onBack}
disabled={saving}
className={`${dialogSecondaryButtonClass} !rounded-[4px] min-w-[72px]`}
>
</Button>
<Button
type="button"
size="sm"
className={`${primaryGradientButtonClass} !rounded-[4px]`}
disabled={saving || selectedIds.length === 0}
onClick={() => void submitAdd()}
>
{saving ? "添加中…" : `添加 (${selectedIds.length})`}
</Button>
</div>
</>
);
}
@@ -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<Workspace[]>([]);
const [projectLoading, setProjectLoading] = useState(true);
const [projectDialogOpen, setProjectDialogOpen] = useState(false);
const [projectForm, setProjectForm] = useState(EMPTY_PROJECT_FORM);
const [editingProject, setEditingProject] = useState<Workspace | null>(null);
const [importMemberDialogOpen, setImportMemberDialogOpen] = useState(false);
const [selectedProject, setSelectedProject] = useState<Workspace | null>(null);
const [availableUsers, setAvailableUsers] = useState<Employee[]>([]);
const [selectedUserIds, setSelectedUserIds] = useState<string[]>([]);
const [projectSearchTerm, setProjectSearchTerm] = useState("");
const [saving, setSaving] = useState(false);
const [membersDrawerOpen, setMembersDrawerOpen] = useState(false);
const [currentProjectMembers, setCurrentProjectMembers] = useState<WorkspaceMember[]>([]);
const [membersLoading, setMembersLoading] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<Workspace | null>(null);
const [removeMemberTarget, setRemoveMemberTarget] = useState<WorkspaceMember | null>(null);
const debouncedSearch = useDebouncedValue(projectSearchTerm, 300);
const canManage = user?.role_code === "admin";
const loadProjects = async (): Promise<void> => {
setProjectLoading(true);
const fetchProjects = useCallback(
async (input: { limit: number; cursor: string | null; q: string }) => {
try {
const workspaceList = await api.listWorkspaces();
setProjects(workspaceList);
const page = await api.listWorkspaces(input);
onConnectionChange(true);
return page;
} catch (error) {
onConnectionChange(false);
onNotify({
tone: "error",
message: error instanceof Error ? error.message : "项目列表加载失败",
});
} finally {
setProjectLoading(false);
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
if (!selectedProject || selectedUserIds.length === 0) {
const importMembers = async (userIds: string[]): Promise<void> => {
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 (
<section className={adminPageClass}>
<div className={adminToolbarClass}>
@@ -291,30 +324,14 @@ export function ProjectManagementPage({
</TableRow>
</TableHeader>
<TableBody>
{projectLoading ? (
<TableRow className="min-h-[64px] border-t border-[#edf1f5] hover:bg-[#f7fafc] data-[state=selected]:bg-transparent">
{projectLoading || projects.length === 0 ? (
<TableRow className="min-h-[64px] border-t border-[#edf1f5] hover:bg-transparent data-[state=selected]:bg-transparent">
<TableCell colSpan={5} className="p-0">
<p className={adminEmptyClass}></p>
</TableCell>
</TableRow>
) : projects.length === 0 ? (
<TableRow className="min-h-[64px] border-t border-[#edf1f5] hover:bg-[#f7fafc] data-[state=selected]:bg-transparent">
<TableCell colSpan={5} className="p-0">
<p className={adminEmptyClass}></p>
<p className={adminEmptyClass}>{emptyMessage}</p>
</TableCell>
</TableRow>
) : (
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) => (
projects.map((project) => (
<TableRow key={project.workspace_id} className="min-h-[64px] border-t border-[#edf1f5] hover:bg-[#f7fafc] data-[state=selected]:bg-transparent">
<TableCell className="p-3 px-4 align-middle">
<span className={employeeNameClass}>
@@ -327,13 +344,17 @@ export function ProjectManagementPage({
</TableCell>
<TableCell className="p-3 px-4 align-middle"><code>{project.workspace_code}</code></TableCell>
<TableCell className="p-3 px-4 align-middle">
<span>
<span className={statusPillClass(project.status)}>
{project.status === "active" ? "正常" : project.status === "archived" ? "已归档" : "已删除"}
</span>
</TableCell>
<TableCell className="p-3 px-4 align-middle">
<span>
{project.quota_bytes > 0
? `${(project.quota_bytes / 1024 / 1024 / 1024).toFixed(1)} GB`
: "无限制"}
</span>
</TableCell>
<TableCell className="p-3 px-4 align-middle"><span>{project.quota_bytes > 0 ? `${(project.quota_bytes / 1024 / 1024 / 1024).toFixed(1)} GB` : "无限制"}</span></TableCell>
<TableCell className="p-3 px-4 align-middle">
<span className={employeeActionsClass}>
<Button
@@ -375,6 +396,18 @@ export function ProjectManagementPage({
</TableBody>
</Table>
<AdminPagination
page={page}
totalPages={totalPages}
totalCount={meta.total_count}
loading={projectLoading}
hasMore={meta.has_more}
canGoToPage={canGoToPage}
onPrev={goPrev}
onNext={goNext}
onGoToPage={goToPage}
/>
<ProjectEditDialog
open={projectDialogOpen}
editingProject={editingProject}
@@ -386,32 +419,20 @@ export function ProjectManagementPage({
onClose={() => setProjectDialogOpen(false)}
/>
<ImportMemberDialog
open={importMemberDialogOpen}
selectedProject={selectedProject}
availableUsers={availableUsers}
selectedUserIds={selectedUserIds}
existingMemberIds={currentProjectMembers.map((m) => m.user_id)}
saving={saving}
onNotify={onNotify}
onChangeSelectedUserIds={setSelectedUserIds}
onSubmit={() => void importMember()}
onClose={() => setImportMemberDialogOpen(false)}
/>
<ProjectMembersDrawer
open={membersDrawerOpen}
selectedProject={selectedProject}
members={currentProjectMembers}
membersLoading={membersLoading}
canManage={canManage}
onClose={() => 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}
/>
<ConfirmDialog
@@ -433,6 +454,26 @@ export function ProjectManagementPage({
setDeleteTarget(null);
}}
/>
<ConfirmDialog
open={removeMemberTarget !== null}
onOpenChange={(nextOpen) => {
if (!nextOpen) setRemoveMemberTarget(null);
}}
title="确定移除成员?"
description={
removeMemberTarget
? `确定将"${removeMemberTarget.display_name}"从项目中移除吗?`
: ""
}
confirmLabel="移除"
destructive
onConfirm={async () => {
if (!removeMemberTarget) return;
await executeRemoveMember(removeMemberTarget);
setRemoveMemberTarget(null);
}}
/>
</section>
);
}
@@ -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,35 +14,46 @@ 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> | void;
loadUsers: LoadUsersFn;
}) {
const [panel, setPanel] = useState<DrawerPanel>("members");
useEffect(() => {
if (!open) setPanel("members");
}, [open]);
if (!open || !selectedProject) return null;
return (
<>
<div className="drawer-overlay" onClick={() => onClose()} style={{
<div
className="drawer-overlay"
onClick={() => onClose()}
style={{
position: "fixed",
top: 0,
left: 0,
right: 0,
bottom: 0,
inset: 0,
backgroundColor: "rgba(0,0,0,0.3)",
zIndex: 99,
}} />
<aside className="drawer" style={{
zIndex: 40,
}}
/>
<aside
className="drawer"
style={{
position: "fixed",
right: 0,
top: 0,
@@ -46,43 +63,58 @@ export function ProjectMembersDrawer({
boxShadow: "-2px 0 8px rgba(0,0,0,0.1)",
display: "flex",
flexDirection: "column",
zIndex: 100,
}}>
<div className="drawer__header" style={{
zIndex: 41,
}}
>
<div
className="drawer__header"
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "16px 20px",
borderBottom: "1px solid #e8e8e8",
}}>
}}
>
<div>
<span className="modal__eyebrow" style={{ fontSize: 12, color: "#666" }}>MEMBERS</span>
<h3 style={{ margin: 0, fontSize: 18 }}></h3>
<p style={{ margin: "4px 0 0", fontSize: 13, color: "#888" }}>{selectedProject.workspace_name}</p>
{/* <span style={{ fontSize: 12, color: "#666" }}>
{panel === "members" ? "MEMBERS" : "ADD MEMBER"}
</span> */}
<h3 style={{ margin: 0, fontSize: 16 }}>
{panel === "members" ? "项目成员" : "添加成员"}
</h3>
<p style={{ margin: "4px 0 0", fontSize: 12, color: "#888" }}>
{selectedProject.workspace_name}
</p>
</div>
<button className="icon-button" type="button" onClick={() => onClose()}>
<X />
</button>
</div>
<div style={{
{panel === "members" ? (
<>
<div
style={{
padding: "12px 20px",
borderBottom: "1px solid #f0f0f0",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}>
<span style={{ fontSize: 14, color: "#666" }}>
{members.length}
</span>
<button
className={primaryGradientButtonClass}
}}
>
<span style={{ fontSize: 14, color: "#666" }}> {members.length} </span>
{canManage && (
<Button
type="button"
onClick={() => onAddMembers()}
size="sm"
className={primaryGradientButtonClass}
onClick={() => setPanel("add")}
style={{ fontSize: 13, padding: "6px 12px" }}
>
<Plus size={14} />
</button>
</Button>
)}
</div>
<div style={{ flex: 1, overflow: "auto", padding: "12px 20px" }}>
@@ -100,14 +132,13 @@ export function ProjectMembersDrawer({
style={{
display: "flex",
alignItems: "center",
padding: "6px",
padding: 6,
border: "1px solid #e8e8e8",
borderRadius: 6,
backgroundColor: "#fafafa",
}}
>
<div
className="avatar"
style={{
width: 32,
height: 32,
@@ -125,10 +156,11 @@ export function ProjectMembersDrawer({
{member.display_name.slice(0, 1)}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 500, marginBottom: 2, fontSize: 13 }}>{member.display_name}</div>
<div style={{ fontWeight: 500, marginBottom: 2, fontSize: 13 }}>
{member.display_name}
</div>
<div style={{ fontSize: 11, color: "#888" }}>{member.username}</div>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<button
type="button"
onClick={() => onRemoveMember(member.user_id)}
@@ -136,10 +168,10 @@ export function ProjectMembersDrawer({
padding: "3px 8px",
fontSize: 11,
color: isAdmin ? "#999" : "#ff4d4f",
border: "1px solid " + (isAdmin ? "#ddd" : "#ff4d4f"),
border: `1px solid ${isAdmin ? "#ddd" : "#ff4d4f"}`,
borderRadius: 4,
backgroundColor: "#fff",
cursor: isAdmin ? "not-allowed" : canManage ? "pointer" : "not-allowed",
cursor: isAdmin || !canManage ? "not-allowed" : "pointer",
}}
disabled={!canManage || isAdmin}
title={isAdmin ? "管理员不能被移除" : canManage ? "移除成员" : "无权限"}
@@ -147,12 +179,21 @@ export function ProjectMembersDrawer({
</button>
</div>
</div>
);
})}
</div>
)}
</div>
</>
) : (
<MemberAddPanel
existingMemberIds={members.map((m) => m.user_id)}
saving={saving}
loadUsers={loadUsers}
onBack={() => setPanel("members")}
onAddMembers={onAddMembers}
/>
)}
</aside>
</>
);
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Plus, Search } from "lucide-react";
import { ApiRequestError, type Employee, type Role } from "../../services/api";
@@ -20,6 +20,8 @@ import {
TableRow,
} from "~/components/ui/table";
import { AdminColgroup, USER_PROJECT_COL_WIDTHS } from "./AdminTable";
import { AdminPagination } from "./AdminPagination";
import { useCursorPage } from "./useCursorPage";
import {
formFieldClass,
formInputClass,
@@ -42,6 +44,7 @@ import {
} from "./adminUi";
import { primaryGradientButtonClass } from "~/components/common/buttonClasses";
import { useDebouncedValue } from "./useDebouncedValue";
const EMPTY_FORM = {
username: "",
@@ -54,6 +57,7 @@ const EMPTY_FORM = {
const FORM_INPUT_CLASS =
"h-[39px] w-full rounded-md border border-[#d6e0e9] bg-white px-[11px] text-xs text-[#283a4e] outline-none focus:border-[#5b9ddb] focus:ring-3 focus:ring-brand/10 disabled:cursor-not-allowed disabled:opacity-50";
export function UserManagementPage({
onNotify,
onConnectionChange,
@@ -63,42 +67,55 @@ export function UserManagementPage({
}) {
const api = useApi();
const { user } = useAuth();
const [employees, setEmployees] = useState<Employee[]>([]);
const [roles, setRoles] = useState<Role[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [editing, setEditing] = useState<Employee | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [form, setForm] = useState(EMPTY_FORM);
const [userSearchTerm, setUserSearchTerm] = useState("");
const [deleteTarget, setDeleteTarget] = useState<Employee | null>(null);
const debouncedSearch = useDebouncedValue(userSearchTerm, 300);
const canManage = user?.role_code === "admin";
const load = async (): Promise<void> => {
setLoading(true);
const fetchEmployees = useCallback(
async (input: { limit: number; cursor: string | null; q: string }) => {
try {
const [employeeList, roleList] = await Promise.all([
api.listPlatformEmployees(),
api.listPlatformRoles(),
]);
setEmployees(employeeList);
setRoles(roleList);
const page = await api.listPlatformEmployees(input);
onConnectionChange(true);
return page;
} catch (error) {
onConnectionChange(false);
onNotify({
tone: "error",
message: error instanceof Error ? error.message : "用户列表加载失败",
});
} finally {
setLoading(false);
throw error;
}
};
},
[api, onConnectionChange, onNotify],
);
const {
items: employees,
setItems: setEmployees,
page,
loading,
meta,
totalPages,
goNext,
goPrev,
goToPage,
canGoToPage,
reload,
refreshFromStart,
} = useCursorPage(fetchEmployees, debouncedSearch);
useEffect(() => {
void load();
}, []);
void api.listPlatformRoles().then(setRoles).catch(() => {
/* roles only needed for edit dialog */
});
}, [api]);
const openCreate = (): void => {
setEditing(null);
@@ -122,12 +139,10 @@ export function UserManagementPage({
const submit = async (event: React.FormEvent): Promise<void> => {
event.preventDefault();
if (!form.display_name.trim() || (!editing && !form.username.trim())) return;
// 新建用户时密码必填
if (!editing && !form.password.trim()) {
onNotify({ tone: "error", message: "请输入密码" });
return;
}
// 编辑自身时,前端二次拦截禁用 status / role_code 的修改;管理员账号禁止停用/锁定
if (editing) {
const editingSelf = editing.user_id === user?.user_id;
const targetIsAdmin = editing.role_code === "admin";
@@ -144,7 +159,6 @@ export function UserManagementPage({
return;
}
}
// 密码长度校验 8~72 字符
if (form.password && (form.password.length < 8 || form.password.length > 72)) {
onNotify({ tone: "error", message: "密码长度必须在 8~72 字符之间" });
return;
@@ -158,20 +172,20 @@ export function UserManagementPage({
role_code: form.role_code,
status: form.status,
});
setEmployees((current) => current.map(
(item) => item.user_id === updated.user_id ? updated : item,
));
setEmployees((current) =>
current.map((item) => (item.user_id === updated.user_id ? updated : item)),
);
onNotify({ tone: "success", message: "用户信息已更新" });
} else {
const created = await api.createPlatformEmployee({
await api.createPlatformEmployee({
username: form.username.trim(),
display_name: form.display_name.trim(),
email: form.email.trim() || undefined,
password: form.password,
role_code: "developer",
});
setEmployees((current) => [...current, created]);
onNotify({ tone: "success", message: "用户已添加" });
await refreshFromStart();
}
setDialogOpen(false);
} catch (error) {
@@ -187,8 +201,12 @@ export function UserManagementPage({
const executeRemove = async (employee: Employee): Promise<void> => {
try {
await api.deletePlatformEmployee(employee.user_id);
setEmployees((current) => current.filter((item) => item.user_id !== employee.user_id));
onNotify({ tone: "success", message: "用户已删除" });
if (employees.length <= 1 && page > 1) {
goPrev();
} else {
reload();
}
} catch (error) {
onNotify({
tone: "error",
@@ -197,6 +215,12 @@ export function UserManagementPage({
}
};
const emptyMessage = useMemo(() => {
if (loading) return "正在加载用户…";
if (debouncedSearch.trim()) return "未找到匹配的用户";
return "暂无用户";
}, [loading, debouncedSearch]);
return (
<section className={adminPageClass}>
<div className={adminToolbarClass}>
@@ -236,24 +260,14 @@ export function UserManagementPage({
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow className="min-h-[64px] border-t border-[#edf1f5] hover:bg-[#f7fafc] data-[state=selected]:bg-transparent">
{loading || employees.length === 0 ? (
<TableRow className="min-h-[64px] border-t border-[#edf1f5] hover:bg-transparent data-[state=selected]:bg-transparent">
<TableCell colSpan={5} className="p-0">
<p className={adminEmptyClass}></p>
<p className={adminEmptyClass}>{emptyMessage}</p>
</TableCell>
</TableRow>
) : (
employees
.filter((employee) => {
const term = userSearchTerm.toLowerCase().trim();
if (!term) return true;
return (
employee.display_name.toLowerCase().includes(term) ||
employee.username.toLowerCase().includes(term) ||
(employee.email && employee.email.toLowerCase().includes(term))
);
})
.map((employee) => {
employees.map((employee) => {
const isProtectedAdmin = employee.role_code === "admin";
return (
<TableRow key={employee.user_id} className="min-h-[64px] border-t border-[#edf1f5] hover:bg-[#f7fafc] data-[state=selected]:bg-transparent">
@@ -291,6 +305,18 @@ export function UserManagementPage({
</TableBody>
</Table>
<AdminPagination
page={page}
totalPages={totalPages}
totalCount={meta.total_count}
loading={loading}
hasMore={meta.has_more}
canGoToPage={canGoToPage}
onPrev={goPrev}
onNext={goNext}
onGoToPage={goToPage}
/>
<AppFormDialog
open={dialogOpen}
onOpenChange={(nextOpen) => {
@@ -1,173 +0,0 @@
import { useEffect, useState, useRef } from "react";
import { type Employee } from "../../services/api";
import { adminToolbarInputClass } from "./adminUi";
// 多选用户下拉框组件 - 带 tag 显示
export function UserMultiSelect({
users,
selectedUserIds,
onChange,
existingMemberIds,
}: {
users: Employee[];
selectedUserIds: string[];
onChange: (ids: string[]) => void;
existingMemberIds?: string[];
}) {
const [isOpen, setIsOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
// 点击外部关闭下拉框
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const selectedUsers = users.filter((user) => selectedUserIds.includes(user.user_id));
const toggleUser = (userId: string) => {
if (selectedUserIds.includes(userId)) {
onChange(selectedUserIds.filter((id) => id !== userId));
} else {
onChange([...selectedUserIds, userId]);
}
};
const removeUser = (userId: string, event: React.MouseEvent) => {
event.stopPropagation();
onChange(selectedUserIds.filter((id) => id !== userId));
};
return (
<div ref={containerRef} style={{ position: "relative" }}>
{/* 选择框 - 显示 tag */}
<div
className={adminToolbarInputClass}
onClick={() => setIsOpen(!isOpen)}
style={{
display: "flex",
alignItems: "center",
flexWrap: "wrap",
gap: "4px",
padding: "6px 32px 6px 8px",
minHeight: "36px",
border: "1px solid #ddd",
borderRadius: "4px",
cursor: "pointer",
backgroundColor: "#fff",
}}
>
{selectedUsers.length === 0 ? (
<span style={{ color: "#999" }}></span>
) : (
selectedUsers.map((user) => (
<span
key={user.user_id}
style={{
display: "inline-flex",
alignItems: "center",
backgroundColor: "#e6f7ff",
color: "#1890ff",
border: "1px solid #91d5ff",
borderRadius: "2px",
padding: "2px 6px",
fontSize: "13px",
}}
>
{user.display_name}
<span
onClick={(e) => removeUser(user.user_id, e)}
style={{
marginLeft: "4px",
cursor: "pointer",
fontWeight: "bold",
fontSize: "14px",
}}
>
×
</span>
</span>
))
)}
<span
style={{
position: "absolute",
right: "12px",
top: "50%",
transform: "translateY(-50%)",
color: "#999",
pointerEvents: "none",
}}
>
{isOpen ? "▲" : "▼"}
</span>
</div>
{/* 下拉列表 */}
{isOpen && (
<div
style={{
position: "absolute",
top: "100%",
left: 0,
right: 0,
maxHeight: "136px",
overflowY: "auto",
border: "1px solid #1890ff",
borderRadius: "4px",
backgroundColor: "#fff",
zIndex: 100,
marginTop: "2px",
boxShadow: "0 2px 8px rgba(0,0,0,0.15)",
}}
>
{users.length === 0 ? (
<div style={{ padding: "12px", color: "#999", textAlign: "center", fontSize: "13px" }}>
</div>
) : (
users.map((user) => {
const isSelected = selectedUserIds.includes(user.user_id);
const isExisting = existingMemberIds?.includes(user.user_id);
const isDisabled = isExisting;
return (
<div
key={user.user_id}
onClick={() => !isDisabled && toggleUser(user.user_id)}
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "6px 12px",
cursor: isDisabled ? "not-allowed" : "pointer",
backgroundColor: isSelected ? "#e6f7ff" : isExisting ? "#f5f5f5" : "transparent",
borderBottom: "1px solid #f0f0f0",
opacity: isDisabled ? 0.6 : 1,
}}
>
<span>
{user.display_name} <span style={{ color: "#999" }}>({user.username})</span>
</span>
<div style={{ display: "flex", alignItems: "center", gap: "6px" }}>
{isExisting && (
<span style={{ fontSize: "11px", color: "#999", backgroundColor: "#f0f0f0", padding: "1px 6px", borderRadius: "2px" }}>
</span>
)}
{isSelected && <span style={{ color: "#1890ff" }}></span>}
</div>
</div>
);
})
)}
</div>
)}
</div>
);
}
@@ -367,8 +367,8 @@ export const useAdminStore = create<State & Actions>((set, get) => ({
const api = requireApi();
set({ projectsLoading: true });
try {
const list = await api.listWorkspaces();
set({ projects: list });
const page = await api.listWorkspaces({ limit: 10 });
set({ projects: page.items });
onConnectionChange(true);
} catch (error) {
onConnectionChange(false);
@@ -0,0 +1,128 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { CursorPage, CursorPageMeta } from "~/services/api";
export const ADMIN_PAGE_SIZE = 10;
type Fetcher<T> = (input: {
limit: number;
cursor: string | null;
q: string;
}) => Promise<CursorPage<T>>;
/**
* Cursor-stack pagination for admin tables.
* Supports prev/next and jumping to already-visited pages.
* Changing ``q`` resets to page 1.
*/
export function useCursorPage<T>(fetcher: Fetcher<T>, q: string) {
const [items, setItems] = useState<T[]>([]);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(true);
const [meta, setMeta] = useState<CursorPageMeta>({
limit: ADMIN_PAGE_SIZE,
page_count: 0,
total_count: 0,
has_more: false,
next_cursor: null,
});
// cursor used to *enter* page N. Page 1 is always null.
const cursorByPageRef = useRef<Record<number, string | null>>({ 1: null });
const nextCursorByPageRef = useRef<Record<number, string | null>>({});
const requestIdRef = useRef(0);
const totalPages = Math.max(1, Math.ceil(meta.total_count / ADMIN_PAGE_SIZE));
const loadPage = useCallback(
async (targetPage: number, options?: { resetStack?: boolean }) => {
const requestId = ++requestIdRef.current;
setLoading(true);
try {
if (options?.resetStack) {
cursorByPageRef.current = { 1: null };
nextCursorByPageRef.current = {};
}
const cursor = cursorByPageRef.current[targetPage] ?? null;
if (targetPage > 1 && cursor === null && targetPage !== 1) {
// Unvisited deep page — refuse rather than invent offset.
return;
}
const result = await fetcher({
limit: ADMIN_PAGE_SIZE,
cursor: targetPage === 1 ? null : cursor,
q,
});
if (requestId !== requestIdRef.current) return;
setItems(result.items);
setMeta(result.meta);
setPage(targetPage);
nextCursorByPageRef.current[targetPage] = result.meta.next_cursor;
if (result.meta.next_cursor) {
cursorByPageRef.current[targetPage + 1] = result.meta.next_cursor;
}
} catch {
// Caller is responsible for notifying; keep previous items.
} finally {
if (requestId === requestIdRef.current) {
setLoading(false);
}
}
},
[fetcher, q],
);
useEffect(() => {
void loadPage(1, { resetStack: true });
}, [loadPage]);
const goNext = useCallback(() => {
if (!meta.has_more) return;
void loadPage(page + 1);
}, [loadPage, meta.has_more, page]);
const goPrev = useCallback(() => {
if (page <= 1) return;
void loadPage(page - 1);
}, [loadPage, page]);
const goToPage = useCallback(
(targetPage: number) => {
if (targetPage < 1 || targetPage > totalPages) return;
if (targetPage === page) return;
// Only allow visited pages (cursor known) or page 1.
if (targetPage !== 1 && cursorByPageRef.current[targetPage] == null) {
return;
}
void loadPage(targetPage);
},
[loadPage, page, totalPages],
);
const canGoToPage = useCallback(
(targetPage: number) => {
if (targetPage < 1 || targetPage > totalPages) return false;
if (targetPage === 1 || targetPage === page) return true;
return cursorByPageRef.current[targetPage] != null;
},
[page, totalPages],
);
const reload = useCallback(() => {
void loadPage(page);
}, [loadPage, page]);
return {
items,
setItems,
page,
loading,
meta,
totalPages,
goNext,
goPrev,
goToPage,
canGoToPage,
reload,
refreshFromStart: () => loadPage(1, { resetStack: true }),
};
}
@@ -0,0 +1,12 @@
import { useEffect, useState } from "react";
export function useDebouncedValue<T>(value: T, delayMs: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = window.setTimeout(() => setDebounced(value), delayMs);
return () => window.clearTimeout(timer);
}, [value, delayMs]);
return debounced;
}
+77 -7
View File
@@ -230,6 +230,25 @@ type ApiEnvelope<T> = {
meta: Record<string, unknown>;
};
export type CursorPageMeta = {
limit: number;
page_count: number;
total_count: number;
has_more: boolean;
next_cursor: string | null;
};
export type CursorPage<T> = {
items: T[];
meta: CursorPageMeta;
};
export type CursorListParams = {
limit?: number;
cursor?: string | null;
q?: string;
};
type ApiErrorEnvelope = {
detail?: string | {
code?: string;
@@ -269,6 +288,15 @@ async function apiRequest<T>(
init: RequestInit = {},
workspaceId?: string,
): Promise<T> {
const result = await apiRequestWithMeta<T>(path, init, workspaceId);
return result.data;
}
async function apiRequestWithMeta<T>(
path: string,
init: RequestInit = {},
workspaceId?: string,
): Promise<{ data: T; meta: Record<string, unknown> }> {
const finalPath = workspaceId ? appendWorkspaceId(path, workspaceId) : path;
const response = await fetch(finalPath, {
...init,
@@ -311,7 +339,33 @@ async function apiRequest<T>(
: error.error?.code,
);
}
return (payload as ApiEnvelope<T>).data;
const envelope = payload as ApiEnvelope<T>;
return { data: envelope.data, meta: envelope.meta ?? {} };
}
function parseCursorPageMeta(meta: Record<string, unknown>): CursorPageMeta {
const totalFromMeta =
typeof meta.total_count === "number"
? meta.total_count
: typeof meta.count === "number"
? meta.count
: 0;
return {
limit: typeof meta.limit === "number" ? meta.limit : 10,
page_count: typeof meta.page_count === "number" ? meta.page_count : 0,
total_count: totalFromMeta,
has_more: Boolean(meta.has_more),
next_cursor: typeof meta.next_cursor === "string" ? meta.next_cursor : null,
};
}
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(
@@ -1153,8 +1207,14 @@ export async function hideScheduleArtifact(
}
// 系统管理级别接口 - 不区分 workspace
export async function listPlatformEmployees(): Promise<Employee[]> {
return apiRequest<Employee[]>("/api/v1/platform/employees");
export async function listPlatformEmployees(
input: CursorListParams = {},
): Promise<CursorPage<Employee>> {
const query = buildCursorQuery(input);
const { data, meta } = await apiRequestWithMeta<Employee[]>(
`/api/v1/platform/employees?${query}`,
);
return { items: data, meta: parseCursorPageMeta(meta) };
}
export async function createPlatformEmployee(
@@ -1302,8 +1362,14 @@ export async function deleteEmployee(
// Workspace (Project) Management APIs - 对应 API.md 第七部分系统管理接口
// ----------------------------------------------------------------------------
export async function listWorkspaces(): Promise<Workspace[]> {
return apiRequest<Workspace[]>("/api/v1/platform/workspaces");
export async function listWorkspaces(
input: CursorListParams = {},
): Promise<CursorPage<Workspace>> {
const query = buildCursorQuery(input);
const { data, meta } = await apiRequestWithMeta<Workspace[]>(
`/api/v1/platform/workspaces?${query}`,
);
return { items: data, meta: parseCursorPageMeta(meta) };
}
export async function createWorkspace(
@@ -1670,7 +1736,9 @@ export type WorkspaceBoundApi = {
artifact_preserved: boolean;
}>;
listEmployees: () => Promise<Employee[]>;
listPlatformEmployees: () => Promise<Employee[]>;
listPlatformEmployees: (
input?: CursorListParams,
) => Promise<CursorPage<Employee>>;
createEmployee: (
input: Parameters<typeof createEmployee>[1],
) => Promise<Employee>;
@@ -1757,7 +1825,9 @@ export type WorkspaceBoundApi = {
nodeRunId: string,
) => Promise<ScheduleNodeRunArtifacts>;
// Workspace (Project) Management - 系统管理接口
listWorkspaces: () => Promise<Workspace[]>;
listWorkspaces: (
input?: CursorListParams,
) => Promise<CursorPage<Workspace>>;
createWorkspace: (input: WorkspaceCreatePayload) => Promise<Workspace>;
updateWorkspace: (
workspaceId: string,