收敛用户角色并共享项目库

- 后端限制系统只保留默认 admin 管理员,新建用户固定为标注员,并拒绝观察员或额外管理员角色。

- 将项目、帧、媒体解析、AI 标注、任务、Dashboard 和导出接口改为共享项目库访问,标注员具备同等项目管理和标注能力。

- 前端用户管理移除角色选择和观察员入口,只展示唯一管理员与标注员状态。

- 更新后端/前端测试,覆盖唯一 admin、旧 viewer 归一为标注员、用户删除和共享项目库访问。

- 同步更新 AGENTS 与 doc 文档中的角色权限、共享项目库和测试计划说明。
This commit is contained in:
2026-05-04 05:20:28 +08:00
parent 02635abab1
commit 523beeb446
21 changed files with 214 additions and 172 deletions

View File

@@ -238,9 +238,9 @@ uvicorn main:app --host 0.0.0.0 --port 8000 --reload
## 主要业务流程
1. 登录:`Login.tsx` 调用 `POST /api/auth/login`,后端用 `users` 表和密码哈希校验凭证,默认启动时会种子化开发管理员 `admin / 123456`;成功后返回签名 JWT`GET /api/auth/me` 可读取当前用户;角色包括 `admin``annotator``viewer`写入类业务接口要求 `admin/annotator`,用户管理后台要求 `admin`
2. 用户管理:`Sidebar` 仅对 `admin` 显示“用户管理”,`UserAdmin.tsx` 调用 `/api/admin/users` 新增、停用/启用、改角色、改密码和删除无项目用户,并调用 `/api/admin/audit-logs` 展示登录和管理操作审计;演示部署可通过“恢复演示出厂设置”二次确认后调用 `/api/admin/demo-factory-reset`,清空演示数据,只保留默认 admin、演示视频项目和一个已按文件名自然顺序生成帧的演示 DICOM 项目同时按内置权威定义恢复“腹腔镜胆囊切除术”和“头颈部CT分割”系统模板缺失的会重建被修改或删减的语义分类树会覆盖回默认状态。
3. 项目管理:`ProjectLibrary.tsx` 调用项目 API 创建项目、拉取列表、重命名项目、复制项目和删除项目;项目卡片删除按钮旁提供复制入口,复制时可选择“新项目重置”(复制项目媒体和已生成帧序列,但清空标注/mask或“全内容复制”复制项目、帧序列、标注和关联 mask 元数据任务运行历史不复制删除当前项目后会清空工作区当前项目、帧、mask 和选区。
1. 登录:`Login.tsx` 调用 `POST /api/auth/login`,后端用 `users` 表和密码哈希校验凭证,默认启动时会种子化唯一管理员 `admin / 123456`;成功后返回签名 JWT`GET /api/auth/me` 可读取当前用户;角色包括 `admin``annotator`,非默认 admin 的历史管理员或旧 `viewer` 会归一为 `annotator`写入类业务接口要求 `admin/annotator`,用户管理、审计日志和演示出厂设置后台仅 `admin` 可用
2. 用户管理:`Sidebar` 仅对 `admin` 显示“用户管理”,`UserAdmin.tsx` 调用 `/api/admin/users` 新增标注员、停用/启用、改密码和删除用户,并调用 `/api/admin/audit-logs` 展示登录和管理操作审计;系统不允许新增第二个管理员,也不再支持观察员角色;演示部署可通过“恢复演示出厂设置”二次确认后调用 `/api/admin/demo-factory-reset`,清空演示数据,只保留默认 admin、演示视频项目和一个已按文件名自然顺序生成帧的演示 DICOM 项目同时按内置权威定义恢复“腹腔镜胆囊切除术”和“头颈部CT分割”系统模板缺失的会重建被修改或删减的语义分类树会覆盖回默认状态。
3. 项目管理:`ProjectLibrary.tsx` 调用项目 API 创建项目、拉取列表、重命名项目、复制项目和删除项目;项目库为所有登录用户共享标注员和管理员在项目创建、导入、解析、标注、AI 推理、任务查看、导出和删除方面能力一致;项目卡片删除按钮旁提供复制入口,复制时可选择“新项目重置”(复制项目媒体和已生成帧序列,但清空标注/mask或“全内容复制”复制项目、帧序列、标注和关联 mask 元数据任务运行历史不复制删除当前项目后会清空工作区当前项目、帧、mask 和选区。
4. 上传资源:视频走 `/api/media/upload`只上传源文件并关联项目不自动拆帧项目库在视频上传期间显示导入进度条、百分比和已上传字节。只有视频项目在尚未生成帧、未处于项目名称编辑状态且未解析中时显示“生成帧”DICOM 项目不显示生成帧入口DICOM 批量走 `/api/media/upload/dicom`,前端和后端都会按文件名自然顺序排序 `.dcm` 文件,避免 `10.dcm` 排在 `2.dcm` 前导致切片错位DICOM 上传期间显示导入进度条、本次有效文件数量和已上传字节,上传完成后轮询解析任务进度直到完成、失败或取消。
5. 生成帧入队:用户在项目库点击“生成帧”,选择目标 FPS 后前端调用 `/api/media/parse`;后端创建 `ProcessingTask` 并投递 Celery接口支持 `parse_fps``max_frames``target_width` 标准帧序列参数;项目库会继续轮询任务进度,解析成功后重新拉取项目列表和当前项目对象,使后端生成的 `thumbnail_url` 立即显示为项目封面;项目库和模板库的成功/失败短反馈使用非阻塞 `TransientNotice`,会自动消失。
6. worker 执行Celery worker 用 FFmpeg 优先拆视频帧,失败后用 OpenCV fallbackDICOM 使用 pydicomworker 下载和读取 DICOM 时也按文件名自然顺序排序;视频/DICOM 解析完成后都按 `frame_%06d.jpg` 连续生成项目帧序列,并记录 `timestamp_ms``source_frame_number` 和任务 `frame_sequence` 元数据后续工作区、时间轴、AI 传播、标注和导出共用同一套帧序列逻辑。
@@ -317,9 +317,9 @@ uvicorn main:app --host 0.0.0.0 --port 8000 --reload
## 安全注意事项
- FastAPI 已有真实 `users` 表、密码哈希和签名 JWT默认 `admin / 123456` 只是开发种子用户,生产部署应通过环境变量或数据库改密。
- 业务路由会校验 Bearer token项目、帧、标注、任务、Dashboard 和导出按当前用户拥有的项目过滤,模板支持系统模板(`owner_user_id IS NULL`)和用户模板。
- 角色分为 `admin``annotator``viewer``admin/annotator` 可调用写入类业务接口`viewer` 只能调用读接口`/api/admin/*` 仅允许 `admin`
- 管理员后台支持用户新增、停用/启用、改角色、站内弹窗改密码、站内确认删除无项目用户、查看登录/用户管理审计日志,以及站内二次确认后恢复演示出厂设置;禁止管理员删除、停用或降级自己。
- 业务路由会校验 Bearer token项目、帧、标注、任务、Dashboard 和导出使用全员共享项目库,模板支持系统模板(`owner_user_id IS NULL`)和用户模板。
- 角色分为唯一默认 `admin``annotator``admin/annotator` 可调用写入类业务接口;`/api/admin/*` 仅允许默认 `admin`,包括用户管理、审计日志和演示环境出厂设置
- 管理员后台支持新增标注员、停用/启用、站内弹窗改密码、站内确认删除用户、查看登录/用户管理审计日志,以及站内二次确认后恢复演示出厂设置;禁止新增第二个管理员,禁止管理员删除、停用、改名或降级自己。
- JWT 默认开发密钥在 `backend/config.py`,生产部署必须通过 `backend/.env` 覆盖 `JWT_SECRET_KEY`
- `backend/.env``.gitignore` 忽略不要提交真实数据库、MinIO、Redis、模型路径等敏感配置。
- `start_services.sh` 中包含本机路径和 sudo 启动逻辑,迁移机器时要审查。

View File

@@ -10,7 +10,7 @@ from sqlalchemy.orm import Session
from config import settings
from database import get_db
from models import Annotation, AuditLog, Frame, Mask, ProcessingTask, Project, Template, User
from routers.auth import ensure_default_admin, hash_password, require_admin, write_audit_log
from routers.auth import SUPPORTED_ROLES, ensure_default_admin, hash_password, normalize_user_role, require_admin, write_audit_log
from schemas import (
AdminUserCreate,
AdminUserUpdate,
@@ -30,18 +30,22 @@ from services.default_templates import restore_default_templates
router = APIRouter(prefix="/api/admin", tags=["Admin"])
VALID_ROLES = {"admin", "annotator", "viewer"}
DEMO_RESET_CONFIRMATION = "RESET_DEMO_FACTORY"
DEMO_PROJECT_NAME = DEMO_DICOM_PROJECT_NAME
def _normalize_role(role: str | None) -> str:
normalized = (role or "annotator").strip().lower()
if normalized not in VALID_ROLES:
if normalized not in SUPPORTED_ROLES:
raise HTTPException(status_code=400, detail=f"Unsupported role: {role}")
return normalized
def _assert_non_admin_role(role: str) -> None:
if role == "admin":
raise HTTPException(status_code=400, detail="Only the default admin account can have admin role")
@router.get("/users", response_model=List[UserOut], summary="List users")
def list_users(
db: Session = Depends(get_db),
@@ -49,7 +53,8 @@ def list_users(
) -> List[User]:
"""Return all users for the administrator console."""
_ = admin_user
return db.query(User).order_by(User.id).all()
users = db.query(User).order_by(User.id).all()
return [normalize_user_role(db, user) for user in users]
@router.post(
@@ -69,10 +74,12 @@ def create_user(
raise HTTPException(status_code=400, detail="Username is required")
if len(payload.password) < 6:
raise HTTPException(status_code=400, detail="Password must be at least 6 characters")
role = _normalize_role(payload.role)
_assert_non_admin_role(role)
user = User(
username=username,
password_hash=hash_password(payload.password),
role=_normalize_role(payload.role),
role=role,
is_active=1 if payload.is_active else 0,
)
db.add(user)
@@ -104,6 +111,7 @@ def update_user(
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
user = normalize_user_role(db, user)
updates = payload.model_dump(exclude_unset=True)
audit_detail: dict = {"before": {"username": user.username, "role": user.role, "is_active": bool(user.is_active)}}
@@ -111,6 +119,8 @@ def update_user(
username = (updates["username"] or "").strip()
if not username:
raise HTTPException(status_code=400, detail="Username is required")
if user.role == "admin" and username != settings.default_admin_username:
raise HTTPException(status_code=400, detail="Default admin username cannot be changed")
user.username = username
if "password" in updates:
password = updates["password"] or ""
@@ -119,8 +129,11 @@ def update_user(
user.password_hash = hash_password(password)
if "role" in updates:
next_role = _normalize_role(updates["role"])
if user.id == admin_user.id and next_role != "admin":
raise HTTPException(status_code=400, detail="Cannot remove your own admin role")
if user.username == settings.default_admin_username:
if next_role != "admin":
raise HTTPException(status_code=400, detail="Cannot remove the default admin role")
else:
_assert_non_admin_role(next_role)
user.role = next_role
if "is_active" in updates:
if user.id == admin_user.id and not updates["is_active"]:
@@ -158,9 +171,9 @@ def delete_user(
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
owned_project_count = db.query(Project).filter(Project.owner_user_id == user_id).count()
if owned_project_count:
raise HTTPException(status_code=409, detail="User owns projects; deactivate or migrate projects first")
user = normalize_user_role(db, user)
if user.role == "admin":
raise HTTPException(status_code=400, detail="Cannot delete the default admin account")
username = user.username
db.delete(user)
db.commit()

View File

@@ -45,21 +45,20 @@ GT_IMPORT_CONTOUR_EPSILON_RATIO = 0.00075
GT_IMPORT_MIN_CONTOUR_EPSILON = 0.35
def _owned_project_or_404(project_id: int, db: Session, current_user: User) -> Project:
project = db.query(Project).filter(
Project.id == project_id,
Project.owner_user_id == current_user.id,
).first()
def _shared_project_or_404(project_id: int, db: Session, current_user: User) -> Project:
_ = current_user
project = db.query(Project).filter(Project.id == project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
return project
def _owned_frame_or_404(frame_id: int, db: Session, current_user: User, project_id: int | None = None) -> Frame:
def _shared_frame_or_404(frame_id: int, db: Session, current_user: User, project_id: int | None = None) -> Frame:
_ = current_user
query = (
db.query(Frame)
.join(Project, Project.id == Frame.project_id)
.filter(Frame.id == frame_id, Project.owner_user_id == current_user.id)
.filter(Frame.id == frame_id)
)
if project_id is not None:
query = query.filter(Frame.project_id == project_id)
@@ -480,7 +479,7 @@ def predict(
- **interactive**: `prompt_data` is `{ "box": [...], "points": [[x, y]], "labels": [1, 0] }`.
- **semantic**: disabled in the current SAM 2.1 point/box product flow.
"""
frame = _owned_frame_or_404(payload.image_id, db, current_user)
frame = _shared_frame_or_404(payload.image_id, db, current_user)
image = _load_frame_image(frame)
prompt_type = payload.prompt_type.lower()
@@ -649,7 +648,7 @@ def analyze_mask(
) -> dict:
"""Return backend-computed mask properties for the frontend inspector."""
if payload.frame_id is not None:
_owned_frame_or_404(payload.frame_id, db, current_user)
_shared_frame_or_404(payload.frame_id, db, current_user)
mask_data = payload.mask_data or {}
polygons = mask_data.get("polygons") or []
@@ -705,7 +704,7 @@ def smooth_mask(
to the current mask, then save through the normal annotation endpoint.
"""
if payload.frame_id is not None:
_owned_frame_or_404(payload.frame_id, db, current_user)
_shared_frame_or_404(payload.frame_id, db, current_user)
polygons = payload.mask_data.get("polygons") or []
valid_polygons = _normalize_polygons(polygons)
@@ -751,8 +750,8 @@ def propagate(
raise HTTPException(status_code=400, detail="direction must be forward, backward, or both")
max_frames = max(1, min(int(payload.max_frames or 30), 500))
_owned_project_or_404(payload.project_id, db, current_user)
source_frame = _owned_frame_or_404(payload.frame_id, db, current_user, payload.project_id)
_shared_project_or_404(payload.project_id, db, current_user)
source_frame = _shared_frame_or_404(payload.frame_id, db, current_user, payload.project_id)
seed = payload.seed.model_dump(exclude_none=True)
polygons = seed.get("polygons") or []
@@ -881,8 +880,8 @@ def queue_propagate_task(
current_user: User = Depends(require_editor),
) -> ProcessingTaskOut:
"""Queue multiple seed/direction propagation steps as one background task."""
_owned_project_or_404(payload.project_id, db, current_user)
source_frame = _owned_frame_or_404(payload.frame_id, db, current_user, payload.project_id)
_shared_project_or_404(payload.project_id, db, current_user)
source_frame = _shared_frame_or_404(payload.frame_id, db, current_user, payload.project_id)
if not payload.steps:
raise HTTPException(status_code=400, detail="Propagation task requires at least one step")
@@ -936,7 +935,7 @@ def auto_segment(
current_user: User = Depends(require_editor),
) -> dict:
"""Run automatic mask generation on a frame using a grid of point prompts."""
frame = _owned_frame_or_404(image_id, db, current_user)
frame = _shared_frame_or_404(image_id, db, current_user)
image = _load_frame_image(frame)
try:
@@ -959,10 +958,10 @@ def save_annotation(
current_user: User = Depends(require_editor),
) -> Annotation:
"""Persist an annotation (mask, points, bbox) into the database."""
_owned_project_or_404(payload.project_id, db, current_user)
_shared_project_or_404(payload.project_id, db, current_user)
if payload.frame_id:
_owned_frame_or_404(payload.frame_id, db, current_user, payload.project_id)
_shared_frame_or_404(payload.frame_id, db, current_user, payload.project_id)
if payload.template_id:
_visible_template_or_404(payload.template_id, db, current_user)
@@ -998,8 +997,8 @@ async def import_gt_mask(
the frontend an editable point-region representation instead of a static
bitmap layer.
"""
_owned_project_or_404(project_id, db, current_user)
frame = _owned_frame_or_404(frame_id, db, current_user, project_id)
_shared_project_or_404(project_id, db, current_user)
frame = _shared_frame_or_404(frame_id, db, current_user, project_id)
if unknown_color_policy not in {"discard", "undefined"}:
raise HTTPException(status_code=400, detail="unknown_color_policy must be discard or undefined")
@@ -1143,11 +1142,11 @@ def list_annotations(
current_user: User = Depends(get_current_user),
) -> List[Annotation]:
"""Return persisted annotations for a project, optionally scoped to one frame."""
_owned_project_or_404(project_id, db, current_user)
_shared_project_or_404(project_id, db, current_user)
query = db.query(Annotation).filter(Annotation.project_id == project_id)
if frame_id is not None:
_owned_frame_or_404(frame_id, db, current_user, project_id)
_shared_frame_or_404(frame_id, db, current_user, project_id)
query = query.filter(Annotation.frame_id == frame_id)
return query.order_by(Annotation.id).all()
@@ -1167,7 +1166,7 @@ def update_annotation(
annotation = (
db.query(Annotation)
.join(Project, Project.id == Annotation.project_id)
.filter(Annotation.id == annotation_id, Project.owner_user_id == current_user.id)
.filter(Annotation.id == annotation_id)
.first()
)
if not annotation:
@@ -1200,7 +1199,7 @@ def delete_annotation(
annotation = (
db.query(Annotation)
.join(Project, Project.id == Annotation.project_id)
.filter(Annotation.id == annotation_id, Project.owner_user_id == current_user.id)
.filter(Annotation.id == annotation_id)
.first()
)
if not annotation:

View File

@@ -18,6 +18,7 @@ from schemas import LoginResponse, UserOut
router = APIRouter(prefix="/api/auth", tags=["Auth"])
password_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
bearer_scheme = HTTPBearer(auto_error=False)
SUPPORTED_ROLES = {"admin", "annotator"}
class LoginRequest(BaseModel):
@@ -50,9 +51,26 @@ def create_access_token(user: User, expires_delta: timedelta | None = None) -> s
def ensure_default_admin(db: Session) -> User:
"""Create the default development admin if the user table is empty."""
"""Create and enforce the single default administrator account."""
existing = db.query(User).filter(User.username == settings.default_admin_username).first()
if existing:
changed = False
if existing.role != "admin":
existing.role = "admin"
changed = True
if not existing.is_active:
existing.is_active = 1
changed = True
extra_admins = db.query(User).filter(
User.role == "admin",
User.id != existing.id,
).all()
for user in extra_admins:
user.role = "annotator"
changed = True
if changed:
db.commit()
db.refresh(existing)
return existing
user = User(
username=settings.default_admin_username,
@@ -63,6 +81,31 @@ def ensure_default_admin(db: Session) -> User:
db.add(user)
db.commit()
db.refresh(user)
extra_admins = db.query(User).filter(
User.role == "admin",
User.id != user.id,
).all()
if extra_admins:
for extra_user in extra_admins:
extra_user.role = "annotator"
db.commit()
db.refresh(user)
return user
def normalize_user_role(db: Session, user: User) -> User:
"""Keep legacy accounts within the current two-role policy."""
desired_role = "admin" if user.username == settings.default_admin_username else "annotator"
changed = False
if user.role != desired_role:
user.role = desired_role
changed = True
if user.username == settings.default_admin_username and not user.is_active:
user.is_active = 1
changed = True
if changed:
db.commit()
db.refresh(user)
return user
@@ -92,6 +135,8 @@ def get_current_user(
) from exc
user = db.query(User).filter(User.id == user_id).first()
if user:
user = normalize_user_role(db, user)
if not user or not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
@@ -110,7 +155,7 @@ def require_admin(current_user: User = Depends(get_current_user)) -> User:
def require_editor(current_user: User = Depends(get_current_user)) -> User:
"""Require a user role that can modify segmentation data."""
if current_user.role not in {"admin", "annotator"}:
if current_user.role not in SUPPORTED_ROLES:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Edit permission required")
return current_user
@@ -143,6 +188,8 @@ def login(payload: LoginRequest, db: Session = Depends(get_db)) -> dict:
"""Authenticate a user and return a signed JWT."""
ensure_default_admin(db)
user = db.query(User).filter(User.username == payload.username).first()
if user:
user = normalize_user_role(db, user)
if not user or not user.is_active or not verify_password(payload.password, user.password_hash):
write_audit_log(
db,

View File

@@ -58,12 +58,12 @@ def get_dashboard_overview(
current_user: User = Depends(get_current_user),
) -> dict[str, Any]:
"""Return live dashboard data derived from persisted backend records."""
owned_project_ids_query = db.query(Project.id).filter(Project.owner_user_id == current_user.id)
project_count = db.query(func.count(Project.id)).filter(Project.owner_user_id == current_user.id).scalar() or 0
frame_count = db.query(func.count(Frame.id)).filter(Frame.project_id.in_(owned_project_ids_query)).scalar() or 0
shared_project_ids_query = db.query(Project.id)
project_count = db.query(func.count(Project.id)).scalar() or 0
frame_count = db.query(func.count(Frame.id)).filter(Frame.project_id.in_(shared_project_ids_query)).scalar() or 0
annotation_count = (
db.query(func.count(Annotation.id))
.filter(Annotation.project_id.in_(owned_project_ids_query))
.filter(Annotation.project_id.in_(shared_project_ids_query))
.scalar()
or 0
)
@@ -76,7 +76,6 @@ def get_dashboard_overview(
active_task_count = (
db.query(func.count(ProcessingTask.id))
.outerjoin(Project, Project.id == ProcessingTask.project_id)
.filter((ProcessingTask.project_id.is_(None)) | (Project.owner_user_id == current_user.id))
.filter(ProcessingTask.status.in_(ACTIVE_TASK_STATUSES))
.scalar()
or 0
@@ -84,14 +83,12 @@ def get_dashboard_overview(
projects = (
db.query(Project)
.filter(Project.owner_user_id == current_user.id)
.order_by(Project.updated_at.desc())
.all()
)
recent_tasks = (
db.query(ProcessingTask)
.outerjoin(Project, Project.id == ProcessingTask.project_id)
.filter((ProcessingTask.project_id.is_(None)) | (Project.owner_user_id == current_user.id))
.order_by(ProcessingTask.created_at.desc())
.limit(50)
.all()
@@ -120,7 +117,7 @@ def get_dashboard_overview(
recent_annotations = (
db.query(Annotation)
.filter(Annotation.project_id.in_(owned_project_ids_query))
.filter(Annotation.project_id.in_(shared_project_ids_query))
.order_by(Annotation.updated_at.desc())
.limit(10)
.all()

View File

@@ -206,10 +206,8 @@ def _frame_image_extension(frame: Frame) -> str:
def _project_or_404(project_id: int, db: Session, current_user: User) -> Project:
project = db.query(Project).filter(
Project.id == project_id,
Project.owner_user_id == current_user.id,
).first()
_ = current_user
project = db.query(Project).filter(Project.id == project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
return project

View File

@@ -72,10 +72,7 @@ async def upload_media(
file_url = get_presigned_url(object_name, expires=3600)
if project_id:
project = db.query(Project).filter(
Project.id == project_id,
Project.owner_user_id == current_user.id,
).first()
project = db.query(Project).filter(Project.id == project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
project.video_path = object_name
@@ -141,10 +138,7 @@ async def upload_dicom_batch(
uploaded = []
if project_id:
project = db.query(Project).filter(
Project.id == project_id,
Project.owner_user_id == current_user.id,
).first()
project = db.query(Project).filter(Project.id == project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
else:
@@ -202,10 +196,7 @@ def parse_media(
The Celery worker performs the heavy FFmpeg/OpenCV/pydicom work and
updates the persisted task record as it progresses.
"""
project = db.query(Project).filter(
Project.id == project_id,
Project.owner_user_id == current_user.id,
).first()
project = db.query(Project).filter(Project.id == project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")

View File

@@ -16,12 +16,12 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/projects", tags=["Projects"])
def _next_project_copy_name(db: Session, owner_user_id: int, source_name: str) -> str:
def _next_project_copy_name(db: Session, source_name: str) -> str:
base_name = f"{source_name} 副本"
existing_names = {
row[0]
for row in db.query(Project.name)
.filter(Project.owner_user_id == owner_user_id, Project.name.like(f"{base_name}%"))
.filter(Project.name.like(f"{base_name}%"))
.all()
}
if base_name not in existing_names:
@@ -76,7 +76,6 @@ def list_projects(
"""Retrieve a paginated list of projects."""
projects = (
db.query(Project)
.filter(Project.owner_user_id == current_user.id)
.offset(skip)
.limit(limit)
.all()
@@ -97,10 +96,7 @@ def get_project(
current_user: User = Depends(get_current_user),
) -> Project:
"""Retrieve a project by its ID."""
project = db.query(Project).filter(
Project.id == project_id,
Project.owner_user_id == current_user.id,
).first()
project = db.query(Project).filter(Project.id == project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
return _prepare_project_response(project)
@@ -119,16 +115,13 @@ def copy_project(
current_user: User = Depends(require_editor),
) -> Project:
"""Copy a project. Reset copies media/frame sequence; full also copies annotations and mask metadata."""
source = db.query(Project).filter(
Project.id == project_id,
Project.owner_user_id == current_user.id,
).first()
source = db.query(Project).filter(Project.id == project_id).first()
if not source:
raise HTTPException(status_code=404, detail="Project not found")
next_name = (payload.name or "").strip() if payload.name is not None else ""
if not next_name:
next_name = _next_project_copy_name(db, current_user.id, source.name)
next_name = _next_project_copy_name(db, source.name)
copied = Project(
name=next_name,
@@ -196,10 +189,7 @@ def update_project(
current_user: User = Depends(require_editor),
) -> Project:
"""Update project fields partially."""
project = db.query(Project).filter(
Project.id == project_id,
Project.owner_user_id == current_user.id,
).first()
project = db.query(Project).filter(Project.id == project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
@@ -227,10 +217,7 @@ def delete_project(
current_user: User = Depends(require_editor),
) -> None:
"""Delete a project and all related frames and annotations."""
project = db.query(Project).filter(
Project.id == project_id,
Project.owner_user_id == current_user.id,
).first()
project = db.query(Project).filter(Project.id == project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
@@ -255,10 +242,7 @@ def create_frame(
current_user: User = Depends(require_editor),
) -> Frame:
"""Register a new frame under a project."""
project = db.query(Project).filter(
Project.id == project_id,
Project.owner_user_id == current_user.id,
).first()
project = db.query(Project).filter(Project.id == project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
@@ -282,10 +266,7 @@ def list_frames(
current_user: User = Depends(get_current_user),
) -> List[Frame]:
"""Retrieve all frames belonging to a project."""
project = db.query(Project).filter(
Project.id == project_id,
Project.owner_user_id == current_user.id,
).first()
project = db.query(Project).filter(Project.id == project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
@@ -320,7 +301,6 @@ def get_frame(
.filter(
Frame.project_id == project_id,
Frame.id == frame_id,
Project.owner_user_id == current_user.id,
)
.first()
)

View File

@@ -33,13 +33,11 @@ def _now() -> datetime:
def _get_task_or_404(task_id: int, db: Session, current_user: User) -> ProcessingTask:
_ = current_user
task = (
db.query(ProcessingTask)
.outerjoin(Project, Project.id == ProcessingTask.project_id)
.filter(
ProcessingTask.id == task_id,
(ProcessingTask.project_id.is_(None)) | (Project.owner_user_id == current_user.id),
)
.filter(ProcessingTask.id == task_id)
.first()
)
if not task:
@@ -60,9 +58,8 @@ def list_tasks(
current_user: User = Depends(get_current_user),
) -> List[ProcessingTask]:
"""Return recent background processing tasks."""
query = db.query(ProcessingTask).outerjoin(Project, Project.id == ProcessingTask.project_id).filter(
(ProcessingTask.project_id.is_(None)) | (Project.owner_user_id == current_user.id)
)
_ = current_user
query = db.query(ProcessingTask).outerjoin(Project, Project.id == ProcessingTask.project_id)
if project_id is not None:
query = query.filter(ProcessingTask.project_id == project_id)
if status is not None:
@@ -130,10 +127,7 @@ def retry_task(
if previous.project_id is None:
raise HTTPException(status_code=400, detail="Task has no project_id")
project = db.query(Project).filter(
Project.id == previous.project_id,
Project.owner_user_id == current_user.id,
).first()
project = db.query(Project).filter(Project.id == previous.project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
is_propagation_task = previous.task_type == "propagate_masks"

View File

@@ -12,14 +12,14 @@ def test_admin_user_management_and_audit_logs(client, db_session):
})
assert created.status_code == 201
user_id = created.json()["id"]
assert created.json()["role"] == "annotator"
updated = client.patch(f"/api/admin/users/{user_id}", json={
"role": "viewer",
"password": "newsecret",
"is_active": False,
})
assert updated.status_code == 200
assert updated.json()["role"] == "viewer"
assert updated.json()["role"] == "annotator"
assert updated.json()["is_active"] == 0
users = client.get("/api/admin/users")
@@ -37,8 +37,41 @@ def test_admin_user_management_and_audit_logs(client, db_session):
assert "admin.user_deleted" in actions
def test_only_default_admin_role_is_supported(client, db_session):
extra_admin = client.post("/api/admin/users", json={
"username": "chief",
"password": "secret123",
"role": "admin",
"is_active": True,
})
assert extra_admin.status_code == 400
viewer = client.post("/api/admin/users", json={
"username": "observer",
"password": "secret123",
"role": "viewer",
"is_active": True,
})
assert viewer.status_code == 400
created = client.post("/api/admin/users", json={
"username": "doctor",
"password": "secret123",
"is_active": True,
})
assert created.status_code == 201
user_id = created.json()["id"]
assert created.json()["role"] == "annotator"
assert client.patch(f"/api/admin/users/{user_id}", json={"role": "admin"}).status_code == 400
assert client.patch(f"/api/admin/users/{user_id}", json={"role": "viewer"}).status_code == 400
admin_id = client.get("/api/auth/me").json()["id"]
assert client.patch(f"/api/admin/users/{admin_id}", json={"role": "annotator"}).status_code == 400
assert client.patch(f"/api/admin/users/{admin_id}", json={"username": "chief"}).status_code == 400
def test_admin_routes_require_admin_role(client, db_session):
user = User(username="viewer", password_hash=hash_password("secret123"), role="viewer", is_active=1)
user = User(username="doctor", password_hash=hash_password("secret123"), role="annotator", is_active=1)
db_session.add(user)
db_session.commit()
db_session.refresh(user)
@@ -51,7 +84,7 @@ def test_admin_routes_require_admin_role(client, db_session):
client.headers.update({"Authorization": original_auth})
def test_viewer_role_is_read_only_for_business_mutations(client, db_session):
def test_legacy_viewer_role_is_promoted_to_annotator(client, db_session):
project = client.post("/api/projects", json={"name": "Readonly Check"}).json()
user = User(username="readonly", password_hash=hash_password("secret123"), role="viewer", is_active=1)
db_session.add(user)
@@ -61,14 +94,14 @@ def test_viewer_role_is_read_only_for_business_mutations(client, db_session):
client.headers.update({"Authorization": f"Bearer {create_access_token(user)}"})
try:
assert client.get("/api/projects").status_code == 200
assert client.post("/api/projects", json={"name": "Nope"}).status_code == 403
assert client.patch(f"/api/projects/{project['id']}", json={"name": "Nope"}).status_code == 403
assert client.post("/api/ai/annotate", json={"project_id": project["id"]}).status_code == 403
assert client.post("/api/projects", json={"name": "Annotator Project"}).status_code == 201
assert client.patch(f"/api/projects/{project['id']}", json={"name": "Shared Edit"}).status_code == 200
assert client.get("/api/auth/me").json()["role"] == "annotator"
finally:
client.headers.update({"Authorization": original_auth})
def test_admin_cannot_delete_self_or_user_with_projects(client, db_session):
def test_admin_cannot_delete_self_but_can_delete_project_author(client, db_session):
me = client.get("/api/auth/me").json()
assert client.delete(f"/api/admin/users/{me['id']}").status_code == 400
@@ -80,7 +113,8 @@ def test_admin_cannot_delete_self_or_user_with_projects(client, db_session):
db_session.commit()
response = client.delete(f"/api/admin/users/{user.id}")
assert response.status_code == 409
assert response.status_code == 204
assert db_session.query(Project).filter(Project.name == "Owned").count() == 1
def test_demo_factory_reset_leaves_admin_and_parsed_demo_dicom(client, db_session, monkeypatch, tmp_path):

View File

@@ -186,7 +186,7 @@ def test_project_and_frame_404s(client):
assert client.get("/api/projects/999/frames/1").status_code == 404
def test_projects_are_scoped_to_authenticated_owner(client, db_session):
def test_projects_are_shared_between_authenticated_users(client, db_session):
owner_project = client.post("/api/projects", json={"name": "Owner Project"}).json()
other_user = User(
username="other",
@@ -203,14 +203,19 @@ def test_projects_are_scoped_to_authenticated_owner(client, db_session):
db_session.refresh(other_project)
listing = client.get("/api/projects")
assert [project["id"] for project in listing.json()] == [owner_project["id"]]
assert client.get(f"/api/projects/{other_project.id}").status_code == 404
assert {project["id"] for project in listing.json()} == {owner_project["id"], other_project.id}
assert client.get(f"/api/projects/{other_project.id}").status_code == 200
original_auth = client.headers["Authorization"]
client.headers.update({"Authorization": f"Bearer {create_access_token(other_user)}"})
try:
other_listing = client.get("/api/projects")
assert [project["id"] for project in other_listing.json()] == [other_project.id]
assert client.get(f"/api/projects/{owner_project['id']}").status_code == 404
assert {project["id"] for project in other_listing.json()} == {owner_project["id"], other_project.id}
assert client.get(f"/api/projects/{owner_project['id']}").status_code == 200
renamed = client.patch(f"/api/projects/{owner_project['id']}", json={"name": "Edited By Other"})
assert renamed.status_code == 200
assert renamed.json()["name"] == "Edited By Other"
finally:
client.headers.update({"Authorization": original_auth})
assert client.get(f"/api/projects/{owner_project['id']}").json()["name"] == "Edited By Other"

View File

@@ -64,12 +64,12 @@
3. FastAPI `backend/routers/auth.py` 查询 `users` 表并校验密码哈希。
4. 前端把返回 JWT 写入 localStorage并把用户资料写入 store。
5. 后续业务请求带 `Authorization: Bearer <token>`,后端按当前用户过滤项目资源。
6. `admin/annotator` 可调用写入类业务接口`viewer` 只能读取`/api/admin/*` 仅允许 `admin`
6. 系统只支持唯一默认 `admin``annotator``admin/annotator` 可调用写入类业务接口;`/api/admin/*` 仅允许默认 `admin`
### 管理员用户管理
1. `Sidebar.tsx` 仅对 `currentUser.role === 'admin'` 显示“用户管理”。
2. `UserAdmin.tsx` 调用 `GET/POST/PATCH/DELETE /api/admin/users` 完成用户新增、停用/启用、角色修改、改密码和删除无项目用户。
2. `UserAdmin.tsx` 调用 `GET/POST/PATCH/DELETE /api/admin/users` 完成标注员新增、停用/启用、改密码和删除用户;不提供观察员或第二个管理员入口
3. `UserAdmin.tsx` 调用 `GET /api/admin/audit-logs` 展示登录成功/失败以及用户管理操作审计。
4. `UserAdmin.tsx` 危险区“恢复演示出厂设置”需要浏览器确认和输入 `RESET_DEMO_FACTORY`,随后调用 `POST /api/admin/demo-factory-reset`
5. 后端 `backend/routers/admin.py` 会阻止管理员删除、停用或降级自己,并阻止删除仍拥有项目的用户;演示出厂重置会清空其它用户、项目帧、标注、任务和私有模板,重新创建演示视频项目和一个已按文件名自然顺序生成帧的演示 DICOM 项目。
@@ -114,4 +114,4 @@
- AI 当前启用 SAM 2.1 tiny/small/base+/large 点/框/interactive 路径;语义文本提示和 SAM 3 产品入口已禁用,`model=sam3` 会被后端拒绝。SAM 3 源码保留但不计入当前可用功能。
- 工作区顶部“分割结果导出”和保存状态按钮、左侧工具栏“导入 GT Mask”已接入统一导出、GT 多类别导入、标注新增和 dirty 标注更新;导入 GT Mask 仅支持 8-bit 二值/灰度 maskid 图和 8-bit RGB 三通道完全相同的 `[X,X,X]` maskid 图,未知 maskid 可由用户选择舍弃或导入为未定义类别16-bit/uint16 GT_label 和普通彩色类别图会被拒绝尺寸不同会自动最近邻拉伸到当前帧GT 连通域会生成高精度 polygon导入后和普通 mask 一样不显示黄色 seed point并与普通 mask 共用拓扑统计、边缘平滑、编辑和保存链路。保存状态按钮会按待保存数量显示“保存 X 个改动”或“已全部保存”;统一导出可选择整体视频、特定范围帧或当前图片,并勾选分开 mask、GT_label 黑白图、Pro_label 彩色图和 Mix_label 原图叠加图;特定范围帧导出支持直接输入起止帧,也支持在播放进度条或视频处理进度条上点击/拖拽选择范围Mix_label 支持默认 0.3 的透明度调节和首帧预览;后端统一导出 ZIP 固定包含 maskid/GT 像素值映射 JSON 与原始图片文件夹GT_label 固定输出 8-bit uint8 PNG像素值使用类别真实 maskid其中 `maskid:0` 的“待分类”和背景同为 0缺失 maskid 的旧标注才补下一个可用正整数,正整数 maskid 超出 1-255 会拒绝导出,并按客户命名规则输出分开 Mask、GT_label、Pro_label 和 Mix_label 文件夹;清空当前帧遮罩会删除对应后端标注,存在传播链时同一弹窗提供取消/当前帧/按帧范围选择/所有传播帧,按范围清空复用时间轴范围选择和最终确认;按范围或全部清空遇到人工/AI 标注帧时会二次确认选择保留则整帧保留。手工绘制、polygon 顶点拖动/删除、区域合并/去除和撤销重做已经落到前端 mask 数据结构;多边形、矩形、圆和画笔创建遵循“有选中 mask 则并入选中 mask、无选中 mask 才新建”的规则,即使新几何和旧区域不重叠也会组成同一个多 polygon mask无选中分类的新建多边形/矩形/圆会默认归入 `maskid:0` 的“待分类”,画笔无选中 mask 时仍要求右侧语义分类树有 active class`Esc` 只取消选区和临时绘制状态,不删除已有 mask。
- Dashboard 初始统计、队列和活动日志来自后端聚合接口;解析队列来自 `processing_tasks`worker 进度通过 Redis `seg:progress` 转发到 WebSocket。任务取消、重试和失败详情已接入前后端。
- 后端已接入 Bearer JWT 鉴权、当前用户项目隔离和角色权限;写入类业务接口要求 `admin/annotator`,管理员用户后台要求 `admin`。当前审计覆盖登录和用户管理操作,全业务级审计仍可继续扩展。
- 后端已接入 Bearer JWT 鉴权、共享项目库和角色权限;写入类业务接口要求 `admin/annotator`,管理员用户后台要求默认 `admin`。当前审计覆盖登录和用户管理操作,全业务级审计仍可继续扩展。

View File

@@ -32,8 +32,8 @@
|------|------|------|
| 侧栏“用户管理”入口 | 真实可用 | 仅当前用户 `role=admin` 时显示;非管理员无法看到入口,后端 `/api/admin/*` 也会返回 403 |
| 用户列表 | 真实可用 | 调用 `GET /api/admin/users`,展示用户 id、用户名、角色、启停用状态和创建时间 |
| 新增用户 | 真实可用 | 调用 `POST /api/admin/users`,支持设置用户名初始密码`admin/annotator/viewer` 角色;后端校验用户名唯一密码长度 |
| 修改角色 / 启停用 / 改密码 | 真实可用 | 调用 `PATCH /api/admin/users/{id}`;后端禁止管理员把自己降级或停用,避免锁死后台 |
| 新增用户 | 真实可用 | 调用 `POST /api/admin/users`,支持设置用户名初始密码,新用户固定为标注员;后端校验用户名唯一密码长度,并拒绝第二个管理员或观察员角色 |
| 启停用 / 改密码 | 真实可用 | 调用 `PATCH /api/admin/users/{id}`;后端禁止管理员把自己降级、改名或停用,避免锁死后台 |
| 删除用户 | 真实可用 | 调用 `DELETE /api/admin/users/{id}`;后端禁止删除自己,且用户名下仍有项目时返回 409避免悬空项目数据 |
| 审计日志 | 真实可用 | 调用 `GET /api/admin/audit-logs`,展示登录成功/失败、用户新增、修改和删除等管理操作 |
| 恢复演示出厂设置 | 真实可用 | 管理员点击危险区按钮后先浏览器确认,再输入 `RESET_DEMO_FACTORY`;前端调用 `POST /api/admin/demo-factory-reset`,后端只保留默认 admin、演示视频项目和一个已按文件名自然顺序生成帧的演示 DICOM 项目并清空用户、项目帧、标注、任务和私有模板等演示数据“腹腔镜胆囊切除术”和“头颈部CT分割”系统模板会按内置默认定义重建或覆盖恢复 |

View File

@@ -15,7 +15,7 @@ timeout: 30000
Authorization: Bearer <token>
```
当前后端业务接口会校验该 header。缺失、过期或无效 token 返回 401项目、帧、标注、任务、Dashboard 和导出会按当前用户拥有的项目过滤
当前后端业务接口会校验该 header。缺失、过期或无效 token 返回 401项目、帧、标注、任务、Dashboard 和导出使用全员共享项目库,所有登录用户可读取,`admin/annotator` 可写入
## 前端封装的 API

View File

@@ -12,9 +12,9 @@
- 页面刷新后前端会用已有 token 调用 `/api/auth/me` 恢复当前用户。
- 登录失败时显示错误信息。
- 业务接口必须校验 Bearer token缺失或无效 token 返回 401。
- 项目、帧、标注、任务、Dashboard 和导出必须按当前用户的项目隔离;用户不能读取、修改或删除其他用户项目资源
- 角色包括 `admin``annotator``viewer``admin/annotator` 可写入业务数据和触发 AI/传播,`viewer` 只能访问读接口,用户管理后台仅 `admin` 可用。
- 管理员侧栏显示“用户管理”入口;管理员可以新增用户、修改角色、停用/启用、修改密码、删除无项目用户。
- 项目、帧、标注、任务、Dashboard 和导出使用全员共享项目库;所有登录用户读取同一项目库,`admin/annotator` 可创建、导入、解析、标注、AI 推理、导出、复制、重命名和删除项目
- 角色包括唯一默认 `admin``annotator`;历史 `viewer` 或额外管理员会归一为标注员;用户管理、审计日志和演示环境出厂设置后台仅默认 `admin` 可用。
- 管理员侧栏显示“用户管理”入口;管理员可以新增标注员、停用/启用、修改密码、删除用户。
- 系统记录登录成功/失败和用户管理操作到 `audit_logs`,管理员后台可查看最近审计日志。
- 管理员后台提供“恢复演示出厂设置”危险操作;前端必须二次确认,后端也必须校验 `confirmation=RESET_DEMO_FACTORY`,执行后只保留默认 admin 账号、系统模板、演示视频项目和一个已按文件名自然顺序生成帧的演示 DICOM 项目,清空其它用户、项目、帧、标注、任务、用户模板和旧审计记录,并写入本次重置审计。
- 系统默认模板至少包含“腹腔镜胆囊切除术”和“头颈部CT分割”头颈部 CT 默认分类名必须使用纯中文,不带括号英文翻译;恢复演示出厂设置不得删除系统默认模板,并必须重建缺失的默认模板、覆盖恢复被修改或删减的默认语义分类树。

View File

@@ -84,11 +84,11 @@
### 用户隔离
1. `Project.owner_user_id` 指向 `users.id`;启动时默认 admin 用户会被创建,历史 `owner_user_id IS NULL` 的项目会迁移归属到 admin
1. `Project.owner_user_id` 指向 `users.id` 作为创建者/历史元数据保留;项目库访问不再按该字段隔离,所有登录用户共享同一项目库
2. 项目、帧、媒体上传/拆帧、AI 标注、传播任务、任务列表、Dashboard 和导出接口都通过当前 JWT 用户过滤项目资源。
3. `Template.owner_user_id` 支持用户模板;`owner_user_id IS NULL` 的模板视为系统模板,可作为默认分类体系对用户可见。
4. 角色分为 `admin``annotator``viewer``admin/annotator` 可调用写入类业务接口`viewer` 只能调用读接口`/api/admin/*` 仅允许 `admin`
5. `UserAdmin.tsx` 仅在当前用户角色为 `admin` 时从 `Sidebar` 展示,调用 `/api/admin/users` 完成新增、角色修改、停用/启用、密码修改和删除无项目用户,调用 `/api/admin/audit-logs` 展示登录和管理操作审计;改密码、删除用户和危险区“恢复演示出厂设置”均使用站内弹窗确认,恢复出厂设置要求输入 `RESET_DEMO_FACTORY` 后调用 `/api/admin/demo-factory-reset`
4. 角色分为唯一默认 `admin``annotator``admin/annotator` 可调用写入类业务接口;`/api/admin/*` 仅允许默认 `admin`,用于用户管理、审计日志和演示环境出厂设置
5. `UserAdmin.tsx` 仅在当前用户角色为 `admin` 时从 `Sidebar` 展示,调用 `/api/admin/users` 完成标注员新增、停用/启用、密码修改和删除用户,调用 `/api/admin/audit-logs` 展示登录和管理操作审计;改密码、删除用户和危险区“恢复演示出厂设置”均使用站内弹窗确认,恢复出厂设置要求输入 `RESET_DEMO_FACTORY` 后调用 `/api/admin/demo-factory-reset`
6. `POST /api/admin/demo-factory-reset` 仅允许 `admin`,会重置默认 admin 密码/角色/启用状态删除其它用户、项目、帧、标注、mask、任务、用户模板和旧审计重新创建 `settings.demo_video_path` 指向的演示视频项目,以及 `settings.demo_dicom_dir` 指向的演示 DICOM 项目DICOM 会按文件名自然顺序上传和生成帧;系统模板保留以保证重置后仍可标注。
7. 缺失、过期或伪造的 Bearer token 会在业务路由返回 401权限不足返回 403其他用户项目资源对当前用户表现为 404。

View File

@@ -14,7 +14,7 @@
| 需求 | 测试文件 | 覆盖点 |
|------|----------|--------|
| R1 登录与会话 | `src/components/Login.test.tsx`, `src/components/Sidebar.test.tsx`, `src/components/UserAdmin.test.tsx`, `src/store/useStore.test.ts`, `backend/tests/test_auth.py`, `backend/tests/test_admin.py` | 成功登录、JWT/token 写入、当前用户写入、刷新恢复基础状态、失败提示、登录输入 autocomplete、后端 401、`/api/auth/me`、管理员入口、用户 CRUD、角色权限、审计日志、viewer 读写权限边界、改密码/删除用户站内确认、演示出厂设置站内二次确认和重置结果 |
| R1 登录与会话 | `src/components/Login.test.tsx`, `src/components/Sidebar.test.tsx`, `src/components/UserAdmin.test.tsx`, `src/store/useStore.test.ts`, `backend/tests/test_auth.py`, `backend/tests/test_admin.py` | 成功登录、JWT/token 写入、当前用户写入、刷新恢复基础状态、失败提示、登录输入 autocomplete、后端 401、`/api/auth/me`、管理员入口、用户 CRUD、唯一 admin/标注员角色权限、审计日志、viewer 归一为标注员、改密码/删除用户站内确认、演示出厂设置站内二次确认和重置结果 |
| R2 项目管理 | `src/lib/api.test.ts`, `src/components/ProjectLibrary.test.tsx`, `backend/tests/test_projects.py` | 前端字段映射、PATCH 更新、项目卡片复制/删除、修改项目名称时隐藏生成帧、DICOM 项目不显示生成帧、复制项目 reset/full 契约、DELETE 契约、后端 CRUD、删除级联、帧列表、项目按当前 JWT 用户隔离 |
| R3 媒体上传与拆帧 | `src/components/ProjectLibrary.test.tsx`, `src/components/TransientNotice.test.tsx`, `backend/tests/test_media.py`, `backend/tests/test_tasks.py` | 视频导入不自动拆帧、视频/DICOM 上传进度可视化、DICOM 导入显示有效文件数量并在上传后持续显示解析任务进度、显式生成帧 FPS 选择、视频生成帧入队后轮询解析任务并在成功后自动刷新项目封面、项目卡片显示目标 parse_fps 而非原视频 FPS、扩展名校验、自动建项目、关联项目、创建异步任务、非阻塞自动消失操作提示、标准帧序列参数、帧时间戳/源帧号、任务序列元数据、worker 注册帧、取消任务、重试任务、取消后 worker 停止 |
| R4 工作区与帧浏览 | `src/components/VideoWorkspace.test.tsx`, `src/components/FrameTimeline.test.tsx` | 加载帧、无帧项目不自动解析并提示生成帧、工作区短状态自动消失、工作区/AI 画布底图默认居中且保留边距、工作区 mask 透明度、回显已保存标注时保留本地未保存 draft mask、选中 mask 后跨帧自动跟随同一传播链结果、左侧工具栏清空遮罩优先作用于当前帧选中 mask/无选中时作用于当前帧全部 mask、无传播链时直接执行、有传播链时可选取消/只清当前帧/按帧范围选择/清空所有传播帧且按范围清空需最终确认、按范围清空或清空所有传播帧遇到人工/AI 标注帧时二次询问并支持保留人工帧、顶栏不显示重复的清空片段遮罩、传播进度存在时任务 message 只显示在蓝色进度面板内且不重复出现在灰色状态文字里、传播链布尔操作按帧范围选择并二次确认、清空/删除前预检后端 annotation id 并跳过本地陈旧 id、删除单个传播 mask 后空帧不保留传播历史颜色、传播权重下拉深色可读配色、自动传播范围选择时显示传播权重和向前/向后帧数、缩略图/range/视频处理进度条、视频处理进度条点击跳帧、人工/AI 标注帧红色竖线和标识点击跳帧、自动传播帧通过 source/lineage metadata 识别为蓝色区段和标识点击跳帧、最近自动传播历史片段同一蓝色系按新旧递进纯色显示,旧记录第 5 次后统一阈值色、当前帧白色贯穿线、传播/布尔/清空范围边界贯穿线、缩略图红/蓝边框、人工/AI 标注帧叠加传播状态时红框优先保留并显示蓝色内描边、当前人工/AI 标注帧青色外框加红色内描边、普通状态不显示传播范围黄色选区、播放进度条和视频处理进度条选择传播/布尔/清空范围、左右方向键切帧、播放、按项目 FPS 显示当前/总时长 |
@@ -32,7 +32,7 @@
| 需求 | 功能点 | 对应测试 | 当前状态 |
|------|--------|----------|----------|
| R1 | 登录页、默认开发管理员、JWT 写入、当前用户写入、刷新恢复基础状态、失败提示、后端 401、`/api/auth/me`、管理员用户管理、角色权限、审计日志、演示出厂设置二次确认、重置后只保留 admin、演示视频项目和已生成帧的自然排序演示 DICOM 项目 | `Login.test.tsx`, `Sidebar.test.tsx`, `UserAdmin.test.tsx`, `useStore.test.ts`, `test_auth.py`, `test_admin.py` | 已覆盖 |
| R1 | 登录页、唯一默认管理员、JWT 写入、当前用户写入、刷新恢复基础状态、失败提示、后端 401、`/api/auth/me`、管理员用户管理、角色权限、审计日志、演示出厂设置二次确认、重置后只保留 admin、演示视频项目和已生成帧的自然排序演示 DICOM 项目 | `Login.test.tsx`, `Sidebar.test.tsx`, `UserAdmin.test.tsx`, `useStore.test.ts`, `test_auth.py`, `test_admin.py` | 已覆盖 |
| R2 | 项目列表/创建/选择/重命名/复制、重命名时不触发生成帧、DICOM 不显示生成帧、项目复制 reset/full、项目按用户隔离、视频导入、DICOM 导入、DICOM 前端选择自然排序、后端项目和帧 CRUD | `ProjectLibrary.test.tsx`, `api.test.ts`, `test_projects.py` | 已覆盖 |
| R3 | 文件类型校验、自动/指定项目上传、视频导入与生成帧分离、视频/DICOM 上传进度可视化、DICOM 导入显示有效文件数量并在上传后持续显示解析任务进度、显式 FPS 生成帧、视频生成帧完成后自动刷新项目封面、项目卡片 FPS 徽标显示 `parse_fps`、视频/DICOM 拆帧任务、DICOM 上传/下载/读取自然排序、非阻塞自动消失操作提示、`parse_fps/max_frames/target_width`、标准帧序列 metadata、任务查询、取消、重试、worker 取消停止 | `ProjectLibrary.test.tsx`, `TransientNotice.test.tsx`, `api.test.ts`, `test_media.py`, `test_tasks.py` | 已覆盖 |
| R4 | 工作区加载帧、无帧项目不自动解析、工作区短状态自动消失、后端标注回显保留本地未保存 draft mask、Canvas/AI 底图居中适配且保留边距、工作区 mask 透明度、选中 mask 后跨帧自动跟随同一传播链结果、左侧工具栏当前帧清空优先作用于选中 mask、无传播链时直接执行、有传播链时可选当前帧/传播所有帧/取消、清空人工/AI 标注帧前二次确认、取消确认不删除、仅自动传播帧不确认、删除单个传播 mask 后空帧不保留传播历史颜色、传播权重下拉深色可读配色、缩略图/range/视频处理进度条、视频处理进度条点击跳帧、人工/AI 标注帧红色竖线和标识点击跳帧、自动传播帧蓝色区段和标识点击跳帧、最近自动传播历史片段同一蓝色系按新旧递进显示,旧记录第 5 次后统一阈值色、当前帧白色贯穿线、传播范围洋红/黄绿色边界贯穿线、缩略图红/蓝边框、人工/AI 标注帧叠加传播状态时红框优先保留并显示蓝色内描边、当前人工/AI 标注帧青色外框加红色内描边、普通状态不显示传播范围黄色选区、播放进度条/视频处理进度条拖拽选择传播范围、Canvas/AI 画布拖拽平移回写 position state、左右方向键切帧、播放、按 FPS 显示时间 | `VideoWorkspace.test.tsx`, `FrameTimeline.test.tsx`, `CanvasArea.test.tsx`, `AISegmentation.test.tsx` | 已覆盖 |

View File

@@ -314,7 +314,7 @@ admin / 123456
首次启动会自动创建默认管理员,密码以哈希形式写入 `users` 表;登录返回签名 JWT业务接口会校验 `Authorization: Bearer <token>`。生产环境必须修改 `jwt_secret_key` 和默认管理员密码。
默认管理员登录后会看到“用户管理”后台,可新增用户、停用/启用用户、修改角色、重置密码、删除无项目用户并查看登录与用户管理审计日志。角色分为 `admin``annotator``viewer``admin/annotator` 可以执行写入类业务操作,`viewer` 只读。演示部署可在该后台使用“恢复演示出厂设置”,二次确认后只保留默认 admin、演示视频项目和一个已按文件名自然顺序生成帧的演示 DICOM 项目;视频来自 `demo_video_path`DICOM 序列来自 `demo_dicom_dir`
默认管理员登录后会看到“用户管理”后台,可新增标注员、停用/启用用户、重置密码、删除用户并查看登录与用户管理审计日志。系统只支持唯一默认 `admin``annotator` 两类角色标注员不能新增用户、查看审计日志或恢复演示出厂设置但可以和管理员共享同一项目库并执行项目管理、标注、AI 推理、任务和导出等业务操作。演示部署可在该后台使用“恢复演示出厂设置”,二次确认后只保留默认 admin、演示视频项目和一个已按文件名自然顺序生成帧的演示 DICOM 项目;视频来自 `demo_video_path`DICOM 序列来自 `demo_dicom_dir`
---

View File

@@ -52,11 +52,11 @@ describe('UserAdmin', () => {
expect(screen.getByText('当前管理员admin')).toBeInTheDocument();
});
it('creates a user with role and password', async () => {
it('creates new users as annotators', async () => {
apiMock.createAdminUser.mockResolvedValueOnce({
id: 3,
username: 'nurse',
role: 'viewer',
role: 'annotator',
is_active: 1,
});
@@ -64,30 +64,26 @@ describe('UserAdmin', () => {
await screen.findByText('doctor');
fireEvent.change(screen.getByPlaceholderText('用户名'), { target: { value: 'nurse' } });
fireEvent.change(screen.getByPlaceholderText('初始密码'), { target: { value: 'secret123' } });
fireEvent.change(screen.getAllByDisplayValue('标注员')[0], { target: { value: 'viewer' } });
fireEvent.click(screen.getByRole('button', { name: /新增用户/ }));
await waitFor(() => expect(apiMock.createAdminUser).toHaveBeenCalledWith({
username: 'nurse',
password: 'secret123',
role: 'viewer',
role: 'annotator',
is_active: true,
}));
expect(await screen.findByText('用户已创建')).toBeInTheDocument();
});
it('updates role, status and password, and deletes users', async () => {
apiMock.updateAdminUser.mockResolvedValueOnce({ id: 2, username: 'doctor', role: 'viewer', is_active: 1 });
apiMock.updateAdminUser.mockResolvedValueOnce({ id: 2, username: 'doctor', role: 'viewer', is_active: 0 });
apiMock.updateAdminUser.mockResolvedValueOnce({ id: 2, username: 'doctor', role: 'viewer', is_active: 0 });
it('updates status and password, and deletes users', async () => {
apiMock.updateAdminUser.mockResolvedValueOnce({ id: 2, username: 'doctor', role: 'annotator', is_active: 0 });
apiMock.updateAdminUser.mockResolvedValueOnce({ id: 2, username: 'doctor', role: 'annotator', is_active: 0 });
apiMock.deleteAdminUser.mockResolvedValueOnce(undefined);
render(<UserAdmin />);
await screen.findByText('doctor');
const roleSelects = screen.getAllByDisplayValue('标注员');
fireEvent.change(roleSelects[1], { target: { value: 'viewer' } });
await waitFor(() => expect(apiMock.updateAdminUser).toHaveBeenCalledWith(2, { role: 'viewer' }));
expect(screen.queryByText('观察员')).not.toBeInTheDocument();
fireEvent.click(screen.getAllByRole('button', { name: '启用' })[1]);
await waitFor(() => expect(apiMock.updateAdminUser).toHaveBeenCalledWith(2, { is_active: false }));

View File

@@ -17,7 +17,6 @@ import { TransientNotice, type NoticeState, type NoticeTone } from './TransientN
const roleLabels: Record<string, string> = {
admin: '管理员',
annotator: '标注员',
viewer: '观察员',
};
function formatTime(value: string): string {
@@ -47,7 +46,6 @@ export function UserAdmin() {
const [notice, setNotice] = useState<NoticeState | null>(null);
const [newUsername, setNewUsername] = useState('');
const [newPassword, setNewPassword] = useState('');
const [newRole, setNewRole] = useState('annotator');
const [passwordTarget, setPasswordTarget] = useState<AdminUser | null>(null);
const [nextPassword, setNextPassword] = useState('');
const [deleteUserTarget, setDeleteUserTarget] = useState<AdminUser | null>(null);
@@ -88,13 +86,12 @@ export function UserAdmin() {
const created = await createAdminUser({
username: newUsername.trim(),
password: newPassword,
role: newRole,
role: 'annotator',
is_active: true,
});
setUsers((prev) => [...prev, created]);
setNewUsername('');
setNewPassword('');
setNewRole('annotator');
showNotice('用户已创建', 'success');
setAuditLogs(await getAuditLogs(100));
} catch (err: any) {
@@ -188,7 +185,7 @@ export function UserAdmin() {
<div className="flex items-center justify-between gap-4">
<div>
<h1 className="text-xl font-semibold text-white"></h1>
<p className="mt-1 text-xs text-gray-500"></p>
<p className="mt-1 text-xs text-gray-500"></p>
</div>
<div className="flex items-center gap-3 text-xs text-gray-400">
<span className="rounded border border-cyan-400/20 bg-cyan-400/10 px-3 py-1 text-cyan-100">
@@ -209,7 +206,7 @@ export function UserAdmin() {
{isLoading && <Loader2 size={16} className="animate-spin text-cyan-300" />}
</div>
<form onSubmit={handleCreateUser} className="grid grid-cols-[1fr_1fr_150px_auto] gap-2 border-b border-white/10 p-4">
<form onSubmit={handleCreateUser} className="grid grid-cols-[1fr_1fr_auto] gap-2 border-b border-white/10 p-4">
<input
value={newUsername}
onChange={(event) => setNewUsername(event.target.value)}
@@ -225,15 +222,6 @@ export function UserAdmin() {
autoComplete="new-password"
className="rounded border border-white/10 bg-[#181818] px-3 py-2 text-sm text-white outline-none focus:border-cyan-400/50"
/>
<select
value={newRole}
onChange={(event) => setNewRole(event.target.value)}
className="rounded border border-white/10 bg-[#181818] px-3 py-2 text-sm text-white outline-none focus:border-cyan-400/50"
>
<option value="annotator"></option>
<option value="viewer"></option>
<option value="admin"></option>
</select>
<button
type="submit"
disabled={isSaving}
@@ -262,16 +250,16 @@ export function UserAdmin() {
<div className="text-xs text-gray-500">ID {user.id}</div>
</td>
<td className="px-4 py-3">
<select
value={user.role}
onChange={(event) => void handlePatchUser(user, { role: event.target.value })}
disabled={isSaving}
className="rounded border border-white/10 bg-[#181818] px-2 py-1 text-xs text-cyan-100"
<span
className={cn(
'inline-flex rounded border px-2 py-1 text-xs',
user.role === 'admin'
? 'border-cyan-400/30 bg-cyan-400/10 text-cyan-100'
: 'border-white/10 bg-white/5 text-gray-200',
)}
>
<option value="admin"></option>
<option value="annotator"></option>
<option value="viewer"></option>
</select>
{roleLabels[user.role] || '标注员'}
</span>
</td>
<td className="px-4 py-3">
<button

View File

@@ -180,12 +180,12 @@ describe('api client contracts', () => {
.mockResolvedValueOnce({ data: [{ id: 1, username: 'admin', role: 'admin', is_active: 1 }] })
.mockResolvedValueOnce({ data: [{ id: 9, action: 'admin.user_created', created_at: 'now' }] });
axiosMock.client.post.mockResolvedValueOnce({ data: { id: 2, username: 'doctor', role: 'annotator', is_active: 1 } });
axiosMock.client.patch.mockResolvedValueOnce({ data: { id: 2, username: 'doctor', role: 'viewer', is_active: 1 } });
axiosMock.client.patch.mockResolvedValueOnce({ data: { id: 2, username: 'doctor', role: 'annotator', is_active: 0 } });
axiosMock.client.delete.mockResolvedValueOnce({ data: null });
await expect(getAdminUsers()).resolves.toEqual([expect.objectContaining({ username: 'admin' })]);
await createAdminUser({ username: 'doctor', password: 'secret123', role: 'annotator', is_active: true });
await updateAdminUser(2, { role: 'viewer' });
await updateAdminUser(2, { is_active: false });
await deleteAdminUser(2);
await expect(getAuditLogs(50)).resolves.toEqual([expect.objectContaining({ action: 'admin.user_created' })]);
@@ -196,7 +196,7 @@ describe('api client contracts', () => {
role: 'annotator',
is_active: true,
});
expect(axiosMock.client.patch).toHaveBeenCalledWith('/api/admin/users/2', { role: 'viewer' });
expect(axiosMock.client.patch).toHaveBeenCalledWith('/api/admin/users/2', { is_active: false });
expect(axiosMock.client.delete).toHaveBeenCalledWith('/api/admin/users/2');
expect(axiosMock.client.get).toHaveBeenNthCalledWith(2, '/api/admin/audit-logs', { params: { limit: 50 } });