diff --git a/api/app/api/v1/atp_assets.py b/api/app/api/v1/atp_assets.py index 10e5134..c472dbd 100644 --- a/api/app/api/v1/atp_assets.py +++ b/api/app/api/v1/atp_assets.py @@ -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, diff --git a/api/app/core/database.py b/api/app/core/database.py index d7ba6c3..a1f27e2 100644 --- a/api/app/core/database.py +++ b/api/app/core/database.py @@ -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) diff --git a/api/app/models/atp_asset.py b/api/app/models/atp_asset.py index 99d7ae2..02c4426 100644 --- a/api/app/models/atp_asset.py +++ b/api/app/models/atp_asset.py @@ -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) diff --git a/api/app/schemas/atp_asset.py b/api/app/schemas/atp_asset.py index 0d83507..2adbc95 100644 --- a/api/app/schemas/atp_asset.py +++ b/api/app/schemas/atp_asset.py @@ -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): diff --git a/api/app/services/atp_asset_service.py b/api/app/services/atp_asset_service.py index 96f077a..c985254 100644 --- a/api/app/services/atp_asset_service.py +++ b/api/app/services/atp_asset_service.py @@ -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: diff --git a/api/tests/test_atp_asset_service.py b/api/tests/test_atp_asset_service.py index ce0ee13..4e4dfc8 100644 --- a/api/tests/test_atp_asset_service.py +++ b/api/tests/test_atp_asset_service.py @@ -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() diff --git a/web/src/app/admin/atp-models/page.tsx b/web/src/app/admin/atp-models/page.tsx index 4dd5f42..cfce7c9 100644 --- a/web/src/app/admin/atp-models/page.tsx +++ b/web/src/app/admin/atp-models/page.tsx @@ -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>; @@ -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 ( deleteMutation.mutate(item.id)} disabled={!canManage || rowBusy} > - @@ -529,24 +533,42 @@ export default function AtpModelsPage() { relativePath?: string; }; - const [assetsInCurrentPath, setAssetsInCurrentPath] = useState([]); + 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(() => { 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(); 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(); - 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(); + 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() { />
- {assetsQuery.isLoading || assetsQuery.isFetching ? ( + {assetsQuery.isLoading || assetsQuery.isFetching || isFileViewLoading ? (
@@ -972,7 +972,7 @@ export default function AtpModelsPage() { {item.item && ( { - setFileList((prev) => [...prev, file]); + setFileList([file]); return false; }} - directory - multiple showUploadList={false} > - + {fileList.length > 0 && (
- {(file as any).webkitRelativePath || file.name} + {getUploadRelativePath(file)}
))}
diff --git a/web/src/types/auth.ts b/web/src/types/auth.ts index 9ec01ca..f420230 100644 --- a/web/src/types/auth.ts +++ b/web/src/types/auth.ts @@ -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;