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 |
|
| `POST` | `/api/v1/platform/employees` | 创建平台员工账号(返回 201);不自动加入任何 workspace |
|
||||||
| `PATCH` | `/api/v1/platform/employees/{user_id}` | 改员工资料/状态/平台角色(仅系统管理员) |
|
| `PATCH` | `/api/v1/platform/employees/{user_id}` | 改员工资料/状态/平台角色(仅系统管理员) |
|
||||||
| `DELETE` | `/api/v1/platform/employees/{user_id}` | 软删员工;级联软删其 workspace 成员关系(仅系统管理员) |
|
| `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 成员 |
|
| `POST` | `/api/v1/platform/workspaces` | 创建 workspace(返回 201);创建者自动成为 admin 成员 |
|
||||||
| `GET` | `/api/v1/platform/workspaces/{workspace_id}` | 单个 workspace(含已 disabled 的,用于恢复) |
|
| `GET` | `/api/v1/platform/workspaces/{workspace_id}` | 单个 workspace(含已 disabled 的,用于恢复) |
|
||||||
| `PATCH` | `/api/v1/platform/workspaces/{workspace_id}` | 改 workspace 字段;`status` 仅允许 `active`/`archived` |
|
| `PATCH` | `/api/v1/platform/workspaces/{workspace_id}` | 改 workspace 字段;`status` 仅允许 `active`/`archived` |
|
||||||
@@ -651,9 +651,35 @@ Base 前缀 `/api/v1/admin`。
|
|||||||
> - 每个 workspace 必须始终保留至少一个 `admin` 角色的活跃成员;对最后 admin 做降级 / 停用 / 删除 → 409。
|
> - 每个 workspace 必须始终保留至少一个 `admin` 角色的活跃成员;对最后 admin 做降级 / 停用 / 删除 → 409。
|
||||||
> - `PATCH /employees/{user_id}` 降级 admin → developer 时同样触发 workspace last-admin 守卫(因为 workspace 角色继承自 platform 角色,降级会级联到所有活跃 membership);platform 必须始终保留至少一个 `active` 系统管理员;对最后系统管理员做降级 / 停用 / 删除 → 409。
|
> - `PATCH /employees/{user_id}` 降级 admin → developer 时同样触发 workspace last-admin 守卫(因为 workspace 角色继承自 platform 角色,降级会级联到所有活跃 membership);platform 必须始终保留至少一个 `active` 系统管理员;对最后系统管理员做降级 / 停用 / 删除 → 409。
|
||||||
> - 系统管理员不能通过 `DELETE .../members/{self}` 把自己移除(403)。唯一退出方式是 `DELETE /workspaces/{id}` 软删整个 workspace,后者会级联软删所有成员。
|
> - 系统管理员不能通过 `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 内成员管理)不要混淆。
|
> - 跨 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`
|
### 7.1 `POST /api/v1/platform/workspaces`
|
||||||
|
|
||||||
创建 workspace;创建者(当前系统管理员)自动成为该 workspace 的 `admin` 成员。
|
创建 workspace;创建者(当前系统管理员)自动成为该 workspace 的 `admin` 成员。
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ from backend.api.platform._deps import (
|
|||||||
system_admin_context,
|
system_admin_context,
|
||||||
)
|
)
|
||||||
from backend.api.platform.employees import router as employees_router
|
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.roles import router as roles_router
|
||||||
from backend.api.platform.workspaces import router as workspaces_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 = APIRouter()
|
||||||
router.include_router(employees_router)
|
router.include_router(employees_router)
|
||||||
router.include_router(workspaces_router)
|
router.include_router(workspaces_router)
|
||||||
|
router.include_router(members_router)
|
||||||
router.include_router(roles_router)
|
router.include_router(roles_router)
|
||||||
|
|
||||||
# 保留 system_admin_context 的 re-export,供其他文件使用
|
# 保留 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.auth.passwords import hash_password
|
||||||
from common.db.models import Roles, Users, WorkspaceMembers, Workspaces
|
from common.db.models import Roles, Users, WorkspaceMembers, Workspaces
|
||||||
from common.ids import new_ulid
|
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 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 sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from backend.api.dependencies import database_session
|
from backend.api.dependencies import database_session
|
||||||
@@ -25,6 +25,13 @@ from backend.api.platform._deps import (
|
|||||||
_load_role_by_code,
|
_load_role_by_code,
|
||||||
system_admin_context,
|
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 一致。
|
# 平台角色 code 的字符串约束:与 RoleCreate.role_code 一致。
|
||||||
# 之所以从 Literal["admin","developer"] 放宽为 str,是因为 ``listPlatformRoles``
|
# 之所以从 Literal["admin","developer"] 放宽为 str,是因为 ``listPlatformRoles``
|
||||||
@@ -91,22 +98,63 @@ def platform_employee_payload(
|
|||||||
# 列出整个平台的非删除用户;不局限于某一个工作区。
|
# 列出整个平台的非删除用户;不局限于某一个工作区。
|
||||||
@router.get("/employees")
|
@router.get("/employees")
|
||||||
async def list_platform_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),
|
context: SystemAdminContext = Depends(system_admin_context),
|
||||||
session: AsyncSession = Depends(database_session),
|
session: AsyncSession = Depends(database_session),
|
||||||
) -> dict[str, Any]:
|
) -> 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 = (
|
rows = (
|
||||||
await session.execute(
|
await session.execute(
|
||||||
select(Users, Roles)
|
select(Users, Roles)
|
||||||
.outerjoin(Roles, Roles.role_id == Users.platform_role_id)
|
.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)
|
.order_by(Users.created_at, Users.user_id)
|
||||||
|
.limit(limit + 1)
|
||||||
)
|
)
|
||||||
).all()
|
).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(
|
return _envelope(
|
||||||
context.request_id,
|
context.request_id,
|
||||||
[platform_employee_payload(user, role) for user, role in rows],
|
[platform_employee_payload(user, role) for user, role in page_rows],
|
||||||
{"count": len(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.
|
"""Workspace CRUD endpoints.
|
||||||
Five workspace endpoints plus five membership endpoints, gated by
|
|
||||||
``system_admin_context`` (except ``GET .../members``, which also admits workspace
|
Membership endpoints live in ``members.py``. Soft-delete cascades for
|
||||||
members). Last-admin guards and soft-delete cascades live here.
|
workspace DELETE still live here.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -12,29 +12,29 @@ from typing import Any, Literal
|
|||||||
|
|
||||||
from common.db.models import Roles, Users, WorkspaceMembers, Workspaces
|
from common.db.models import Roles, Users, WorkspaceMembers, Workspaces
|
||||||
from common.ids import new_ulid
|
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 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 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 (
|
from backend.api.platform._deps import (
|
||||||
SystemAdminContext,
|
SystemAdminContext,
|
||||||
_count_active_admins,
|
|
||||||
_envelope,
|
_envelope,
|
||||||
_is_system_admin,
|
|
||||||
_load_role_by_code,
|
_load_role_by_code,
|
||||||
system_admin_context,
|
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"])
|
router = APIRouter(prefix="/api/v1/platform", tags=["platform"])
|
||||||
|
|
||||||
WORKSPACE_CODE_PATTERN = re.compile(r"^[a-z0-9-]{3,32}$")
|
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):
|
class WorkspaceCreate(BaseModel):
|
||||||
@@ -55,24 +55,6 @@ class WorkspaceUpdate(BaseModel):
|
|||||||
# 'disabled' is rejected here on purpose — soft delete must go through DELETE.
|
# 'disabled' is rejected here on purpose — soft delete must go through DELETE.
|
||||||
status: Literal["active", "archived"] | None = None
|
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]:
|
def workspace_payload(workspace: Workspaces) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"workspace_id": workspace.workspace_id,
|
"workspace_id": workspace.workspace_id,
|
||||||
@@ -114,28 +96,66 @@ async def _load_workspace(session: AsyncSession, workspace_id: str) -> Workspace
|
|||||||
|
|
||||||
@router.get("/workspaces")
|
@router.get("/workspaces")
|
||||||
async def list_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),
|
context: SystemAdminContext = Depends(system_admin_context),
|
||||||
session: AsyncSession = Depends(database_session),
|
session: AsyncSession = Depends(database_session),
|
||||||
) -> dict[str, Any]:
|
) -> 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 = (
|
rows = (
|
||||||
await session.execute(
|
await session.execute(
|
||||||
select(Workspaces)
|
select(Workspaces)
|
||||||
.where(
|
.where(*page_filters)
|
||||||
Workspaces.status != "disabled",
|
|
||||||
Workspaces.is_deleted == 0,
|
|
||||||
)
|
|
||||||
.order_by(Workspaces.created_at, Workspaces.workspace_id)
|
.order_by(Workspaces.created_at, Workspaces.workspace_id)
|
||||||
.limit(LIST_PAGE_SIZE)
|
.limit(limit + 1)
|
||||||
)
|
)
|
||||||
).scalars().all()
|
).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(
|
return _envelope(
|
||||||
context.request_id,
|
context.request_id,
|
||||||
[workspace_payload(w) for w in rows],
|
[workspace_payload(w) for w in page_rows],
|
||||||
{"count": len(rows), "page_size": LIST_PAGE_SIZE},
|
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)
|
@router.post("/workspaces", status_code=status.HTTP_201_CREATED)
|
||||||
@@ -252,246 +272,3 @@ async def delete_workspace(
|
|||||||
await session.flush()
|
await session.flush()
|
||||||
await session.refresh(workspace)
|
await session.refresh(workspace)
|
||||||
return _envelope(context.request_id, workspace_payload(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 (
|
return (
|
||||||
<Sonner
|
<Sonner
|
||||||
theme="light"
|
theme="light"
|
||||||
|
position="bottom-right"
|
||||||
className="toaster group"
|
className="toaster group"
|
||||||
icons={{
|
icons={{
|
||||||
success: (
|
success: (
|
||||||
|
|||||||
@@ -270,7 +270,7 @@ export function useApi(): WorkspaceBoundApi {
|
|||||||
hideScheduleArtifact: (versionsId) =>
|
hideScheduleArtifact: (versionsId) =>
|
||||||
rawApi.hideScheduleArtifact(workspaceId, versionsId),
|
rawApi.hideScheduleArtifact(workspaceId, versionsId),
|
||||||
listEmployees: () => rawApi.listEmployees(workspaceId),
|
listEmployees: () => rawApi.listEmployees(workspaceId),
|
||||||
listPlatformEmployees: () => rawApi.listPlatformEmployees(),
|
listPlatformEmployees: (input) => rawApi.listPlatformEmployees(input),
|
||||||
createEmployee: (input) => rawApi.createEmployee(workspaceId, input),
|
createEmployee: (input) => rawApi.createEmployee(workspaceId, input),
|
||||||
createPlatformEmployee: (input) => rawApi.createPlatformEmployee(input),
|
createPlatformEmployee: (input) => rawApi.createPlatformEmployee(input),
|
||||||
updateEmployee: (userId, input) =>
|
updateEmployee: (userId, input) =>
|
||||||
@@ -312,7 +312,7 @@ export function useApi(): WorkspaceBoundApi {
|
|||||||
getScheduleNodeRunArtifacts: (runId, nodeRunId) =>
|
getScheduleNodeRunArtifacts: (runId, nodeRunId) =>
|
||||||
rawApi.getScheduleNodeRunArtifacts(workspaceId, runId, nodeRunId),
|
rawApi.getScheduleNodeRunArtifacts(workspaceId, runId, nodeRunId),
|
||||||
// Workspace (Project) Management - 系统管理接口(跨 workspace,不需要传入 workspaceId)
|
// Workspace (Project) Management - 系统管理接口(跨 workspace,不需要传入 workspaceId)
|
||||||
listWorkspaces: () => rawApi.listWorkspaces(),
|
listWorkspaces: (input) => rawApi.listWorkspaces(input),
|
||||||
createWorkspace: (input) => rawApi.createWorkspace(input),
|
createWorkspace: (input) => rawApi.createWorkspace(input),
|
||||||
updateWorkspace: (workspaceId, input) => rawApi.updateWorkspace(workspaceId, input),
|
updateWorkspace: (workspaceId, input) => rawApi.updateWorkspace(workspaceId, input),
|
||||||
deleteWorkspace: (workspaceId) => rawApi.deleteWorkspace(workspaceId),
|
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 { 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 { useApi, useAuth } from "../../context/AuthContext";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { ConfirmDialog } from "~/components/common/ConfirmDialog";
|
import { ConfirmDialog } from "~/components/common/ConfirmDialog";
|
||||||
@@ -14,9 +14,11 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
} from "~/components/ui/table";
|
} from "~/components/ui/table";
|
||||||
import { AdminColgroup, USER_PROJECT_COL_WIDTHS } from "./AdminTable";
|
import { AdminColgroup, USER_PROJECT_COL_WIDTHS } from "./AdminTable";
|
||||||
|
import { AdminPagination } from "./AdminPagination";
|
||||||
import { ProjectEditDialog } from "./ProjectEditDialog";
|
import { ProjectEditDialog } from "./ProjectEditDialog";
|
||||||
import { ImportMemberDialog } from "./ImportMemberDialog";
|
|
||||||
import { ProjectMembersDrawer } from "./ProjectMembersDrawer";
|
import { ProjectMembersDrawer } from "./ProjectMembersDrawer";
|
||||||
|
import { useCursorPage } from "./useCursorPage";
|
||||||
|
import { useDebouncedValue } from "./useDebouncedValue";
|
||||||
import {
|
import {
|
||||||
adminEmptyClass,
|
adminEmptyClass,
|
||||||
adminPageClass,
|
adminPageClass,
|
||||||
@@ -50,44 +52,53 @@ export function ProjectManagementPage({
|
|||||||
}) {
|
}) {
|
||||||
const api = useApi();
|
const api = useApi();
|
||||||
const { user, refreshWorkspaces } = useAuth();
|
const { user, refreshWorkspaces } = useAuth();
|
||||||
const [projects, setProjects] = useState<Workspace[]>([]);
|
|
||||||
const [projectLoading, setProjectLoading] = useState(true);
|
|
||||||
const [projectDialogOpen, setProjectDialogOpen] = useState(false);
|
const [projectDialogOpen, setProjectDialogOpen] = useState(false);
|
||||||
const [projectForm, setProjectForm] = useState(EMPTY_PROJECT_FORM);
|
const [projectForm, setProjectForm] = useState(EMPTY_PROJECT_FORM);
|
||||||
const [editingProject, setEditingProject] = useState<Workspace | null>(null);
|
const [editingProject, setEditingProject] = useState<Workspace | null>(null);
|
||||||
const [importMemberDialogOpen, setImportMemberDialogOpen] = useState(false);
|
|
||||||
const [selectedProject, setSelectedProject] = useState<Workspace | null>(null);
|
const [selectedProject, setSelectedProject] = useState<Workspace | null>(null);
|
||||||
const [availableUsers, setAvailableUsers] = useState<Employee[]>([]);
|
|
||||||
const [selectedUserIds, setSelectedUserIds] = useState<string[]>([]);
|
|
||||||
const [projectSearchTerm, setProjectSearchTerm] = useState("");
|
const [projectSearchTerm, setProjectSearchTerm] = useState("");
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [membersDrawerOpen, setMembersDrawerOpen] = useState(false);
|
const [membersDrawerOpen, setMembersDrawerOpen] = useState(false);
|
||||||
const [currentProjectMembers, setCurrentProjectMembers] = useState<WorkspaceMember[]>([]);
|
const [currentProjectMembers, setCurrentProjectMembers] = useState<WorkspaceMember[]>([]);
|
||||||
const [membersLoading, setMembersLoading] = useState(false);
|
const [membersLoading, setMembersLoading] = useState(false);
|
||||||
const [deleteTarget, setDeleteTarget] = useState<Workspace | null>(null);
|
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 canManage = user?.role_code === "admin";
|
||||||
|
|
||||||
const loadProjects = async (): Promise<void> => {
|
const fetchProjects = useCallback(
|
||||||
setProjectLoading(true);
|
async (input: { limit: number; cursor: string | null; q: string }) => {
|
||||||
try {
|
try {
|
||||||
const workspaceList = await api.listWorkspaces();
|
const page = await api.listWorkspaces(input);
|
||||||
setProjects(workspaceList);
|
|
||||||
onConnectionChange(true);
|
onConnectionChange(true);
|
||||||
|
return page;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
onConnectionChange(false);
|
onConnectionChange(false);
|
||||||
onNotify({
|
onNotify({
|
||||||
tone: "error",
|
tone: "error",
|
||||||
message: error instanceof Error ? error.message : "项目列表加载失败",
|
message: error instanceof Error ? error.message : "项目列表加载失败",
|
||||||
});
|
});
|
||||||
} finally {
|
throw error;
|
||||||
setProjectLoading(false);
|
|
||||||
}
|
}
|
||||||
};
|
},
|
||||||
|
[api, onConnectionChange, onNotify],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
const {
|
||||||
void loadProjects();
|
items: projects,
|
||||||
}, []);
|
setItems: setProjects,
|
||||||
|
page,
|
||||||
|
loading: projectLoading,
|
||||||
|
meta,
|
||||||
|
totalPages,
|
||||||
|
goNext,
|
||||||
|
goPrev,
|
||||||
|
goToPage,
|
||||||
|
canGoToPage,
|
||||||
|
reload,
|
||||||
|
refreshFromStart,
|
||||||
|
} = useCursorPage(fetchProjects, debouncedSearch);
|
||||||
|
|
||||||
const openCreateProject = (): void => {
|
const openCreateProject = (): void => {
|
||||||
setEditingProject(null);
|
setEditingProject(null);
|
||||||
@@ -125,26 +136,36 @@ export function ProjectManagementPage({
|
|||||||
description: projectForm.description.trim() || undefined,
|
description: projectForm.description.trim() || undefined,
|
||||||
});
|
});
|
||||||
setProjects((current) =>
|
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: "项目信息已更新" });
|
onNotify({ tone: "success", message: "项目信息已更新" });
|
||||||
} else {
|
} else {
|
||||||
const generatedCode = projectForm.workspace_code.trim() || projectForm.workspace_name.trim().toLowerCase().replace(/[^a-z0-9-]/g, "-").slice(0, 32);
|
const generatedCode =
|
||||||
const created = await api.createWorkspace({
|
projectForm.workspace_code.trim() ||
|
||||||
|
projectForm.workspace_name
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9-]/g, "-")
|
||||||
|
.slice(0, 32);
|
||||||
|
await api.createWorkspace({
|
||||||
workspace_code: generatedCode,
|
workspace_code: generatedCode,
|
||||||
workspace_name: projectForm.workspace_name.trim(),
|
workspace_name: projectForm.workspace_name.trim(),
|
||||||
quota_bytes: projectForm.quota_bytes,
|
quota_bytes: projectForm.quota_bytes,
|
||||||
description: projectForm.description.trim() || undefined,
|
description: projectForm.description.trim() || undefined,
|
||||||
});
|
});
|
||||||
setProjects((current) => [...current, created]);
|
|
||||||
void refreshWorkspaces();
|
void refreshWorkspaces();
|
||||||
onNotify({ tone: "success", message: "项目已创建" });
|
onNotify({ tone: "success", message: "项目已创建" });
|
||||||
|
await refreshFromStart();
|
||||||
}
|
}
|
||||||
setProjectDialogOpen(false);
|
setProjectDialogOpen(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
onNotify({
|
onNotify({
|
||||||
tone: "error",
|
tone: "error",
|
||||||
message: error instanceof ApiRequestError ? error.message : (editingProject ? "更新项目失败" : "创建项目失败"),
|
message: error instanceof ApiRequestError
|
||||||
|
? error.message
|
||||||
|
: editingProject
|
||||||
|
? "更新项目失败"
|
||||||
|
: "创建项目失败",
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
@@ -154,8 +175,12 @@ export function ProjectManagementPage({
|
|||||||
const executeDeleteProject = async (project: Workspace): Promise<void> => {
|
const executeDeleteProject = async (project: Workspace): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
await api.deleteWorkspace(project.workspace_id);
|
await api.deleteWorkspace(project.workspace_id);
|
||||||
setProjects((current) => current.filter((p) => p.workspace_id !== project.workspace_id));
|
|
||||||
onNotify({ tone: "success", message: "项目已删除" });
|
onNotify({ tone: "success", message: "项目已删除" });
|
||||||
|
if (projects.length <= 1 && page > 1) {
|
||||||
|
goPrev();
|
||||||
|
} else {
|
||||||
|
reload();
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
onNotify({
|
onNotify({
|
||||||
tone: "error",
|
tone: "error",
|
||||||
@@ -164,26 +189,21 @@ export function ProjectManagementPage({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const openImportMemberDialog = async (project: Workspace): Promise<void> => {
|
const loadImportUsers = useCallback(
|
||||||
setSelectedProject(project);
|
async (input: { q: string; cursor: string | null; limit: number }) => {
|
||||||
setSelectedUserIds([]);
|
const page = await api.listPlatformEmployees({
|
||||||
setImportMemberDialogOpen(true);
|
limit: input.limit,
|
||||||
|
cursor: input.cursor,
|
||||||
// 加载可用用户和项目成员
|
q: input.q,
|
||||||
try {
|
});
|
||||||
const [allUsers, currentMembers] = await Promise.all([
|
return {
|
||||||
api.listPlatformEmployees(),
|
items: page.items,
|
||||||
api.listWorkspaceMembers(project.workspace_id),
|
hasMore: page.meta.has_more,
|
||||||
]);
|
nextCursor: page.meta.next_cursor,
|
||||||
setAvailableUsers(allUsers);
|
|
||||||
setCurrentProjectMembers(currentMembers);
|
|
||||||
} catch (error) {
|
|
||||||
// 如果加载失败,仍显示所有用户
|
|
||||||
const allUsers = await api.listPlatformEmployees();
|
|
||||||
setAvailableUsers(allUsers);
|
|
||||||
setCurrentProjectMembers([]);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
},
|
||||||
|
[api],
|
||||||
|
);
|
||||||
|
|
||||||
const openMembersDrawer = (project: Workspace): void => {
|
const openMembersDrawer = (project: Workspace): void => {
|
||||||
setSelectedProject(project);
|
setSelectedProject(project);
|
||||||
@@ -206,17 +226,23 @@ export function ProjectManagementPage({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeMember = async (userId: string): Promise<void> => {
|
const requestRemoveMember = (userId: string): void => {
|
||||||
if (!selectedProject) return;
|
|
||||||
// 管理员不能被移除
|
|
||||||
const targetMember = currentProjectMembers.find((m) => m.user_id === userId);
|
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: "管理员不能被移除" });
|
onNotify({ tone: "error", message: "管理员不能被移除" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
setRemoveMemberTarget(targetMember);
|
||||||
|
};
|
||||||
|
|
||||||
|
const executeRemoveMember = async (member: WorkspaceMember): Promise<void> => {
|
||||||
|
if (!selectedProject) return;
|
||||||
try {
|
try {
|
||||||
await api.deleteWorkspaceMember(selectedProject.workspace_id, userId);
|
await api.deleteWorkspaceMember(selectedProject.workspace_id, member.user_id);
|
||||||
setCurrentProjectMembers((current) => current.filter((m) => m.user_id !== userId));
|
setCurrentProjectMembers((current) =>
|
||||||
|
current.filter((m) => m.user_id !== member.user_id),
|
||||||
|
);
|
||||||
onNotify({ tone: "success", message: "成员已移除" });
|
onNotify({ tone: "success", message: "成员已移除" });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
onNotify({
|
onNotify({
|
||||||
@@ -226,32 +252,39 @@ export function ProjectManagementPage({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const importMember = async (): Promise<void> => {
|
const importMembers = async (userIds: string[]): Promise<void> => {
|
||||||
if (!selectedProject || selectedUserIds.length === 0) {
|
if (!selectedProject || userIds.length === 0) {
|
||||||
onNotify({ tone: "error", message: "请选择要添加的用户" });
|
onNotify({ tone: "error", message: "请选择要添加的用户" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
selectedUserIds.map((userId) =>
|
userIds.map((userId) =>
|
||||||
api.addWorkspaceMember(selectedProject.workspace_id, {
|
api.addWorkspaceMember(selectedProject.workspace_id, {
|
||||||
user_id: userId,
|
user_id: userId,
|
||||||
})
|
}),
|
||||||
)
|
),
|
||||||
);
|
);
|
||||||
setImportMemberDialogOpen(false);
|
onNotify({ tone: "success", message: `已添加 ${userIds.length} 名成员` });
|
||||||
onNotify({ tone: "success", message: `已添加 ${selectedUserIds.length} 名成员` });
|
await loadProjectMembers(selectedProject.workspace_id);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
onNotify({
|
onNotify({
|
||||||
tone: "error",
|
tone: "error",
|
||||||
message: error instanceof ApiRequestError ? error.message : "添加成员失败",
|
message: error instanceof ApiRequestError ? error.message : "添加成员失败",
|
||||||
});
|
});
|
||||||
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const emptyMessage = useMemo(() => {
|
||||||
|
if (projectLoading) return "正在加载项目…";
|
||||||
|
if (debouncedSearch.trim()) return "未找到匹配的项目";
|
||||||
|
return "暂无项目";
|
||||||
|
}, [projectLoading, debouncedSearch]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className={adminPageClass}>
|
<section className={adminPageClass}>
|
||||||
<div className={adminToolbarClass}>
|
<div className={adminToolbarClass}>
|
||||||
@@ -291,30 +324,14 @@ export function ProjectManagementPage({
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{projectLoading ? (
|
{projectLoading || projects.length === 0 ? (
|
||||||
<TableRow className="min-h-[64px] border-t border-[#edf1f5] hover:bg-[#f7fafc] data-[state=selected]:bg-transparent">
|
<TableRow className="min-h-[64px] border-t border-[#edf1f5] hover:bg-transparent data-[state=selected]:bg-transparent">
|
||||||
<TableCell colSpan={5} className="p-0">
|
<TableCell colSpan={5} className="p-0">
|
||||||
<p className={adminEmptyClass}>正在加载项目…</p>
|
<p className={adminEmptyClass}>{emptyMessage}</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>
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
projects
|
projects.map((project) => (
|
||||||
.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) => (
|
|
||||||
<TableRow key={project.workspace_id} className="min-h-[64px] border-t border-[#edf1f5] hover:bg-[#f7fafc] data-[state=selected]:bg-transparent">
|
<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">
|
<TableCell className="p-3 px-4 align-middle">
|
||||||
<span className={employeeNameClass}>
|
<span className={employeeNameClass}>
|
||||||
@@ -327,13 +344,17 @@ export function ProjectManagementPage({
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="p-3 px-4 align-middle"><code>{project.workspace_code}</code></TableCell>
|
<TableCell className="p-3 px-4 align-middle"><code>{project.workspace_code}</code></TableCell>
|
||||||
<TableCell className="p-3 px-4 align-middle">
|
<TableCell className="p-3 px-4 align-middle">
|
||||||
<span>
|
|
||||||
<span className={statusPillClass(project.status)}>
|
<span className={statusPillClass(project.status)}>
|
||||||
{project.status === "active" ? "正常" : project.status === "archived" ? "已归档" : "已删除"}
|
{project.status === "active" ? "正常" : project.status === "archived" ? "已归档" : "已删除"}
|
||||||
</span>
|
</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>
|
</span>
|
||||||
</TableCell>
|
</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">
|
<TableCell className="p-3 px-4 align-middle">
|
||||||
<span className={employeeActionsClass}>
|
<span className={employeeActionsClass}>
|
||||||
<Button
|
<Button
|
||||||
@@ -375,6 +396,18 @@ export function ProjectManagementPage({
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</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
|
<ProjectEditDialog
|
||||||
open={projectDialogOpen}
|
open={projectDialogOpen}
|
||||||
editingProject={editingProject}
|
editingProject={editingProject}
|
||||||
@@ -386,32 +419,20 @@ export function ProjectManagementPage({
|
|||||||
onClose={() => setProjectDialogOpen(false)}
|
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
|
<ProjectMembersDrawer
|
||||||
open={membersDrawerOpen}
|
open={membersDrawerOpen}
|
||||||
selectedProject={selectedProject}
|
selectedProject={selectedProject}
|
||||||
members={currentProjectMembers}
|
members={currentProjectMembers}
|
||||||
membersLoading={membersLoading}
|
membersLoading={membersLoading}
|
||||||
canManage={canManage}
|
canManage={canManage}
|
||||||
onClose={() => setMembersDrawerOpen(false)}
|
saving={saving}
|
||||||
onAddMembers={() => {
|
onClose={() => {
|
||||||
setMembersDrawerOpen(false);
|
setMembersDrawerOpen(false);
|
||||||
if (selectedProject) void openImportMemberDialog(selectedProject);
|
setRemoveMemberTarget(null);
|
||||||
}}
|
}}
|
||||||
onRemoveMember={(userId) => void removeMember(userId)}
|
onRemoveMember={requestRemoveMember}
|
||||||
onNotify={onNotify}
|
onAddMembers={importMembers}
|
||||||
|
loadUsers={loadImportUsers}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
@@ -433,6 +454,26 @@ export function ProjectManagementPage({
|
|||||||
setDeleteTarget(null);
|
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>
|
</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 { 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 { primaryGradientButtonClass } from "~/components/common/buttonClasses";
|
||||||
|
import { MemberAddPanel, type LoadUsersFn } from "./MemberAddPanel";
|
||||||
|
|
||||||
|
type DrawerPanel = "members" | "add";
|
||||||
|
|
||||||
export function ProjectMembersDrawer({
|
export function ProjectMembersDrawer({
|
||||||
open,
|
open,
|
||||||
@@ -8,35 +14,46 @@ export function ProjectMembersDrawer({
|
|||||||
members,
|
members,
|
||||||
membersLoading,
|
membersLoading,
|
||||||
canManage,
|
canManage,
|
||||||
|
saving,
|
||||||
onClose,
|
onClose,
|
||||||
onAddMembers,
|
|
||||||
onRemoveMember,
|
onRemoveMember,
|
||||||
onNotify,
|
onAddMembers,
|
||||||
|
loadUsers,
|
||||||
}: {
|
}: {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
selectedProject: Workspace | null;
|
selectedProject: Workspace | null;
|
||||||
members: WorkspaceMember[];
|
members: WorkspaceMember[];
|
||||||
membersLoading: boolean;
|
membersLoading: boolean;
|
||||||
canManage: boolean;
|
canManage: boolean;
|
||||||
|
saving: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onAddMembers: () => void;
|
|
||||||
onRemoveMember: (userId: string) => 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;
|
if (!open || !selectedProject) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="drawer-overlay" onClick={() => onClose()} style={{
|
<div
|
||||||
|
className="drawer-overlay"
|
||||||
|
onClick={() => onClose()}
|
||||||
|
style={{
|
||||||
position: "fixed",
|
position: "fixed",
|
||||||
top: 0,
|
inset: 0,
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
backgroundColor: "rgba(0,0,0,0.3)",
|
backgroundColor: "rgba(0,0,0,0.3)",
|
||||||
zIndex: 99,
|
zIndex: 40,
|
||||||
}} />
|
}}
|
||||||
<aside className="drawer" style={{
|
/>
|
||||||
|
<aside
|
||||||
|
className="drawer"
|
||||||
|
style={{
|
||||||
position: "fixed",
|
position: "fixed",
|
||||||
right: 0,
|
right: 0,
|
||||||
top: 0,
|
top: 0,
|
||||||
@@ -46,43 +63,58 @@ export function ProjectMembersDrawer({
|
|||||||
boxShadow: "-2px 0 8px rgba(0,0,0,0.1)",
|
boxShadow: "-2px 0 8px rgba(0,0,0,0.1)",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
zIndex: 100,
|
zIndex: 41,
|
||||||
}}>
|
}}
|
||||||
<div className="drawer__header" style={{
|
>
|
||||||
|
<div
|
||||||
|
className="drawer__header"
|
||||||
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
padding: "16px 20px",
|
padding: "16px 20px",
|
||||||
borderBottom: "1px solid #e8e8e8",
|
borderBottom: "1px solid #e8e8e8",
|
||||||
}}>
|
}}
|
||||||
|
>
|
||||||
<div>
|
<div>
|
||||||
<span className="modal__eyebrow" style={{ fontSize: 12, color: "#666" }}>MEMBERS</span>
|
{/* <span style={{ fontSize: 12, color: "#666" }}>
|
||||||
<h3 style={{ margin: 0, fontSize: 18 }}>项目成员</h3>
|
{panel === "members" ? "MEMBERS" : "ADD MEMBER"}
|
||||||
<p style={{ margin: "4px 0 0", fontSize: 13, color: "#888" }}>{selectedProject.workspace_name}</p>
|
</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>
|
</div>
|
||||||
<button className="icon-button" type="button" onClick={() => onClose()}>
|
<button className="icon-button" type="button" onClick={() => onClose()}>
|
||||||
<X />
|
<X />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{
|
{panel === "members" ? (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
padding: "12px 20px",
|
padding: "12px 20px",
|
||||||
borderBottom: "1px solid #f0f0f0",
|
borderBottom: "1px solid #f0f0f0",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
}}>
|
}}
|
||||||
<span style={{ fontSize: 14, color: "#666" }}>
|
>
|
||||||
共 {members.length} 名成员
|
<span style={{ fontSize: 14, color: "#666" }}>共 {members.length} 名成员</span>
|
||||||
</span>
|
{canManage && (
|
||||||
<button
|
<Button
|
||||||
className={primaryGradientButtonClass}
|
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onAddMembers()}
|
size="sm"
|
||||||
|
className={primaryGradientButtonClass}
|
||||||
|
onClick={() => setPanel("add")}
|
||||||
style={{ fontSize: 13, padding: "6px 12px" }}
|
style={{ fontSize: 13, padding: "6px 12px" }}
|
||||||
>
|
>
|
||||||
<Plus size={14} /> 添加成员
|
<Plus size={14} /> 添加成员
|
||||||
</button>
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ flex: 1, overflow: "auto", padding: "12px 20px" }}>
|
<div style={{ flex: 1, overflow: "auto", padding: "12px 20px" }}>
|
||||||
@@ -100,14 +132,13 @@ export function ProjectMembersDrawer({
|
|||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
padding: "6px",
|
padding: 6,
|
||||||
border: "1px solid #e8e8e8",
|
border: "1px solid #e8e8e8",
|
||||||
borderRadius: 6,
|
borderRadius: 6,
|
||||||
backgroundColor: "#fafafa",
|
backgroundColor: "#fafafa",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="avatar"
|
|
||||||
style={{
|
style={{
|
||||||
width: 32,
|
width: 32,
|
||||||
height: 32,
|
height: 32,
|
||||||
@@ -125,10 +156,11 @@ export function ProjectMembersDrawer({
|
|||||||
{member.display_name.slice(0, 1)}
|
{member.display_name.slice(0, 1)}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<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 style={{ fontSize: 11, color: "#888" }}>{member.username}</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onRemoveMember(member.user_id)}
|
onClick={() => onRemoveMember(member.user_id)}
|
||||||
@@ -136,10 +168,10 @@ export function ProjectMembersDrawer({
|
|||||||
padding: "3px 8px",
|
padding: "3px 8px",
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
color: isAdmin ? "#999" : "#ff4d4f",
|
color: isAdmin ? "#999" : "#ff4d4f",
|
||||||
border: "1px solid " + (isAdmin ? "#ddd" : "#ff4d4f"),
|
border: `1px solid ${isAdmin ? "#ddd" : "#ff4d4f"}`,
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
backgroundColor: "#fff",
|
backgroundColor: "#fff",
|
||||||
cursor: isAdmin ? "not-allowed" : canManage ? "pointer" : "not-allowed",
|
cursor: isAdmin || !canManage ? "not-allowed" : "pointer",
|
||||||
}}
|
}}
|
||||||
disabled={!canManage || isAdmin}
|
disabled={!canManage || isAdmin}
|
||||||
title={isAdmin ? "管理员不能被移除" : canManage ? "移除成员" : "无权限"}
|
title={isAdmin ? "管理员不能被移除" : canManage ? "移除成员" : "无权限"}
|
||||||
@@ -147,12 +179,21 @@ export function ProjectMembersDrawer({
|
|||||||
移除
|
移除
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<MemberAddPanel
|
||||||
|
existingMemberIds={members.map((m) => m.user_id)}
|
||||||
|
saving={saving}
|
||||||
|
loadUsers={loadUsers}
|
||||||
|
onBack={() => setPanel("members")}
|
||||||
|
onAddMembers={onAddMembers}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</aside>
|
</aside>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
import { Plus, Search } from "lucide-react";
|
import { Plus, Search } from "lucide-react";
|
||||||
import { ApiRequestError, type Employee, type Role } from "../../services/api";
|
import { ApiRequestError, type Employee, type Role } from "../../services/api";
|
||||||
@@ -20,6 +20,8 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
} from "~/components/ui/table";
|
} from "~/components/ui/table";
|
||||||
import { AdminColgroup, USER_PROJECT_COL_WIDTHS } from "./AdminTable";
|
import { AdminColgroup, USER_PROJECT_COL_WIDTHS } from "./AdminTable";
|
||||||
|
import { AdminPagination } from "./AdminPagination";
|
||||||
|
import { useCursorPage } from "./useCursorPage";
|
||||||
import {
|
import {
|
||||||
formFieldClass,
|
formFieldClass,
|
||||||
formInputClass,
|
formInputClass,
|
||||||
@@ -42,6 +44,7 @@ import {
|
|||||||
} from "./adminUi";
|
} from "./adminUi";
|
||||||
|
|
||||||
import { primaryGradientButtonClass } from "~/components/common/buttonClasses";
|
import { primaryGradientButtonClass } from "~/components/common/buttonClasses";
|
||||||
|
import { useDebouncedValue } from "./useDebouncedValue";
|
||||||
|
|
||||||
const EMPTY_FORM = {
|
const EMPTY_FORM = {
|
||||||
username: "",
|
username: "",
|
||||||
@@ -54,6 +57,7 @@ const EMPTY_FORM = {
|
|||||||
|
|
||||||
const FORM_INPUT_CLASS =
|
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";
|
"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({
|
export function UserManagementPage({
|
||||||
onNotify,
|
onNotify,
|
||||||
onConnectionChange,
|
onConnectionChange,
|
||||||
@@ -63,42 +67,55 @@ export function UserManagementPage({
|
|||||||
}) {
|
}) {
|
||||||
const api = useApi();
|
const api = useApi();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const [employees, setEmployees] = useState<Employee[]>([]);
|
|
||||||
const [roles, setRoles] = useState<Role[]>([]);
|
const [roles, setRoles] = useState<Role[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [editing, setEditing] = useState<Employee | null>(null);
|
const [editing, setEditing] = useState<Employee | null>(null);
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
const [form, setForm] = useState(EMPTY_FORM);
|
const [form, setForm] = useState(EMPTY_FORM);
|
||||||
const [userSearchTerm, setUserSearchTerm] = useState("");
|
const [userSearchTerm, setUserSearchTerm] = useState("");
|
||||||
const [deleteTarget, setDeleteTarget] = useState<Employee | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<Employee | null>(null);
|
||||||
|
const debouncedSearch = useDebouncedValue(userSearchTerm, 300);
|
||||||
|
|
||||||
const canManage = user?.role_code === "admin";
|
const canManage = user?.role_code === "admin";
|
||||||
|
|
||||||
const load = async (): Promise<void> => {
|
const fetchEmployees = useCallback(
|
||||||
setLoading(true);
|
async (input: { limit: number; cursor: string | null; q: string }) => {
|
||||||
try {
|
try {
|
||||||
const [employeeList, roleList] = await Promise.all([
|
const page = await api.listPlatformEmployees(input);
|
||||||
api.listPlatformEmployees(),
|
|
||||||
api.listPlatformRoles(),
|
|
||||||
]);
|
|
||||||
setEmployees(employeeList);
|
|
||||||
setRoles(roleList);
|
|
||||||
onConnectionChange(true);
|
onConnectionChange(true);
|
||||||
|
return page;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
onConnectionChange(false);
|
onConnectionChange(false);
|
||||||
onNotify({
|
onNotify({
|
||||||
tone: "error",
|
tone: "error",
|
||||||
message: error instanceof Error ? error.message : "用户列表加载失败",
|
message: error instanceof Error ? error.message : "用户列表加载失败",
|
||||||
});
|
});
|
||||||
} finally {
|
throw error;
|
||||||
setLoading(false);
|
|
||||||
}
|
}
|
||||||
};
|
},
|
||||||
|
[api, onConnectionChange, onNotify],
|
||||||
|
);
|
||||||
|
|
||||||
|
const {
|
||||||
|
items: employees,
|
||||||
|
setItems: setEmployees,
|
||||||
|
page,
|
||||||
|
loading,
|
||||||
|
meta,
|
||||||
|
totalPages,
|
||||||
|
goNext,
|
||||||
|
goPrev,
|
||||||
|
goToPage,
|
||||||
|
canGoToPage,
|
||||||
|
reload,
|
||||||
|
refreshFromStart,
|
||||||
|
} = useCursorPage(fetchEmployees, debouncedSearch);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void load();
|
void api.listPlatformRoles().then(setRoles).catch(() => {
|
||||||
}, []);
|
/* roles only needed for edit dialog */
|
||||||
|
});
|
||||||
|
}, [api]);
|
||||||
|
|
||||||
const openCreate = (): void => {
|
const openCreate = (): void => {
|
||||||
setEditing(null);
|
setEditing(null);
|
||||||
@@ -122,12 +139,10 @@ export function UserManagementPage({
|
|||||||
const submit = async (event: React.FormEvent): Promise<void> => {
|
const submit = async (event: React.FormEvent): Promise<void> => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!form.display_name.trim() || (!editing && !form.username.trim())) return;
|
if (!form.display_name.trim() || (!editing && !form.username.trim())) return;
|
||||||
// 新建用户时密码必填
|
|
||||||
if (!editing && !form.password.trim()) {
|
if (!editing && !form.password.trim()) {
|
||||||
onNotify({ tone: "error", message: "请输入密码" });
|
onNotify({ tone: "error", message: "请输入密码" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// 编辑自身时,前端二次拦截禁用 status / role_code 的修改;管理员账号禁止停用/锁定
|
|
||||||
if (editing) {
|
if (editing) {
|
||||||
const editingSelf = editing.user_id === user?.user_id;
|
const editingSelf = editing.user_id === user?.user_id;
|
||||||
const targetIsAdmin = editing.role_code === "admin";
|
const targetIsAdmin = editing.role_code === "admin";
|
||||||
@@ -144,7 +159,6 @@ export function UserManagementPage({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 密码长度校验 8~72 字符
|
|
||||||
if (form.password && (form.password.length < 8 || form.password.length > 72)) {
|
if (form.password && (form.password.length < 8 || form.password.length > 72)) {
|
||||||
onNotify({ tone: "error", message: "密码长度必须在 8~72 字符之间" });
|
onNotify({ tone: "error", message: "密码长度必须在 8~72 字符之间" });
|
||||||
return;
|
return;
|
||||||
@@ -158,20 +172,20 @@ export function UserManagementPage({
|
|||||||
role_code: form.role_code,
|
role_code: form.role_code,
|
||||||
status: form.status,
|
status: form.status,
|
||||||
});
|
});
|
||||||
setEmployees((current) => current.map(
|
setEmployees((current) =>
|
||||||
(item) => item.user_id === updated.user_id ? updated : item,
|
current.map((item) => (item.user_id === updated.user_id ? updated : item)),
|
||||||
));
|
);
|
||||||
onNotify({ tone: "success", message: "用户信息已更新" });
|
onNotify({ tone: "success", message: "用户信息已更新" });
|
||||||
} else {
|
} else {
|
||||||
const created = await api.createPlatformEmployee({
|
await api.createPlatformEmployee({
|
||||||
username: form.username.trim(),
|
username: form.username.trim(),
|
||||||
display_name: form.display_name.trim(),
|
display_name: form.display_name.trim(),
|
||||||
email: form.email.trim() || undefined,
|
email: form.email.trim() || undefined,
|
||||||
password: form.password,
|
password: form.password,
|
||||||
role_code: "developer",
|
role_code: "developer",
|
||||||
});
|
});
|
||||||
setEmployees((current) => [...current, created]);
|
|
||||||
onNotify({ tone: "success", message: "用户已添加" });
|
onNotify({ tone: "success", message: "用户已添加" });
|
||||||
|
await refreshFromStart();
|
||||||
}
|
}
|
||||||
setDialogOpen(false);
|
setDialogOpen(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -187,8 +201,12 @@ export function UserManagementPage({
|
|||||||
const executeRemove = async (employee: Employee): Promise<void> => {
|
const executeRemove = async (employee: Employee): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
await api.deletePlatformEmployee(employee.user_id);
|
await api.deletePlatformEmployee(employee.user_id);
|
||||||
setEmployees((current) => current.filter((item) => item.user_id !== employee.user_id));
|
|
||||||
onNotify({ tone: "success", message: "用户已删除" });
|
onNotify({ tone: "success", message: "用户已删除" });
|
||||||
|
if (employees.length <= 1 && page > 1) {
|
||||||
|
goPrev();
|
||||||
|
} else {
|
||||||
|
reload();
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
onNotify({
|
onNotify({
|
||||||
tone: "error",
|
tone: "error",
|
||||||
@@ -197,6 +215,12 @@ export function UserManagementPage({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const emptyMessage = useMemo(() => {
|
||||||
|
if (loading) return "正在加载用户…";
|
||||||
|
if (debouncedSearch.trim()) return "未找到匹配的用户";
|
||||||
|
return "暂无用户";
|
||||||
|
}, [loading, debouncedSearch]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className={adminPageClass}>
|
<section className={adminPageClass}>
|
||||||
<div className={adminToolbarClass}>
|
<div className={adminToolbarClass}>
|
||||||
@@ -236,24 +260,14 @@ export function UserManagementPage({
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{loading ? (
|
{loading || employees.length === 0 ? (
|
||||||
<TableRow className="min-h-[64px] border-t border-[#edf1f5] hover:bg-[#f7fafc] data-[state=selected]:bg-transparent">
|
<TableRow className="min-h-[64px] border-t border-[#edf1f5] hover:bg-transparent data-[state=selected]:bg-transparent">
|
||||||
<TableCell colSpan={5} className="p-0">
|
<TableCell colSpan={5} className="p-0">
|
||||||
<p className={adminEmptyClass}>正在加载用户…</p>
|
<p className={adminEmptyClass}>{emptyMessage}</p>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
employees
|
employees.map((employee) => {
|
||||||
.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) => {
|
|
||||||
const isProtectedAdmin = employee.role_code === "admin";
|
const isProtectedAdmin = employee.role_code === "admin";
|
||||||
return (
|
return (
|
||||||
<TableRow key={employee.user_id} className="min-h-[64px] border-t border-[#edf1f5] hover:bg-[#f7fafc] data-[state=selected]:bg-transparent">
|
<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>
|
</TableBody>
|
||||||
</Table>
|
</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
|
<AppFormDialog
|
||||||
open={dialogOpen}
|
open={dialogOpen}
|
||||||
onOpenChange={(nextOpen) => {
|
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();
|
const api = requireApi();
|
||||||
set({ projectsLoading: true });
|
set({ projectsLoading: true });
|
||||||
try {
|
try {
|
||||||
const list = await api.listWorkspaces();
|
const page = await api.listWorkspaces({ limit: 10 });
|
||||||
set({ projects: list });
|
set({ projects: page.items });
|
||||||
onConnectionChange(true);
|
onConnectionChange(true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
onConnectionChange(false);
|
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>;
|
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 = {
|
type ApiErrorEnvelope = {
|
||||||
detail?: string | {
|
detail?: string | {
|
||||||
code?: string;
|
code?: string;
|
||||||
@@ -269,6 +288,15 @@ async function apiRequest<T>(
|
|||||||
init: RequestInit = {},
|
init: RequestInit = {},
|
||||||
workspaceId?: string,
|
workspaceId?: string,
|
||||||
): Promise<T> {
|
): 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 finalPath = workspaceId ? appendWorkspaceId(path, workspaceId) : path;
|
||||||
const response = await fetch(finalPath, {
|
const response = await fetch(finalPath, {
|
||||||
...init,
|
...init,
|
||||||
@@ -311,7 +339,33 @@ async function apiRequest<T>(
|
|||||||
: error.error?.code,
|
: 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(
|
export async function listScripts(
|
||||||
@@ -1153,8 +1207,14 @@ export async function hideScheduleArtifact(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 系统管理级别接口 - 不区分 workspace
|
// 系统管理级别接口 - 不区分 workspace
|
||||||
export async function listPlatformEmployees(): Promise<Employee[]> {
|
export async function listPlatformEmployees(
|
||||||
return apiRequest<Employee[]>("/api/v1/platform/employees");
|
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(
|
export async function createPlatformEmployee(
|
||||||
@@ -1302,8 +1362,14 @@ export async function deleteEmployee(
|
|||||||
// Workspace (Project) Management APIs - 对应 API.md 第七部分系统管理接口
|
// Workspace (Project) Management APIs - 对应 API.md 第七部分系统管理接口
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
export async function listWorkspaces(): Promise<Workspace[]> {
|
export async function listWorkspaces(
|
||||||
return apiRequest<Workspace[]>("/api/v1/platform/workspaces");
|
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(
|
export async function createWorkspace(
|
||||||
@@ -1670,7 +1736,9 @@ export type WorkspaceBoundApi = {
|
|||||||
artifact_preserved: boolean;
|
artifact_preserved: boolean;
|
||||||
}>;
|
}>;
|
||||||
listEmployees: () => Promise<Employee[]>;
|
listEmployees: () => Promise<Employee[]>;
|
||||||
listPlatformEmployees: () => Promise<Employee[]>;
|
listPlatformEmployees: (
|
||||||
|
input?: CursorListParams,
|
||||||
|
) => Promise<CursorPage<Employee>>;
|
||||||
createEmployee: (
|
createEmployee: (
|
||||||
input: Parameters<typeof createEmployee>[1],
|
input: Parameters<typeof createEmployee>[1],
|
||||||
) => Promise<Employee>;
|
) => Promise<Employee>;
|
||||||
@@ -1757,7 +1825,9 @@ export type WorkspaceBoundApi = {
|
|||||||
nodeRunId: string,
|
nodeRunId: string,
|
||||||
) => Promise<ScheduleNodeRunArtifacts>;
|
) => Promise<ScheduleNodeRunArtifacts>;
|
||||||
// Workspace (Project) Management - 系统管理接口
|
// Workspace (Project) Management - 系统管理接口
|
||||||
listWorkspaces: () => Promise<Workspace[]>;
|
listWorkspaces: (
|
||||||
|
input?: CursorListParams,
|
||||||
|
) => Promise<CursorPage<Workspace>>;
|
||||||
createWorkspace: (input: WorkspaceCreatePayload) => Promise<Workspace>;
|
createWorkspace: (input: WorkspaceCreatePayload) => Promise<Workspace>;
|
||||||
updateWorkspace: (
|
updateWorkspace: (
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
|
|||||||
Reference in New Issue
Block a user