修复演示恢复项目帧数据
- 恢复演示出厂设置后直接解析演视LC视频序列并生成可打开帧序列 - 保持演视DICOM序列按文件名自然顺序恢复并生成帧 - 增加 MinIO 浏览器访问端点配置,修复 Docker 部署中封面和帧图预签名地址使用容器内主机名的问题 - 更新管理员恢复测试覆盖视频和 DICOM 帧数量 - 更新 README 和前后端契约/设计/测试文档中的演示恢复说明
This commit is contained in:
@@ -417,7 +417,7 @@ cd ~/Desktop/Seg_Server
|
||||
|
||||
后端启动时会自动种子化默认管理员 `admin / 123456`,密码以哈希形式存入 `users` 表。登录成功返回签名 JWT,前端会把 token 写入 `localStorage` 并通过 `Authorization: Bearer <token>` 调用业务接口;页面刷新后会用 `/api/auth/me` 恢复当前用户。
|
||||
|
||||
当前项目、帧、标注、任务、Dashboard 和导出接口已经按当前 JWT 用户拥有的项目隔离;模板支持系统模板(`owner_user_id IS NULL`)和用户模板。角色分为 `admin`、`annotator`、`viewer`:`admin/annotator` 可调用写入类业务接口,`viewer` 只能读取;管理员会在侧栏看到“用户管理”,可通过 `/api/admin/users` 新增、停用/启用、改角色、改密码和删除无项目用户,并通过 `/api/admin/audit-logs` 查看登录与用户管理审计。演示部署还提供“恢复演示出厂设置”,站内二次确认后调用 `/api/admin/demo-factory-reset`,直接从 `demo/` 读取“演视LC视频序列”和“演视DICOM序列”,只保留默认 admin、演示视频项目和一个已按文件名自然顺序生成帧的演示 DICOM 项目。生产部署时必须在 `backend/.env` 覆盖 `JWT_SECRET_KEY` 并修改默认管理员密码。
|
||||
当前项目、帧、标注、任务、Dashboard 和导出接口已经按当前 JWT 用户拥有的项目隔离;模板支持系统模板(`owner_user_id IS NULL`)和用户模板。角色分为 `admin`、`annotator`、`viewer`:`admin/annotator` 可调用写入类业务接口,`viewer` 只能读取;管理员会在侧栏看到“用户管理”,可通过 `/api/admin/users` 新增、停用/启用、改角色、改密码和删除无项目用户,并通过 `/api/admin/audit-logs` 查看登录与用户管理审计。演示部署还提供“恢复演示出厂设置”,站内二次确认后调用 `/api/admin/demo-factory-reset`,直接从 `demo/` 读取“演视LC视频序列”和“演视DICOM序列”,只保留默认 admin、已生成帧的演示视频项目和一个已按文件名自然顺序生成帧的演示 DICOM 项目。生产部署时必须在 `backend/.env` 覆盖 `JWT_SECRET_KEY` 并修改默认管理员密码。
|
||||
|
||||
系统默认模板会在后端启动时幂等补齐,当前包括“腹腔镜胆囊切除术”和“头颈部CT分割”;头颈部 CT 默认分类名使用纯中文,不带括号英文翻译。所有新建、复制、导入和后端返回的模板都会归一化带上黑色 `maskid: 0` 的“待分类”保留类,并固定在语义分类树最后。恢复演示出厂设置只删除用户私有模板,并会按内置权威定义重建缺失的默认系统模板、覆盖恢复被修改或删减的默认语义分类树。模板库左侧“生效中模板架构清单”里的复制按钮会把任一模板复制成当前用户私有副本,并保留分类名称、颜色、maskid、内部层级顺序和规则。
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ class Settings(BaseSettings):
|
||||
|
||||
# MinIO
|
||||
minio_endpoint: str = "192.168.3.11:9000"
|
||||
minio_public_endpoint: str | None = None
|
||||
minio_access_key: str = "minioadmin"
|
||||
minio_secret_key: str = "minioadmin"
|
||||
minio_secure: bool = False
|
||||
|
||||
@@ -71,7 +71,7 @@ def _seed_default_project_sync() -> None:
|
||||
LEGACY_DEMO_DICOM_PROJECT_NAMES,
|
||||
LEGACY_DEMO_VIDEO_PROJECT_NAMES,
|
||||
create_parsed_dicom_demo_project,
|
||||
create_unparsed_video_demo_project,
|
||||
create_parsed_video_demo_project,
|
||||
demo_dicom_files,
|
||||
)
|
||||
|
||||
@@ -96,7 +96,7 @@ def _seed_default_project_sync() -> None:
|
||||
db.commit()
|
||||
existing_video = db.query(Project).filter(Project.name == DEMO_VIDEO_PROJECT_NAME).first()
|
||||
if existing_video is None and os.path.exists(settings.demo_video_path):
|
||||
video_project = create_unparsed_video_demo_project(
|
||||
video_project = create_parsed_video_demo_project(
|
||||
db,
|
||||
owner=admin,
|
||||
video_path=settings.demo_video_path,
|
||||
|
||||
@@ -14,6 +14,7 @@ logger = logging.getLogger(__name__)
|
||||
BUCKET_NAME = "seg-media"
|
||||
|
||||
_minio_client: Optional[Minio] = None
|
||||
_minio_public_client: Optional[Minio] = None
|
||||
|
||||
|
||||
def get_minio_client() -> Minio:
|
||||
@@ -29,6 +30,20 @@ def get_minio_client() -> Minio:
|
||||
return _minio_client
|
||||
|
||||
|
||||
def get_minio_public_client() -> Minio:
|
||||
"""Return a MinIO client configured for browser-facing presigned URLs."""
|
||||
global _minio_public_client
|
||||
if _minio_public_client is None:
|
||||
endpoint = settings.minio_public_endpoint or settings.minio_endpoint
|
||||
_minio_public_client = Minio(
|
||||
endpoint,
|
||||
access_key=settings.minio_access_key,
|
||||
secret_key=settings.minio_secret_key,
|
||||
secure=settings.minio_secure,
|
||||
)
|
||||
return _minio_public_client
|
||||
|
||||
|
||||
def ensure_bucket_exists() -> None:
|
||||
"""Create the bucket if it does not already exist."""
|
||||
client = get_minio_client()
|
||||
@@ -97,7 +112,7 @@ def get_presigned_url(
|
||||
Returns:
|
||||
Presigned URL string.
|
||||
"""
|
||||
client = get_minio_client()
|
||||
client = get_minio_public_client()
|
||||
try:
|
||||
url = client.get_presigned_url(method, BUCKET_NAME, object_name, expires=timedelta(seconds=expires))
|
||||
return url
|
||||
|
||||
@@ -23,7 +23,7 @@ from services.demo_media import (
|
||||
DEMO_DICOM_PROJECT_NAME,
|
||||
DEMO_VIDEO_PROJECT_NAME,
|
||||
create_parsed_dicom_demo_project,
|
||||
create_unparsed_video_demo_project,
|
||||
create_parsed_video_demo_project,
|
||||
demo_dicom_files,
|
||||
)
|
||||
from services.default_templates import restore_default_templates
|
||||
@@ -252,13 +252,12 @@ def reset_demo_factory(
|
||||
|
||||
restored_templates = restore_default_templates(db)
|
||||
|
||||
video_project = create_unparsed_video_demo_project(
|
||||
video_project = create_parsed_video_demo_project(
|
||||
db,
|
||||
owner=preserved_admin,
|
||||
video_path=settings.demo_video_path,
|
||||
project_name=DEMO_VIDEO_PROJECT_NAME,
|
||||
)
|
||||
video_project.frame_count = 0
|
||||
|
||||
dicom_project = create_parsed_dicom_demo_project(
|
||||
db,
|
||||
|
||||
@@ -12,12 +12,20 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from minio_client import upload_file
|
||||
from models import Frame, Project, User
|
||||
from services.frame_parser import natural_filename_key, parse_dicom, upload_frames_to_minio
|
||||
from services.frame_parser import (
|
||||
extract_thumbnail,
|
||||
natural_filename_key,
|
||||
parse_dicom,
|
||||
parse_video,
|
||||
upload_frames_to_minio,
|
||||
)
|
||||
from statuses import PROJECT_STATUS_PENDING, PROJECT_STATUS_READY
|
||||
|
||||
DEMO_DICOM_PROJECT_NAME = "演视DICOM序列"
|
||||
DEMO_DICOM_PARSE_FPS = 30.0
|
||||
DEMO_VIDEO_PROJECT_NAME = "演视LC视频序列"
|
||||
DEMO_VIDEO_PARSE_FPS = 30.0
|
||||
DEMO_VIDEO_TARGET_WIDTH = 640
|
||||
LEGACY_DEMO_VIDEO_PROJECT_NAMES = {"Data_MyVideo_1"}
|
||||
LEGACY_DEMO_DICOM_PROJECT_NAMES = {"演示DICOM序列"}
|
||||
|
||||
@@ -67,6 +75,85 @@ def create_unparsed_video_demo_project(
|
||||
return project
|
||||
|
||||
|
||||
def create_parsed_video_demo_project(
|
||||
db: Session,
|
||||
*,
|
||||
owner: User,
|
||||
video_path: str,
|
||||
project_name: str = DEMO_VIDEO_PROJECT_NAME,
|
||||
) -> Project:
|
||||
"""Create the bundled demo video project and register its extracted frame sequence."""
|
||||
source = Path(video_path)
|
||||
if not source.exists() or not source.is_file():
|
||||
raise FileNotFoundError(f"Demo video not found: {video_path}")
|
||||
|
||||
project = Project(
|
||||
name=project_name,
|
||||
description="默认演示视频,已生成帧",
|
||||
status=PROJECT_STATUS_PENDING,
|
||||
source_type="video",
|
||||
parse_fps=DEMO_VIDEO_PARSE_FPS,
|
||||
original_fps=None,
|
||||
owner_user_id=owner.id,
|
||||
)
|
||||
db.add(project)
|
||||
db.flush()
|
||||
|
||||
data = source.read_bytes()
|
||||
object_name = f"uploads/{project.id}/{source.name}"
|
||||
upload_file(object_name, data, content_type="video/mp4", length=len(data))
|
||||
project.video_path = object_name
|
||||
|
||||
tmp_dir = tempfile.mkdtemp(prefix=f"seg_demo_video_{project.id}_")
|
||||
try:
|
||||
output_dir = os.path.join(tmp_dir, "frames")
|
||||
frame_files, original_fps = parse_video(
|
||||
str(source),
|
||||
output_dir,
|
||||
fps=int(DEMO_VIDEO_PARSE_FPS),
|
||||
target_width=DEMO_VIDEO_TARGET_WIDTH,
|
||||
)
|
||||
project.original_fps = original_fps
|
||||
object_names = upload_frames_to_minio(frame_files, project.id)
|
||||
|
||||
for idx, obj_name in enumerate(object_names):
|
||||
image = cv2.imread(frame_files[idx])
|
||||
height, width = image.shape[:2] if image is not None else (None, None)
|
||||
db.add(Frame(
|
||||
project_id=project.id,
|
||||
frame_index=idx,
|
||||
image_url=obj_name,
|
||||
width=width,
|
||||
height=height,
|
||||
timestamp_ms=idx * 1000.0 / DEMO_VIDEO_PARSE_FPS,
|
||||
source_frame_number=idx,
|
||||
))
|
||||
|
||||
thumbnail_path = os.path.join(tmp_dir, "thumbnail.jpg")
|
||||
try:
|
||||
extract_thumbnail(str(source), thumbnail_path)
|
||||
with open(thumbnail_path, "rb") as thumbnail_file:
|
||||
thumbnail_data = thumbnail_file.read()
|
||||
thumbnail_object = f"projects/{project.id}/thumbnail.jpg"
|
||||
upload_file(
|
||||
thumbnail_object,
|
||||
thumbnail_data,
|
||||
content_type="image/jpeg",
|
||||
length=len(thumbnail_data),
|
||||
)
|
||||
project.thumbnail_url = thumbnail_object
|
||||
except Exception: # noqa: BLE001
|
||||
if object_names:
|
||||
project.thumbnail_url = object_names[0]
|
||||
|
||||
project.status = PROJECT_STATUS_READY
|
||||
db.commit()
|
||||
db.refresh(project)
|
||||
return project
|
||||
finally:
|
||||
shutil.rmtree(tmp_dir, ignore_errors=True)
|
||||
|
||||
|
||||
def create_parsed_dicom_demo_project(
|
||||
db: Session,
|
||||
*,
|
||||
|
||||
@@ -132,6 +132,11 @@ def test_demo_factory_reset_leaves_admin_and_parsed_demo_dicom(client, db_sessio
|
||||
frame_path = tmp_path / f"frame_{idx:06d}.jpg"
|
||||
frame_path.write_bytes(b"frame")
|
||||
parsed_frame_paths.append(str(frame_path))
|
||||
parsed_video_frame_paths = []
|
||||
for idx in range(2):
|
||||
frame_path = tmp_path / f"video_frame_{idx:06d}.jpg"
|
||||
frame_path.write_bytes(b"video-frame")
|
||||
parsed_video_frame_paths.append(str(frame_path))
|
||||
|
||||
uploaded = []
|
||||
monkeypatch.setattr("services.demo_media.upload_file", lambda object_name, data, content_type, length: uploaded.append({
|
||||
@@ -140,6 +145,10 @@ def test_demo_factory_reset_leaves_admin_and_parsed_demo_dicom(client, db_sessio
|
||||
"content_type": content_type,
|
||||
"length": length,
|
||||
}))
|
||||
monkeypatch.setattr(
|
||||
"services.demo_media.parse_video",
|
||||
lambda video_path_arg, output_dir, fps, target_width: (parsed_video_frame_paths, 30.0),
|
||||
)
|
||||
monkeypatch.setattr("services.demo_media.parse_dicom", lambda dicom_dir_arg, output_dir: parsed_frame_paths)
|
||||
monkeypatch.setattr(
|
||||
"services.demo_media.upload_frames_to_minio",
|
||||
@@ -195,9 +204,9 @@ def test_demo_factory_reset_leaves_admin_and_parsed_demo_dicom(client, db_sessio
|
||||
assert data["project"]["frame_count"] == 3
|
||||
assert data["project"]["video_path"] == f"uploads/{data['project']['id']}/dicom"
|
||||
assert [project["name"] for project in data["projects"]] == ["演视LC视频序列", "演视DICOM序列"]
|
||||
assert data["projects"][0]["status"] == "pending"
|
||||
assert data["projects"][0]["status"] == PROJECT_STATUS_READY
|
||||
assert data["projects"][0]["source_type"] == "video"
|
||||
assert data["projects"][0]["frame_count"] == 0
|
||||
assert data["projects"][0]["frame_count"] == 2
|
||||
assert data["projects"][1]["status"] == PROJECT_STATUS_READY
|
||||
assert data["projects"][1]["source_type"] == "dicom"
|
||||
assert data["projects"][1]["frame_count"] == 3
|
||||
@@ -211,8 +220,18 @@ def test_demo_factory_reset_leaves_admin_and_parsed_demo_dicom(client, db_sessio
|
||||
|
||||
assert [user.username for user in db_session.query(User).all()] == ["admin"]
|
||||
assert db_session.query(Project).count() == 2
|
||||
assert db_session.query(Frame).count() == 3
|
||||
assert [frame.source_frame_number for frame in db_session.query(Frame).order_by(Frame.frame_index).all()] == [0, 1, 2]
|
||||
assert db_session.query(Frame).count() == 5
|
||||
frames_by_project = {}
|
||||
for project in db_session.query(Project).order_by(Project.id).all():
|
||||
frames_by_project[project.name] = [
|
||||
frame.source_frame_number
|
||||
for frame in db_session.query(Frame)
|
||||
.filter(Frame.project_id == project.id)
|
||||
.order_by(Frame.frame_index)
|
||||
.all()
|
||||
]
|
||||
assert frames_by_project["演视LC视频序列"] == [0, 1]
|
||||
assert frames_by_project["演视DICOM序列"] == [0, 1, 2]
|
||||
assert db_session.query(Annotation).count() == 0
|
||||
assert db_session.query(Mask).count() == 0
|
||||
assert db_session.query(ProcessingTask).count() == 0
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
- 检查 MinIO bucket。
|
||||
- 测试 Redis。
|
||||
- Seed 默认模板。
|
||||
- 如果存在 `demo/演视LC视频序列.mp4` 和 `demo/演视DICOM序列/`,创建名为“演视LC视频序列”的默认演示视频项目和名为“演视DICOM序列”的演示 DICOM 项目,DICOM 按文件名自然顺序生成帧;启动时会把旧显示名 `Data_MyVideo_1` / `演示DICOM序列` 迁移为新显示名。
|
||||
- 如果存在 `demo/演视LC视频序列.mp4` 和 `demo/演视DICOM序列/`,创建名为“演视LC视频序列”的默认演示视频项目和名为“演视DICOM序列”的演示 DICOM 项目,视频和 DICOM 都会生成帧,DICOM 按文件名自然顺序读取;启动时会把旧显示名 `Data_MyVideo_1` / `演示DICOM序列` 迁移为新显示名。
|
||||
|
||||
## 前端模块切换
|
||||
|
||||
@@ -72,7 +72,7 @@
|
||||
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` 会阻止管理员删除、停用、改名或降级自己;项目库已共享,因此删除标注员不会删除或迁移项目;演示出厂重置会清空其它用户、项目帧、标注、任务和私有模板,直接从 `demo/` 重新创建名为“演视LC视频序列”的演示视频项目和名为“演视DICOM序列”的已自然排序演示 DICOM 项目。
|
||||
5. 后端 `backend/routers/admin.py` 会阻止管理员删除、停用、改名或降级自己;项目库已共享,因此删除标注员不会删除或迁移项目;演示出厂重置会清空其它用户、项目帧、标注、任务和私有模板,直接从 `demo/` 重新创建名为“演视LC视频序列”的已生成帧演示视频项目和名为“演视DICOM序列”的已自然排序演示 DICOM 项目。
|
||||
|
||||
### 项目与拆帧
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
| 启停用 / 改密码 | 真实可用 | 调用 `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`,后端直接从 `demo/` 读取“演视LC视频序列”和“演视DICOM序列”,只保留默认 admin、演示视频项目和一个已按文件名自然顺序生成帧的演示 DICOM 项目,并清空用户、项目帧、标注、任务和私有模板等演示数据;“腹腔镜胆囊切除术”和“头颈部CT分割”系统模板会按内置默认定义重建或覆盖恢复 |
|
||||
| 恢复演示出厂设置 | 真实可用 | 管理员点击危险区按钮后先浏览器确认,再输入 `RESET_DEMO_FACTORY`;前端调用 `POST /api/admin/demo-factory-reset`,后端直接从 `demo/` 读取“演视LC视频序列”和“演视DICOM序列”,只保留默认 admin、已生成帧的演示视频项目和一个已按文件名自然顺序生成帧的演示 DICOM 项目,并清空用户、项目帧、标注、任务和私有模板等演示数据;“腹腔镜胆囊切除术”和“头颈部CT分割”系统模板会按内置默认定义重建或覆盖恢复 |
|
||||
|
||||
## Dashboard 系统概况
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ Authorization: Bearer <token>
|
||||
| GET | `/api/auth/me` | 当前用户 |
|
||||
| GET/POST/PATCH/DELETE | `/api/admin/users` | 管理员用户管理 |
|
||||
| GET | `/api/admin/audit-logs` | 管理员审计日志 |
|
||||
| POST | `/api/admin/demo-factory-reset` | 演示部署恢复出厂设置;请求体需 `confirmation=RESET_DEMO_FACTORY`;重置后保留默认 admin、从 `demo/演视LC视频序列.mp4` 创建的演示视频项目和从 `demo/演视DICOM序列/` 创建的已按文件名自然顺序生成帧的演示 DICOM 项目;同时按内置权威定义重建缺失的“腹腔镜胆囊切除术”“头颈部CT分割”系统模板,并覆盖恢复被修改或删减的默认语义分类树;响应包含兼容单个 `project` 和完整 `projects` 列表 |
|
||||
| POST | `/api/admin/demo-factory-reset` | 演示部署恢复出厂设置;请求体需 `confirmation=RESET_DEMO_FACTORY`;重置后保留默认 admin、从 `demo/演视LC视频序列.mp4` 创建的已生成帧演示视频项目和从 `demo/演视DICOM序列/` 创建的已按文件名自然顺序生成帧的演示 DICOM 项目;同时按内置权威定义重建缺失的“腹腔镜胆囊切除术”“头颈部CT分割”系统模板,并覆盖恢复被修改或删减的默认语义分类树;响应包含兼容单个 `project` 和完整 `projects` 列表 |
|
||||
| POST | `/api/projects` | 创建项目 |
|
||||
| GET | `/api/projects` | 项目列表 |
|
||||
| GET | `/api/projects/{project_id}` | 项目详情 |
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
- 角色只包括唯一默认 `admin` 和 `annotator`;历史 `viewer` 或额外管理员会归一为标注员;用户管理、审计日志和演示环境出厂设置后台仅默认 `admin` 可用。
|
||||
- 管理员侧栏显示“用户管理”入口;管理员可以新增标注员、停用/启用、修改密码、删除用户。
|
||||
- 系统记录登录成功/失败和用户管理操作到 `audit_logs`,管理员后台可查看最近审计日志。
|
||||
- 管理员后台提供“恢复演示出厂设置”危险操作;前端必须二次确认,后端也必须校验 `confirmation=RESET_DEMO_FACTORY`,执行后只保留默认 admin 账号、系统模板、从 `demo/演视LC视频序列.mp4` 创建的演示视频项目和从 `demo/演视DICOM序列/` 创建的已按文件名自然顺序生成帧的演示 DICOM 项目,清空其它用户、项目、帧、标注、任务、用户模板和旧审计记录,并写入本次重置审计。
|
||||
- 管理员后台提供“恢复演示出厂设置”危险操作;前端必须二次确认,后端也必须校验 `confirmation=RESET_DEMO_FACTORY`,执行后只保留默认 admin 账号、系统模板、从 `demo/演视LC视频序列.mp4` 创建的已生成帧演示视频项目和从 `demo/演视DICOM序列/` 创建的已按文件名自然顺序生成帧的演示 DICOM 项目,清空其它用户、项目、帧、标注、任务、用户模板和旧审计记录,并写入本次重置审计。
|
||||
- 系统默认模板至少包含“腹腔镜胆囊切除术”和“头颈部CT分割”;头颈部 CT 默认分类名必须使用纯中文,不带括号英文翻译;恢复演示出厂设置不得删除系统默认模板,并必须重建缺失的默认模板、覆盖恢复被修改或删减的默认语义分类树。
|
||||
|
||||
## R2 项目管理
|
||||
|
||||
@@ -89,7 +89,7 @@
|
||||
3. `Template.owner_user_id` 支持用户模板;`owner_user_id IS NULL` 的模板视为系统模板,可作为默认分类体系对用户可见。
|
||||
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、任务、用户模板和旧审计,重新创建 `demo/演视LC视频序列.mp4` 指向且显示名为“演视LC视频序列”的演示视频项目,以及 `demo/演视DICOM序列/` 指向且显示名为“演视DICOM序列”的演示 DICOM 项目;DICOM 会按文件名自然顺序上传和生成帧;系统模板保留以保证重置后仍可标注。
|
||||
6. `POST /api/admin/demo-factory-reset` 仅允许 `admin`,会重置默认 admin 密码/角色/启用状态,删除其它用户、项目、帧、标注、mask、任务、用户模板和旧审计,重新创建 `demo/演视LC视频序列.mp4` 指向且显示名为“演视LC视频序列”的已生成帧演示视频项目,以及 `demo/演视DICOM序列/` 指向且显示名为“演视DICOM序列”的演示 DICOM 项目;视频和 DICOM 都会上传源媒体并生成帧,DICOM 会按文件名自然顺序读取;系统模板保留以保证重置后仍可标注。
|
||||
7. 缺失、过期或伪造的 Bearer token 会在业务路由返回 401,权限不足返回 403,其他用户项目资源对当前用户表现为 404。
|
||||
|
||||
### 项目导入与生成帧
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
|
||||
| 需求 | 功能点 | 对应测试 | 当前状态 |
|
||||
|------|--------|----------|----------|
|
||||
| R1 | 登录页 logo 和系统标题文案、唯一默认管理员、JWT 写入、当前用户写入、刷新恢复基础状态、失败提示、后端 401、`/api/auth/me`、管理员用户管理入口图标、底部退出入口图标和 tooltip 命中范围、角色权限、审计日志、演示出厂设置二次确认、重置后只保留 admin、名为“演视LC视频序列”的演示视频项目和名为“演视DICOM序列”的已生成帧自然排序演示 DICOM 项目 | `Login.test.tsx`, `Sidebar.test.tsx`, `UserAdmin.test.tsx`, `useStore.test.ts`, `test_auth.py`, `test_admin.py` | 已覆盖 |
|
||||
| R1 | 登录页 logo 和系统标题文案、唯一默认管理员、JWT 写入、当前用户写入、刷新恢复基础状态、失败提示、后端 401、`/api/auth/me`、管理员用户管理入口图标、底部退出入口图标和 tooltip 命中范围、角色权限、审计日志、演示出厂设置二次确认、重置后只保留 admin、名为“演视LC视频序列”的已生成帧演示视频项目和名为“演视DICOM序列”的已生成帧自然排序演示 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` | 已覆盖 |
|
||||
|
||||
@@ -317,7 +317,7 @@ admin / 123456
|
||||
|
||||
首次启动会自动创建默认管理员,密码以哈希形式写入 `users` 表;登录返回签名 JWT,业务接口会校验 `Authorization: Bearer <token>`。生产环境必须修改 `jwt_secret_key` 和默认管理员密码。
|
||||
|
||||
默认管理员登录后会看到“用户管理”后台,可新增标注员、停用/启用用户、重置密码、删除用户并查看登录与用户管理审计日志。系统只支持唯一默认 `admin` 和 `annotator` 两类角色:标注员不能新增用户、查看审计日志或恢复演示出厂设置,但可以和管理员共享同一项目库并执行项目管理、标注、AI 推理、任务和导出等业务操作。演示部署可在该后台使用“恢复演示出厂设置”,二次确认后只保留默认 admin、名为“演视LC视频序列”的演示视频项目和名为“演视DICOM序列”的已按文件名自然顺序生成帧的演示 DICOM 项目;视频来自 `demo_video_path`,DICOM 序列来自 `demo_dicom_dir`。
|
||||
默认管理员登录后会看到“用户管理”后台,可新增标注员、停用/启用用户、重置密码、删除用户并查看登录与用户管理审计日志。系统只支持唯一默认 `admin` 和 `annotator` 两类角色:标注员不能新增用户、查看审计日志或恢复演示出厂设置,但可以和管理员共享同一项目库并执行项目管理、标注、AI 推理、任务和导出等业务操作。演示部署可在该后台使用“恢复演示出厂设置”,二次确认后只保留默认 admin、名为“演视LC视频序列”的已生成帧演示视频项目和名为“演视DICOM序列”的已按文件名自然顺序生成帧的演示 DICOM 项目;视频来自 `demo_video_path`,DICOM 序列来自 `demo_dicom_dir`。
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user