update:添加分页、成员添加优化
This commit is contained in:
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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},
|
||||
)
|
||||
|
||||
@@ -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},
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -5,6 +5,7 @@ const Toaster = ({ ...props }: ToasterProps) => {
|
||||
return (
|
||||
<Sonner
|
||||
theme="light"
|
||||
position="bottom-right"
|
||||
className="toaster group"
|
||||
icons={{
|
||||
success: (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user