diff --git a/API.md b/API.md index 9ec2bfb..3bb71d7 100644 --- a/API.md +++ b/API.md @@ -50,6 +50,42 @@ > - `is_system_admin: bool` —— 派生自 `users.platform_role_id` 指向的角色 `role_code == 'admin'` 且用户状态为 `active`。前端据此决定是否渲染"系统管理"入口。 > - `permissions: string[]` —— 当前用户通过其平台角色(`platform_role_id`)间接持有的菜单权限码列表(`permission_code`),按字典序排列;未分配平台角色时为空数组。前端据此过滤菜单与 `` 路由守卫。**仅控制前端展示,不参与后端 endpoint 鉴权**——后端鉴权继续由 `system_admin_context`(`role_code == 'admin'`)与 workspace membership 负责。详见 §7.12-7.14。 +### 1.1 登录 / 会话 + +| 方法 | 路径 | 说明 | +|---|---|---| +| `POST` | `/api/v1/auth/login` | 校验用户名密码,种 `access_token` HttpOnly Cookie,返回 `user` + `workspaces` | +| `POST` | `/api/v1/auth/logout` | 清除 Cookie(幂等) | +| `GET` | `/api/v1/auth/me` | 返回当前登录用户与可访问 workspace 列表 | + +### 1.2 `PATCH /api/v1/auth/me` — 修改本人资料 + +当前登录用户修改自己的显示名 / 邮箱。不需要 `workspace_id`。 + +- **请求体**(至少提供一个字段;多余字段 → 422): + +| 字段 | 类型 | 限制 | 说明 | +|---|---|---|---| +| `display_name` | string | 1~100 | trim 后写入 | +| `email` | string \| null | ≤255 | trim 后写入;空字符串归一为 `null`;与其他未软删用户冲突 → 409 "邮箱已存在" | + +- 不可通过本端点修改:`username`、`password`、`status`、`platform_role_id`。 +- **响应 200**:`data.user` 与 `GET /me` 中的 `user` 形状一致(含 `role_code` / `is_system_admin` / `permissions`)。 + +### 1.3 `POST /api/v1/auth/password` — 本人修改密码 + +- **请求体**: + +| 字段 | 类型 | 限制 | 说明 | +|---|---|---|---| +| `current_password` | string | 1~72 | 当前密码 | +| `new_password` | string | 8~72 | 新密码 | + +- 当前密码错误 → 400 "当前密码不正确"。 +- 新密码与当前密码相同 → 400 "新密码不能与当前密码相同"。 +- **成功后清除会话 Cookie**,客户端须引导用户重新登录。 +- **响应 200**:`data.password_changed = true`。 + --- ## 二、统一约定 @@ -508,6 +544,8 @@ queued ──→ running ──┬─→ succeeded | `POST` | `/api/v1/data-resources/uploads/{upload_id}/bind` | 绑定已完成上传为数据资源 | | `GET` | `/api/v1/data-resources` | 列表(owner 作用域 + visibility 过滤) | | `GET` | `/api/v1/data-resources/{id}` | 详情 | +| `GET` | `/api/v1/data-resources/{id}/content` | 同源流式读取文件字节(预览/下载) | +| `GET` | `/api/v1/data-resources/{id}/preview` | 表格抽样预览(csv/tsv, `limit` 默认 100) | | `POST` | `/api/v1/data-resources/{id}/download-url` | 生成 presigned GET URL | | `DELETE` | `/api/v1/data-resources/{id}` | 软删 | @@ -630,11 +668,12 @@ Base 前缀 `/api/v1/admin`。 | 方法 | 路径 | 说明 | |---|---|---| -| `GET` | `/api/v1/platform/employees` | 列全平台未软删员工;包含停用、锁定及无平台角色用户 | +| `GET` | `/api/v1/platform/employees` | 列全平台未软删员工(cursor 分页 + `q` 搜索,见 §7.0) | | `POST` | `/api/v1/platform/employees` | 创建平台员工账号(返回 201);不自动加入任何 workspace | | `PATCH` | `/api/v1/platform/employees/{user_id}` | 改员工资料/状态/平台角色(仅系统管理员) | +| `POST` | `/api/v1/platform/employees/{user_id}/reset-password` | 重置员工密码(仅系统管理员;不需要旧密码) | | `DELETE` | `/api/v1/platform/employees/{user_id}` | 软删员工;级联软删其 workspace 成员关系(仅系统管理员) | -| `GET` | `/api/v1/platform/workspaces` | 列 workspace(`active`/`archived`);已软删的过滤掉 | +| `GET` | `/api/v1/platform/workspaces` | 列 workspace(`active`/`archived`)(cursor 分页 + `q` 搜索,见 §7.0) | | `POST` | `/api/v1/platform/workspaces` | 创建 workspace(返回 201);创建者自动成为 admin 成员 | | `GET` | `/api/v1/platform/workspaces/{workspace_id}` | 单个 workspace(含已 disabled 的,用于恢复) | | `PATCH` | `/api/v1/platform/workspaces/{workspace_id}` | 改 workspace 字段;`status` 仅允许 `active`/`archived` | @@ -651,9 +690,35 @@ Base 前缀 `/api/v1/admin`。 > - 每个 workspace 必须始终保留至少一个 `admin` 角色的活跃成员;对最后 admin 做降级 / 停用 / 删除 → 409。 > - `PATCH /employees/{user_id}` 降级 admin → developer 时同样触发 workspace last-admin 守卫(因为 workspace 角色继承自 platform 角色,降级会级联到所有活跃 membership);platform 必须始终保留至少一个 `active` 系统管理员;对最后系统管理员做降级 / 停用 / 删除 → 409。 > - 系统管理员不能通过 `DELETE .../members/{self}` 把自己移除(403)。唯一退出方式是 `DELETE /workspaces/{id}` 软删整个 workspace,后者会级联软删所有成员。 -> - workspace 与成员列表接口静默 `pageSize=100` 上限,无客户端分页参数(YAGNI);`GET /employees` 按契约返回全部未软删员工,不设隐藏上限。 +> - `GET /employees` 与 `GET /workspaces` 支持 cursor 分页与关键字搜索(见 §7.0);成员列表仍静默 `pageSize=100` 上限。 > - 跨 workspace 操作**不**需要 `?workspace_id=` query 参数,与 `/api/v1/admin/...`(workspace 内成员管理)不要混淆。 +### 7.0 列表分页约定(employees / workspaces) + +`GET /api/v1/platform/employees` 与 `GET /api/v1/platform/workspaces` 使用 **keyset cursor** 分页(无 `offset` / `page`)。 + +| Query | 类型 | 默认 | 说明 | +|---|---|---|---| +| `limit` | int | `10` | 每页条数,范围 1~200 | +| `cursor` | string | 无 | 上一页返回的 `meta.next_cursor`;缺省为第一页;非法值 → 400 | +| `q` | string | 无 | 关键字搜索。employees 匹配 `display_name`/`username`/`email`;workspaces 匹配 `workspace_name`/`workspace_code`/`description` | + +响应 `meta`: + +```json +{ + "limit": 10, + "page_count": 10, + "total_count": 156, + "has_more": true, + "next_cursor": "..." +} +``` + +- `total_count`:当前筛选条件下的总条数(用于页码展示)。 +- `next_cursor`:无下一页时为 `null`。 +- 排序键:`(created_at ASC, id ASC)`。前端用 cursor 栈实现「上一页 / 下一页 + 已访问页码」;不支持任意跳到未访问过的深页。 + ### 7.1 `POST /api/v1/platform/workspaces` 创建 workspace;创建者(当前系统管理员)自动成为该 workspace 的 `admin` 成员。 @@ -836,6 +901,19 @@ Base 前缀 `/api/v1/admin`。 - **响应 200**:返回更新后的 `PlatformEmployeePayload`,`role_code` / `role_name` 反映最新的 `platform_role_id`。 +### 7.10.1 `POST /api/v1/platform/employees/{user_id}/reset-password` + +系统管理员为指定员工设置新密码(不校验旧密码)。JWT 无服务端吊销列表,已有会话在过期前仍可用;对方下次登录须用新密码。 + +- **请求体**: + +| 字段 | 类型 | 限制 | 说明 | +|---|---|---|---| +| `new_password` | string | 8~72 | 新密码;bcrypt 哈希后写入 `password_hash` | + +- 目标不存在或已软删 → 404 "用户不存在"。 +- **响应 200**:`data = { "user_id": "...", "password_reset": true }`。 + ### 7.11 `DELETE /api/v1/platform/employees/{user_id}` 软删除平台员工;级联软删其所有 `workspace_members` 行。调用者必须是系统管理员。 diff --git a/backend/src/backend/api/_resources_common.py b/backend/src/backend/api/_resources_common.py new file mode 100644 index 0000000..e35d2a3 --- /dev/null +++ b/backend/src/backend/api/_resources_common.py @@ -0,0 +1,130 @@ +"""数据资源路由共享的 payload / 可见性辅助。""" + +from __future__ import annotations + +import os +from pathlib import Path, PurePosixPath +from typing import Any + +from common.db.models import DataResources, StorageObjects +from common.storage import workspaces_root +from fastapi import HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from backend.api.dependencies import RequestContext +from backend.api.scripts import _escape_like_pattern, normalize_user_path + + +def build_list_resources_descendant_prefix(parent_path: str) -> str: + """Return the escaped materialized-path prefix for direct children.""" + normalized = normalize_user_path(parent_path) + escaped = _escape_like_pattern(normalized) + return f"{escaped}/" if escaped else "" + + +def compute_jupyter_relative_path(script_path: str, resource_relative: str) -> str: + """从当前脚本所在目录算到资源文件的 Jupyter 相对路径。""" + script_dir = PurePosixPath(script_path).parent.as_posix() + if not script_dir or script_dir == ".": + return resource_relative + return os.path.relpath(resource_relative, start=script_dir) + + +def resource_directory( + object_key: str, + workspace_id: str, + owner_user_id: str, +) -> str: + """从 object_key 解析资源所在目录(相对于用户根目录,根目录返回 "")。""" + prefix = f"{workspace_id}/{owner_user_id}/" + if not object_key.startswith(prefix): + return "" + tail = object_key[len(prefix):] + directory, _, _ = tail.rpartition("/") + return directory + + +def resource_payload( + resource: DataResources, + storage_object: StorageObjects, + owner_display_name: str | None = None, +) -> dict[str, Any]: + workspace_prefix = f"{resource.workspace_id}/" + user_prefix = f"{resource.owner_user_id}/" + jupyter_accessible_path = "" + absolute_path = "" + if storage_object.object_key and storage_object.object_key.startswith( + workspace_prefix + ): + remainder = storage_object.object_key[len(workspace_prefix):] + if remainder.startswith(user_prefix): + tail = remainder[len(user_prefix):] + jupyter_accessible_path = tail + absolute_path = ( + workspaces_root() + / resource.workspace_id + / resource.owner_user_id + / Path(tail) + ).as_posix() + else: + jupyter_accessible_path = remainder + elif storage_object.object_key: + jupyter_accessible_path = storage_object.object_key + return { + "resource_id": resource.resource_id, + "workspace_id": resource.workspace_id, + "storage_object_id": resource.storage_object_id, + "owner_user_id": resource.owner_user_id, + "owner_display_name": owner_display_name, + "resource_name": resource.resource_name, + "description": resource.description, + "visibility": resource.visibility, + "status": resource.status, + "created_at": resource.created_at.isoformat(), + "updated_at": resource.updated_at.isoformat(), + "file": { + "file_name": storage_object.file_name, + "file_extension": storage_object.file_extension, + "mime_type": storage_object.mime_type, + "size_bytes": storage_object.size_bytes, + "content_hash": storage_object.content_hash, + "object_status": storage_object.object_status, + }, + "jupyter_accessible_path": jupyter_accessible_path, + "absolute_path": absolute_path, + } + + +def can_view(resource: DataResources, context: RequestContext) -> bool: + if resource.owner_user_id == context.user.user_id: + return True + if resource.visibility in {"workspace", "public"}: + return True + return context.is_admin + + +async def get_visible_resource( + resource_id: str, + context: RequestContext, + session: AsyncSession, +) -> tuple[DataResources, StorageObjects]: + row = ( + await session.execute( + select(DataResources, StorageObjects) + .join( + StorageObjects, + StorageObjects.storage_object_id + == DataResources.storage_object_id, + ) + .where( + DataResources.resource_id == resource_id, + DataResources.workspace_id + == context.workspace.workspace_id, + DataResources.status == "active", + ) + ) + ).one_or_none() + if row is None or not can_view(row[0], context): + raise HTTPException(status.HTTP_404_NOT_FOUND, "resource not found") + return row diff --git a/backend/src/backend/api/auth.py b/backend/src/backend/api/auth.py index 4870385..74ebf8e 100644 --- a/backend/src/backend/api/auth.py +++ b/backend/src/backend/api/auth.py @@ -12,6 +12,8 @@ The user-facing flow is: ``backend.api.dependencies.request_context`` 3. POST /api/v1/auth/logout — clear the cookie 4. GET /api/v1/auth/me — return the current user + 5. PATCH /api/v1/auth/me — update display_name / email + 6. POST /api/v1/auth/password — change password (clears session cookie) Service-to-service calls do not use these endpoints — they live on the shared Docker network and have no application-layer auth. See @@ -24,15 +26,16 @@ from typing import Any from common.auth.jwt import JwtError, issue_jwt, verify_jwt_token from common.auth.membership import resolve_is_system_admin -from common.auth.passwords import verify_password +from common.auth.passwords import hash_password, verify_password from common.config import settings from common.db.models import Roles, Users, WorkspaceMembers, Workspaces from common.ids import new_ulid from fastapi import APIRouter, Depends, HTTPException, Request, Response, status +from pydantic import BaseModel, ConfigDict, Field from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from backend.api.dependencies import database_session, load_user_permissions +from backend.api.dependencies import current_user, database_session, load_user_permissions router = APIRouter(prefix="/api/v1/auth", tags=["auth"]) @@ -95,6 +98,29 @@ def _workspace_payload( } +class ProfileUpdate(BaseModel): + model_config = ConfigDict(extra="forbid") + + display_name: str | None = Field(default=None, min_length=1, max_length=100) + email: str | None = Field(default=None, max_length=255) + + +class PasswordChange(BaseModel): + model_config = ConfigDict(extra="forbid") + + current_password: str = Field(min_length=1, max_length=72) + new_password: str = Field(min_length=8, max_length=72) + + +async def _platform_role_code(session: AsyncSession, user: Users) -> str | None: + if user.platform_role_id is None: + return None + platform_role_row = await session.scalar( + select(Roles).where(Roles.role_id == user.platform_role_id) + ) + return platform_role_row.role_code if platform_role_row is not None else None + + # 校验账号密码,设置登录 Cookie,并返回用户可进入的工作区列表。 @router.post("/login") async def login( @@ -167,13 +193,7 @@ async def login( # (``Users.platform_role_id``); the iteration over rows above was # a legacy way to find the "highest" workspace role and is no # longer correct now that admin/developer are platform-only. - user_role_code: str | None = None - if user.platform_role_id is not None: - platform_role_row = await session.scalar( - select(Roles).where(Roles.role_id == user.platform_role_id) - ) - if platform_role_row is not None: - user_role_code = platform_role_row.role_code + user_role_code = await _platform_role_code(session, user) token = issue_jwt(user.user_id, ttl_seconds=COOKIE_TTL_SECONDS) _set_session_cookie(request, response, token) @@ -257,13 +277,7 @@ async def me( workspaces = [_workspace_payload(ws, role) for ws, role, _ in rows] default_workspace_id = workspaces[0]["workspace_id"] if workspaces else None - user_role_code: str | None = None - if user.platform_role_id is not None: - platform_role_row = await session.scalar( - select(Roles).where(Roles.role_id == user.platform_role_id) - ) - if platform_role_row is not None: - user_role_code = platform_role_row.role_code + user_role_code = await _platform_role_code(session, user) is_system_admin = await resolve_is_system_admin(session, user) permissions = await load_user_permissions(session, user) @@ -284,6 +298,86 @@ async def me( } +# 当前登录用户修改显示名 / 邮箱(不可改 username、角色、密码)。 +@router.patch("/me") +async def update_me( + payload: ProfileUpdate, + user: Users = Depends(current_user), + session: AsyncSession = Depends(database_session), +) -> dict[str, Any]: + """Update the authenticated user's display_name and/or email.""" + if payload.display_name is None and payload.email is None: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + "至少提供一个可修改字段", + ) + + if payload.display_name is not None: + user.display_name = payload.display_name.strip() + + if payload.email is not None: + normalized = payload.email.strip() or None + if normalized is not None: + duplicate = await session.scalar( + select(Users.user_id).where( + Users.email == normalized, + Users.user_id != user.user_id, + Users.is_deleted == 0, + ) + ) + if duplicate is not None: + raise HTTPException(status.HTTP_409_CONFLICT, "邮箱已存在") + user.email = normalized + + await session.flush() + await session.refresh(user) + + user_role_code = await _platform_role_code(session, user) + is_system_admin = await resolve_is_system_admin(session, user) + permissions = await load_user_permissions(session, user) + + return { + "request_id": new_ulid(), + "data": { + "user": _user_payload( + user, + user_role_code, + is_system_admin=is_system_admin, + permissions=permissions, + ), + }, + "meta": {}, + } + + +# 当前登录用户修改密码;成功后清除 Cookie,需重新登录。 +@router.post("/password") +async def change_password( + payload: PasswordChange, + response: Response, + user: Users = Depends(current_user), + session: AsyncSession = Depends(database_session), +) -> dict[str, Any]: + """Change password after verifying the current one; clears the session cookie.""" + if not verify_password(payload.current_password, user.password_hash): + raise HTTPException(status.HTTP_400_BAD_REQUEST, "当前密码不正确") + if payload.current_password == payload.new_password: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + "新密码不能与当前密码相同", + ) + + user.password_hash = hash_password(payload.new_password) + await session.flush() + _clear_session_cookie(response) + + return { + "request_id": new_ulid(), + "data": {"password_changed": True}, + "meta": {}, + } + + __all__ = [ "COOKIE_NAME", "COOKIE_TTL_SECONDS", diff --git a/backend/src/backend/api/platform/__init__.py b/backend/src/backend/api/platform/__init__.py index f9c8ea2..24f7bd8 100644 --- a/backend/src/backend/api/platform/__init__.py +++ b/backend/src/backend/api/platform/__init__.py @@ -73,6 +73,7 @@ from backend.api.platform._deps import ( system_admin_context, ) from backend.api.platform.employees import router as employees_router +from backend.api.platform.members import router as members_router from backend.api.platform.roles import router as roles_router from backend.api.platform.workspaces import router as workspaces_router @@ -81,6 +82,7 @@ from backend.api.platform.workspaces import router as workspaces_router router = APIRouter() router.include_router(employees_router) router.include_router(workspaces_router) +router.include_router(members_router) router.include_router(roles_router) # 保留 system_admin_context 的 re-export,供其他文件使用 diff --git a/backend/src/backend/api/platform/_pagination.py b/backend/src/backend/api/platform/_pagination.py new file mode 100644 index 0000000..c336032 --- /dev/null +++ b/backend/src/backend/api/platform/_pagination.py @@ -0,0 +1,60 @@ +"""Cursor (keyset) pagination helpers for platform list endpoints. + +Cursor encodes the sort key ``(created_at, id)`` as a URL-safe base64 +string. Clients pass it back via ``?cursor=`` to fetch the next page. +Invalid cursors raise HTTP 400 — never silently treated as page 1. +""" + +from __future__ import annotations + +import base64 +import datetime +from typing import Any + +from fastapi import HTTPException, status + +DEFAULT_PAGE_LIMIT = 10 +MAX_PAGE_LIMIT = 200 + + +def encode_cursor(created_at: datetime.datetime, row_id: str) -> str: + """Encode ``(created_at, id)`` into an opaque cursor string.""" + if created_at.tzinfo is not None: + created_at = created_at.replace(tzinfo=None) + raw = f"{created_at.isoformat()}|{row_id}".encode("utf-8") + return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=") + + +def decode_cursor(cursor: str) -> tuple[datetime.datetime, str]: + """Decode a cursor; raise 400 on malformed input.""" + try: + padded = cursor + "=" * (-len(cursor) % 4) + raw = base64.urlsafe_b64decode(padded.encode("ascii")).decode("utf-8") + ts_part, _, row_id = raw.partition("|") + if not ts_part or not row_id: + raise ValueError("missing parts") + created_at = datetime.datetime.fromisoformat(ts_part) + if created_at.tzinfo is not None: + created_at = created_at.replace(tzinfo=None) + return created_at, row_id + except (ValueError, TypeError, UnicodeDecodeError) as exc: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + "无效的分页 cursor", + ) from exc + + +def page_meta( + *, + limit: int, + page_count: int, + total_count: int, + next_cursor: str | None, +) -> dict[str, Any]: + return { + "limit": limit, + "page_count": page_count, + "total_count": total_count, + "has_more": next_cursor is not None, + "next_cursor": next_cursor, + } diff --git a/backend/src/backend/api/platform/employees.py b/backend/src/backend/api/platform/employees.py index 398cc06..6be9e69 100644 --- a/backend/src/backend/api/platform/employees.py +++ b/backend/src/backend/api/platform/employees.py @@ -11,9 +11,9 @@ from typing import Any, Literal from common.auth.passwords import hash_password from common.db.models import Roles, Users, WorkspaceMembers, Workspaces from common.ids import new_ulid -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, Query, status from pydantic import BaseModel, ConfigDict, Field -from sqlalchemy import or_, select, update +from sqlalchemy import func, or_, select, tuple_, update from sqlalchemy.ext.asyncio import AsyncSession from backend.api.dependencies import database_session @@ -25,6 +25,13 @@ from backend.api.platform._deps import ( _load_role_by_code, system_admin_context, ) +from backend.api.platform._pagination import ( + DEFAULT_PAGE_LIMIT, + MAX_PAGE_LIMIT, + decode_cursor, + encode_cursor, + page_meta, +) # 平台角色 code 的字符串约束:与 RoleCreate.role_code 一致。 # 之所以从 Literal["admin","developer"] 放宽为 str,是因为 ``listPlatformRoles`` @@ -91,22 +98,63 @@ def platform_employee_payload( # 列出整个平台的非删除用户;不局限于某一个工作区。 @router.get("/employees") async def list_platform_employees( + limit: int = Query(default=DEFAULT_PAGE_LIMIT, ge=1, le=MAX_PAGE_LIMIT), + cursor: str | None = Query(default=None), + q: str | None = Query(default=None, max_length=100), context: SystemAdminContext = Depends(system_admin_context), session: AsyncSession = Depends(database_session), ) -> dict[str, Any]: - """List every non-soft-deleted platform user.""" + """List non-soft-deleted platform users with cursor pagination + search.""" + base_filters = [Users.is_deleted == 0] + keyword = (q or "").strip() + if keyword: + like = f"%{keyword}%" + base_filters.append( + or_( + Users.display_name.like(like), + Users.username.like(like), + Users.email.like(like), + ) + ) + + total_count = int( + await session.scalar( + select(func.count()).select_from(Users).where(*base_filters) + ) + or 0 + ) + + page_filters = list(base_filters) + if cursor is not None: + cursor_ts, cursor_id = decode_cursor(cursor) + page_filters.append( + tuple_(Users.created_at, Users.user_id) > (cursor_ts, cursor_id) + ) + rows = ( await session.execute( select(Users, Roles) .outerjoin(Roles, Roles.role_id == Users.platform_role_id) - .where(Users.is_deleted == 0) + .where(*page_filters) .order_by(Users.created_at, Users.user_id) + .limit(limit + 1) ) ).all() + has_more = len(rows) > limit + page_rows = rows[:limit] + next_cursor = None + if has_more and page_rows: + last_user = page_rows[-1][0] + next_cursor = encode_cursor(last_user.created_at, last_user.user_id) return _envelope( context.request_id, - [platform_employee_payload(user, role) for user, role in rows], - {"count": len(rows)}, + [platform_employee_payload(user, role) for user, role in page_rows], + page_meta( + limit=limit, + page_count=len(page_rows), + total_count=total_count, + next_cursor=next_cursor, + ), ) @@ -348,3 +396,30 @@ async def delete_platform_employee( ) +class PlatformEmployeePasswordReset(BaseModel): + model_config = ConfigDict(extra="forbid") + + new_password: str = Field(min_length=8, max_length=72) + + +# 系统管理员重置指定员工密码(不需要旧密码)。 +@router.post("/employees/{user_id}/reset-password") +async def reset_platform_employee_password( + user_id: str, + payload: PlatformEmployeePasswordReset, + context: SystemAdminContext = Depends(system_admin_context), + session: AsyncSession = Depends(database_session), +) -> dict[str, Any]: + """Reset a platform employee's password. Caller must be a system admin.""" + user = await session.get(Users, user_id) + if user is None or user.is_deleted != 0: + raise HTTPException(status.HTTP_404_NOT_FOUND, "用户不存在") + + user.password_hash = hash_password(payload.new_password) + await session.flush() + return _envelope( + context.request_id, + {"user_id": user_id, "password_reset": True}, + ) + + diff --git a/backend/src/backend/api/platform/members.py b/backend/src/backend/api/platform/members.py new file mode 100644 index 0000000..0c30928 --- /dev/null +++ b/backend/src/backend/api/platform/members.py @@ -0,0 +1,297 @@ +"""Workspace membership CRUD endpoints. + +``GET .../members`` admits system admins or active workspace members; +write endpoints require ``system_admin_context``. +""" + +from __future__ import annotations + +import datetime +from typing import Any, Literal + +from common.db.models import Roles, Users, WorkspaceMembers +from common.ids import new_ulid +from fastapi import APIRouter, Depends, HTTPException, Request, status +from pydantic import BaseModel, ConfigDict, Field +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from backend.api.dependencies import current_user, database_session +from backend.api.platform._deps import ( + SystemAdminContext, + _count_active_admins, + _envelope, + _is_system_admin, + system_admin_context, +) +from backend.api.platform.workspaces import ( + _load_workspace, + member_payload, +) + +router = APIRouter(prefix="/api/v1/platform", tags=["platform"]) + +LIST_PAGE_SIZE = 100 + + +class MemberCreate(BaseModel): + """Add a user to a workspace. Role is inherited from the user's + platform role (Users.platform_role_id) — not set here.""" + + model_config = ConfigDict(extra="forbid") + + user_id: str = Field(min_length=26, max_length=26) + + +class MemberUpdate(BaseModel): + """Update a workspace membership's status. Role cannot be changed + via this endpoint — workspace role is always inherited from the + user's platform role. To change a member's role, PATCH + /platform/employees/{user_id} instead.""" + + model_config = ConfigDict(extra="forbid") + + member_status: Literal["active", "disabled", "locked"] | None = None + + +@router.get("/workspaces/{workspace_id}/members") +async def list_members( + workspace_id: str, + request: Request, + session: AsyncSession = Depends(database_session), +) -> dict[str, Any]: + """List active and historical (non-soft-deleted) members of a workspace. + + Accessible to system admins (any workspace) and to active members of the + workspace itself. The script explorer calls this to seed the per-owner + directory-tree groups for non-admin users; visibility filters on the + scripts/data-resources endpoints still keep each peer's private content + hidden, so this only exposes membership (names), not private files. + """ + user = await current_user(request, session) + is_system_admin = await _is_system_admin(session, user) + if not is_system_admin: + membership = await session.scalar( + select(WorkspaceMembers).where( + WorkspaceMembers.workspace_id == workspace_id, + WorkspaceMembers.user_id == user.user_id, + WorkspaceMembers.is_deleted == 0, + WorkspaceMembers.member_status == "active", + ) + ) + if membership is None: + raise HTTPException( + status.HTTP_403_FORBIDDEN, + "需要系统管理员或该工作区成员权限", + ) + await _load_workspace(session, workspace_id) + rows = ( + await session.execute( + select(Users, Roles, WorkspaceMembers) + .join( + WorkspaceMembers, + WorkspaceMembers.user_id == Users.user_id, + ) + .join(Roles, Roles.role_id == WorkspaceMembers.role_id) + .where( + WorkspaceMembers.workspace_id == workspace_id, + WorkspaceMembers.is_deleted == 0, + ) + .order_by(WorkspaceMembers.joined_at, Users.user_id) + .limit(LIST_PAGE_SIZE) + ) + ).all() + request_id = request.headers.get("X-Request-ID") or new_ulid() + return _envelope( + request_id, + [member_payload(u, r, m) for u, r, m in rows], + {"count": len(rows), "page_size": LIST_PAGE_SIZE}, + ) + +@router.post( + "/workspaces/{workspace_id}/members", + status_code=status.HTTP_201_CREATED, +) +async def add_member( + workspace_id: str, + payload: MemberCreate, + context: SystemAdminContext = Depends(system_admin_context), + session: AsyncSession = Depends(database_session), +) -> dict[str, Any]: + """Add a user to a workspace. The new row starts with member_status='active'. + + The role is inherited from the target user's ``platform_role_id``; + the request body does NOT take a ``role_code``. To change a member's + role, PATCH ``/api/v1/platform/employees/{user_id}`` instead. + """ + await _load_workspace(session, workspace_id) + user = await session.get(Users, payload.user_id) + if user is None or user.is_deleted != 0: + raise HTTPException(status.HTTP_404_NOT_FOUND, "用户不存在") + if user.status != "active": + raise HTTPException( + status.HTTP_409_CONFLICT, + f"用户状态为 {user.status},无法加入 workspace", + ) + if user.platform_role_id is None: + raise HTTPException( + status.HTTP_409_CONFLICT, + "目标用户尚未分配平台角色,无法加入 workspace;" + "请先 PATCH /api/v1/platform/employees/{user_id} 设置 role_code", + ) + role = await session.scalar( + select(Roles).where( + Roles.role_id == user.platform_role_id, + Roles.is_deleted == 0, + ) + ) + if role is None: + raise HTTPException( + status.HTTP_422_UNPROCESSABLE_ENTITY, + "用户的平台角色行不存在或已被删除", + ) + # ``WorkspaceMembers`` 的主键是 ``(workspace_id, user_id)`` 复合 PK, + # 而 ``remove_member`` / ``delete_platform_employee`` 都是软删除 (保留行, + # 仅置 ``is_deleted=1``). 因此这里必须按主键查整行,而不是只看活跃行: + # 否则软删行会被 active-duplicate 检查漏过,然后 INSERT 直接撞 PK. + existing = await session.scalar( + select(WorkspaceMembers).where( + WorkspaceMembers.workspace_id == workspace_id, + WorkspaceMembers.user_id == payload.user_id, + ) + ) + if existing is not None: + if existing.is_deleted == 0: + raise HTTPException( + status.HTTP_409_CONFLICT, + "用户已是该 workspace 成员;workspace 角色继承自平台角色," + "要变更请 PATCH /api/v1/platform/employees/{user_id} 修改 role_code", + ) + # 复活软删除行. 保留 ``joined_at`` 作为历史记录;``role_id`` 重新继承 + # 当前用户的平台角色 (用户在中间可能改过 platform_role);清掉 + # ``deleted_at`` 标记本轮已不在软删状态. + existing.is_deleted = 0 + existing.deleted_at = None + existing.role_id = role.role_id + existing.member_status = "active" + await session.flush() + await session.refresh(existing) + return _envelope( + context.request_id, member_payload(user, role, existing), + ) + membership = WorkspaceMembers( + workspace_id=workspace_id, + user_id=payload.user_id, + role_id=role.role_id, + member_status="active", + ) + session.add(membership) + await session.flush() + await session.refresh(membership) + return _envelope(context.request_id, member_payload(user, role, membership)) + +@router.patch("/workspaces/{workspace_id}/members/{user_id}") +async def update_member( + workspace_id: str, + user_id: str, + payload: MemberUpdate, + context: SystemAdminContext = Depends(system_admin_context), + session: AsyncSession = Depends(database_session), +) -> dict[str, Any]: + """Update a workspace membership's status. Role is not editable here. + + Workspace role is always inherited from the user's platform role + (``Users.platform_role_id``). To change role, PATCH + ``/api/v1/platform/employees/{user_id}`` instead. + + Last-admin guard still applies to ``member_status`` changes: setting + the only active admin to ``disabled``/``locked`` would leave the + workspace without admin coverage. + """ + await _load_workspace(session, workspace_id) + row = ( + await session.execute( + select(Users, Roles, WorkspaceMembers) + .join( + WorkspaceMembers, + WorkspaceMembers.user_id == Users.user_id, + ) + .join(Roles, Roles.role_id == WorkspaceMembers.role_id) + .where( + WorkspaceMembers.workspace_id == workspace_id, + WorkspaceMembers.user_id == user_id, + WorkspaceMembers.is_deleted == 0, + ) + ) + ).first() + if row is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "成员不存在") + user, role, membership = row + + if payload.member_status is not None and payload.member_status != membership.member_status: + if ( + role.role_code == "admin" + and payload.member_status != "active" + ): + remaining = await _count_active_admins( + session, workspace_id, exclude_user_id=user_id, + ) + if remaining == 0: + raise HTTPException( + status.HTTP_409_CONFLICT, + "workspace 必须保留至少一个 admin", + ) + membership.member_status = payload.member_status + + await session.flush() + await session.refresh(membership) + return _envelope(context.request_id, member_payload(user, role, membership)) + +@router.delete("/workspaces/{workspace_id}/members/{user_id}") +async def remove_member( + workspace_id: str, + user_id: str, + context: SystemAdminContext = Depends(system_admin_context), + session: AsyncSession = Depends(database_session), +) -> dict[str, Any]: + """Soft-delete a workspace membership. + + System admins cannot remove themselves — the only escape is to delete + the entire workspace, which cascades membership soft-deletion. + """ + await _load_workspace(session, workspace_id) + if user_id == context.user.user_id: + raise HTTPException( + status.HTTP_403_FORBIDDEN, + "系统管理员不能把自己从 workspace 移除;如需退出,请删除整个 workspace", + ) + row = ( + await session.execute( + select(Roles, WorkspaceMembers) + .join(Roles, Roles.role_id == WorkspaceMembers.role_id) + .where( + WorkspaceMembers.workspace_id == workspace_id, + WorkspaceMembers.user_id == user_id, + WorkspaceMembers.is_deleted == 0, + ) + ) + ).first() + if row is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "成员不存在") + role, membership = row + if role.role_code == "admin" and membership.member_status == "active": + remaining = await _count_active_admins( + session, workspace_id, exclude_user_id=user_id, + ) + if remaining == 0: + raise HTTPException( + status.HTTP_409_CONFLICT, + "workspace 必须保留至少一个 admin", + ) + membership.is_deleted = 1 + membership.deleted_at = datetime.datetime.utcnow() + await session.flush() + return _envelope( + context.request_id, + {"workspace_id": workspace_id, "user_id": user_id, "removed": True}, + ) diff --git a/backend/src/backend/api/platform/workspaces.py b/backend/src/backend/api/platform/workspaces.py index 5cda1d3..ce0ebc0 100644 --- a/backend/src/backend/api/platform/workspaces.py +++ b/backend/src/backend/api/platform/workspaces.py @@ -1,7 +1,7 @@ -"""Workspace & membership CRUD endpoints. -Five workspace endpoints plus five membership endpoints, gated by -``system_admin_context`` (except ``GET .../members``, which also admits workspace -members). Last-admin guards and soft-delete cascades live here. +"""Workspace CRUD endpoints. + +Membership endpoints live in ``members.py``. Soft-delete cascades for +workspace DELETE still live here. """ from __future__ import annotations @@ -12,29 +12,29 @@ from typing import Any, Literal from common.db.models import Roles, Users, WorkspaceMembers, Workspaces from common.ids import new_ulid -from fastapi import APIRouter, Depends, HTTPException, Request, status +from fastapi import APIRouter, Depends, HTTPException, Query, status from pydantic import BaseModel, ConfigDict, Field -from sqlalchemy import select, update +from sqlalchemy import func, or_, select, tuple_, update from sqlalchemy.ext.asyncio import AsyncSession -from backend.api.dependencies import current_user, database_session +from backend.api.dependencies import database_session from backend.api.platform._deps import ( SystemAdminContext, - _count_active_admins, _envelope, - _is_system_admin, _load_role_by_code, system_admin_context, ) +from backend.api.platform._pagination import ( + DEFAULT_PAGE_LIMIT, + MAX_PAGE_LIMIT, + decode_cursor, + encode_cursor, + page_meta, +) router = APIRouter(prefix="/api/v1/platform", tags=["platform"]) WORKSPACE_CODE_PATTERN = re.compile(r"^[a-z0-9-]{3,32}$") -LIST_PAGE_SIZE = 100 - -WORKSPACE_EDITABLE_STATUS = ("active", "archived") -MEMBER_ROLE_CODES = ("admin", "developer") -MEMBER_STATUS_VALUES = ("active", "disabled", "locked") # 创建工作区时前端提交的请求体;禁止未声明字段。 class WorkspaceCreate(BaseModel): @@ -55,24 +55,6 @@ class WorkspaceUpdate(BaseModel): # 'disabled' is rejected here on purpose — soft delete must go through DELETE. status: Literal["active", "archived"] | None = None -class MemberCreate(BaseModel): - """Add a user to a workspace. Role is inherited from the user's - platform role (Users.platform_role_id) — not set here.""" - - model_config = ConfigDict(extra="forbid") - - user_id: str = Field(min_length=26, max_length=26) - -class MemberUpdate(BaseModel): - """Update a workspace membership's status. Role cannot be changed - via this endpoint — workspace role is always inherited from the - user's platform role. To change a member's role, PATCH - /platform/employees/{user_id} instead.""" - - model_config = ConfigDict(extra="forbid") - - member_status: Literal["active", "disabled", "locked"] | None = None - def workspace_payload(workspace: Workspaces) -> dict[str, Any]: return { "workspace_id": workspace.workspace_id, @@ -114,28 +96,66 @@ async def _load_workspace(session: AsyncSession, workspace_id: str) -> Workspace @router.get("/workspaces") async def list_workspaces( + limit: int = Query(default=DEFAULT_PAGE_LIMIT, ge=1, le=MAX_PAGE_LIMIT), + cursor: str | None = Query(default=None), + q: str | None = Query(default=None, max_length=100), context: SystemAdminContext = Depends(system_admin_context), session: AsyncSession = Depends(database_session), ) -> dict[str, Any]: - """List active/archived workspaces. Soft-deleted rows are filtered out. + """List active/archived workspaces with cursor pagination + search.""" + base_filters = [ + Workspaces.status != "disabled", + Workspaces.is_deleted == 0, + ] + keyword = (q or "").strip() + if keyword: + like = f"%{keyword}%" + base_filters.append( + or_( + Workspaces.workspace_name.like(like), + Workspaces.workspace_code.like(like), + Workspaces.description.like(like), + ) + ) + + total_count = int( + await session.scalar( + select(func.count()).select_from(Workspaces).where(*base_filters) + ) + or 0 + ) + + page_filters = list(base_filters) + if cursor is not None: + cursor_ts, cursor_id = decode_cursor(cursor) + page_filters.append( + tuple_(Workspaces.created_at, Workspaces.workspace_id) + > (cursor_ts, cursor_id) + ) - Silent ``pageSize=100`` cap — YAGNI on real pagination until needed. - """ rows = ( await session.execute( select(Workspaces) - .where( - Workspaces.status != "disabled", - Workspaces.is_deleted == 0, - ) + .where(*page_filters) .order_by(Workspaces.created_at, Workspaces.workspace_id) - .limit(LIST_PAGE_SIZE) + .limit(limit + 1) ) ).scalars().all() + has_more = len(rows) > limit + page_rows = list(rows[:limit]) + next_cursor = None + if has_more and page_rows: + last = page_rows[-1] + next_cursor = encode_cursor(last.created_at, last.workspace_id) return _envelope( context.request_id, - [workspace_payload(w) for w in rows], - {"count": len(rows), "page_size": LIST_PAGE_SIZE}, + [workspace_payload(w) for w in page_rows], + page_meta( + limit=limit, + page_count=len(page_rows), + total_count=total_count, + next_cursor=next_cursor, + ), ) @router.post("/workspaces", status_code=status.HTTP_201_CREATED) @@ -252,246 +272,3 @@ async def delete_workspace( await session.flush() await session.refresh(workspace) return _envelope(context.request_id, workspace_payload(workspace)) - -@router.get("/workspaces/{workspace_id}/members") -async def list_members( - workspace_id: str, - request: Request, - session: AsyncSession = Depends(database_session), -) -> dict[str, Any]: - """List active and historical (non-soft-deleted) members of a workspace. - - Accessible to system admins (any workspace) and to active members of the - workspace itself. The script explorer calls this to seed the per-owner - directory-tree groups for non-admin users; visibility filters on the - scripts/data-resources endpoints still keep each peer's private content - hidden, so this only exposes membership (names), not private files. - """ - user = await current_user(request, session) - is_system_admin = await _is_system_admin(session, user) - if not is_system_admin: - membership = await session.scalar( - select(WorkspaceMembers).where( - WorkspaceMembers.workspace_id == workspace_id, - WorkspaceMembers.user_id == user.user_id, - WorkspaceMembers.is_deleted == 0, - WorkspaceMembers.member_status == "active", - ) - ) - if membership is None: - raise HTTPException( - status.HTTP_403_FORBIDDEN, - "需要系统管理员或该工作区成员权限", - ) - await _load_workspace(session, workspace_id) - rows = ( - await session.execute( - select(Users, Roles, WorkspaceMembers) - .join( - WorkspaceMembers, - WorkspaceMembers.user_id == Users.user_id, - ) - .join(Roles, Roles.role_id == WorkspaceMembers.role_id) - .where( - WorkspaceMembers.workspace_id == workspace_id, - WorkspaceMembers.is_deleted == 0, - ) - .order_by(WorkspaceMembers.joined_at, Users.user_id) - .limit(LIST_PAGE_SIZE) - ) - ).all() - request_id = request.headers.get("X-Request-ID") or new_ulid() - return _envelope( - request_id, - [member_payload(u, r, m) for u, r, m in rows], - {"count": len(rows), "page_size": LIST_PAGE_SIZE}, - ) - -@router.post( - "/workspaces/{workspace_id}/members", - status_code=status.HTTP_201_CREATED, -) -async def add_member( - workspace_id: str, - payload: MemberCreate, - context: SystemAdminContext = Depends(system_admin_context), - session: AsyncSession = Depends(database_session), -) -> dict[str, Any]: - """Add a user to a workspace. The new row starts with member_status='active'. - - The role is inherited from the target user's ``platform_role_id``; - the request body does NOT take a ``role_code``. To change a member's - role, PATCH ``/api/v1/platform/employees/{user_id}`` instead. - """ - await _load_workspace(session, workspace_id) - user = await session.get(Users, payload.user_id) - if user is None or user.is_deleted != 0: - raise HTTPException(status.HTTP_404_NOT_FOUND, "用户不存在") - if user.status != "active": - raise HTTPException( - status.HTTP_409_CONFLICT, - f"用户状态为 {user.status},无法加入 workspace", - ) - if user.platform_role_id is None: - raise HTTPException( - status.HTTP_409_CONFLICT, - "目标用户尚未分配平台角色,无法加入 workspace;" - "请先 PATCH /api/v1/platform/employees/{user_id} 设置 role_code", - ) - role = await session.scalar( - select(Roles).where( - Roles.role_id == user.platform_role_id, - Roles.is_deleted == 0, - ) - ) - if role is None: - raise HTTPException( - status.HTTP_422_UNPROCESSABLE_ENTITY, - "用户的平台角色行不存在或已被删除", - ) - # ``WorkspaceMembers`` 的主键是 ``(workspace_id, user_id)`` 复合 PK, - # 而 ``remove_member`` / ``delete_platform_employee`` 都是软删除 (保留行, - # 仅置 ``is_deleted=1``). 因此这里必须按主键查整行,而不是只看活跃行: - # 否则软删行会被 active-duplicate 检查漏过,然后 INSERT 直接撞 PK. - existing = await session.scalar( - select(WorkspaceMembers).where( - WorkspaceMembers.workspace_id == workspace_id, - WorkspaceMembers.user_id == payload.user_id, - ) - ) - if existing is not None: - if existing.is_deleted == 0: - raise HTTPException( - status.HTTP_409_CONFLICT, - "用户已是该 workspace 成员;workspace 角色继承自平台角色," - "要变更请 PATCH /api/v1/platform/employees/{user_id} 修改 role_code", - ) - # 复活软删除行. 保留 ``joined_at`` 作为历史记录;``role_id`` 重新继承 - # 当前用户的平台角色 (用户在中间可能改过 platform_role);清掉 - # ``deleted_at`` 标记本轮已不在软删状态. - existing.is_deleted = 0 - existing.deleted_at = None - existing.role_id = role.role_id - existing.member_status = "active" - await session.flush() - await session.refresh(existing) - return _envelope( - context.request_id, member_payload(user, role, existing), - ) - membership = WorkspaceMembers( - workspace_id=workspace_id, - user_id=payload.user_id, - role_id=role.role_id, - member_status="active", - ) - session.add(membership) - await session.flush() - await session.refresh(membership) - return _envelope(context.request_id, member_payload(user, role, membership)) - -@router.patch("/workspaces/{workspace_id}/members/{user_id}") -async def update_member( - workspace_id: str, - user_id: str, - payload: MemberUpdate, - context: SystemAdminContext = Depends(system_admin_context), - session: AsyncSession = Depends(database_session), -) -> dict[str, Any]: - """Update a workspace membership's status. Role is not editable here. - - Workspace role is always inherited from the user's platform role - (``Users.platform_role_id``). To change role, PATCH - ``/api/v1/platform/employees/{user_id}`` instead. - - Last-admin guard still applies to ``member_status`` changes: setting - the only active admin to ``disabled``/``locked`` would leave the - workspace without admin coverage. - """ - await _load_workspace(session, workspace_id) - row = ( - await session.execute( - select(Users, Roles, WorkspaceMembers) - .join( - WorkspaceMembers, - WorkspaceMembers.user_id == Users.user_id, - ) - .join(Roles, Roles.role_id == WorkspaceMembers.role_id) - .where( - WorkspaceMembers.workspace_id == workspace_id, - WorkspaceMembers.user_id == user_id, - WorkspaceMembers.is_deleted == 0, - ) - ) - ).first() - if row is None: - raise HTTPException(status.HTTP_404_NOT_FOUND, "成员不存在") - user, role, membership = row - - if payload.member_status is not None and payload.member_status != membership.member_status: - if ( - role.role_code == "admin" - and payload.member_status != "active" - ): - remaining = await _count_active_admins( - session, workspace_id, exclude_user_id=user_id, - ) - if remaining == 0: - raise HTTPException( - status.HTTP_409_CONFLICT, - "workspace 必须保留至少一个 admin", - ) - membership.member_status = payload.member_status - - await session.flush() - await session.refresh(membership) - return _envelope(context.request_id, member_payload(user, role, membership)) - -@router.delete("/workspaces/{workspace_id}/members/{user_id}") -async def remove_member( - workspace_id: str, - user_id: str, - context: SystemAdminContext = Depends(system_admin_context), - session: AsyncSession = Depends(database_session), -) -> dict[str, Any]: - """Soft-delete a workspace membership. - - System admins cannot remove themselves — the only escape is to delete - the entire workspace, which cascades membership soft-deletion. - """ - await _load_workspace(session, workspace_id) - if user_id == context.user.user_id: - raise HTTPException( - status.HTTP_403_FORBIDDEN, - "系统管理员不能把自己从 workspace 移除;如需退出,请删除整个 workspace", - ) - row = ( - await session.execute( - select(Roles, WorkspaceMembers) - .join(Roles, Roles.role_id == WorkspaceMembers.role_id) - .where( - WorkspaceMembers.workspace_id == workspace_id, - WorkspaceMembers.user_id == user_id, - WorkspaceMembers.is_deleted == 0, - ) - ) - ).first() - if row is None: - raise HTTPException(status.HTTP_404_NOT_FOUND, "成员不存在") - role, membership = row - if role.role_code == "admin" and membership.member_status == "active": - remaining = await _count_active_admins( - session, workspace_id, exclude_user_id=user_id, - ) - if remaining == 0: - raise HTTPException( - status.HTTP_409_CONFLICT, - "workspace 必须保留至少一个 admin", - ) - membership.is_deleted = 1 - membership.deleted_at = datetime.datetime.utcnow() - await session.flush() - return _envelope( - context.request_id, - {"workspace_id": workspace_id, "user_id": user_id, "removed": True}, - ) - diff --git a/backend/src/backend/api/resources.py b/backend/src/backend/api/resources.py index 8a16e6e..a1fef77 100644 --- a/backend/src/backend/api/resources.py +++ b/backend/src/backend/api/resources.py @@ -7,17 +7,13 @@ from __future__ import annotations -import os from datetime import UTC, datetime -from pathlib import Path, PurePosixPath from typing import Any from common.db.models import DataResources, StorageObjects, Users from common.ids import new_ulid -from common.storage import workspaces_root from common.storage.schemas import ( CreateUploadRequest, - DownloadUrlRequest, ) from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request, status from sqlalchemy import func, or_, select @@ -28,8 +24,15 @@ from backend.api.dependencies import ( database_session, request_context, ) -from backend.api.scripts import _escape_like_pattern, normalize_user_path -from backend.schemas.common import DownloadUrlRequest +from backend.api._resources_common import ( + build_list_resources_descendant_prefix as _build_list_resources_descendant_prefix, + can_view, + compute_jupyter_relative_path, + get_visible_resource, + resource_directory, + resource_payload, +) +from backend.api.scripts import _escape_like_pattern from backend.schemas.resources import ( CompleteResourceUploadRequest, CreateResourceUploadRequest, @@ -37,7 +40,6 @@ from backend.schemas.resources import ( ) from backend.services.storage import ( acquire_named_lock, - create_download_url_payload, create_upload_record, release_named_lock, soft_delete_object, @@ -47,119 +49,6 @@ from backend.services.storage import ( router = APIRouter(prefix="/api/v1/data-resources", tags=["data-resources"]) -def _build_list_resources_descendant_prefix(parent_path: str) -> str: - """Return the escaped materialized-path prefix for direct children - of ``parent_path`` against ``StorageObjects.object_key``. - - The full object_key is ``{ws_id}/{owner_user_id}/{jupyter_path}``. - ``list_resources`` prepends ``{ws_id}/{owner_user_id}`` (the requester - by default, or the ``owner_user_id`` query param) to this prefix and - applies ``LIKE '{ws_id}/{owner}/{prefix}%' AND NOT LIKE '...%/%'`` so - only that owner's direct children under ``parent_path`` match. LIKE - wildcards in parent_path are escaped so folder names containing ``_`` - or ``%`` do not act as wildcards. - """ - normalized = normalize_user_path(parent_path) - escaped = _escape_like_pattern(normalized) - return f"{escaped}/" if escaped else "" - - -def compute_jupyter_relative_path(script_path: str, resource_relative: str) -> str: - """从当前脚本所在目录算到资源文件的 Jupyter 相对路径。 - - script_path / resource_relative 都是相对于 user root_dir 的 POSIX 路径。 - """ - script_dir = PurePosixPath(script_path).parent.as_posix() - if not script_dir or script_dir == ".": - return resource_relative - return os.path.relpath(resource_relative, start=script_dir) - - -def resource_directory( - object_key: str, - workspace_id: str, - owner_user_id: str, -) -> str: - """从 object_key 解析资源所在目录(相对于用户根目录,根目录返回 "")。 - - object_key 形如 ``{ws_id}/{user_id}/{target_path}/{file_name}``; - 不匹配该前缀的键(如无 ws/user 前缀的旧数据)统一视为根目录。 - """ - prefix = f"{workspace_id}/{owner_user_id}/" - if not object_key.startswith(prefix): - return "" - tail = object_key[len(prefix):] - directory, _, _ = tail.rpartition("/") - return directory - - -def resource_payload( - resource: DataResources, - storage_object: StorageObjects, - owner_display_name: str | None = None, -) -> dict[str, Any]: - # ``object_key`` now follows ``{ws_id}/{user_id}/{target_path}/{file_name}`` - # (target_path may be empty). Legacy objects still live under - # ``.resources/{file_name}`` and must remain readable. Both shapes share - # the same derivation: strip the workspace/user prefix and use the rest - # as the Jupyter-relative path. - workspace_prefix = f"{resource.workspace_id}/" - user_prefix = f"{resource.owner_user_id}/" - jupyter_accessible_path = "" - absolute_path = "" - if storage_object.object_key and storage_object.object_key.startswith( - workspace_prefix - ): - remainder = storage_object.object_key[len(workspace_prefix):] - if remainder.startswith(user_prefix): - tail = remainder[len(user_prefix):] - jupyter_accessible_path = tail - absolute_path = ( - workspaces_root() - / resource.workspace_id - / resource.owner_user_id - / Path(tail) - ).as_posix() - else: - jupyter_accessible_path = remainder - elif storage_object.object_key: - jupyter_accessible_path = storage_object.object_key - return { - "resource_id": resource.resource_id, - "workspace_id": resource.workspace_id, - "storage_object_id": resource.storage_object_id, - "owner_user_id": resource.owner_user_id, - "owner_display_name": owner_display_name, - "resource_name": resource.resource_name, - "description": resource.description, - "visibility": resource.visibility, - "status": resource.status, - "created_at": resource.created_at.isoformat(), - "updated_at": resource.updated_at.isoformat(), - "file": { - "file_name": storage_object.file_name, - "file_extension": storage_object.file_extension, - "mime_type": storage_object.mime_type, - "size_bytes": storage_object.size_bytes, - "content_hash": storage_object.content_hash, - "object_status": storage_object.object_status, - }, - "jupyter_accessible_path": jupyter_accessible_path, - "absolute_path": absolute_path, - } - - -def can_view(resource: DataResources, context: RequestContext) -> bool: - # 同一 workspace 内:owner 永远可见自己的资源(含 private); - # 其他成员只见 visibility in {workspace, public} 的资源; - # admin 全部可见。 - if resource.owner_user_id == context.user.user_id: - return True - if resource.visibility in {"workspace", "public"}: - return True - return context.is_admin - - # 根据当前脚本位置计算资源的相对路径,便于 Notebook 中用相对路径读取文件。 @router.post("/{resource_id}/jupyter-relative-path") async def resource_jupyter_relative_path( @@ -465,31 +354,6 @@ async def list_resources( } -async def get_visible_resource( - resource_id: str, - context: RequestContext, - session: AsyncSession, -) -> tuple[DataResources, StorageObjects]: - row = ( - await session.execute( - select(DataResources, StorageObjects) - .join( - StorageObjects, - StorageObjects.storage_object_id - == DataResources.storage_object_id, - ) - .where( - DataResources.resource_id == resource_id, - DataResources.workspace_id - == context.workspace.workspace_id, - DataResources.status == "active", - ) - ) - ).one_or_none() - if row is None or not can_view(row[0], context): - raise HTTPException(status.HTTP_404_NOT_FOUND, "resource not found") - return row - # 查询单个数据资源的元数据与其关联文件信息。 @router.get("/{resource_id}") @@ -510,28 +374,6 @@ async def get_resource( } -# 为资源文件生成带时效的下载链接。 -@router.post("/{resource_id}/download-url") -async def resource_download_url( - resource_id: str, - payload: DownloadUrlRequest, - request: Request, - context: RequestContext = Depends(request_context), - session: AsyncSession = Depends(database_session), -) -> dict[str, Any]: - resource, _ = await get_visible_resource( - resource_id, - context, - session, - ) - data = await create_download_url_payload( - await session.get(StorageObjects, resource.storage_object_id), - DownloadUrlRequest(expires_seconds=payload.expires_seconds), - request, - ) - return {"request_id": context.request_id, "data": data["data"], "meta": {}} - - # 软删除数据资源及其关联对象,遵循存储层的回收站策略。 @router.delete("/{resource_id}") async def delete_resource( diff --git a/backend/src/backend/api/resources_content.py b/backend/src/backend/api/resources_content.py new file mode 100644 index 0000000..7789a28 --- /dev/null +++ b/backend/src/backend/api/resources_content.py @@ -0,0 +1,231 @@ +"""数据资源下载、同源内容流与表格抽样预览接口。 + +与 ``resources.py`` 共用前缀 ``/api/v1/data-resources``,在 ``main`` 中并列挂载。 +""" + +from __future__ import annotations + +import csv +import io +from typing import Any +from urllib.parse import quote + +from common.db.models import StorageObjects +from fastapi import APIRouter, Depends, HTTPException, Query, Request, status +from fastapi.responses import StreamingResponse +from sqlalchemy.ext.asyncio import AsyncSession + +from backend.api.dependencies import ( + RequestContext, + database_session, + request_context, +) +from backend.api._resources_common import get_visible_resource +from backend.schemas.common import DownloadUrlRequest +from backend.services.storage import create_download_url_payload + +router = APIRouter(prefix="/api/v1/data-resources", tags=["data-resources"]) + +_PREVIEW_MAX_BYTES = 2 * 1024 * 1024 +_PREVIEW_DEFAULT_LIMIT = 100 +_PREVIEW_MAX_LIMIT = 500 +_TABLE_EXTENSIONS = {".csv", ".tsv"} + + +def _extension_of(file_name: str | None, resource_name: str | None) -> str: + for candidate in (file_name, resource_name): + if not candidate: + continue + lower = candidate.lower() + for ext in _TABLE_EXTENSIONS: + if lower.endswith(ext): + return ext + return "" + + +def _decode_preview_text(raw: bytes) -> str: + for encoding in ("utf-8-sig", "utf-8", "gb18030"): + try: + return raw.decode(encoding) + except UnicodeDecodeError: + continue + return raw.decode("utf-8", errors="replace") + + +async def _read_prefix_bytes(store: Any, object_key: str, max_bytes: int) -> bytes: + chunks: list[bytes] = [] + total = 0 + async for chunk in store.get_stream(object_key): + if not chunk: + continue + chunks.append(chunk) + total += len(chunk) + if total >= max_bytes: + break + data = b"".join(chunks) + return data[:max_bytes] + + +def _parse_table_preview( + text: str, + *, + delimiter: str, + limit: int, + byte_truncated: bool, +) -> dict[str, Any]: + reader = csv.reader(io.StringIO(text), delimiter=delimiter) + try: + header = next(reader) + except StopIteration: + return { + "kind": "table", + "columns": [], + "rows": [], + "row_count": 0, + "truncated": byte_truncated, + "delimiter": delimiter, + } + + columns = [str(cell) if cell is not None else "" for cell in header] + if not any(columns): + columns = [f"col_{index + 1}" for index in range(max(len(header), 1))] + + rows: list[list[str]] = [] + truncated = byte_truncated + for row in reader: + if len(rows) >= limit: + truncated = True + break + cells = [str(cell) if cell is not None else "" for cell in row] + if len(cells) < len(columns): + cells.extend([""] * (len(columns) - len(cells))) + elif len(cells) > len(columns): + cells = cells[: len(columns)] + rows.append(cells) + + return { + "kind": "table", + "columns": columns, + "rows": rows, + "row_count": len(rows), + "truncated": truncated, + "delimiter": delimiter, + } + + +@router.post("/{resource_id}/download-url") +async def resource_download_url( + resource_id: str, + payload: DownloadUrlRequest, + request: Request, + context: RequestContext = Depends(request_context), + session: AsyncSession = Depends(database_session), +) -> dict[str, Any]: + resource, _ = await get_visible_resource( + resource_id, + context, + session, + ) + data = await create_download_url_payload( + await session.get(StorageObjects, resource.storage_object_id), + DownloadUrlRequest(expires_seconds=payload.expires_seconds), + request, + ) + return {"request_id": context.request_id, "data": data["data"], "meta": {}} + + +@router.get("/{resource_id}/content") +async def resource_content( + resource_id: str, + request: Request, + context: RequestContext = Depends(request_context), + session: AsyncSession = Depends(database_session), +) -> StreamingResponse: + """同源流式读取资源字节,供前端预览器加载。""" + resource, storage_object = await get_visible_resource( + resource_id, + context, + session, + ) + if storage_object.object_status != "available": + raise HTTPException(status.HTTP_404_NOT_FOUND, "object not found") + if ( + not storage_object.bucket_name + or not storage_object.object_key + or storage_object.bucket_name not in request.app.state.object_stores + ): + raise HTTPException( + status.HTTP_409_CONFLICT, + "object does not support content download", + ) + + file_name = storage_object.file_name or resource.resource_name or "file" + media_type = storage_object.mime_type or "application/octet-stream" + store = request.app.state.object_stores[storage_object.bucket_name] + stream = store.get_stream(storage_object.object_key) + headers = { + "Content-Disposition": f"inline; filename*=UTF-8''{quote(file_name)}", + "Cache-Control": "private, no-store", + } + if storage_object.size_bytes is not None: + headers["Content-Length"] = str(storage_object.size_bytes) + + return StreamingResponse( + stream, + media_type=media_type, + headers=headers, + ) + + +@router.get("/{resource_id}/preview") +async def resource_preview( + resource_id: str, + request: Request, + context: RequestContext = Depends(request_context), + session: AsyncSession = Depends(database_session), + limit: int = Query(default=_PREVIEW_DEFAULT_LIMIT, ge=1, le=_PREVIEW_MAX_LIMIT), +) -> dict[str, Any]: + """表格类数据资源抽样预览(csv / tsv)。""" + resource, storage_object = await get_visible_resource( + resource_id, + context, + session, + ) + if storage_object.object_status != "available": + raise HTTPException(status.HTTP_404_NOT_FOUND, "object not found") + if ( + not storage_object.bucket_name + or not storage_object.object_key + or storage_object.bucket_name not in request.app.state.object_stores + ): + raise HTTPException( + status.HTTP_409_CONFLICT, + "object does not support preview", + ) + + extension = _extension_of(storage_object.file_name, resource.resource_name) + if extension not in _TABLE_EXTENSIONS: + raise HTTPException( + status.HTTP_422_UNPROCESSABLE_ENTITY, + "仅支持预览 .csv / .tsv 表格文件", + ) + + store = request.app.state.object_stores[storage_object.bucket_name] + raw = await _read_prefix_bytes( + store, + storage_object.object_key, + _PREVIEW_MAX_BYTES, + ) + byte_truncated = ( + storage_object.size_bytes is not None + and storage_object.size_bytes > len(raw) + ) or len(raw) >= _PREVIEW_MAX_BYTES + text = _decode_preview_text(raw) + delimiter = "\t" if extension == ".tsv" else "," + payload = _parse_table_preview( + text, + delimiter=delimiter, + limit=limit, + byte_truncated=byte_truncated, + ) + return {"request_id": context.request_id, "data": payload, "meta": {}} diff --git a/backend/src/backend/main.py b/backend/src/backend/main.py index 9b45c2b..ea46820 100644 --- a/backend/src/backend/main.py +++ b/backend/src/backend/main.py @@ -40,6 +40,7 @@ from backend.api.jupyter import router as jupyter_router from backend.api.operations import router as operations_router from backend.api.platform import router as platform_router from backend.api.resources import router as resources_router +from backend.api.resources_content import router as resources_content_router from backend.api.schedules.runs import router as schedule_runs_router from backend.api.schedules.schedules import router as schedules_router from backend.api.scripts import router as scripts_router @@ -143,6 +144,7 @@ app = create_service_app( app.include_router(auth_router) app.include_router(jupyter_router) app.include_router(resources_router) +app.include_router(resources_content_router) app.include_router(schedule_runs_router) app.include_router(schedules_router) app.include_router(scripts_router) diff --git a/backend/tests/test_auth_profile_password.py b/backend/tests/test_auth_profile_password.py new file mode 100644 index 0000000..fa69bb1 --- /dev/null +++ b/backend/tests/test_auth_profile_password.py @@ -0,0 +1,39 @@ +"""Unit tests for auth profile / password request schemas.""" + +from __future__ import annotations + +import pytest +from pydantic import ValidationError + +from backend.api.auth import PasswordChange, ProfileUpdate +from backend.api.platform.employees import PlatformEmployeePasswordReset + + +def test_profile_update_allows_partial_fields() -> None: + only_name = ProfileUpdate(display_name="张三") + assert only_name.display_name == "张三" + assert only_name.email is None + + only_email = ProfileUpdate(email="a@example.com") + assert only_email.email == "a@example.com" + + +def test_profile_update_forbids_unknown_fields() -> None: + with pytest.raises(ValidationError): + ProfileUpdate(display_name="张三", username="hacked") # type: ignore[call-arg] + + +def test_password_change_enforces_new_password_length() -> None: + with pytest.raises(ValidationError): + PasswordChange(current_password="old-pass", new_password="short") + + ok = PasswordChange(current_password="old-pass-1", new_password="new-pass-12") + assert ok.new_password == "new-pass-12" + + +def test_admin_password_reset_schema() -> None: + with pytest.raises(ValidationError): + PlatformEmployeePasswordReset(new_password="1234567") + + payload = PlatformEmployeePasswordReset(new_password="reset-pass-9") + assert payload.new_password == "reset-pass-9" diff --git a/backend/tests/test_platform_pagination.py b/backend/tests/test_platform_pagination.py new file mode 100644 index 0000000..eb380cf --- /dev/null +++ b/backend/tests/test_platform_pagination.py @@ -0,0 +1,57 @@ +"""Unit tests for platform cursor pagination helpers.""" + +from __future__ import annotations + +import datetime + +import pytest +from fastapi import HTTPException + +from backend.api.platform._pagination import ( + decode_cursor, + encode_cursor, + page_meta, +) + + +def test_encode_decode_roundtrip() -> None: + created_at = datetime.datetime(2026, 3, 15, 12, 30, 45, 123000) + row_id = "01HXY9C5B8N3K4P7Q6RT2V0J8D" + cursor = encode_cursor(created_at, row_id) + decoded_ts, decoded_id = decode_cursor(cursor) + assert decoded_ts == created_at + assert decoded_id == row_id + + +def test_decode_strips_timezone() -> None: + created_at = datetime.datetime(2026, 1, 1, 0, 0, 0, tzinfo=datetime.timezone.utc) + cursor = encode_cursor(created_at, "abc") + decoded_ts, decoded_id = decode_cursor(cursor) + assert decoded_ts.tzinfo is None + assert decoded_id == "abc" + + +def test_decode_invalid_cursor_raises_400() -> None: + with pytest.raises(HTTPException) as exc_info: + decode_cursor("not-a-valid-cursor!!!") + assert exc_info.value.status_code == 400 + + +def test_page_meta_has_more() -> None: + meta = page_meta( + limit=10, + page_count=10, + total_count=25, + next_cursor="abc", + ) + assert meta["has_more"] is True + assert meta["next_cursor"] == "abc" + assert meta["total_count"] == 25 + + meta_end = page_meta( + limit=10, + page_count=5, + total_count=25, + next_cursor=None, + ) + assert meta_end["has_more"] is False diff --git a/frontend/.gitignore b/frontend/.gitignore index 271afdb..2f75b43 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -6,6 +6,12 @@ /.react-router/ /build/ +# Vite / file-viewer 构建产物,不要提交 +/dist/ + # monaco-editor 预构建 min/vs(由 scripts/copy-monaco.mjs 在 postinstall 时 # 从 node_modules/monaco-editor/min/vs 复制生成,不要提交) /public/monaco/vs/ + +# @file-viewer/vite-plugin copyAssets 生成的 office 预览 vendor,不要提交 +/public/file-viewer/ diff --git a/frontend/app/components/common/AccountSettingsDialog.tsx b/frontend/app/components/common/AccountSettingsDialog.tsx new file mode 100644 index 0000000..4b3f81a --- /dev/null +++ b/frontend/app/components/common/AccountSettingsDialog.tsx @@ -0,0 +1,269 @@ +import { useEffect, useState } from "react"; +import { toast } from "sonner"; + +import { useAuth } from "~/context/AuthContext"; +import { Button } from "~/components/ui/button"; +import { Input } from "~/components/ui/input"; +import { + formFieldClass, + formHintClass, + formInputClass, + modalFormClass, +} from "~/features/platform/modalUi"; +import { cn } from "~/lib/utils"; + +import { + AppFormDialog, + dialogPrimaryButtonClass, + dialogSecondaryButtonClass, +} from "./AppFormDialog"; + +type AccountSettingsDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; +}; + +type SettingsTab = "profile" | "password"; + +const TAB_ITEMS: Array<{ id: SettingsTab; label: string }> = [ + { id: "profile", label: "基本信息" }, + { id: "password", label: "修改密码" }, +]; + +export function AccountSettingsDialog({ + open, + onOpenChange, +}: AccountSettingsDialogProps) { + const { user, updateProfile, changePassword, logout } = useAuth(); + const [tab, setTab] = useState("profile"); + const [displayName, setDisplayName] = useState(""); + const [email, setEmail] = useState(""); + const [savingProfile, setSavingProfile] = useState(false); + const [currentPassword, setCurrentPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [savingPassword, setSavingPassword] = useState(false); + + useEffect(() => { + if (!open || !user) return; + setTab("profile"); + setDisplayName(user.display_name); + setEmail(user.email ?? ""); + setCurrentPassword(""); + setNewPassword(""); + setConfirmPassword(""); + }, [open, user]); + + const handleSaveProfile = async (event: React.FormEvent) => { + event.preventDefault(); + const trimmedName = displayName.trim(); + if (!trimmedName) { + toast.error("显示名称不能为空"); + return; + } + setSavingProfile(true); + try { + await updateProfile({ + display_name: trimmedName, + email: email.trim(), + }); + toast.success("资料已更新"); + onOpenChange(false); + } catch (error) { + toast.error(error instanceof Error ? error.message : "资料更新失败"); + } finally { + setSavingProfile(false); + } + }; + + const handleChangePassword = async (event: React.FormEvent) => { + event.preventDefault(); + if (!currentPassword) { + toast.error("请输入当前密码"); + return; + } + if (newPassword.length < 8 || newPassword.length > 72) { + toast.error("新密码长度需为 8~72 字符"); + return; + } + if (newPassword !== confirmPassword) { + toast.error("两次输入的新密码不一致"); + return; + } + if (newPassword === currentPassword) { + toast.error("新密码不能与当前密码相同"); + return; + } + setSavingPassword(true); + try { + await changePassword({ + current_password: currentPassword, + new_password: newPassword, + }); + toast.success("密码已修改,请重新登录"); + onOpenChange(false); + await logout(); + } catch (error) { + toast.error(error instanceof Error ? error.message : "密码修改失败"); + } finally { + setSavingPassword(false); + } + }; + + const formId = + tab === "profile" ? "account-profile-form" : "account-password-form"; + const saving = tab === "profile" ? savingProfile : savingPassword; + const primaryLabel = + tab === "profile" + ? savingProfile + ? "保存中…" + : "保存资料" + : savingPassword + ? "修改中…" + : "修改密码"; + + return ( + + + + + } + > +
+
+ {TAB_ITEMS.map((item) => { + const active = tab === item.id; + return ( + + ); + })} +
+
+ +
+ {tab === "profile" ? ( +
void handleSaveProfile(event)} + > + + + +
+ ) : ( +
void handleChangePassword(event)} + > + + + +
+ )} +
+
+ ); +} diff --git a/frontend/app/components/common/ConfirmDialog.tsx b/frontend/app/components/common/ConfirmDialog.tsx index b568603..a1653eb 100644 --- a/frontend/app/components/common/ConfirmDialog.tsx +++ b/frontend/app/components/common/ConfirmDialog.tsx @@ -39,7 +39,7 @@ function ConfirmDialog({ }: ConfirmDialogProps) { return ( - + {title} diff --git a/frontend/app/components/common/Topbar.tsx b/frontend/app/components/common/Topbar.tsx index 2425f45..5d7f981 100644 --- a/frontend/app/components/common/Topbar.tsx +++ b/frontend/app/components/common/Topbar.tsx @@ -1,8 +1,10 @@ -import { ChevronRight, LayoutGrid } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { ChevronRight, LayoutGrid, LogOut, Settings } from "lucide-react"; import type { AuthUser, AuthWorkspace } from "../../context/AuthContext"; +import { AccountSettingsDialog } from "./AccountSettingsDialog"; type TopbarProps = { - activePage: string; + pageTitle: string; apiOnline: boolean; user: AuthUser | null; currentWorkspace: AuthWorkspace; @@ -14,7 +16,7 @@ type TopbarProps = { }; export function Topbar({ - activePage, + pageTitle, apiOnline, user, currentWorkspace, @@ -24,18 +26,30 @@ export function Topbar({ onSetCurrentWorkspace, onLogout, }: TopbarProps) { + const [userMenuOpen, setUserMenuOpen] = useState(false); + const [settingsOpen, setSettingsOpen] = useState(false); + const userMenuRef = useRef(null); + + useEffect(() => { + if (!userMenuOpen) return; + const onPointerDown = (event: MouseEvent) => { + if (!userMenuRef.current?.contains(event.target as Node)) { + setUserMenuOpen(false); + } + }; + document.addEventListener("mousedown", onPointerDown); + return () => document.removeEventListener("mousedown", onPointerDown); + }, [userMenuOpen]); + + const openUserMenu = () => { + onSetWorkspaceMenuOpen(false); + setUserMenuOpen((open) => !open); + }; const roleLabel = user?.is_system_admin || user?.role_code === "admin" ? "管理员" : ["business", "business_team", "biz"].includes(user?.role_code ?? "") ? "业务团队" : "模型团队"; - const pageTitles: Record = { - home: "工作台", - scripts: "构建脚本", - schedules: "调度配置", - operations: "A卡模型运维", - system: "系统管理", - }; return (
@@ -44,14 +58,14 @@ export function Topbar({ className="grid h-[31px] w-[31px] rotate-180 cursor-pointer place-items-center rounded-[7px] border border-transparent bg-white text-[#7b8999] hover:border-[#b9c9da] hover:bg-[#f7faff]" type="button" > - +
开发工作区

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

@@ -70,7 +84,10 @@ export function Topbar({ {workspaceMenuOpen && ( -
+
{workspaces.map((workspace) => { const isSelected = workspace.workspace_id === currentWorkspace.workspace_id; @@ -117,10 +134,13 @@ export function Topbar({
)}
-
+
- + {userMenuOpen && ( +
+
+ + {user?.display_name ?? "未知用户"} + + + {user?.username} + {user?.role_code === "admin" ? " · 管理员" : " · 开发人员"} + +
+ + +
+ )}
+ +
); } diff --git a/frontend/app/components/ui/sonner.tsx b/frontend/app/components/ui/sonner.tsx index 1919f52..ee0335c 100644 --- a/frontend/app/components/ui/sonner.tsx +++ b/frontend/app/components/ui/sonner.tsx @@ -5,6 +5,7 @@ const Toaster = ({ ...props }: ToasterProps) => { return ( Promise; logout: () => Promise; + updateProfile: (input: { + display_name?: string; + email?: string; + }) => Promise; + changePassword: (input: { + current_password: string; + new_password: string; + }) => Promise; setCurrentWorkspace: (workspaceId: string) => void; refreshWorkspaces: () => Promise; }; @@ -162,6 +170,27 @@ export function AuthProvider({ children }: { children: ReactNode }) { } }, [navigate]); + const updateProfile = useCallback(async (input: { + display_name?: string; + email?: string; + }) => { + const result = await authRequest<{ user: AuthUser }>("/api/v1/auth/me", { + method: "PATCH", + body: JSON.stringify(input), + }); + setUser(result.user); + }, []); + + const changePassword = useCallback(async (input: { + current_password: string; + new_password: string; + }) => { + await authRequest<{ password_changed: boolean }>("/api/v1/auth/password", { + method: "POST", + body: JSON.stringify(input), + }); + }, []); + const setCurrentWorkspace = useCallback((workspaceId: string) => { const workspace = workspaces.find((item) => item.workspace_id === workspaceId); if (!workspace) return; @@ -193,9 +222,22 @@ export function AuthProvider({ children }: { children: ReactNode }) { loading, login, logout, + updateProfile, + changePassword, setCurrentWorkspace, refreshWorkspaces, - }), [currentWorkspace, loading, login, logout, setCurrentWorkspace, user, workspaces, refreshWorkspaces]); + }), [ + changePassword, + currentWorkspace, + loading, + login, + logout, + refreshWorkspaces, + setCurrentWorkspace, + updateProfile, + user, + workspaces, + ]); if (loading) { return ( @@ -238,6 +280,10 @@ export function useApi(): WorkspaceBoundApi { rawApi.bindResourceUpload(workspaceId, uploadId, body), deleteResource: (resourceId) => rawApi.deleteResource(workspaceId, resourceId), + fetchResourceContentFile: (resourceId, fileName, signal) => + rawApi.fetchResourceContentFile(workspaceId, resourceId, fileName, signal), + fetchResourcePreview: (resourceId, input, signal) => + rawApi.fetchResourcePreview(workspaceId, resourceId, input, signal), updateScript: (scriptId, input) => rawApi.updateScript(workspaceId, scriptId, input), deleteScript: (scriptId) => rawApi.deleteScript(workspaceId, scriptId), @@ -270,7 +316,7 @@ export function useApi(): WorkspaceBoundApi { hideScheduleArtifact: (versionsId) => rawApi.hideScheduleArtifact(workspaceId, versionsId), listEmployees: () => rawApi.listEmployees(workspaceId), - listPlatformEmployees: () => rawApi.listPlatformEmployees(), + listPlatformEmployees: (input) => rawApi.listPlatformEmployees(input), createEmployee: (input) => rawApi.createEmployee(workspaceId, input), createPlatformEmployee: (input) => rawApi.createPlatformEmployee(input), updateEmployee: (userId, input) => @@ -279,6 +325,8 @@ export function useApi(): WorkspaceBoundApi { rawApi.updatePlatformEmployee(userId, input), deleteEmployee: (userId) => rawApi.deleteEmployee(workspaceId, userId), deletePlatformEmployee: (userId) => rawApi.deletePlatformEmployee(userId), + resetPlatformEmployeePassword: (userId, newPassword) => + rawApi.resetPlatformEmployeePassword(userId, newPassword), // Role Management - 角色管理接口(跨 workspace,不需要传入 workspaceId) listPlatformRoles: () => rawApi.listPlatformRoles(), getPlatformRole: (roleCode) => rawApi.getPlatformRole(roleCode), @@ -312,7 +360,7 @@ export function useApi(): WorkspaceBoundApi { getScheduleNodeRunArtifacts: (runId, nodeRunId) => rawApi.getScheduleNodeRunArtifacts(workspaceId, runId, nodeRunId), // Workspace (Project) Management - 系统管理接口(跨 workspace,不需要传入 workspaceId) - listWorkspaces: () => rawApi.listWorkspaces(), + listWorkspaces: (input) => rawApi.listWorkspaces(input), createWorkspace: (input) => rawApi.createWorkspace(input), updateWorkspace: (workspaceId, input) => rawApi.updateWorkspace(workspaceId, input), deleteWorkspace: (workspaceId) => rawApi.deleteWorkspace(workspaceId), diff --git a/frontend/app/features/admin/AdminPagination.tsx b/frontend/app/features/admin/AdminPagination.tsx new file mode 100644 index 0000000..41702b6 --- /dev/null +++ b/frontend/app/features/admin/AdminPagination.tsx @@ -0,0 +1,117 @@ +import { ChevronLeft, ChevronRight } from "lucide-react"; + +import { Button } from "~/components/ui/button"; + +/** Build a compact page number list with ellipsis, e.g. 1 … 4 5 6 … 20 */ +function visiblePages(current: number, total: number): Array { + if (total <= 7) { + return Array.from({ length: total }, (_, index) => index + 1); + } + const pages = new Set([1, total, current, current - 1, current + 1]); + if (current <= 3) { + pages.add(2); + pages.add(3); + pages.add(4); + } + if (current >= total - 2) { + pages.add(total - 1); + pages.add(total - 2); + pages.add(total - 3); + } + const sorted = [...pages].filter((p) => p >= 1 && p <= total).sort((a, b) => a - b); + const result: Array = []; + for (const page of sorted) { + const prev = result[result.length - 1]; + if (typeof prev === "number" && page - prev > 1) { + result.push("ellipsis"); + } + result.push(page); + } + return result; +} + +export function AdminPagination({ + page, + totalPages, + totalCount, + loading, + hasMore, + canGoToPage, + onPrev, + onNext, + onGoToPage, +}: { + page: number; + totalPages: number; + totalCount: number; + loading?: boolean; + hasMore: boolean; + canGoToPage: (page: number) => boolean; + onPrev: () => void; + onNext: () => void; + onGoToPage: (page: number) => void; +}) { + if (totalCount === 0) return null; + + const pages = visiblePages(page, totalPages); + + return ( +
+ + 共 {totalCount} 条,第 {page}/{totalPages} 页 + +
+ + {pages.map((item, index) => + item === "ellipsis" ? ( + + … + + ) : ( + + ), + )} + +
+
+ ); +} diff --git a/frontend/app/features/admin/CreateUserProjectField.tsx b/frontend/app/features/admin/CreateUserProjectField.tsx new file mode 100644 index 0000000..8395872 --- /dev/null +++ b/frontend/app/features/admin/CreateUserProjectField.tsx @@ -0,0 +1,62 @@ +import type { Workspace } from "../../services/api"; +import { Checkbox } from "~/components/ui/checkbox"; +import { formFieldClass } from "../platform/modalUi"; + +const CHECKBOX_CLASS = + "size-[16px] shrink-0 rounded-[3px] border-[#cdd9e4] bg-white data-checked:border-[#1277d3] data-checked:bg-[#1277d3] data-checked:text-white"; + +/** 新建用户时可选加入的项目列表(多选,非必填)。 */ +export function CreateUserProjectField({ + projects, + loading, + selectedIds, + onToggle, +}: { + projects: Workspace[]; + loading: boolean; + selectedIds: string[]; + onToggle: (workspaceId: string) => void; +}) { + return ( +
+ + 加入项目 + (可选) + +
+ {loading ? ( +

加载项目中…

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

暂无可用项目

+ ) : ( +
+ {projects.map((project) => { + const checked = selectedIds.includes(project.workspace_id); + return ( + + ); + })} +
+ )} +
+

+ 未加入任何项目时,该用户将无法登录。 +

+
+ ); +} diff --git a/frontend/app/features/admin/ImportMemberDialog.tsx b/frontend/app/features/admin/ImportMemberDialog.tsx deleted file mode 100644 index 8ada20d..0000000 --- a/frontend/app/features/admin/ImportMemberDialog.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { type Employee, type Workspace } from "../../services/api"; -import { - AppFormDialog, - dialogPrimaryButtonClass, - dialogSecondaryButtonClass, -} from "~/components/common/AppFormDialog"; -import { Button } from "~/components/ui/button"; -import { - formFieldClass, - modalFormClass, -} from "../platform/modalUi"; -import { UserMultiSelect } from "./UserMultiSelect"; - -export function ImportMemberDialog({ - open, - selectedProject, - availableUsers, - selectedUserIds, - existingMemberIds, - saving, - onChangeSelectedUserIds, - onSubmit, - onClose, -}: { - open: boolean; - selectedProject: Workspace | null; - availableUsers: Employee[]; - selectedUserIds: string[]; - existingMemberIds: string[]; - saving: boolean; - onNotify: (notice: { tone: "success" | "error" | "info"; message: string }) => void; - onChangeSelectedUserIds: (ids: string[]) => void; - onSubmit: () => Promise | void; - onClose: () => void; -}) { - return ( - { - if (!nextOpen) onClose(); - }} - eyebrow="IMPORT MEMBER" - title={`导入成员到 ${selectedProject?.workspace_name ?? "项目"}`} - footer={ - <> - - - - } - > -
- -
-
- ); -} diff --git a/frontend/app/features/admin/MemberAddPanel.tsx b/frontend/app/features/admin/MemberAddPanel.tsx new file mode 100644 index 0000000..3af80de --- /dev/null +++ b/frontend/app/features/admin/MemberAddPanel.tsx @@ -0,0 +1,359 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +import { ArrowLeft, Search } from "lucide-react"; +import { type Employee } from "../../services/api"; +import { Button } from "~/components/ui/button"; +import { dialogSecondaryButtonClass } from "~/components/common/AppFormDialog"; +import { primaryGradientButtonClass } from "~/components/common/buttonClasses"; +import { useDebouncedValue } from "./useDebouncedValue"; + +export type LoadUsersFn = (input: { + q: string; + cursor: string | null; + limit: number; +}) => Promise<{ + items: Employee[]; + hasMore: boolean; + nextCursor: string | null; +}>; + +export function MemberAddPanel({ + existingMemberIds, + saving, + loadUsers, + onBack, + onAddMembers, +}: { + existingMemberIds: string[]; + saving: boolean; + loadUsers: LoadUsersFn; + onBack: () => void; + onAddMembers: (userIds: string[]) => Promise | void; +}) { + const [keyword, setKeyword] = useState(""); + const debouncedKeyword = useDebouncedValue(keyword, 300); + const [users, setUsers] = useState([]); + const [selectedIds, setSelectedIds] = useState([]); + const [selectedCache, setSelectedCache] = useState>({}); + const [loading, setLoading] = useState(false); + const [loadingMore, setLoadingMore] = useState(false); + const [hasMore, setHasMore] = useState(false); + const nextCursorRef = useRef(null); + const requestIdRef = useRef(0); + + const fetchPage = useCallback( + async (cursor: string | null, append: boolean) => { + const requestId = ++requestIdRef.current; + if (append) setLoadingMore(true); + else setLoading(true); + try { + const result = await loadUsers({ + q: debouncedKeyword, + cursor, + limit: 10, + }); + if (requestId !== requestIdRef.current) return; + setUsers((current) => (append ? [...current, ...result.items] : result.items)); + setHasMore(result.hasMore); + nextCursorRef.current = result.nextCursor; + } finally { + if (requestId === requestIdRef.current) { + setLoading(false); + setLoadingMore(false); + } + } + }, + [debouncedKeyword, loadUsers], + ); + + useEffect(() => { + nextCursorRef.current = null; + void fetchPage(null, false); + }, [debouncedKeyword, fetchPage]); + + const toggleUser = (user: Employee) => { + if (existingMemberIds.includes(user.user_id)) return; + setSelectedCache((cache) => ({ ...cache, [user.user_id]: user })); + setSelectedIds((ids) => + ids.includes(user.user_id) + ? ids.filter((id) => id !== user.user_id) + : [...ids, user.user_id], + ); + }; + + const removeSelected = (userId: string) => { + setSelectedIds((ids) => ids.filter((id) => id !== userId)); + }; + + const loadMore = () => { + if (loadingMore || loading || !hasMore || !nextCursorRef.current) return; + void fetchPage(nextCursorRef.current, true); + }; + + const submitAdd = async () => { + if (selectedIds.length === 0) return; + try { + await onAddMembers(selectedIds); + onBack(); + } catch { + // stay on add panel; page already toasts + } + }; + + const selectedUsers = selectedIds + .map((id) => selectedCache[id] ?? users.find((u) => u.user_id === id)) + .filter((u): u is Employee => Boolean(u)); + + return ( + <> +
+ +
+ + setKeyword(event.target.value)} + placeholder="搜索姓名 / 账号" + style={{ + flex: 1, + border: 0, + outline: "none", + background: "transparent", + fontSize: 12, + color: "#20364c", + }} + /> +
+ {selectedUsers.length > 0 && ( +
+ {selectedUsers.map((user) => ( + + {user.display_name} + + + ))} +
+ )} +
+ +
+ {loading && users.length === 0 ? ( +

+ 加载中… +

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

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

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

正在加载项目…

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

暂无项目

+

{emptyMessage}

) : ( - projects - .filter((project) => { - const term = projectSearchTerm.toLowerCase().trim(); - if (!term) return true; - return ( - project.workspace_name.toLowerCase().includes(term) || - project.workspace_code.toLowerCase().includes(term) || - (project.description && project.description.toLowerCase().includes(term)) - ); - }) - .map((project) => ( - - - - {project.workspace_name.slice(0, 1)} - - {project.workspace_name} - {project.description ?? "无描述"} - - - - {project.workspace_code} - + projects.map((project) => ( + + + + {project.workspace_name.slice(0, 1)} - - {project.status === "active" ? "正常" : project.status === "archived" ? "已归档" : "已删除"} - + {project.workspace_name} + {project.description ?? "无描述"} - - {project.quota_bytes > 0 ? `${(project.quota_bytes / 1024 / 1024 / 1024).toFixed(1)} GB` : "无限制"} - - - - - - - - - )) + + + {project.workspace_code} + + + {project.status === "active" ? "正常" : project.status === "archived" ? "已归档" : "已删除"} + + + + + {project.quota_bytes > 0 + ? `${(project.quota_bytes / 1024 / 1024 / 1024).toFixed(1)} GB` + : "无限制"} + + + + + + + + + + + )) )}
+ + setProjectDialogOpen(false)} /> - m.user_id)} - saving={saving} - onNotify={onNotify} - onChangeSelectedUserIds={setSelectedUserIds} - onSubmit={() => void importMember()} - onClose={() => setImportMemberDialogOpen(false)} - /> - setMembersDrawerOpen(false)} - onAddMembers={() => { + saving={saving} + onClose={() => { setMembersDrawerOpen(false); - if (selectedProject) void openImportMemberDialog(selectedProject); + setRemoveMemberTarget(null); }} - onRemoveMember={(userId) => void removeMember(userId)} - onNotify={onNotify} + onRemoveMember={requestRemoveMember} + onAddMembers={importMembers} + loadUsers={loadImportUsers} /> + + { + if (!nextOpen) setRemoveMemberTarget(null); + }} + title="确定移除成员?" + description={ + removeMemberTarget + ? `确定将"${removeMemberTarget.display_name}"从项目中移除吗?` + : "" + } + confirmLabel="移除" + destructive + onConfirm={async () => { + if (!removeMemberTarget) return; + await executeRemoveMember(removeMemberTarget); + setRemoveMemberTarget(null); + }} + />
); } diff --git a/frontend/app/features/admin/ProjectMembersDrawer.tsx b/frontend/app/features/admin/ProjectMembersDrawer.tsx index 84687d5..26cb605 100644 --- a/frontend/app/features/admin/ProjectMembersDrawer.tsx +++ b/frontend/app/features/admin/ProjectMembersDrawer.tsx @@ -1,6 +1,12 @@ -import { type Workspace, type WorkspaceMember } from "../../services/api"; +import { useEffect, useState } from "react"; + import { Plus, X } from "lucide-react"; +import { type Workspace, type WorkspaceMember } from "../../services/api"; +import { Button } from "~/components/ui/button"; import { primaryGradientButtonClass } from "~/components/common/buttonClasses"; +import { MemberAddPanel, type LoadUsersFn } from "./MemberAddPanel"; + +type DrawerPanel = "members" | "add"; export function ProjectMembersDrawer({ open, @@ -8,151 +14,186 @@ export function ProjectMembersDrawer({ members, membersLoading, canManage, + saving, onClose, - onAddMembers, onRemoveMember, - onNotify, + onAddMembers, + loadUsers, }: { open: boolean; selectedProject: Workspace | null; members: WorkspaceMember[]; membersLoading: boolean; canManage: boolean; + saving: boolean; onClose: () => void; - onAddMembers: () => void; onRemoveMember: (userId: string) => void; - onNotify: (notice: { tone: "success" | "error" | "info"; message: string }) => void; + onAddMembers: (userIds: string[]) => Promise | void; + loadUsers: LoadUsersFn; }) { + const [panel, setPanel] = useState("members"); + + useEffect(() => { + if (!open) setPanel("members"); + }, [open]); + if (!open || !selectedProject) return null; return ( <> -
onClose()} style={{ - position: "fixed", - top: 0, - left: 0, - right: 0, - bottom: 0, - backgroundColor: "rgba(0,0,0,0.3)", - zIndex: 99, - }} /> -