[fix]:[FL-216][ATP模型管理: 去掉版本概念,上传文件直接展示]

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
chengkai3
2026-06-28 19:37:49 +08:00
parent 2b030f9b21
commit 18ed6ede30
8 changed files with 359 additions and 148 deletions
+16 -16
View File
@@ -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,
+36
View File
@@ -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)
+2
View File
@@ -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)
+6
View File
@@ -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):
+141 -35
View File
@@ -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
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:
+61
View File
@@ -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()
+57 -59
View File
@@ -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 }> = [];
filesQueries.forEach((query, index) => {
const asset = matchingAssets[index];
if (!asset || !query.data?.items) {
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,
});
});
}
});
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) => {
@@ -697,7 +699,8 @@ export default function AtpModelsPage() {
isDir: file.isDir,
relativePath: file.relativePath,
}));
} else {
}
const pathInAsset = fileViewPath.slice(4).join("/");
const prefix = pathInAsset + "/";
@@ -730,12 +733,9 @@ export default function AtpModelsPage() {
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>
+2
View File
@@ -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;