[fix]:[FL-216][ATP模型管理: 去掉版本概念,上传文件直接展示]
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -9,7 +9,6 @@ from ...schemas.atp_asset import (
|
||||
AtpAssetCreateRequest,
|
||||
AtpAssetDetail,
|
||||
AtpAssetFileListResponse,
|
||||
AtpAssetFileUploadResponse,
|
||||
AtpAssetListResponse,
|
||||
AtpAssetReleaseCreateRequest,
|
||||
AtpAssetReleaseDetail,
|
||||
@@ -40,9 +39,9 @@ from ...services.atp_asset_service import (
|
||||
run_release,
|
||||
serialize_asset,
|
||||
serialize_release_detail,
|
||||
upload_asset_files,
|
||||
update_asset,
|
||||
update_release,
|
||||
upload_asset_archive,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/atp", tags=["atp-assets"], dependencies=[Depends(require_enabled_menu_route)])
|
||||
@@ -91,28 +90,20 @@ def create_atp_asset_endpoint(
|
||||
return AtpAssetDetail(**created.model_dump())
|
||||
|
||||
|
||||
@router.post("/assets/{asset_id}/files/upload", response_model=AtpAssetFileUploadResponse)
|
||||
@router.post("/assets/{asset_id}/files", response_model=AtpAssetDetail)
|
||||
def upload_atp_asset_files_endpoint(
|
||||
asset_id: str,
|
||||
archive: UploadFile = File(...),
|
||||
files: list[UploadFile] = File(...),
|
||||
current_user: CurrentUser = Depends(require_permission("atp.manage")),
|
||||
db: Session = Depends(get_db),
|
||||
) -> AtpAssetFileUploadResponse:
|
||||
try:
|
||||
archive_content = archive.file.read()
|
||||
finally:
|
||||
try:
|
||||
archive.file.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return upload_asset_archive(
|
||||
) -> AtpAssetDetail:
|
||||
updated = upload_asset_files(
|
||||
db,
|
||||
asset_id=asset_id,
|
||||
archive_filename=archive.filename or "model.zip",
|
||||
archive_content=archive_content,
|
||||
files=files,
|
||||
actor_user_id=current_user.user.id,
|
||||
)
|
||||
return AtpAssetDetail(**updated.model_dump())
|
||||
|
||||
|
||||
@router.get("/assets/{asset_id}", response_model=AtpAssetDetail)
|
||||
@@ -161,6 +152,15 @@ def delete_atp_asset_endpoint(
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@router.get("/assets/{asset_id}/files", response_model=AtpAssetFileListResponse)
|
||||
def get_atp_asset_files(
|
||||
asset_id: str,
|
||||
_: CurrentUser = Depends(require_any_permission("atp.read", "atp.run", "atp.manage")),
|
||||
db: Session = Depends(get_db),
|
||||
) -> AtpAssetFileListResponse:
|
||||
return list_asset_files(db, asset_id=asset_id)
|
||||
|
||||
|
||||
@router.get("/assets/{asset_id}/releases", response_model=AtpAssetReleaseListResponse)
|
||||
def get_atp_asset_releases(
|
||||
asset_id: str,
|
||||
|
||||
@@ -389,6 +389,41 @@ def _ensure_atp_simulation_run_column_compatibility() -> None:
|
||||
)
|
||||
|
||||
|
||||
def _ensure_atp_asset_column_compatibility() -> None:
|
||||
"""
|
||||
Keep `atp_asset` columns aligned with the current ORM mapping.
|
||||
"""
|
||||
if not database_url.startswith("postgresql"):
|
||||
return
|
||||
|
||||
schema = settings.resolved_db_schema
|
||||
with engine.begin() as connection:
|
||||
db_inspector = inspect(connection)
|
||||
if not db_inspector.has_table("atp_asset", schema=schema):
|
||||
return
|
||||
|
||||
column_names = {
|
||||
column["name"]
|
||||
for column in db_inspector.get_columns("atp_asset", schema=schema)
|
||||
}
|
||||
|
||||
if "storage_mount_code" not in column_names:
|
||||
connection.execute(
|
||||
text("ALTER TABLE atp_asset ADD COLUMN IF NOT EXISTS storage_mount_code VARCHAR(64)"),
|
||||
)
|
||||
logger.warning(
|
||||
"Detected missing atp_asset.storage_mount_code; added nullable mount code column.",
|
||||
)
|
||||
|
||||
if "storage_root_path" not in column_names:
|
||||
connection.execute(
|
||||
text("ALTER TABLE atp_asset ADD COLUMN IF NOT EXISTS storage_root_path VARCHAR(2048)"),
|
||||
)
|
||||
logger.warning(
|
||||
"Detected missing atp_asset.storage_root_path; added nullable storage root path column.",
|
||||
)
|
||||
|
||||
|
||||
def _ensure_tower_model_column_compatibility() -> None:
|
||||
"""
|
||||
Keep `tower_model` columns aligned with the current ORM mapping.
|
||||
@@ -586,6 +621,7 @@ def init_db() -> None:
|
||||
_ensure_user_email_nullable()
|
||||
_ensure_elevation_dataset_column_compatibility()
|
||||
_ensure_atp_simulation_run_column_compatibility()
|
||||
_ensure_atp_asset_column_compatibility()
|
||||
_ensure_tower_model_column_compatibility()
|
||||
_ensure_tower_profile_column_compatibility()
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
@@ -31,6 +31,8 @@ class AtpAsset(Base):
|
||||
tower_type: Mapped[str | None] = mapped_column(String(64), index=True)
|
||||
scene_type: Mapped[str | None] = mapped_column(String(32), index=True)
|
||||
arrester_config: Mapped[str | None] = mapped_column(String(64), index=True)
|
||||
storage_mount_code: Mapped[str | None] = mapped_column(String(64), index=True)
|
||||
storage_root_path: Mapped[str | None] = mapped_column(String(2048), index=True)
|
||||
tags_json: Mapped[list[str]] = mapped_column(JSON, default=list)
|
||||
latest_release_no: Mapped[int] = mapped_column(Integer, default=0)
|
||||
active_release_no: Mapped[int | None] = mapped_column(Integer)
|
||||
|
||||
@@ -22,6 +22,8 @@ class AtpAssetSummary(BaseModel):
|
||||
tower_type: str | None = None
|
||||
scene_type: str | None = None
|
||||
arrester_config: str | None = None
|
||||
storage_mount_code: str | None = None
|
||||
storage_root_path: str | None = None
|
||||
latest_release_no: int = 0
|
||||
active_release_no: int | None = None
|
||||
active_release_id: str | None = None
|
||||
@@ -56,6 +58,8 @@ class AtpAssetCreateRequest(BaseModel):
|
||||
tower_type: str | None = Field(default=None, max_length=64)
|
||||
scene_type: str | None = Field(default=None, max_length=32)
|
||||
arrester_config: str | None = Field(default=None, max_length=64)
|
||||
storage_mount_code: str | None = Field(default=None, max_length=64)
|
||||
storage_root_path: str | None = Field(default=None, max_length=2048)
|
||||
|
||||
|
||||
class AtpAssetUpdateRequest(BaseModel):
|
||||
@@ -66,6 +70,8 @@ class AtpAssetUpdateRequest(BaseModel):
|
||||
tower_type: str | None = Field(default=None, max_length=64)
|
||||
scene_type: str | None = Field(default=None, max_length=32)
|
||||
arrester_config: str | None = Field(default=None, max_length=64)
|
||||
storage_mount_code: str | None = Field(default=None, max_length=64)
|
||||
storage_root_path: str | None = Field(default=None, max_length=2048)
|
||||
|
||||
|
||||
class AtpAssetReleaseSummary(BaseModel):
|
||||
|
||||
@@ -55,6 +55,7 @@ from .storage_driver import (
|
||||
StorageObject,
|
||||
StoragePathNotFoundError,
|
||||
build_storage_driver,
|
||||
join_virtual_path,
|
||||
normalize_virtual_path,
|
||||
)
|
||||
|
||||
@@ -67,7 +68,7 @@ VALID_RUNNER_KIND = {"atp", "egm", "hybrid"}
|
||||
VALID_RUN_STATUS = {"pending", "running", "success", "failed"}
|
||||
LOG_MAX_CHARS = 200_000
|
||||
ATP_ASSET_RELEASES_ROOT = "/atp-library"
|
||||
ATP_ASSET_FILES_ROOT = "/atp-library/assets"
|
||||
ATP_ASSET_FILES_ROOT = "/atp-assets"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@@ -358,12 +359,38 @@ def _build_release_storage_root(asset_code: str, release_no: int, voltage_level:
|
||||
return normalize_virtual_path(f"{ATP_ASSET_RELEASES_ROOT}/{voltage_segment}/{tower_segment}/r{release_no}")
|
||||
|
||||
|
||||
def _build_asset_storage_root(asset: AtpAsset) -> str:
|
||||
return normalize_virtual_path(f"{ATP_ASSET_FILES_ROOT}/{asset.id}")
|
||||
def _build_asset_storage_root(asset_code: str) -> str:
|
||||
asset_segment = _sanitize_storage_segment(asset_code, fallback="asset")
|
||||
return normalize_virtual_path(f"{ATP_ASSET_FILES_ROOT}/{asset_segment}")
|
||||
|
||||
|
||||
def _asset_storage_mount() -> str:
|
||||
return "main"
|
||||
def _check_asset_storage_path_conflict(db: Session, storage_root_path: str, current_asset_id: str | None = None) -> None:
|
||||
asset_stmt = select(AtpAsset).where(AtpAsset.storage_root_path == storage_root_path)
|
||||
release_stmt = select(AtpAssetRelease).where(AtpAssetRelease.storage_root_path == storage_root_path)
|
||||
if current_asset_id:
|
||||
asset_stmt = asset_stmt.where(AtpAsset.id != current_asset_id)
|
||||
release_stmt = release_stmt.where(AtpAssetRelease.asset_id != current_asset_id)
|
||||
|
||||
existing_asset = db.execute(asset_stmt).scalar_one_or_none()
|
||||
existing_release = db.execute(release_stmt).scalar_one_or_none()
|
||||
if existing_asset or existing_release:
|
||||
conflict_code = existing_asset.code if existing_asset else existing_release.asset.code
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"存储路径冲突:该路径已被模型 {conflict_code} 占用。",
|
||||
)
|
||||
|
||||
|
||||
def _resolve_asset_file_location(db: Session, asset: AtpAsset, *, allow_legacy_release_root: bool) -> tuple[str, str]:
|
||||
fallback_release = next((release for release in asset.releases if release.is_active), None)
|
||||
if allow_legacy_release_root and fallback_release is None and asset.releases:
|
||||
fallback_release = asset.releases[0]
|
||||
|
||||
storage_mount_code = asset.storage_mount_code or (fallback_release.storage_mount_code if fallback_release else None) or "main"
|
||||
storage_root_path = asset.storage_root_path or (fallback_release.storage_root_path if fallback_release else None)
|
||||
if storage_root_path is None:
|
||||
storage_root_path = _build_asset_storage_root(asset.code)
|
||||
return storage_mount_code, storage_root_path
|
||||
|
||||
|
||||
def _write_archive_to_storage(
|
||||
@@ -609,6 +636,7 @@ def serialize_asset(
|
||||
last_run_date: datetime | None,
|
||||
active_release: AtpAssetRelease | None,
|
||||
) -> AtpAssetSummary:
|
||||
fallback_release = active_release or (item.releases[0] if item.releases else None)
|
||||
return AtpAssetSummary(
|
||||
id=item.id,
|
||||
code=item.code,
|
||||
@@ -619,12 +647,12 @@ def serialize_asset(
|
||||
tower_type=item.tower_type,
|
||||
scene_type=item.scene_type,
|
||||
arrester_config=item.arrester_config,
|
||||
storage_mount_code=item.storage_mount_code or (fallback_release.storage_mount_code if fallback_release else None),
|
||||
storage_root_path=item.storage_root_path or (fallback_release.storage_root_path if fallback_release else None),
|
||||
latest_release_no=item.latest_release_no,
|
||||
active_release_no=item.active_release_no,
|
||||
active_release_id=active_release.id if active_release else None,
|
||||
active_release_tag=active_release.release_tag if active_release else None,
|
||||
storage_mount_code=_asset_storage_mount(),
|
||||
storage_root_path=_build_asset_storage_root(item),
|
||||
release_count=release_count,
|
||||
run_count=run_count,
|
||||
last_run_status=last_run_status, # type: ignore[arg-type]
|
||||
@@ -852,6 +880,10 @@ def create_asset(db: Session, payload: AtpAssetCreateRequest, *, actor_user_id:
|
||||
return None
|
||||
|
||||
now = utcnow()
|
||||
storage_mount_code = _normalize_optional_str(payload.storage_mount_code) or "main"
|
||||
storage_root_path = _normalize_optional_str(payload.storage_root_path)
|
||||
if storage_root_path is None:
|
||||
storage_root_path = _build_asset_storage_root(payload.code.strip())
|
||||
item = AtpAsset(
|
||||
code=payload.code.strip(),
|
||||
name=payload.name.strip(),
|
||||
@@ -861,6 +893,8 @@ def create_asset(db: Session, payload: AtpAssetCreateRequest, *, actor_user_id:
|
||||
tower_type=_normalize_optional_str(payload.tower_type),
|
||||
scene_type=_normalize_optional_str(payload.scene_type),
|
||||
arrester_config=_normalize_optional_str(payload.arrester_config),
|
||||
storage_mount_code=storage_mount_code,
|
||||
storage_root_path=storage_root_path,
|
||||
latest_release_no=0,
|
||||
active_release_no=None,
|
||||
create_user=actor_user_id,
|
||||
@@ -868,6 +902,7 @@ def create_asset(db: Session, payload: AtpAssetCreateRequest, *, actor_user_id:
|
||||
create_date=now,
|
||||
update_date=now,
|
||||
)
|
||||
_check_asset_storage_path_conflict(db, storage_root_path)
|
||||
db.add(item)
|
||||
db.commit()
|
||||
|
||||
@@ -891,8 +926,8 @@ def upload_asset_archive(
|
||||
if not asset:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Asset not found")
|
||||
|
||||
storage_mount_code = _asset_storage_mount()
|
||||
storage_root_path = _build_asset_storage_root(asset)
|
||||
storage_mount_code = "main"
|
||||
storage_root_path = _build_asset_storage_root(asset.code)
|
||||
mount = _resolve_mount(db, storage_mount_code)
|
||||
driver = _build_driver_or_400(mount)
|
||||
root_parent = _parent_virtual_path(storage_root_path)
|
||||
@@ -1007,6 +1042,13 @@ def update_asset(
|
||||
item.scene_type = _normalize_optional_str(update_data["scene_type"])
|
||||
if "arrester_config" in update_data:
|
||||
item.arrester_config = _normalize_optional_str(update_data["arrester_config"])
|
||||
if "storage_mount_code" in update_data:
|
||||
item.storage_mount_code = _normalize_optional_str(update_data["storage_mount_code"])
|
||||
if "storage_root_path" in update_data:
|
||||
next_storage_root_path = _normalize_optional_str(update_data["storage_root_path"])
|
||||
if next_storage_root_path is not None:
|
||||
_check_asset_storage_path_conflict(db, next_storage_root_path, asset_id)
|
||||
item.storage_root_path = next_storage_root_path
|
||||
|
||||
item.update_user = actor_user_id
|
||||
item.update_date = utcnow()
|
||||
@@ -1032,11 +1074,20 @@ def delete_asset(db: Session, asset_id: str) -> bool:
|
||||
if not item:
|
||||
return False
|
||||
|
||||
if item.storage_root_path:
|
||||
try:
|
||||
mount_code = item.storage_mount_code or "main"
|
||||
mount = _resolve_mount(db, mount_code)
|
||||
driver = _build_driver_or_400(mount)
|
||||
driver.delete_path(item.storage_root_path, is_dir=True, recursive=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Delete physical files for all releases before deleting database records
|
||||
try:
|
||||
asset_mount = _resolve_mount(db, _asset_storage_mount())
|
||||
asset_mount = _resolve_mount(db, item.storage_mount_code or "main")
|
||||
asset_driver = _build_driver_or_400(asset_mount)
|
||||
asset_driver.delete_path(_build_asset_storage_root(item), is_dir=True, recursive=True)
|
||||
asset_driver.delete_path(_build_asset_storage_root(item.code), is_dir=True, recursive=True)
|
||||
except Exception:
|
||||
pass
|
||||
for release in item.releases:
|
||||
@@ -1458,33 +1509,10 @@ def list_asset_files(db: Session, *, asset_id: str) -> AtpAssetFileListResponse:
|
||||
asset = get_asset_by_id(db, asset_id)
|
||||
if not asset:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Asset not found")
|
||||
|
||||
storage_mount_code = _asset_storage_mount()
|
||||
storage_root_path = _build_asset_storage_root(asset)
|
||||
storage_mount_code, storage_root_path = _resolve_asset_file_location(db, asset, allow_legacy_release_root=True)
|
||||
mount = _resolve_mount(db, storage_mount_code)
|
||||
driver = _build_driver_or_400(mount)
|
||||
try:
|
||||
tree = _walk_storage_tree(driver, storage_root_path)
|
||||
except HTTPException as exc:
|
||||
if exc.status_code == status.HTTP_404_NOT_FOUND:
|
||||
fallback_release = db.execute(
|
||||
select(AtpAssetRelease)
|
||||
.options(joinedload(AtpAssetRelease.asset))
|
||||
.where(AtpAssetRelease.asset_id == asset.id)
|
||||
.order_by(AtpAssetRelease.is_active.desc(), AtpAssetRelease.release_no.desc(), AtpAssetRelease.id.desc())
|
||||
).scalars().first()
|
||||
if fallback_release:
|
||||
return list_release_files(db, release_id=fallback_release.id)
|
||||
return AtpAssetFileListResponse(
|
||||
asset_id=asset.id,
|
||||
release_id=None,
|
||||
storage_mount_code=storage_mount_code,
|
||||
storage_root_path=storage_root_path,
|
||||
items=[],
|
||||
total=0,
|
||||
)
|
||||
raise
|
||||
|
||||
tree = _walk_storage_tree(driver, storage_root_path)
|
||||
items = [
|
||||
AtpAssetFileEntry(
|
||||
relative_path=_relative_from_root(storage_root_path, item.path),
|
||||
@@ -1518,6 +1546,84 @@ def list_asset_files(db: Session, *, asset_id: str) -> AtpAssetFileListResponse:
|
||||
)
|
||||
|
||||
|
||||
def upload_asset_files(
|
||||
db: Session,
|
||||
*,
|
||||
asset_id: str,
|
||||
files: list[Any],
|
||||
actor_user_id: str,
|
||||
) -> AtpAssetSummary:
|
||||
asset = get_asset_by_id(db, asset_id)
|
||||
if not asset:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Asset not found")
|
||||
mount_code, storage_root_path = _resolve_asset_file_location(db, asset, allow_legacy_release_root=False)
|
||||
if asset.storage_root_path is None:
|
||||
storage_root_path = _build_asset_storage_root(asset.code)
|
||||
_check_asset_storage_path_conflict(db, storage_root_path, asset_id)
|
||||
mount = _resolve_mount(db, mount_code)
|
||||
driver = _build_driver_or_400(mount)
|
||||
driver.ensure_directory(storage_root_path)
|
||||
|
||||
def _write_upload_file(upload: Any, *, base_root: str) -> None:
|
||||
raw_name = (getattr(upload, "filename", "") or "").strip()
|
||||
if not raw_name:
|
||||
return
|
||||
try:
|
||||
content = upload.file.read()
|
||||
finally:
|
||||
try:
|
||||
upload.file.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
content_type = getattr(upload, "content_type", None) or mimetypes.guess_type(raw_name)[0]
|
||||
lower_name = raw_name.lower()
|
||||
if lower_name.endswith(".zip"):
|
||||
with zipfile.ZipFile(io.BytesIO(content)) as archive:
|
||||
for member in archive.infolist():
|
||||
if member.is_dir():
|
||||
continue
|
||||
relative_path = _normalize_archive_member_path(member.filename)
|
||||
if relative_path is None:
|
||||
continue
|
||||
target_path = normalize_virtual_path(f"{base_root.rstrip('/')}/{relative_path}")
|
||||
parent_path = _parent_virtual_path(target_path)
|
||||
driver.ensure_directory(parent_path)
|
||||
driver.write_file(
|
||||
target_path,
|
||||
content=archive.read(member),
|
||||
content_type=mimetypes.guess_type(relative_path)[0],
|
||||
)
|
||||
return
|
||||
|
||||
relative_path = _normalize_archive_member_path(raw_name) or raw_name
|
||||
target_path = normalize_virtual_path(f"{base_root.rstrip('/')}/{relative_path}")
|
||||
parent_path = _parent_virtual_path(target_path)
|
||||
driver.ensure_directory(parent_path)
|
||||
driver.write_file(target_path, content=content, content_type=content_type)
|
||||
|
||||
for file in files:
|
||||
_write_upload_file(file, base_root=storage_root_path)
|
||||
|
||||
asset.storage_mount_code = mount.code
|
||||
asset.storage_root_path = storage_root_path
|
||||
asset.update_user = actor_user_id
|
||||
asset.update_date = utcnow()
|
||||
db.commit()
|
||||
|
||||
saved = get_asset_by_id(db, asset_id)
|
||||
if not saved:
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Asset load failed")
|
||||
return serialize_asset(
|
||||
saved,
|
||||
release_count=len(saved.releases),
|
||||
run_count=len(saved.runs),
|
||||
last_run_status=saved.runs[0].status if saved.runs else None,
|
||||
last_run_date=saved.runs[0].create_date if saved.runs else None,
|
||||
active_release=next((release for release in saved.releases if release.is_active), None),
|
||||
)
|
||||
|
||||
|
||||
def _infer_file_role(release: AtpAssetRelease, relative_path: str, is_dir: bool) -> str | None:
|
||||
if is_dir:
|
||||
if release.egm_subdir and relative_path == release.egm_subdir:
|
||||
|
||||
@@ -67,6 +67,13 @@ def _build_zip(entries: dict[str, bytes]) -> bytes:
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
class _DummyUploadFile:
|
||||
def __init__(self, filename: str, content: bytes, content_type: str = "application/octet-stream") -> None:
|
||||
self.filename = filename
|
||||
self.content_type = content_type
|
||||
self.file = io.BytesIO(content)
|
||||
|
||||
|
||||
def test_create_release_auto_detects_entry_file_and_manifest(tmp_path) -> None:
|
||||
testing_session = _build_sessionmaker()
|
||||
session: Session = testing_session()
|
||||
@@ -452,3 +459,57 @@ def test_delete_asset_removes_storage_files(tmp_path) -> None:
|
||||
assert not storage_path.exists()
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def test_upload_asset_files_extracts_zip_and_removes_asset_root(tmp_path) -> None:
|
||||
testing_session = _build_sessionmaker()
|
||||
session: Session = testing_session()
|
||||
try:
|
||||
_seed_vfs_mount(session, root_dir=tmp_path / "vfs")
|
||||
asset = atp_asset_service.create_asset(
|
||||
session,
|
||||
AtpAssetCreateRequest(
|
||||
code="ATP-ASSET-FILES",
|
||||
name="资产文件上传",
|
||||
voltage_level="220",
|
||||
tower_type="sihuita",
|
||||
scene_type="raoji3",
|
||||
),
|
||||
actor_user_id="tester",
|
||||
)
|
||||
assert asset is not None
|
||||
|
||||
updated = atp_asset_service.upload_asset_files(
|
||||
session,
|
||||
asset_id=asset.id,
|
||||
files=[
|
||||
_DummyUploadFile(
|
||||
"model.zip",
|
||||
_build_zip(
|
||||
{
|
||||
"work.atp": b"ATP INPUT",
|
||||
"nested/config.txt": b"nested config",
|
||||
}
|
||||
),
|
||||
)
|
||||
],
|
||||
actor_user_id="tester",
|
||||
)
|
||||
|
||||
assert updated.storage_mount_code == "main"
|
||||
assert updated.storage_root_path == "/atp-assets/ATP-ASSET-FILES"
|
||||
|
||||
asset_root = tmp_path / "vfs" / "atp-assets" / "ATP-ASSET-FILES"
|
||||
assert (asset_root / "work.atp").exists()
|
||||
assert (asset_root / "nested" / "config.txt").exists()
|
||||
|
||||
files = atp_asset_service.list_asset_files(session, asset_id=asset.id)
|
||||
assert files.release_id == asset.id
|
||||
assert files.storage_root_path == "/atp-assets/ATP-ASSET-FILES"
|
||||
assert any(entry.relative_path == "work.atp" and not entry.is_dir for entry in files.items)
|
||||
assert any(entry.relative_path == "nested/config.txt" and not entry.is_dir for entry in files.items)
|
||||
|
||||
assert atp_asset_service.delete_asset(session, asset.id) is True
|
||||
assert not asset_root.exists()
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
Upload,
|
||||
type CardProps,
|
||||
} from "antd";
|
||||
import { UploadOutlined, FolderOutlined, FileOutlined, TableOutlined, FolderOpenOutlined, HomeOutlined } from "@ant-design/icons";
|
||||
import { UploadOutlined, FolderOutlined, FileOutlined, TableOutlined, FolderOpenOutlined, HomeOutlined, DeleteOutlined } from "@ant-design/icons";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ComponentType, type RefAttributes } from "react";
|
||||
|
||||
@@ -30,7 +30,7 @@ import { useAuth } from "@/components/auth-provider";
|
||||
import { useMobileDetection } from "@/hooks/use-mobile-detection";
|
||||
import { useToastFeedback } from "@/hooks/use-toast-feedback";
|
||||
import { readApiError } from "@/lib/api";
|
||||
import type { AtpAssetListResponse, AtpAssetSummary } from "@/types/auth";
|
||||
import type { AtpAssetListResponse, AtpAssetSummary, AtpAssetFileListResponse } from "@/types/auth";
|
||||
|
||||
const AntCard = Card as unknown as ComponentType<CardProps & RefAttributes<HTMLDivElement>>;
|
||||
|
||||
@@ -50,6 +50,11 @@ const EMPTY_FORM: AssetFormValues = {
|
||||
files: [],
|
||||
};
|
||||
|
||||
function getUploadRelativePath(file: File): string {
|
||||
const candidate = file as File & { webkitRelativePath?: string };
|
||||
return candidate.webkitRelativePath || file.name;
|
||||
}
|
||||
|
||||
function formatDateTime(value: string | null | undefined): string {
|
||||
if (!value) {
|
||||
return "-";
|
||||
@@ -205,6 +210,10 @@ export default function AtpModelsPage() {
|
||||
|
||||
const createAssetMutation = useMutation({
|
||||
mutationFn: async (values: AssetFormValues) => {
|
||||
if (values.files.length === 0) {
|
||||
throw new Error("请先选择模型文件");
|
||||
}
|
||||
|
||||
const payload = {
|
||||
code: generateCode(),
|
||||
name: generateName(values),
|
||||
@@ -229,17 +238,12 @@ export default function AtpModelsPage() {
|
||||
|
||||
if (values.files.length > 0) {
|
||||
const formData = new FormData();
|
||||
const JSZip = (await import("jszip")).default;
|
||||
const zip = new JSZip();
|
||||
for (const file of values.files) {
|
||||
const path = (file as any).webkitRelativePath || file.name;
|
||||
zip.file(path, file);
|
||||
formData.append("files", file);
|
||||
}
|
||||
const zipBlob = await zip.generateAsync({ type: "blob" });
|
||||
formData.append("archive", zipBlob, "model.zip");
|
||||
|
||||
const uploadResponse = await fetchWithAuth(
|
||||
`/api/v1/atp/assets/${createdAsset.id}/files/upload`,
|
||||
`/api/v1/atp/assets/${createdAsset.id}/files`,
|
||||
{
|
||||
method: "POST",
|
||||
body: formData,
|
||||
@@ -255,7 +259,7 @@ export default function AtpModelsPage() {
|
||||
},
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["atp-assets"] });
|
||||
setSuccess("模型已创建并上传");
|
||||
setSuccess("模型已创建并上传文件");
|
||||
setError("");
|
||||
setModalOpen(false);
|
||||
setFileList([]);
|
||||
@@ -501,14 +505,14 @@ export default function AtpModelsPage() {
|
||||
return (
|
||||
<Popconfirm
|
||||
title="删除模型"
|
||||
description="这会同时删除其版本与运行记录。"
|
||||
description="这会同时删除其文件与运行记录。"
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
okButtonProps={{ danger: true, loading: deleteLoading }}
|
||||
onConfirm={() => deleteMutation.mutate(item.id)}
|
||||
disabled={!canManage || rowBusy}
|
||||
>
|
||||
<Button danger size="small" loading={deleteLoading} disabled={!canManage || rowBusy}>
|
||||
<Button danger size="small" icon={<DeleteOutlined />} loading={deleteLoading} disabled={!canManage || rowBusy}>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
@@ -529,24 +533,42 @@ export default function AtpModelsPage() {
|
||||
relativePath?: string;
|
||||
};
|
||||
|
||||
const [assetsInCurrentPath, setAssetsInCurrentPath] = useState<AtpAssetSummary[]>([]);
|
||||
const matchingAssets = useMemo(() => {
|
||||
if (fileViewPath.length < 4) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const voltage = fileViewPath[0];
|
||||
const tower = fileViewPath[1];
|
||||
const scene = fileViewPath[2];
|
||||
const arrester = fileViewPath[3];
|
||||
|
||||
return assetItems.filter(
|
||||
(item) =>
|
||||
(item.voltage_level || "未分类") === voltage &&
|
||||
(item.tower_type || "未分类") === tower &&
|
||||
(item.scene_type || "未分类") === scene &&
|
||||
(item.arrester_config || "未分类") === arrester
|
||||
);
|
||||
}, [assetItems, fileViewPath]);
|
||||
|
||||
const filesQueries = useQueries({
|
||||
queries: assetsInCurrentPath.map((asset) => ({
|
||||
queries: matchingAssets.map((asset) => ({
|
||||
queryKey: ["atp-asset-files", asset.id],
|
||||
enabled: Boolean(user && canRead && fileViewPath.length >= 4),
|
||||
enabled: Boolean(user && canRead && displayMode === "file" && fileViewPath.length >= 4),
|
||||
queryFn: async () => {
|
||||
const response = await fetchWithAuth(`/api/v1/atp/assets/${asset.id}/files`);
|
||||
if (!response.ok) {
|
||||
return { assetId: asset.id, items: [] };
|
||||
return { asset_id: asset.id, release_id: null, storage_mount_code: "", storage_root_path: "", items: [], total: 0 } satisfies AtpAssetFileListResponse;
|
||||
}
|
||||
const data = (await response.json()) as { items: Array<{ relative_path: string; name: string; is_dir: boolean }> };
|
||||
return { assetId: asset.id, items: data.items || [] };
|
||||
return (await response.json()) as AtpAssetFileListResponse;
|
||||
},
|
||||
})),
|
||||
});
|
||||
|
||||
const getFileViewItems = useCallback((): FileViewItem[] => {
|
||||
const isFileViewLoading = displayMode === "file" && fileViewPath.length >= 4 && filesQueries.some((query) => query.isLoading);
|
||||
|
||||
const fileViewItems = useMemo<FileViewItem[]>(() => {
|
||||
const currentLevel = fileViewPath.length;
|
||||
|
||||
if (currentLevel === 0) {
|
||||
@@ -635,47 +657,27 @@ export default function AtpModelsPage() {
|
||||
}
|
||||
|
||||
if (currentLevel >= 4) {
|
||||
const voltage = fileViewPath[0];
|
||||
const tower = fileViewPath[1];
|
||||
const scene = fileViewPath[2];
|
||||
const arrester = fileViewPath[3];
|
||||
|
||||
const matchingAssets = assetItems.filter(
|
||||
(item) =>
|
||||
(item.voltage_level || "未分类") === voltage &&
|
||||
(item.tower_type || "未分类") === tower &&
|
||||
(item.scene_type || "未分类") === scene &&
|
||||
(item.arrester_config || "未分类") === arrester
|
||||
);
|
||||
|
||||
if (JSON.stringify(matchingAssets.map(a => a.id)) !== JSON.stringify(assetsInCurrentPath.map(a => a.id))) {
|
||||
setAssetsInCurrentPath(matchingAssets);
|
||||
if (filesQueries.some((query) => query.isLoading)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const allFilesLoaded = filesQueries.every((q) => !q.isLoading);
|
||||
if (!allFilesLoaded || filesQueries.some((q) => q.isLoading)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const allFiles: Array<{ name: string; relativePath: string; isDir: boolean; assetId: string }> = [];
|
||||
filesQueries.forEach((query) => {
|
||||
if (query.data && query.data.items) {
|
||||
query.data.items.forEach((file) => {
|
||||
allFiles.push({
|
||||
name: file.name,
|
||||
relativePath: file.relative_path,
|
||||
isDir: file.is_dir,
|
||||
assetId: query.data.assetId,
|
||||
});
|
||||
});
|
||||
const allFiles: Array<{ name: string; relativePath: string; isDir: boolean }> = [];
|
||||
filesQueries.forEach((query, index) => {
|
||||
const asset = matchingAssets[index];
|
||||
if (!asset || !query.data?.items) {
|
||||
return;
|
||||
}
|
||||
query.data.items.forEach((file) => {
|
||||
allFiles.push({
|
||||
name: file.name,
|
||||
relativePath: file.relative_path,
|
||||
isDir: file.is_dir,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
if (currentLevel === 4) {
|
||||
const rootFiles = allFiles.filter((file) => {
|
||||
return !file.relativePath.includes("/");
|
||||
});
|
||||
const rootFiles = allFiles.filter((file) => !file.relativePath.includes("/"));
|
||||
|
||||
const fileMap = new Map<string, typeof rootFiles[0]>();
|
||||
rootFiles.forEach((file) => {
|
||||
@@ -684,38 +686,6 @@ export default function AtpModelsPage() {
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(fileMap.values())
|
||||
.sort((a, b) => {
|
||||
if (a.isDir === b.isDir) return a.name.localeCompare(b.name, "zh-CN");
|
||||
return a.isDir ? -1 : 1;
|
||||
})
|
||||
.map((file) => ({
|
||||
type: file.isDir ? ("folder" as const) : ("file" as const),
|
||||
name: file.name,
|
||||
displayName: file.name,
|
||||
value: file.relativePath,
|
||||
isDir: file.isDir,
|
||||
relativePath: file.relativePath,
|
||||
}));
|
||||
} else {
|
||||
const pathInAsset = fileViewPath.slice(4).join("/");
|
||||
const prefix = pathInAsset + "/";
|
||||
|
||||
const filesInPath = allFiles.filter((file) => {
|
||||
if (!file.relativePath.startsWith(prefix)) {
|
||||
return false;
|
||||
}
|
||||
const remainder = file.relativePath.substring(prefix.length);
|
||||
return !remainder.includes("/");
|
||||
});
|
||||
|
||||
const fileMap = new Map<string, typeof filesInPath[0]>();
|
||||
filesInPath.forEach((file) => {
|
||||
if (!fileMap.has(file.name) || file.isDir) {
|
||||
fileMap.set(file.name, file);
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(fileMap.values())
|
||||
.sort((a, b) => {
|
||||
if (a.isDir === b.isDir) return a.name.localeCompare(b.name, "zh-CN");
|
||||
@@ -730,12 +700,42 @@ export default function AtpModelsPage() {
|
||||
relativePath: file.relativePath,
|
||||
}));
|
||||
}
|
||||
|
||||
const pathInAsset = fileViewPath.slice(4).join("/");
|
||||
const prefix = pathInAsset + "/";
|
||||
|
||||
const filesInPath = allFiles.filter((file) => {
|
||||
if (!file.relativePath.startsWith(prefix)) {
|
||||
return false;
|
||||
}
|
||||
const remainder = file.relativePath.substring(prefix.length);
|
||||
return !remainder.includes("/");
|
||||
});
|
||||
|
||||
const fileMap = new Map<string, typeof filesInPath[0]>();
|
||||
filesInPath.forEach((file) => {
|
||||
if (!fileMap.has(file.name) || file.isDir) {
|
||||
fileMap.set(file.name, file);
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(fileMap.values())
|
||||
.sort((a, b) => {
|
||||
if (a.isDir === b.isDir) return a.name.localeCompare(b.name, "zh-CN");
|
||||
return a.isDir ? -1 : 1;
|
||||
})
|
||||
.map((file) => ({
|
||||
type: file.isDir ? ("folder" as const) : ("file" as const),
|
||||
name: file.name,
|
||||
displayName: file.name,
|
||||
value: file.relativePath,
|
||||
isDir: file.isDir,
|
||||
relativePath: file.relativePath,
|
||||
}));
|
||||
}
|
||||
|
||||
return [];
|
||||
}, [assetItems, fileViewPath, assetsInCurrentPath, filesQueries]);
|
||||
|
||||
const fileViewItems = useMemo(() => getFileViewItems(), [getFileViewItems]);
|
||||
}, [assetItems, fileViewPath, matchingAssets, filesQueries]);
|
||||
|
||||
const handleFileViewItemClick = (item: FileViewItem) => {
|
||||
if (item.type === "folder") {
|
||||
@@ -785,7 +785,7 @@ export default function AtpModelsPage() {
|
||||
onClick={() => {
|
||||
Modal.confirm({
|
||||
title: "删除模型",
|
||||
content: "这会同时删除其版本与运行记录。",
|
||||
content: "这会同时删除其文件与运行记录。",
|
||||
okText: "删除",
|
||||
cancelText: "取消",
|
||||
okButtonProps: { danger: true },
|
||||
@@ -917,7 +917,7 @@ export default function AtpModelsPage() {
|
||||
/>
|
||||
</div>
|
||||
<div style={{ maxHeight: `${tableScrollY}px`, overflow: "auto", border: "1px solid #f0f0f0", borderRadius: "4px" }}>
|
||||
{assetsQuery.isLoading || assetsQuery.isFetching ? (
|
||||
{assetsQuery.isLoading || assetsQuery.isFetching || isFileViewLoading ? (
|
||||
<div style={{ textAlign: "center", padding: "40px 0" }}>
|
||||
<Spin tip="加载中..." />
|
||||
</div>
|
||||
@@ -972,7 +972,7 @@ export default function AtpModelsPage() {
|
||||
{item.item && (
|
||||
<Popconfirm
|
||||
title="删除模型"
|
||||
description="这会同时删除其版本与运行记录。"
|
||||
description="这会同时删除其文件与运行记录。"
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
okButtonProps={{ danger: true }}
|
||||
@@ -1124,14 +1124,12 @@ export default function AtpModelsPage() {
|
||||
<div>
|
||||
<Upload
|
||||
beforeUpload={(file) => {
|
||||
setFileList((prev) => [...prev, file]);
|
||||
setFileList([file]);
|
||||
return false;
|
||||
}}
|
||||
directory
|
||||
multiple
|
||||
showUploadList={false}
|
||||
>
|
||||
<Button icon={<UploadOutlined />}>选择文件夹</Button>
|
||||
<Button icon={<UploadOutlined />}>选择文件</Button>
|
||||
</Upload>
|
||||
{fileList.length > 0 && (
|
||||
<div style={{
|
||||
@@ -1154,7 +1152,7 @@ export default function AtpModelsPage() {
|
||||
borderBottom: index < fileList.length - 1 ? '1px solid #f0f0f0' : 'none',
|
||||
wordBreak: 'break-all'
|
||||
}}>
|
||||
{(file as any).webkitRelativePath || file.name}
|
||||
{getUploadRelativePath(file)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -919,6 +919,8 @@ export type AtpAssetSummary = {
|
||||
tower_type: string | null;
|
||||
scene_type: string | null;
|
||||
arrester_config: string | null;
|
||||
storage_mount_code: string | null;
|
||||
storage_root_path: string | null;
|
||||
latest_release_no: number;
|
||||
active_release_no: number | null;
|
||||
active_release_id: string | null;
|
||||
|
||||
Reference in New Issue
Block a user