[fix/feat]:[FL-82][ATP模型管理改造]
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ...core.database import get_db
|
||||
@@ -13,6 +13,7 @@ from ...schemas.atp_asset import (
|
||||
AtpAssetReleaseCreateRequest,
|
||||
AtpAssetReleaseDetail,
|
||||
AtpAssetReleaseListResponse,
|
||||
AtpAssetReleaseUpdateRequest,
|
||||
AtpAssetRunDetail,
|
||||
AtpAssetRunListResponse,
|
||||
AtpAssetRunRequest,
|
||||
@@ -23,6 +24,7 @@ from ...services.atp_asset_service import (
|
||||
activate_release,
|
||||
create_asset,
|
||||
create_release,
|
||||
create_release_from_archive,
|
||||
delete_asset,
|
||||
get_asset_by_id,
|
||||
get_release_by_id,
|
||||
@@ -150,6 +152,32 @@ def create_atp_asset_release_endpoint(
|
||||
return create_release(db, asset_id=asset_id, payload=payload, actor_user_id=current_user.user.id)
|
||||
|
||||
|
||||
@router.post("/assets/{asset_id}/releases/upload", response_model=AtpAssetReleaseDetail)
|
||||
def upload_atp_asset_release_endpoint(
|
||||
asset_id: str,
|
||||
release_tag: str | None = Form(default=None),
|
||||
archive: UploadFile = File(...),
|
||||
current_user: CurrentUser = Depends(require_permission("atp.manage")),
|
||||
db: Session = Depends(get_db),
|
||||
) -> AtpAssetReleaseDetail:
|
||||
try:
|
||||
archive_content = archive.file.read()
|
||||
finally:
|
||||
try:
|
||||
archive.file.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return create_release_from_archive(
|
||||
db,
|
||||
asset_id=asset_id,
|
||||
release_tag=release_tag,
|
||||
archive_filename=archive.filename or "release.zip",
|
||||
archive_content=archive_content,
|
||||
actor_user_id=current_user.user.id,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/releases", response_model=AtpAssetReleaseListResponse)
|
||||
def get_atp_release_list(
|
||||
active_only: bool = Query(default=False),
|
||||
|
||||
@@ -21,7 +21,6 @@ class AtpAssetSummary(BaseModel):
|
||||
voltage_level: str | None = None
|
||||
tower_type: str | None = None
|
||||
scene_type: str | None = None
|
||||
tags_json: list[str] = Field(default_factory=list)
|
||||
latest_release_no: int = 0
|
||||
active_release_no: int | None = None
|
||||
active_release_id: str | None = None
|
||||
@@ -53,7 +52,6 @@ class AtpAssetCreateRequest(BaseModel):
|
||||
voltage_level: str | None = Field(default=None, max_length=16)
|
||||
tower_type: str | None = Field(default=None, max_length=64)
|
||||
scene_type: str | None = Field(default=None, max_length=32)
|
||||
tags_json: list[str] = Field(default_factory=list, max_length=128)
|
||||
|
||||
|
||||
class AtpAssetUpdateRequest(BaseModel):
|
||||
@@ -63,7 +61,6 @@ class AtpAssetUpdateRequest(BaseModel):
|
||||
voltage_level: str | None = Field(default=None, max_length=16)
|
||||
tower_type: str | None = Field(default=None, max_length=64)
|
||||
scene_type: str | None = Field(default=None, max_length=32)
|
||||
tags_json: list[str] | None = Field(default=None, max_length=128)
|
||||
|
||||
|
||||
class AtpAssetReleaseSummary(BaseModel):
|
||||
|
||||
@@ -2,14 +2,17 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import mimetypes
|
||||
import os
|
||||
from pathlib import Path
|
||||
from pathlib import Path, PurePosixPath
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import zipfile
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
@@ -58,6 +61,7 @@ VALID_RELEASE_STATUS = {"draft", "released", "archived"}
|
||||
VALID_RUNNER_KIND = {"atp", "egm", "hybrid"}
|
||||
VALID_RUN_STATUS = {"pending", "running", "success", "failed"}
|
||||
LOG_MAX_CHARS = 200_000
|
||||
ATP_ASSET_RELEASES_ROOT = "/atp-library/releases"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@@ -101,18 +105,6 @@ def _normalize_optional_str(value: str | None) -> str | None:
|
||||
return normalized or None
|
||||
|
||||
|
||||
def _normalize_tags(values: list[str] | None) -> list[str]:
|
||||
if not values:
|
||||
return []
|
||||
dedup: dict[str, None] = {}
|
||||
for candidate in values:
|
||||
normalized = candidate.strip()
|
||||
if not normalized:
|
||||
continue
|
||||
dedup[normalized] = None
|
||||
return list(dedup.keys())[:128]
|
||||
|
||||
|
||||
def _normalize_relative_path(value: str | None) -> str | None:
|
||||
normalized = _normalize_optional_str(value)
|
||||
if normalized is None:
|
||||
@@ -246,6 +238,96 @@ def _walk_storage_tree(driver: StorageDriver, root_path: str) -> StorageTree:
|
||||
)
|
||||
|
||||
|
||||
def _parent_virtual_path(path: str) -> str:
|
||||
normalized = normalize_virtual_path(path)
|
||||
if normalized == "/":
|
||||
return "/"
|
||||
parent = normalized.rsplit("/", 1)[0]
|
||||
return parent if parent else "/"
|
||||
|
||||
|
||||
def _normalize_archive_member_path(value: str) -> str | None:
|
||||
normalized = value.replace("\\", "/").strip()
|
||||
if not normalized:
|
||||
return None
|
||||
|
||||
parts: list[str] = []
|
||||
for part in PurePosixPath(normalized).parts:
|
||||
if part in {"", ".", "/"}:
|
||||
continue
|
||||
if part == "..":
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"Archive entry escapes target path: {value}")
|
||||
parts.append(part)
|
||||
return "/".join(parts) or None
|
||||
|
||||
|
||||
def _sanitize_storage_segment(value: str, *, fallback: str) -> str:
|
||||
normalized = _normalize_optional_str(value) or fallback
|
||||
normalized = re.sub(r"[^A-Za-z0-9._-]+", "-", normalized).strip(".-")
|
||||
return normalized or fallback
|
||||
|
||||
|
||||
def _build_release_storage_root(asset_code: str, release_no: int) -> str:
|
||||
asset_segment = _sanitize_storage_segment(asset_code, fallback="asset")
|
||||
return normalize_virtual_path(f"{ATP_ASSET_RELEASES_ROOT}/{asset_segment}/r{release_no}")
|
||||
|
||||
|
||||
def _write_archive_to_storage(
|
||||
driver: StorageDriver,
|
||||
*,
|
||||
storage_root_path: str,
|
||||
archive_filename: str,
|
||||
archive_content: bytes,
|
||||
) -> int:
|
||||
filename = (archive_filename or "").strip().lower()
|
||||
if filename and not filename.endswith(".zip"):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Release ZIP 包必须是 zip 格式")
|
||||
if not archive_content:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Release ZIP 包不能为空")
|
||||
|
||||
driver.ensure_directory(storage_root_path)
|
||||
extracted_count = 0
|
||||
try:
|
||||
with zipfile.ZipFile(io.BytesIO(archive_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"{storage_root_path.rstrip('/')}/{relative_path}")
|
||||
driver.ensure_directory(_parent_virtual_path(target_path))
|
||||
try:
|
||||
content = archive.read(member)
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"读取 ZIP 条目失败: {member.filename}: {exc}",
|
||||
) from exc
|
||||
driver.write_file(
|
||||
target_path,
|
||||
content=content,
|
||||
content_type=mimetypes.guess_type(relative_path)[0],
|
||||
)
|
||||
extracted_count += 1
|
||||
except zipfile.BadZipFile as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"Release ZIP 文件损坏: {exc}") from exc
|
||||
|
||||
if extracted_count <= 0:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Release ZIP 包中没有可导入文件")
|
||||
return extracted_count
|
||||
|
||||
|
||||
def _resolve_runner_kind_from_tree(tree: StorageTree) -> str:
|
||||
detected_entry = _auto_detect_entry_file(tree)
|
||||
detected_egm_subdir = _auto_detect_egm_subdir(tree)
|
||||
if detected_entry and detected_egm_subdir:
|
||||
return "hybrid"
|
||||
if detected_egm_subdir:
|
||||
return "egm"
|
||||
return "atp"
|
||||
|
||||
|
||||
def _auto_detect_entry_file(tree: StorageTree) -> str | None:
|
||||
preferred = "work.atp"
|
||||
if preferred in tree.file_paths:
|
||||
@@ -410,7 +492,6 @@ def serialize_asset(
|
||||
voltage_level=item.voltage_level,
|
||||
tower_type=item.tower_type,
|
||||
scene_type=item.scene_type,
|
||||
tags_json=item.tags_json or [],
|
||||
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,
|
||||
@@ -644,7 +725,6 @@ def create_asset(db: Session, payload: AtpAssetCreateRequest, *, actor_user_id:
|
||||
voltage_level=_normalize_optional_str(payload.voltage_level),
|
||||
tower_type=_normalize_optional_str(payload.tower_type),
|
||||
scene_type=_normalize_optional_str(payload.scene_type),
|
||||
tags_json=_normalize_tags(payload.tags_json),
|
||||
latest_release_no=0,
|
||||
active_release_no=None,
|
||||
create_user=actor_user_id,
|
||||
@@ -687,8 +767,6 @@ def update_asset(
|
||||
item.tower_type = _normalize_optional_str(update_data["tower_type"])
|
||||
if "scene_type" in update_data:
|
||||
item.scene_type = _normalize_optional_str(update_data["scene_type"])
|
||||
if "tags_json" in update_data:
|
||||
item.tags_json = _normalize_tags(update_data["tags_json"])
|
||||
|
||||
item.update_user = actor_user_id
|
||||
item.update_date = utcnow()
|
||||
@@ -766,6 +844,18 @@ def get_release_by_id(db: Session, release_id: str) -> AtpAssetRelease | None:
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def _require_asset_dimensions(asset: AtpAsset) -> tuple[str, str, str]:
|
||||
voltage_level = _normalize_optional_str(asset.voltage_level)
|
||||
tower_type = _normalize_optional_str(asset.tower_type)
|
||||
scene_type = _normalize_optional_str(asset.scene_type)
|
||||
if not voltage_level or not tower_type or not scene_type:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="请先为模型补齐电压等级、塔型和场景后再创建 Release",
|
||||
)
|
||||
return voltage_level, tower_type, scene_type
|
||||
|
||||
|
||||
def create_release(
|
||||
db: Session,
|
||||
*,
|
||||
@@ -842,6 +932,55 @@ def create_release(
|
||||
return serialize_release_detail(saved)
|
||||
|
||||
|
||||
def create_release_from_archive(
|
||||
db: Session,
|
||||
*,
|
||||
asset_id: str,
|
||||
release_tag: str | None,
|
||||
archive_filename: str,
|
||||
archive_content: bytes,
|
||||
actor_user_id: str,
|
||||
) -> AtpAssetReleaseDetail:
|
||||
asset = get_asset_by_id(db, asset_id)
|
||||
if not asset:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Asset not found")
|
||||
|
||||
voltage_level, tower_type, scene_type = _require_asset_dimensions(asset)
|
||||
next_release_no = int(
|
||||
db.scalar(select(func.max(AtpAssetRelease.release_no)).where(AtpAssetRelease.asset_id == asset_id)) or 0
|
||||
) + 1
|
||||
storage_root_path = _build_release_storage_root(asset.code, next_release_no)
|
||||
|
||||
mount = _resolve_mount(db, "main")
|
||||
driver = _build_driver_or_400(mount)
|
||||
_write_archive_to_storage(
|
||||
driver,
|
||||
storage_root_path=storage_root_path,
|
||||
archive_filename=archive_filename,
|
||||
archive_content=archive_content,
|
||||
)
|
||||
|
||||
try:
|
||||
tree = _walk_storage_tree(driver, storage_root_path)
|
||||
payload = AtpAssetReleaseCreateRequest(
|
||||
release_tag=_normalize_optional_str(release_tag),
|
||||
status="released",
|
||||
voltage_level=voltage_level,
|
||||
tower_type=tower_type,
|
||||
scene_type=scene_type,
|
||||
runner_kind=_resolve_runner_kind_from_tree(tree), # type: ignore[arg-type]
|
||||
storage_mount_code="main",
|
||||
storage_root_path=storage_root_path,
|
||||
)
|
||||
return create_release(db, asset_id=asset_id, payload=payload, actor_user_id=actor_user_id)
|
||||
except Exception:
|
||||
try:
|
||||
driver.delete_path(storage_root_path, is_dir=True, recursive=True)
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
def update_release(
|
||||
db: Session,
|
||||
*,
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
from pathlib import Path
|
||||
import zipfile
|
||||
|
||||
from fastapi import HTTPException
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
@@ -55,6 +59,14 @@ def _seed_vfs_mount(session: Session, *, root_dir: Path) -> None:
|
||||
session.commit()
|
||||
|
||||
|
||||
def _build_zip(entries: dict[str, bytes]) -> bytes:
|
||||
buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(buffer, mode="w", compression=zipfile.ZIP_DEFLATED) as archive:
|
||||
for path, content in entries.items():
|
||||
archive.writestr(path, content)
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def test_create_release_auto_detects_entry_file_and_manifest(tmp_path) -> None:
|
||||
testing_session = _build_sessionmaker()
|
||||
session: Session = testing_session()
|
||||
@@ -94,6 +106,77 @@ def test_create_release_auto_detects_entry_file_and_manifest(tmp_path) -> None:
|
||||
session.close()
|
||||
|
||||
|
||||
def test_create_release_from_archive_extracts_zip_and_inherits_asset_dimensions(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-UPLOAD",
|
||||
name="ZIP 导入模型",
|
||||
voltage_level="220",
|
||||
tower_type="sihuita",
|
||||
scene_type="raoji3",
|
||||
),
|
||||
actor_user_id="tester",
|
||||
)
|
||||
assert asset is not None
|
||||
|
||||
created = atp_asset_service.create_release_from_archive(
|
||||
session,
|
||||
asset_id=asset.id,
|
||||
release_tag="首版",
|
||||
archive_filename="release.zip",
|
||||
archive_content=_build_zip(
|
||||
{
|
||||
"work.atp": b"ATP INPUT",
|
||||
"EGM/config.txt": b"egm",
|
||||
}
|
||||
),
|
||||
actor_user_id="tester",
|
||||
)
|
||||
|
||||
assert created.release_no == 1
|
||||
assert created.release_tag == "首版"
|
||||
assert created.storage_root_path == "/atp-library/releases/ATP-ASSET-UPLOAD/r1"
|
||||
assert created.entry_file == "work.atp"
|
||||
assert created.runner_kind == "hybrid"
|
||||
assert created.voltage_level == "220"
|
||||
assert created.tower_type == "sihuita"
|
||||
assert created.scene_type == "raoji3"
|
||||
assert (tmp_path / "vfs" / "atp-library" / "releases" / "ATP-ASSET-UPLOAD" / "r1" / "work.atp").exists()
|
||||
assert (tmp_path / "vfs" / "atp-library" / "releases" / "ATP-ASSET-UPLOAD" / "r1" / "EGM" / "config.txt").exists()
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def test_create_release_from_archive_requires_asset_dimensions(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-MISSING", name="缺维度模型"),
|
||||
actor_user_id="tester",
|
||||
)
|
||||
assert asset is not None
|
||||
|
||||
with pytest.raises(HTTPException, match="电压等级、塔型和场景"):
|
||||
atp_asset_service.create_release_from_archive(
|
||||
session,
|
||||
asset_id=asset.id,
|
||||
release_tag="r1",
|
||||
archive_filename="release.zip",
|
||||
archive_content=_build_zip({"work.atp": b"ATP INPUT"}),
|
||||
actor_user_id="tester",
|
||||
)
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def test_run_release_dry_run_materializes_directory(tmp_path, monkeypatch) -> None:
|
||||
testing_session = _build_sessionmaker()
|
||||
monkeypatch.setattr(core_database, "SessionLocal", testing_session)
|
||||
|
||||
@@ -18,14 +18,22 @@ import {
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
Upload,
|
||||
} from "antd";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import type { UploadFile } from "antd/es/upload/interface";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { AdminPageLoading } from "@/components/admin-page-loading";
|
||||
import { useAuth } from "@/components/auth-provider";
|
||||
import { Card } from "@/components/ui-antd";
|
||||
import { readApiError } from "@/lib/api";
|
||||
import {
|
||||
getAtpAssetStatusDisplay,
|
||||
getAtpReleaseStatusDisplay,
|
||||
getAtpRunStatusDisplay,
|
||||
getAtpRunnerKindLabel,
|
||||
} from "@/lib/atp-asset-display";
|
||||
import type {
|
||||
AtpAssetFileEntry,
|
||||
AtpAssetFileListResponse,
|
||||
@@ -42,19 +50,6 @@ import type {
|
||||
type ReleaseFormValues = {
|
||||
release_tag: string;
|
||||
status: "draft" | "released" | "archived";
|
||||
voltage_level: string;
|
||||
tower_type: string;
|
||||
scene_type: string;
|
||||
scenario_code: string;
|
||||
runner_kind: "atp" | "egm" | "hybrid";
|
||||
storage_mount_code: string;
|
||||
storage_root_path: string;
|
||||
entry_file: string;
|
||||
result_file: string;
|
||||
egm_subdir: string;
|
||||
egm_result_file: string;
|
||||
preprocess_script: string;
|
||||
postprocess_script: string;
|
||||
};
|
||||
|
||||
type RunFormValues = {
|
||||
@@ -66,19 +61,6 @@ type RunFormValues = {
|
||||
const EMPTY_RELEASE_FORM: ReleaseFormValues = {
|
||||
release_tag: "",
|
||||
status: "released",
|
||||
voltage_level: "",
|
||||
tower_type: "",
|
||||
scene_type: "",
|
||||
scenario_code: "",
|
||||
runner_kind: "atp",
|
||||
storage_mount_code: "main",
|
||||
storage_root_path: "",
|
||||
entry_file: "",
|
||||
result_file: "",
|
||||
egm_subdir: "",
|
||||
egm_result_file: "",
|
||||
preprocess_script: "",
|
||||
postprocess_script: "",
|
||||
};
|
||||
|
||||
const EMPTY_RUN_FORM: RunFormValues = {
|
||||
@@ -98,52 +80,17 @@ function formatDateTime(value: string | null | undefined): string {
|
||||
return date.toLocaleString("zh-CN", { hour12: false });
|
||||
}
|
||||
|
||||
function statusColor(value: string): string {
|
||||
if (value === "enabled" || value === "released" || value === "success") return "green";
|
||||
if (value === "draft" || value === "running") return "gold";
|
||||
if (value === "pending") return "blue";
|
||||
if (value === "disabled") return "default";
|
||||
if (value === "failed" || value === "archived") return "red";
|
||||
return "blue";
|
||||
}
|
||||
|
||||
function toReleaseFormValues(item: AtpAssetReleaseSummary): ReleaseFormValues {
|
||||
return {
|
||||
release_tag: item.release_tag ?? "",
|
||||
status: item.status,
|
||||
voltage_level: item.voltage_level,
|
||||
tower_type: item.tower_type,
|
||||
scene_type: item.scene_type,
|
||||
scenario_code: item.scenario_code ?? "",
|
||||
runner_kind: item.runner_kind,
|
||||
storage_mount_code: item.storage_mount_code,
|
||||
storage_root_path: item.storage_root_path,
|
||||
entry_file: item.entry_file ?? "",
|
||||
result_file: item.result_file ?? "",
|
||||
egm_subdir: item.egm_subdir ?? "",
|
||||
egm_result_file: item.egm_result_file ?? "",
|
||||
preprocess_script: item.preprocess_script ?? "",
|
||||
postprocess_script: item.postprocess_script ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
function buildReleasePayload(values: ReleaseFormValues) {
|
||||
function buildReleasePatch(values: ReleaseFormValues) {
|
||||
return {
|
||||
release_tag: values.release_tag.trim() || null,
|
||||
status: values.status,
|
||||
voltage_level: values.voltage_level.trim(),
|
||||
tower_type: values.tower_type.trim(),
|
||||
scene_type: values.scene_type.trim(),
|
||||
scenario_code: values.scenario_code.trim() || null,
|
||||
runner_kind: values.runner_kind,
|
||||
storage_mount_code: values.storage_mount_code.trim(),
|
||||
storage_root_path: values.storage_root_path.trim(),
|
||||
entry_file: values.entry_file.trim() || null,
|
||||
result_file: values.result_file.trim() || null,
|
||||
egm_subdir: values.egm_subdir.trim() || null,
|
||||
egm_result_file: values.egm_result_file.trim() || null,
|
||||
preprocess_script: values.preprocess_script.trim() || null,
|
||||
postprocess_script: values.postprocess_script.trim() || null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -170,7 +117,8 @@ export default function AtpAssetDetailPage() {
|
||||
const [releaseModalOpen, setReleaseModalOpen] = useState(false);
|
||||
const [runModalOpen, setRunModalOpen] = useState(false);
|
||||
const [editingRelease, setEditingRelease] = useState<AtpAssetReleaseSummary | null>(null);
|
||||
const [selectedReleaseIdState, setSelectedReleaseIdState] = useState<string>("");
|
||||
const [selectedReleaseIdState, setSelectedReleaseIdState] = useState("");
|
||||
const [releaseArchiveFileList, setReleaseArchiveFileList] = useState<UploadFile[]>([]);
|
||||
|
||||
const canRead = hasPermission("atp.read") || hasPermission("atp.run") || hasPermission("atp.manage");
|
||||
const canRun = hasPermission("atp.run") || hasPermission("atp.manage");
|
||||
@@ -244,10 +192,10 @@ export default function AtpAssetDetailPage() {
|
||||
});
|
||||
|
||||
const runsQuery = useQuery({
|
||||
queryKey: ["atp-release-runs", selectedReleaseIdState],
|
||||
enabled: Boolean(user && canRead && selectedReleaseIdState),
|
||||
queryKey: ["atp-release-runs", selectedReleaseId],
|
||||
enabled: Boolean(user && canRead && selectedReleaseId),
|
||||
queryFn: async () => {
|
||||
const response = await fetchWithAuth(`/api/v1/atp/releases/${selectedReleaseIdState}/runs`);
|
||||
const response = await fetchWithAuth(`/api/v1/atp/releases/${selectedReleaseId}/runs`);
|
||||
if (!response.ok) {
|
||||
throw new Error(await readApiError(response));
|
||||
}
|
||||
@@ -257,18 +205,30 @@ export default function AtpAssetDetailPage() {
|
||||
|
||||
const saveReleaseMutation = useMutation({
|
||||
mutationFn: async (values: ReleaseFormValues) => {
|
||||
const payload = buildReleasePayload(values);
|
||||
const response = editingRelease
|
||||
? await fetchWithAuth(`/api/v1/atp/releases/${editingRelease.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
: await fetchWithAuth(`/api/v1/atp/assets/${assetId}/releases`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (editingRelease) {
|
||||
const response = await fetchWithAuth(`/api/v1/atp/releases/${editingRelease.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(buildReleasePatch(values)),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(await readApiError(response));
|
||||
}
|
||||
return (await response.json()) as AtpAssetReleaseDetail;
|
||||
}
|
||||
|
||||
const archiveFile = releaseArchiveFileList[0]?.originFileObj;
|
||||
if (!(archiveFile instanceof File)) {
|
||||
throw new Error("请上传 Release ZIP 包");
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("release_tag", values.release_tag.trim());
|
||||
formData.append("archive", archiveFile);
|
||||
const response = await fetchWithAuth(`/api/v1/atp/assets/${assetId}/releases/upload`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(await readApiError(response));
|
||||
}
|
||||
@@ -278,14 +238,17 @@ export default function AtpAssetDetailPage() {
|
||||
void queryClient.invalidateQueries({ queryKey: ["atp-asset-detail", assetId] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["atp-asset-releases", assetId] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["atp-release-detail", result.id] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["atp-release-files", result.id] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["atp-release-runs", result.id] });
|
||||
setSelectedReleaseIdState(result.id);
|
||||
setReleaseModalOpen(false);
|
||||
setEditingRelease(null);
|
||||
setReleaseArchiveFileList([]);
|
||||
releaseForm.resetFields();
|
||||
message.success(editingRelease ? "Release 已更新" : "Release 已创建");
|
||||
},
|
||||
onError: (candidate) => {
|
||||
message.error(candidate instanceof Error ? candidate.message : "保存 release 失败");
|
||||
message.error(candidate instanceof Error ? candidate.message : "保存 Release 失败");
|
||||
},
|
||||
});
|
||||
|
||||
@@ -299,10 +262,10 @@ export default function AtpAssetDetailPage() {
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["atp-asset-detail", assetId] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["atp-asset-releases", assetId] });
|
||||
message.success("已切换当前激活 release");
|
||||
message.success("已切换当前激活 Release");
|
||||
},
|
||||
onError: (candidate) => {
|
||||
message.error(candidate instanceof Error ? candidate.message : "激活 release 失败");
|
||||
message.error(candidate instanceof Error ? candidate.message : "激活 Release 失败");
|
||||
},
|
||||
});
|
||||
|
||||
@@ -328,7 +291,6 @@ export default function AtpAssetDetailPage() {
|
||||
message.error(candidate instanceof Error ? candidate.message : "提交运行任务失败");
|
||||
},
|
||||
});
|
||||
const releaseDetail = releaseDetailQuery.data ?? null;
|
||||
|
||||
const releaseColumns = useMemo<ColumnsType<AtpAssetReleaseSummary>>(
|
||||
() => [
|
||||
@@ -339,37 +301,34 @@ export default function AtpAssetDetailPage() {
|
||||
<Space direction="vertical" size={0}>
|
||||
<Typography.Text strong>{item.release_tag || `r${item.release_no}`}</Typography.Text>
|
||||
<Typography.Text type="secondary">
|
||||
{item.runner_kind} / {item.storage_mount_code}
|
||||
{getAtpRunnerKindLabel(item.runner_kind)} / {item.storage_mount_code}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "维度",
|
||||
key: "dimensions",
|
||||
render: (_, item) => (
|
||||
<Space size={[4, 4]} wrap>
|
||||
<Tag>{item.voltage_level}</Tag>
|
||||
<Tag>{item.tower_type}</Tag>
|
||||
<Tag>{item.scene_type}</Tag>
|
||||
{item.scenario_code ? <Tag color="blue">{item.scenario_code}</Tag> : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "存储根",
|
||||
title: "存储目录",
|
||||
dataIndex: "storage_root_path",
|
||||
render: (value: string) => <Typography.Text code>{value}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
title: "状态",
|
||||
key: "status",
|
||||
render: (_, item) => (
|
||||
<Space wrap>
|
||||
<Tag color={statusColor(item.status)}>{item.status}</Tag>
|
||||
{item.is_active ? <Tag color="green">active</Tag> : null}
|
||||
</Space>
|
||||
),
|
||||
render: (_, item) => {
|
||||
const display = getAtpReleaseStatusDisplay(item.status);
|
||||
return (
|
||||
<Space wrap>
|
||||
<Tag color={display.color}>{display.label}</Tag>
|
||||
{item.is_active ? <Tag color="green">当前生效</Tag> : null}
|
||||
{item.scenario_code ? <Tag color="blue">{item.scenario_code}</Tag> : null}
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "更新时间",
|
||||
dataIndex: "update_date",
|
||||
render: (value: string) => formatDateTime(value),
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
@@ -384,6 +343,7 @@ export default function AtpAssetDetailPage() {
|
||||
disabled={!canManage}
|
||||
onClick={() => {
|
||||
setEditingRelease(item);
|
||||
setReleaseArchiveFileList([]);
|
||||
releaseForm.setFieldsValue(toReleaseFormValues(item));
|
||||
setReleaseModalOpen(true);
|
||||
}}
|
||||
@@ -426,19 +386,24 @@ export default function AtpAssetDetailPage() {
|
||||
{
|
||||
title: "状态",
|
||||
key: "status",
|
||||
render: (_, item) => (
|
||||
<Space direction="vertical" size={0}>
|
||||
<Tag color={statusColor(item.status)}>{item.status}</Tag>
|
||||
<Typography.Text type="secondary">{formatDateTime(item.create_date)}</Typography.Text>
|
||||
</Space>
|
||||
),
|
||||
render: (_, item) => {
|
||||
const display = getAtpRunStatusDisplay(item.status);
|
||||
return (
|
||||
<Space direction="vertical" size={0}>
|
||||
<Tag color={display.color}>{display.label}</Tag>
|
||||
<Typography.Text type="secondary">{formatDateTime(item.create_date)}</Typography.Text>
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "执行信息",
|
||||
key: "execution",
|
||||
render: (_, item) => (
|
||||
<Space direction="vertical" size={0}>
|
||||
<Typography.Text>{item.runner_kind} / {item.engine_mode}</Typography.Text>
|
||||
<Typography.Text>
|
||||
{getAtpRunnerKindLabel(item.runner_kind)} / {item.engine_mode}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary">
|
||||
{item.timeout_seconds}s / exit {item.exit_code ?? "-"}
|
||||
</Typography.Text>
|
||||
@@ -460,27 +425,27 @@ export default function AtpAssetDetailPage() {
|
||||
);
|
||||
|
||||
if (initializing) {
|
||||
return <AdminPageLoading tip="加载 ATP 资料包详情中..." minHeightClassName="min-h-[280px]" />;
|
||||
return <AdminPageLoading tip="加载 ATP 模型详情中..." minHeightClassName="min-h-[280px]" />;
|
||||
}
|
||||
|
||||
if (!user || !canRead) {
|
||||
return (
|
||||
<Card title="ATP 资料包详情">
|
||||
<Card title="ATP 模型详情">
|
||||
<Typography.Text type="secondary">
|
||||
{!user ? "请先登录后再查看 ATP 资料包详情。" : "当前账号无 ATP 模块权限(需要 atp.read/atp.run/atp.manage)。"}
|
||||
{!user ? "请先登录后再查看 ATP 模型详情。" : "当前账号无 ATP 模块权限(需要 atp.read/atp.run/atp.manage)。"}
|
||||
</Typography.Text>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (assetQuery.isLoading) {
|
||||
return <AdminPageLoading tip="加载 ATP 资料包详情中..." minHeightClassName="min-h-[280px]" />;
|
||||
return <AdminPageLoading tip="加载 ATP 模型详情中..." minHeightClassName="min-h-[280px]" />;
|
||||
}
|
||||
|
||||
if (assetQuery.error instanceof Error) {
|
||||
return (
|
||||
<Card title="ATP 资料包详情">
|
||||
<Alert type="error" showIcon message="资料包详情加载失败" description={assetQuery.error.message} />
|
||||
<Card title="ATP 模型详情">
|
||||
<Alert type="error" showIcon message="模型详情加载失败" description={assetQuery.error.message} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -488,12 +453,16 @@ export default function AtpAssetDetailPage() {
|
||||
const asset = assetQuery.data;
|
||||
if (!asset) {
|
||||
return (
|
||||
<Card title="ATP 资料包详情">
|
||||
<Empty description="未找到对应资料包" />
|
||||
<Card title="ATP 模型详情">
|
||||
<Empty description="未找到对应模型" />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const assetStatusDisplay = getAtpAssetStatusDisplay(asset.status);
|
||||
const nextReleaseStoragePath = `/atp-library/releases/${asset.code}/r${asset.latest_release_no + 1}`;
|
||||
const releaseDetail = releaseDetailQuery.data ?? null;
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size={16} style={{ width: "100%" }}>
|
||||
<Card
|
||||
@@ -503,20 +472,13 @@ export default function AtpAssetDetailPage() {
|
||||
<Link href="/admin/atp-models">
|
||||
<Button>返回列表</Button>
|
||||
</Link>
|
||||
<Link href="/admin/power-lines/atp-viewer">
|
||||
<Button>Legacy 文本工具</Button>
|
||||
</Link>
|
||||
<Button
|
||||
type="primary"
|
||||
disabled={!canManage}
|
||||
onClick={() => {
|
||||
setEditingRelease(null);
|
||||
releaseForm.setFieldsValue({
|
||||
...EMPTY_RELEASE_FORM,
|
||||
voltage_level: asset.voltage_level ?? "",
|
||||
tower_type: asset.tower_type ?? "",
|
||||
scene_type: asset.scene_type ?? "",
|
||||
});
|
||||
setReleaseArchiveFileList([]);
|
||||
releaseForm.setFieldsValue(EMPTY_RELEASE_FORM);
|
||||
setReleaseModalOpen(true);
|
||||
}}
|
||||
>
|
||||
@@ -535,19 +497,19 @@ export default function AtpAssetDetailPage() {
|
||||
? `模式:${engineQuery.data.mode},执行器:${engineQuery.data.resolved_executable || engineQuery.data.executable_path}。`
|
||||
: engineQuery.error instanceof Error
|
||||
? engineQuery.error.message
|
||||
: "目录化 release 会在运行前物化到本地 wine 允许运行根目录。"
|
||||
: "新建 Release 时会自动解压 ZIP 到约定目录,并自动识别入口文件与 EGM 目录。"
|
||||
}
|
||||
/>
|
||||
|
||||
<Descriptions column={2} bordered size="small">
|
||||
<Descriptions.Item label="编码">{asset.code}</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">
|
||||
<Tag color={statusColor(asset.status)}>{asset.status}</Tag>
|
||||
<Tag color={assetStatusDisplay.color}>{assetStatusDisplay.label}</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="电压等级">{asset.voltage_level || "-"}</Descriptions.Item>
|
||||
<Descriptions.Item label="塔型">{asset.tower_type || "-"}</Descriptions.Item>
|
||||
<Descriptions.Item label="场景">{asset.scene_type || "-"}</Descriptions.Item>
|
||||
<Descriptions.Item label="当前激活发布">
|
||||
<Descriptions.Item label="当前激活 Release">
|
||||
{asset.active_release_tag || (asset.active_release_no ? `r${asset.active_release_no}` : "-")}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="说明" span={2}>
|
||||
@@ -566,7 +528,7 @@ export default function AtpAssetDetailPage() {
|
||||
loading={releasesQuery.isLoading}
|
||||
columns={releaseColumns}
|
||||
dataSource={releases}
|
||||
locale={{ emptyText: "暂无 release" }}
|
||||
locale={{ emptyText: "暂无 Release" }}
|
||||
pagination={false}
|
||||
scroll={{ x: 1080 }}
|
||||
/>
|
||||
@@ -576,18 +538,19 @@ export default function AtpAssetDetailPage() {
|
||||
<Card
|
||||
title={selectedRelease ? `当前 Release:${selectedRelease.release_tag || `r${selectedRelease.release_no}`}` : "当前 Release"}
|
||||
extra={
|
||||
<Space wrap>
|
||||
<Button disabled={!selectedReleaseId || !canRun} onClick={() => {
|
||||
<Button
|
||||
disabled={!selectedReleaseId || !canRun}
|
||||
onClick={() => {
|
||||
runForm.setFieldsValue(EMPTY_RUN_FORM);
|
||||
setRunModalOpen(true);
|
||||
}}>
|
||||
运行 / Dry Run
|
||||
</Button>
|
||||
</Space>
|
||||
}}
|
||||
>
|
||||
运行 / Dry Run
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{!selectedRelease ? (
|
||||
<Empty description="请选择一个 release" />
|
||||
<Empty description="请选择一个 Release" />
|
||||
) : (
|
||||
<Space direction="vertical" size={16} style={{ width: "100%" }}>
|
||||
{releaseDetailQuery.error instanceof Error ? (
|
||||
@@ -597,17 +560,22 @@ export default function AtpAssetDetailPage() {
|
||||
{releaseDetail ? (
|
||||
<>
|
||||
<Descriptions column={2} bordered size="small">
|
||||
<Descriptions.Item label="Runner">{releaseDetail.runner_kind}</Descriptions.Item>
|
||||
<Descriptions.Item label="Storage Mount">{releaseDetail.storage_mount_code}</Descriptions.Item>
|
||||
<Descriptions.Item label="Storage Root" span={2}>
|
||||
<Descriptions.Item label="状态">
|
||||
<Tag color={getAtpReleaseStatusDisplay(releaseDetail.status).color}>
|
||||
{getAtpReleaseStatusDisplay(releaseDetail.status).label}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="运行类型">{getAtpRunnerKindLabel(releaseDetail.runner_kind)}</Descriptions.Item>
|
||||
<Descriptions.Item label="存储挂载">{releaseDetail.storage_mount_code}</Descriptions.Item>
|
||||
<Descriptions.Item label="存储目录">
|
||||
<Typography.Text code>{releaseDetail.storage_root_path}</Typography.Text>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Entry File">{releaseDetail.entry_file || "-"}</Descriptions.Item>
|
||||
<Descriptions.Item label="Result File">{releaseDetail.result_file || "-"}</Descriptions.Item>
|
||||
<Descriptions.Item label="EGM Subdir">{releaseDetail.egm_subdir || "-"}</Descriptions.Item>
|
||||
<Descriptions.Item label="EGM Result">{releaseDetail.egm_result_file || "-"}</Descriptions.Item>
|
||||
<Descriptions.Item label="Preprocess">{releaseDetail.preprocess_script || "-"}</Descriptions.Item>
|
||||
<Descriptions.Item label="Postprocess">{releaseDetail.postprocess_script || "-"}</Descriptions.Item>
|
||||
<Descriptions.Item label="入口文件">{releaseDetail.entry_file || "-"}</Descriptions.Item>
|
||||
<Descriptions.Item label="结果文件">{releaseDetail.result_file || "-"}</Descriptions.Item>
|
||||
<Descriptions.Item label="EGM 目录">{releaseDetail.egm_subdir || "-"}</Descriptions.Item>
|
||||
<Descriptions.Item label="EGM 结果">{releaseDetail.egm_result_file || "-"}</Descriptions.Item>
|
||||
<Descriptions.Item label="预处理脚本">{releaseDetail.preprocess_script || "-"}</Descriptions.Item>
|
||||
<Descriptions.Item label="后处理脚本">{releaseDetail.postprocess_script || "-"}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
<Space direction="vertical" size={8} style={{ width: "100%" }}>
|
||||
@@ -635,7 +603,7 @@ export default function AtpAssetDetailPage() {
|
||||
loading={filesQuery.isLoading}
|
||||
columns={fileColumns}
|
||||
dataSource={filesQuery.data?.items ?? []}
|
||||
locale={{ emptyText: selectedReleaseId ? "当前 release 暂无文件" : "请先选择 release" }}
|
||||
locale={{ emptyText: selectedReleaseId ? "当前 Release 暂无文件" : "请先选择 Release" }}
|
||||
pagination={false}
|
||||
scroll={{ x: 980, y: 320 }}
|
||||
/>
|
||||
@@ -651,7 +619,7 @@ export default function AtpAssetDetailPage() {
|
||||
loading={runsQuery.isLoading}
|
||||
columns={runColumns}
|
||||
dataSource={runsQuery.data?.items ?? []}
|
||||
locale={{ emptyText: selectedReleaseId ? "当前 release 暂无运行记录" : "请先选择 release" }}
|
||||
locale={{ emptyText: selectedReleaseId ? "当前 Release 暂无运行记录" : "请先选择 Release" }}
|
||||
pagination={false}
|
||||
scroll={{ x: 980 }}
|
||||
/>
|
||||
@@ -664,12 +632,13 @@ export default function AtpAssetDetailPage() {
|
||||
onCancel={() => {
|
||||
setReleaseModalOpen(false);
|
||||
setEditingRelease(null);
|
||||
setReleaseArchiveFileList([]);
|
||||
releaseForm.resetFields();
|
||||
}}
|
||||
onOk={() => void releaseForm.submit()}
|
||||
confirmLoading={saveReleaseMutation.isPending}
|
||||
destroyOnClose
|
||||
width={760}
|
||||
width={720}
|
||||
>
|
||||
<Form<ReleaseFormValues>
|
||||
form={releaseForm}
|
||||
@@ -677,63 +646,48 @@ export default function AtpAssetDetailPage() {
|
||||
initialValues={EMPTY_RELEASE_FORM}
|
||||
onFinish={(values) => void saveReleaseMutation.mutateAsync(values)}
|
||||
>
|
||||
<Form.Item name="release_tag" label="Release 标签">
|
||||
<Input placeholder="如 r1 / 220-raoji3" />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="状态" rules={[{ required: true, message: "请选择状态" }]}>
|
||||
<Select
|
||||
options={[
|
||||
{ value: "draft", label: "draft" },
|
||||
{ value: "released", label: "released" },
|
||||
{ value: "archived", label: "archived" },
|
||||
]}
|
||||
{!editingRelease ? (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="Release 目录会自动约定"
|
||||
description={
|
||||
<span>
|
||||
上传 ZIP 后会自动解压到 <Typography.Text code>{nextReleaseStoragePath}</Typography.Text>,并自动识别入口文件与 EGM 目录。
|
||||
</span>
|
||||
}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Form.Item name="release_tag" label="Release 标签" rules={[{ required: true, message: "请输入 Release 标签" }]}>
|
||||
<Input placeholder="如 220-raoji3-v1" />
|
||||
</Form.Item>
|
||||
<Form.Item name="voltage_level" label="电压等级" rules={[{ required: true, message: "请输入电压等级" }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="tower_type" label="塔型" rules={[{ required: true, message: "请输入塔型" }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="scene_type" label="场景" rules={[{ required: true, message: "请输入场景" }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="scenario_code" label="工况编码">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="runner_kind" label="Runner" rules={[{ required: true, message: "请选择 runner" }]}>
|
||||
<Select
|
||||
options={[
|
||||
{ value: "atp", label: "ATP" },
|
||||
{ value: "egm", label: "EGM" },
|
||||
{ value: "hybrid", label: "HYBRID" },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="storage_mount_code" label="Storage Mount" rules={[{ required: true, message: "请输入 mount code" }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="storage_root_path" label="Storage Root" rules={[{ required: true, message: "请输入目录根路径" }]}>
|
||||
<Input placeholder="/atp-library/releases/demo/r1" />
|
||||
</Form.Item>
|
||||
<Form.Item name="entry_file" label="入口文件">
|
||||
<Input placeholder="留空则自动探测 work.atp / 唯一 .atp" />
|
||||
</Form.Item>
|
||||
<Form.Item name="result_file" label="结果文件">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="egm_subdir" label="EGM 子目录">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="egm_result_file" label="EGM 结果文件">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="preprocess_script" label="预处理脚本">
|
||||
<Input placeholder="仅支持 .py,相对 release 根目录" />
|
||||
</Form.Item>
|
||||
<Form.Item name="postprocess_script" label="后处理脚本">
|
||||
<Input placeholder="仅支持 .py,相对 release 根目录" />
|
||||
</Form.Item>
|
||||
|
||||
{editingRelease ? (
|
||||
<Form.Item name="status" label="状态" rules={[{ required: true, message: "请选择状态" }]}>
|
||||
<Select
|
||||
options={[
|
||||
{ value: "draft", label: "草稿" },
|
||||
{ value: "released", label: "已发布" },
|
||||
{ value: "archived", label: "归档" },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
) : (
|
||||
<Form.Item label="Release ZIP 包" required>
|
||||
<Upload
|
||||
accept=".zip,application/zip,application/x-zip-compressed"
|
||||
beforeUpload={() => false}
|
||||
fileList={releaseArchiveFileList}
|
||||
maxCount={1}
|
||||
onChange={(info) => setReleaseArchiveFileList(info.fileList.slice(-1))}
|
||||
>
|
||||
<Button>选择 ZIP 包</Button>
|
||||
</Upload>
|
||||
<Typography.Text type="secondary">仅支持 ZIP。系统会自动解压后写入 MinIO/VFS 约定目录。</Typography.Text>
|
||||
</Form.Item>
|
||||
)}
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
|
||||
@@ -6,10 +6,12 @@ import {
|
||||
Alert,
|
||||
App,
|
||||
Button,
|
||||
Col,
|
||||
Form,
|
||||
Input,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Row,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
@@ -21,8 +23,10 @@ import { useMemo, useState } from "react";
|
||||
|
||||
import { AdminPageLoading } from "@/components/admin-page-loading";
|
||||
import { useAuth } from "@/components/auth-provider";
|
||||
import { CreatableSingleSelect } from "@/components/creatable-single-select";
|
||||
import { Card } from "@/components/ui-antd";
|
||||
import { readApiError } from "@/lib/api";
|
||||
import { getAtpAssetStatusDisplay } from "@/lib/atp-asset-display";
|
||||
import type { AtpAssetListResponse, AtpAssetSummary, AtpEngineStatusResponse } from "@/types/auth";
|
||||
|
||||
type AssetFormValues = {
|
||||
@@ -33,7 +37,6 @@ type AssetFormValues = {
|
||||
voltage_level: string;
|
||||
tower_type: string;
|
||||
scene_type: string;
|
||||
tags_text: string;
|
||||
};
|
||||
|
||||
const EMPTY_FORM: AssetFormValues = {
|
||||
@@ -44,7 +47,6 @@ const EMPTY_FORM: AssetFormValues = {
|
||||
voltage_level: "",
|
||||
tower_type: "",
|
||||
scene_type: "",
|
||||
tags_text: "",
|
||||
};
|
||||
|
||||
function formatDateTime(value: string | null | undefined): string {
|
||||
@@ -58,14 +60,6 @@ function formatDateTime(value: string | null | undefined): string {
|
||||
return date.toLocaleString("zh-CN", { hour12: false });
|
||||
}
|
||||
|
||||
function statusColor(value: string): string {
|
||||
if (value === "enabled" || value === "released") return "green";
|
||||
if (value === "draft") return "gold";
|
||||
if (value === "disabled") return "default";
|
||||
if (value === "archived") return "red";
|
||||
return "blue";
|
||||
}
|
||||
|
||||
function toFormValues(item: AtpAssetSummary): AssetFormValues {
|
||||
return {
|
||||
code: item.code,
|
||||
@@ -75,7 +69,6 @@ function toFormValues(item: AtpAssetSummary): AssetFormValues {
|
||||
voltage_level: item.voltage_level ?? "",
|
||||
tower_type: item.tower_type ?? "",
|
||||
scene_type: item.scene_type ?? "",
|
||||
tags_text: item.tags_json.join(", "),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -88,13 +81,23 @@ function buildPayload(values: AssetFormValues) {
|
||||
voltage_level: values.voltage_level.trim() || null,
|
||||
tower_type: values.tower_type.trim() || null,
|
||||
scene_type: values.scene_type.trim() || null,
|
||||
tags_json: values.tags_text
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
function buildDimensionOptions(items: AtpAssetSummary[], picker: (item: AtpAssetSummary) => string | null): Array<{ label: string; value: string }> {
|
||||
const values = new Set<string>();
|
||||
for (const item of items) {
|
||||
const value = picker(item)?.trim();
|
||||
if (!value) {
|
||||
continue;
|
||||
}
|
||||
values.add(value);
|
||||
}
|
||||
return Array.from(values)
|
||||
.sort((left, right) => left.localeCompare(right, "zh-CN"))
|
||||
.map((value) => ({ label: value, value }));
|
||||
}
|
||||
|
||||
export default function AtpModelsPage() {
|
||||
const { message } = App.useApp();
|
||||
const { user, initializing, fetchWithAuth, hasPermission } = useAuth();
|
||||
@@ -163,13 +166,13 @@ export default function AtpModelsPage() {
|
||||
},
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["atp-assets"] });
|
||||
message.success(editingAsset ? "资料包已更新" : "资料包已创建");
|
||||
message.success(editingAsset ? "模型已更新" : "模型已创建");
|
||||
setModalOpen(false);
|
||||
setEditingAsset(null);
|
||||
form.resetFields();
|
||||
},
|
||||
onError: (candidate) => {
|
||||
message.error(candidate instanceof Error ? candidate.message : "保存资料包失败");
|
||||
message.error(candidate instanceof Error ? candidate.message : "保存模型失败");
|
||||
},
|
||||
});
|
||||
|
||||
@@ -182,19 +185,22 @@ export default function AtpModelsPage() {
|
||||
},
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["atp-assets"] });
|
||||
message.success("资料包已删除");
|
||||
message.success("模型已删除");
|
||||
},
|
||||
onError: (candidate) => {
|
||||
message.error(candidate instanceof Error ? candidate.message : "删除资料包失败");
|
||||
message.error(candidate instanceof Error ? candidate.message : "删除模型失败");
|
||||
},
|
||||
});
|
||||
|
||||
const assetItems = assetsQuery.data?.items ?? [];
|
||||
const voltageLevelOptions = useMemo(() => buildDimensionOptions(assetItems, (item) => item.voltage_level), [assetItems]);
|
||||
const towerTypeOptions = useMemo(() => buildDimensionOptions(assetItems, (item) => item.tower_type), [assetItems]);
|
||||
const sceneTypeOptions = useMemo(() => buildDimensionOptions(assetItems, (item) => item.scene_type), [assetItems]);
|
||||
|
||||
const columns = useMemo<ColumnsType<AtpAssetSummary>>(
|
||||
() => [
|
||||
{
|
||||
title: "资料包",
|
||||
title: "模型",
|
||||
key: "asset",
|
||||
render: (_, item) => (
|
||||
<Space direction="vertical" size={0}>
|
||||
@@ -210,20 +216,20 @@ export default function AtpModelsPage() {
|
||||
key: "dimensions",
|
||||
render: (_, item) => (
|
||||
<Space size={[4, 4]} wrap>
|
||||
<Tag>{item.voltage_level || "未标注电压"}</Tag>
|
||||
<Tag>{item.tower_type || "未标注塔型"}</Tag>
|
||||
<Tag>{item.scene_type || "未标注场景"}</Tag>
|
||||
<Tag>{item.voltage_level || "未设置电压等级"}</Tag>
|
||||
<Tag>{item.tower_type || "未设置塔型"}</Tag>
|
||||
<Tag>{item.scene_type || "未设置场景"}</Tag>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "当前发布",
|
||||
title: "当前 Release",
|
||||
key: "release",
|
||||
render: (_, item) => (
|
||||
<Space direction="vertical" size={0}>
|
||||
<Typography.Text>{item.active_release_tag || (item.active_release_no ? `r${item.active_release_no}` : "-")}</Typography.Text>
|
||||
<Typography.Text type="secondary">
|
||||
{item.release_count} 个 release / {item.run_count} 次运行
|
||||
{item.release_count} 个 Release / {item.run_count} 次运行
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
),
|
||||
@@ -231,7 +237,10 @@ export default function AtpModelsPage() {
|
||||
{
|
||||
title: "状态",
|
||||
dataIndex: "status",
|
||||
render: (value: string) => <Tag color={statusColor(value)}>{value}</Tag>,
|
||||
render: (value: string) => {
|
||||
const display = getAtpAssetStatusDisplay(value);
|
||||
return <Tag color={display.color}>{display.label}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "更新时间",
|
||||
@@ -260,8 +269,8 @@ export default function AtpModelsPage() {
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="删除资料包"
|
||||
description="这会同时删除其 release 与运行记录。"
|
||||
title="删除模型"
|
||||
description="这会同时删除其 Release 与运行记录。"
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
onConfirm={() => void deleteMutation.mutateAsync(item.id)}
|
||||
@@ -279,14 +288,14 @@ export default function AtpModelsPage() {
|
||||
);
|
||||
|
||||
if (initializing) {
|
||||
return <AdminPageLoading tip="加载 ATP 资料包中..." minHeightClassName="min-h-[280px]" />;
|
||||
return <AdminPageLoading tip="加载 ATP 模型中..." minHeightClassName="min-h-[280px]" />;
|
||||
}
|
||||
|
||||
if (!user || !canRead) {
|
||||
return (
|
||||
<Card title="ATP 资料包管理">
|
||||
<Card title="ATP 模型管理">
|
||||
<Typography.Text type="secondary">
|
||||
{!user ? "请先登录后再查看 ATP 资料包管理。" : "当前账号无 ATP 模块权限(需要 atp.read/atp.run/atp.manage)。"}
|
||||
{!user ? "请先登录后再查看 ATP 模型管理。" : "当前账号无 ATP 模块权限(需要 atp.read/atp.run/atp.manage)。"}
|
||||
</Typography.Text>
|
||||
</Card>
|
||||
);
|
||||
@@ -295,24 +304,19 @@ export default function AtpModelsPage() {
|
||||
return (
|
||||
<Space direction="vertical" size={16} style={{ width: "100%" }}>
|
||||
<Card
|
||||
title="ATP 资料包管理"
|
||||
title="ATP 模型管理"
|
||||
extra={
|
||||
<Space wrap>
|
||||
<Link href="/admin/power-lines/atp-viewer">
|
||||
<Button>Legacy 文本工具</Button>
|
||||
</Link>
|
||||
<Button
|
||||
type="primary"
|
||||
disabled={!canManage}
|
||||
onClick={() => {
|
||||
setEditingAsset(null);
|
||||
form.setFieldsValue(EMPTY_FORM);
|
||||
setModalOpen(true);
|
||||
}}
|
||||
>
|
||||
新建资料包
|
||||
</Button>
|
||||
</Space>
|
||||
<Button
|
||||
type="primary"
|
||||
disabled={!canManage}
|
||||
onClick={() => {
|
||||
setEditingAsset(null);
|
||||
form.setFieldsValue(EMPTY_FORM);
|
||||
setModalOpen(true);
|
||||
}}
|
||||
>
|
||||
新建模型
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Space direction="vertical" size={16} style={{ width: "100%" }}>
|
||||
@@ -325,7 +329,7 @@ export default function AtpModelsPage() {
|
||||
? `模式:${engineQuery.data.mode},执行器:${engineQuery.data.resolved_executable || engineQuery.data.executable_path}。`
|
||||
: engineQuery.error instanceof Error
|
||||
? engineQuery.error.message
|
||||
: "目录化 release 会在运行前物化到 wine 允许运行根目录。"
|
||||
: "Release ZIP 会自动解压到约定目录,入口文件与 EGM 目录会自动识别。"
|
||||
}
|
||||
/>
|
||||
<Space wrap>
|
||||
@@ -344,16 +348,16 @@ export default function AtpModelsPage() {
|
||||
style={{ width: 180 }}
|
||||
onChange={(value) => setStatusFilter(value)}
|
||||
options={[
|
||||
{ value: "draft", label: "draft" },
|
||||
{ value: "enabled", label: "enabled" },
|
||||
{ value: "disabled", label: "disabled" },
|
||||
{ value: "archived", label: "archived" },
|
||||
{ value: "draft", label: "草稿" },
|
||||
{ value: "enabled", label: "启用" },
|
||||
{ value: "disabled", label: "停用" },
|
||||
{ value: "archived", label: "归档" },
|
||||
]}
|
||||
/>
|
||||
</Space>
|
||||
|
||||
{assetsQuery.error instanceof Error ? (
|
||||
<Alert type="error" showIcon message="ATP 资料包加载失败" description={assetsQuery.error.message} />
|
||||
<Alert type="error" showIcon message="ATP 模型加载失败" description={assetsQuery.error.message} />
|
||||
) : null}
|
||||
|
||||
<Table<AtpAssetSummary>
|
||||
@@ -361,7 +365,7 @@ export default function AtpModelsPage() {
|
||||
loading={assetsQuery.isLoading}
|
||||
columns={columns}
|
||||
dataSource={assetItems}
|
||||
locale={{ emptyText: "暂无 ATP 资料包" }}
|
||||
locale={{ emptyText: "暂无 ATP 模型" }}
|
||||
pagination={false}
|
||||
scroll={{ x: 1080 }}
|
||||
/>
|
||||
@@ -369,7 +373,7 @@ export default function AtpModelsPage() {
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title={editingAsset ? "编辑 ATP 资料包" : "新建 ATP 资料包"}
|
||||
title={editingAsset ? "编辑 ATP 模型" : "新建 ATP 模型"}
|
||||
open={modalOpen}
|
||||
onCancel={() => {
|
||||
setModalOpen(false);
|
||||
@@ -379,6 +383,7 @@ export default function AtpModelsPage() {
|
||||
onOk={() => void form.submit()}
|
||||
confirmLoading={saveMutation.isPending}
|
||||
destroyOnClose
|
||||
width={760}
|
||||
>
|
||||
<Form<AssetFormValues>
|
||||
form={form}
|
||||
@@ -386,37 +391,50 @@ export default function AtpModelsPage() {
|
||||
initialValues={EMPTY_FORM}
|
||||
onFinish={(values) => void saveMutation.mutateAsync(values)}
|
||||
>
|
||||
<Form.Item name="code" label="编码" rules={[{ required: true, message: "请输入资料包编码" }]}>
|
||||
<Input disabled={Boolean(editingAsset)} />
|
||||
</Form.Item>
|
||||
<Form.Item name="name" label="名称" rules={[{ required: true, message: "请输入资料包名称" }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="描述">
|
||||
<Input.TextArea rows={3} />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="状态" rules={[{ required: true, message: "请选择状态" }]}>
|
||||
<Select
|
||||
options={[
|
||||
{ value: "draft", label: "draft" },
|
||||
{ value: "enabled", label: "enabled" },
|
||||
{ value: "disabled", label: "disabled" },
|
||||
{ value: "archived", label: "archived" },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="voltage_level" label="电压等级">
|
||||
<Input placeholder="如 220 / 500 / 1000" />
|
||||
</Form.Item>
|
||||
<Form.Item name="tower_type" label="塔型">
|
||||
<Input placeholder="如 sihuita / ganzi" />
|
||||
</Form.Item>
|
||||
<Form.Item name="scene_type" label="场景">
|
||||
<Input placeholder="如 fanji / raoji3" />
|
||||
</Form.Item>
|
||||
<Form.Item name="tags_text" label="标签">
|
||||
<Input placeholder="多个标签用逗号分隔" />
|
||||
</Form.Item>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item name="code" label="编码" rules={[{ required: true, message: "请输入模型编码" }]}>
|
||||
<Input disabled={Boolean(editingAsset)} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item name="name" label="名称" rules={[{ required: true, message: "请输入模型名称" }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item name="status" label="状态" rules={[{ required: true, message: "请选择状态" }]}>
|
||||
<Select
|
||||
options={[
|
||||
{ value: "draft", label: "草稿" },
|
||||
{ value: "enabled", label: "启用" },
|
||||
{ value: "disabled", label: "停用" },
|
||||
{ value: "archived", label: "归档" },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item name="voltage_level" label="电压等级" rules={[{ required: true, message: "请选择或新建电压等级" }]}>
|
||||
<CreatableSingleSelect options={voltageLevelOptions} placeholder="请选择或新建电压等级" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item name="tower_type" label="塔型" rules={[{ required: true, message: "请选择或新建塔型" }]}>
|
||||
<CreatableSingleSelect options={towerTypeOptions} placeholder="请选择或新建塔型" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item name="scene_type" label="场景" rules={[{ required: true, message: "请选择或新建场景" }]}>
|
||||
<CreatableSingleSelect options={sceneTypeOptions} placeholder="请选择或新建场景" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Form.Item name="description" label="描述">
|
||||
<Input.TextArea rows={3} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
</Modal>
|
||||
</Space>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,487 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Alert, Button, Empty, Space, Typography } from "antd";
|
||||
import type { Graph as X6Graph } from "@antv/x6";
|
||||
|
||||
import type {
|
||||
AtpElementKind,
|
||||
AtpGraphEdge,
|
||||
AtpGraphJson,
|
||||
AtpGraphNode,
|
||||
} from "@/lib/atp/types";
|
||||
|
||||
type AtpX6ViewerProps = {
|
||||
graph: AtpGraphJson | null;
|
||||
};
|
||||
|
||||
type NodePosition = {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
const H_SPACING = 220;
|
||||
const V_SPACING = 128;
|
||||
|
||||
const BUS_NODE_STYLE = {
|
||||
body: {
|
||||
fill: "#e6f4ff",
|
||||
stroke: "#2f54eb",
|
||||
strokeWidth: 1.2,
|
||||
rx: 8,
|
||||
ry: 8,
|
||||
},
|
||||
label: {
|
||||
fill: "#10239e",
|
||||
fontSize: 12,
|
||||
textAnchor: "middle",
|
||||
textVerticalAnchor: "middle",
|
||||
pointerEvents: "none",
|
||||
},
|
||||
} as const;
|
||||
|
||||
const GROUND_NODE_STYLE = {
|
||||
body: {
|
||||
fill: "#f5f5f5",
|
||||
stroke: "#7f8c8d",
|
||||
strokeWidth: 1,
|
||||
},
|
||||
label: {
|
||||
fill: "#262626",
|
||||
fontSize: 11,
|
||||
textAnchor: "middle",
|
||||
textVerticalAnchor: "middle",
|
||||
pointerEvents: "none",
|
||||
},
|
||||
} as const;
|
||||
|
||||
const EDGE_STYLE = {
|
||||
line: {
|
||||
stroke: "#434343",
|
||||
strokeWidth: 1.4,
|
||||
strokeLinecap: "round",
|
||||
sourceMarker: null,
|
||||
targetMarker: null,
|
||||
},
|
||||
} as const;
|
||||
|
||||
const EDGE_LABEL_MARKUP = [
|
||||
{
|
||||
tagName: "rect",
|
||||
selector: "panel",
|
||||
},
|
||||
{
|
||||
tagName: "image",
|
||||
selector: "symbol",
|
||||
},
|
||||
{
|
||||
tagName: "text",
|
||||
selector: "label",
|
||||
},
|
||||
] as const;
|
||||
|
||||
const EDGE_KIND_COLORS: Record<AtpElementKind, string> = {
|
||||
R: "#cf1322",
|
||||
L: "#0958d9",
|
||||
C: "#531dab",
|
||||
SW: "#389e0d",
|
||||
SRC: "#fa8c16",
|
||||
XFMR: "#08979c",
|
||||
LINE: "#595959",
|
||||
CTRL: "#d46b08",
|
||||
MISC: "#262626",
|
||||
};
|
||||
|
||||
const EDGE_KINDS: AtpElementKind[] = [
|
||||
"R",
|
||||
"L",
|
||||
"C",
|
||||
"SW",
|
||||
"SRC",
|
||||
"XFMR",
|
||||
"LINE",
|
||||
"CTRL",
|
||||
"MISC",
|
||||
];
|
||||
|
||||
const EDGE_SYMBOL_DATA_URI = EDGE_KINDS.reduce(
|
||||
(acc, kind) => {
|
||||
acc[kind] = buildEdgeSymbolDataUri(kind);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<AtpElementKind, string>,
|
||||
);
|
||||
|
||||
function buildEdgeLabelText(edge: AtpGraphEdge): string {
|
||||
return edge.value ? `${edge.name} (${edge.kind}) ${edge.value}` : `${edge.name} (${edge.kind})`;
|
||||
}
|
||||
|
||||
function edgeSymbolGlyph(kind: AtpElementKind, color: string): string {
|
||||
switch (kind) {
|
||||
case "R":
|
||||
return `<polyline points="18,16 22,11 26,21 30,11 34,21 38,11 42,16" fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>`;
|
||||
case "L":
|
||||
return `<path d="M 18 16 C 20 8, 24 8, 26 16 C 28 8, 32 8, 34 16 C 36 8, 40 8, 42 16" fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round"/>`;
|
||||
case "C":
|
||||
return `<line x1="26" y1="9" x2="26" y2="23" stroke="${color}" stroke-width="2"/><line x1="38" y1="9" x2="38" y2="23" stroke="${color}" stroke-width="2"/>`;
|
||||
case "SW":
|
||||
return `<circle cx="24" cy="16" r="1.8" fill="${color}"/><circle cx="40" cy="16" r="1.8" fill="${color}"/><line x1="24" y1="16" x2="40" y2="10" stroke="${color}" stroke-width="2" stroke-linecap="round"/>`;
|
||||
case "SRC":
|
||||
return `<circle cx="32" cy="16" r="8" fill="none" stroke="${color}" stroke-width="2"/><path d="M 27 16 C 28.5 12.5, 30.5 12.5, 32 16 C 33.5 19.5, 35.5 19.5, 37 16" fill="none" stroke="${color}" stroke-width="1.8" stroke-linecap="round"/>`;
|
||||
case "XFMR":
|
||||
return `<path d="M 24 10 C 22 12, 22 20, 24 22 C 26 20, 26 12, 24 10" fill="none" stroke="${color}" stroke-width="2"/><path d="M 32 10 C 30 12, 30 20, 32 22 C 34 20, 34 12, 32 10" fill="none" stroke="${color}" stroke-width="2"/><path d="M 40 10 C 38 12, 38 20, 40 22 C 42 20, 42 12, 40 10" fill="none" stroke="${color}" stroke-width="2"/>`;
|
||||
case "LINE":
|
||||
return `<line x1="24" y1="9" x2="40" y2="23" stroke="${color}" stroke-width="2"/><line x1="24" y1="23" x2="40" y2="9" stroke="${color}" stroke-width="2"/>`;
|
||||
case "CTRL":
|
||||
return `<rect x="23" y="10" width="18" height="12" rx="3" ry="3" fill="none" stroke="${color}" stroke-width="2"/><text x="32" y="19" text-anchor="middle" font-size="8" font-family="Arial, sans-serif" fill="${color}">C</text>`;
|
||||
case "MISC":
|
||||
default:
|
||||
return `<polygon points="32,9 40,16 32,23 24,16" fill="none" stroke="${color}" stroke-width="2"/>`;
|
||||
}
|
||||
}
|
||||
|
||||
function encodeSvgAsDataUri(svg: string): string {
|
||||
return `data:image/svg+xml,${encodeURIComponent(svg)}`;
|
||||
}
|
||||
|
||||
function buildEdgeSymbolDataUri(kind: AtpElementKind): string {
|
||||
const color = EDGE_KIND_COLORS[kind];
|
||||
const glyph = edgeSymbolGlyph(kind, color);
|
||||
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 32"><line x1="2" y1="16" x2="18" y2="16" stroke="${color}" stroke-width="1.8" stroke-linecap="round"/><line x1="46" y1="16" x2="62" y2="16" stroke="${color}" stroke-width="1.8" stroke-linecap="round"/>${glyph}</svg>`;
|
||||
return encodeSvgAsDataUri(svg);
|
||||
}
|
||||
|
||||
function buildEdgeLabel(edge: AtpGraphEdge) {
|
||||
return {
|
||||
markup: [...EDGE_LABEL_MARKUP],
|
||||
attrs: {
|
||||
panel: {
|
||||
ref: "label",
|
||||
refWidth: "118%",
|
||||
refHeight: "190%",
|
||||
refX: "-9%",
|
||||
refY: "-75%",
|
||||
fill: "rgba(255,255,255,0.9)",
|
||||
stroke: "#e5e7eb",
|
||||
strokeWidth: 1,
|
||||
rx: 4,
|
||||
ry: 4,
|
||||
},
|
||||
symbol: {
|
||||
xlinkHref: EDGE_SYMBOL_DATA_URI[edge.kind],
|
||||
width: 48,
|
||||
height: 24,
|
||||
x: -24,
|
||||
y: -30,
|
||||
preserveAspectRatio: "xMidYMid meet",
|
||||
pointerEvents: "none",
|
||||
},
|
||||
label: {
|
||||
text: buildEdgeLabelText(edge),
|
||||
fill: "#262626",
|
||||
fontSize: 11,
|
||||
fontFamily: "JetBrains Mono, Menlo, monospace",
|
||||
textAnchor: "middle",
|
||||
textVerticalAnchor: "middle",
|
||||
y: 4,
|
||||
pointerEvents: "none",
|
||||
},
|
||||
},
|
||||
position: {
|
||||
distance: 0.5,
|
||||
offset: -18,
|
||||
options: {
|
||||
keepGradient: false,
|
||||
ensureLegibility: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function positionKey(position: NodePosition): string {
|
||||
return `${position.x}:${position.y}`;
|
||||
}
|
||||
|
||||
function reservePosition(occupied: Set<string>, preferred: NodePosition): NodePosition {
|
||||
if (!occupied.has(positionKey(preferred))) {
|
||||
occupied.add(positionKey(preferred));
|
||||
return preferred;
|
||||
}
|
||||
|
||||
for (let offset = 1; offset <= 100; offset += 1) {
|
||||
const candidateUp = { x: preferred.x, y: preferred.y - offset * V_SPACING };
|
||||
if (!occupied.has(positionKey(candidateUp))) {
|
||||
occupied.add(positionKey(candidateUp));
|
||||
return candidateUp;
|
||||
}
|
||||
|
||||
const candidateDown = { x: preferred.x, y: preferred.y + offset * V_SPACING };
|
||||
if (!occupied.has(positionKey(candidateDown))) {
|
||||
occupied.add(positionKey(candidateDown));
|
||||
return candidateDown;
|
||||
}
|
||||
}
|
||||
|
||||
const fallback = {
|
||||
x: preferred.x,
|
||||
y: preferred.y + (occupied.size + 1) * V_SPACING,
|
||||
};
|
||||
occupied.add(positionKey(fallback));
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function computeNodePositions(nodes: AtpGraphNode[], edges: AtpGraphEdge[]): Map<string, NodePosition> {
|
||||
const positions = new Map<string, NodePosition>();
|
||||
const occupied = new Set<string>();
|
||||
|
||||
let nextSeedY = 0;
|
||||
|
||||
for (const edge of edges) {
|
||||
const sourcePosition = positions.get(edge.source);
|
||||
const targetPosition = positions.get(edge.target);
|
||||
|
||||
if (!sourcePosition && !targetPosition) {
|
||||
const source = reservePosition(occupied, { x: 0, y: nextSeedY });
|
||||
const target = reservePosition(occupied, { x: H_SPACING, y: nextSeedY });
|
||||
positions.set(edge.source, source);
|
||||
positions.set(edge.target, target);
|
||||
nextSeedY += V_SPACING * 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (sourcePosition && !targetPosition) {
|
||||
const target = reservePosition(occupied, { x: sourcePosition.x + H_SPACING, y: sourcePosition.y });
|
||||
positions.set(edge.target, target);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!sourcePosition && targetPosition) {
|
||||
const source = reservePosition(occupied, { x: targetPosition.x - H_SPACING, y: targetPosition.y });
|
||||
positions.set(edge.source, source);
|
||||
}
|
||||
}
|
||||
|
||||
const unplaced = nodes.filter((node) => !positions.has(node.id));
|
||||
const columns = 4;
|
||||
let col = 0;
|
||||
let row = 0;
|
||||
|
||||
for (const node of unplaced) {
|
||||
const preferred = {
|
||||
x: col * H_SPACING,
|
||||
y: nextSeedY + row * V_SPACING,
|
||||
};
|
||||
positions.set(node.id, reservePosition(occupied, preferred));
|
||||
|
||||
col += 1;
|
||||
if (col >= columns) {
|
||||
col = 0;
|
||||
row += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return positions;
|
||||
}
|
||||
|
||||
export function AtpX6Viewer({ graph }: AtpX6ViewerProps) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const graphRef = useRef<X6Graph | null>(null);
|
||||
const [renderError, setRenderError] = useState("");
|
||||
|
||||
const positions = useMemo(() => {
|
||||
if (!graph) {
|
||||
return new Map<string, NodePosition>();
|
||||
}
|
||||
return computeNodePositions(graph.nodes, graph.edges);
|
||||
}, [graph]);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
let disposed = false;
|
||||
let createdGraph: X6Graph | null = null;
|
||||
|
||||
async function renderGraph() {
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = "";
|
||||
setRenderError("");
|
||||
|
||||
if (!graph || graph.nodes.length === 0 || graph.edges.length === 0) {
|
||||
graphRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { Graph } = await import("@antv/x6");
|
||||
if (disposed || !container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const instance = new Graph({
|
||||
container,
|
||||
interacting: false,
|
||||
panning: true,
|
||||
mousewheel: {
|
||||
enabled: true,
|
||||
modifiers: ["ctrl", "meta"],
|
||||
minScale: 0.25,
|
||||
maxScale: 4,
|
||||
zoomAtMousePosition: true,
|
||||
},
|
||||
background: {
|
||||
color: "#fcfdff",
|
||||
},
|
||||
grid: false,
|
||||
});
|
||||
|
||||
createdGraph = instance;
|
||||
graphRef.current = instance;
|
||||
|
||||
for (const node of graph.nodes) {
|
||||
const position = positions.get(node.id) ?? { x: 0, y: 0 };
|
||||
const nodeId = `node_${node.id}`;
|
||||
|
||||
if (node.kind === "ground") {
|
||||
instance.addNode({
|
||||
id: nodeId,
|
||||
shape: "ellipse",
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
width: 86,
|
||||
height: 44,
|
||||
label: node.label,
|
||||
attrs: GROUND_NODE_STYLE,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
instance.addNode({
|
||||
id: nodeId,
|
||||
shape: "rect",
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
width: 140,
|
||||
height: 46,
|
||||
label: node.label,
|
||||
attrs: BUS_NODE_STYLE,
|
||||
});
|
||||
}
|
||||
|
||||
for (const edge of graph.edges) {
|
||||
instance.addEdge({
|
||||
id: edge.id,
|
||||
source: `node_${edge.source}`,
|
||||
target: `node_${edge.target}`,
|
||||
router: {
|
||||
name: "orth",
|
||||
},
|
||||
connector: {
|
||||
name: "rounded",
|
||||
args: {
|
||||
radius: 8,
|
||||
},
|
||||
},
|
||||
attrs: EDGE_STYLE,
|
||||
labels: [buildEdgeLabel(edge)],
|
||||
});
|
||||
}
|
||||
|
||||
instance.zoomToFit({ padding: 48, maxScale: 1 });
|
||||
instance.centerContent();
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : "X6 渲染失败";
|
||||
setRenderError(detail);
|
||||
}
|
||||
}
|
||||
|
||||
void renderGraph();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
if (createdGraph) {
|
||||
createdGraph.dispose();
|
||||
}
|
||||
if (graphRef.current === createdGraph) {
|
||||
graphRef.current = null;
|
||||
}
|
||||
if (container) {
|
||||
container.innerHTML = "";
|
||||
}
|
||||
};
|
||||
}, [graph, positions]);
|
||||
|
||||
const handleFit = () => {
|
||||
const instance = graphRef.current;
|
||||
if (!instance) {
|
||||
return;
|
||||
}
|
||||
|
||||
instance.zoomToFit({ padding: 48, maxScale: 1 });
|
||||
instance.centerContent();
|
||||
};
|
||||
|
||||
const handleZoomIn = () => {
|
||||
const instance = graphRef.current;
|
||||
if (!instance) {
|
||||
return;
|
||||
}
|
||||
|
||||
const next = Math.min(instance.zoom() + 0.12, 4);
|
||||
instance.zoom(next, { absolute: true });
|
||||
};
|
||||
|
||||
const handleZoomOut = () => {
|
||||
const instance = graphRef.current;
|
||||
if (!instance) {
|
||||
return;
|
||||
}
|
||||
|
||||
const next = Math.max(instance.zoom() - 0.12, 0.25);
|
||||
instance.zoom(next, { absolute: true });
|
||||
};
|
||||
|
||||
const hasRenderableGraph = !!graph && graph.nodes.length > 0 && graph.edges.length > 0;
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size={12} className="w-full">
|
||||
<Space size={8} wrap>
|
||||
<Button size="small" onClick={handleFit} disabled={!hasRenderableGraph}>
|
||||
适配视图
|
||||
</Button>
|
||||
<Button size="small" onClick={handleZoomIn} disabled={!hasRenderableGraph}>
|
||||
放大
|
||||
</Button>
|
||||
<Button size="small" onClick={handleZoomOut} disabled={!hasRenderableGraph}>
|
||||
缩小
|
||||
</Button>
|
||||
{graph && (
|
||||
<Typography.Text type="secondary">
|
||||
节点 {graph.stats.node_count} / 元件 {graph.stats.element_count}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</Space>
|
||||
|
||||
{renderError && <Alert type="error" showIcon message="渲染失败" description={renderError} />}
|
||||
|
||||
<div className="relative min-h-[560px] w-full overflow-hidden rounded border border-gray-200 bg-[#fcfdff]">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="h-[560px] w-full"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"radial-gradient(circle at 1px 1px, rgba(31, 35, 41, 0.08) 1px, transparent 0)",
|
||||
backgroundSize: "20px 20px",
|
||||
}}
|
||||
/>
|
||||
{!hasRenderableGraph && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<Empty description="暂无可渲染图形,先完成 ATP 转换。" image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { Select } from "antd";
|
||||
|
||||
type CreatableSingleSelectProps = {
|
||||
allowClear?: boolean;
|
||||
disabled?: boolean;
|
||||
options: Array<{ label: string; value: string }>;
|
||||
placeholder?: string;
|
||||
value?: string | null;
|
||||
onChange?: (value: string) => void;
|
||||
};
|
||||
|
||||
export function CreatableSingleSelect({
|
||||
allowClear = true,
|
||||
disabled,
|
||||
options,
|
||||
placeholder,
|
||||
value,
|
||||
onChange,
|
||||
}: CreatableSingleSelectProps) {
|
||||
return (
|
||||
<Select
|
||||
allowClear={allowClear}
|
||||
disabled={disabled}
|
||||
mode="tags"
|
||||
maxCount={1}
|
||||
options={options}
|
||||
placeholder={placeholder}
|
||||
value={value ? [value] : []}
|
||||
onChange={(nextValue) => onChange?.(Array.isArray(nextValue) ? (nextValue.at(-1) ?? "") : "")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
getAtpAssetStatusDisplay,
|
||||
getAtpReleaseStatusDisplay,
|
||||
getAtpRunStatusDisplay,
|
||||
getAtpRunnerKindLabel,
|
||||
} from "./atp-asset-display.ts";
|
||||
|
||||
test("ATP asset and release statuses render in chinese labels", () => {
|
||||
assert.deepEqual(getAtpAssetStatusDisplay("enabled"), { label: "启用", color: "green" });
|
||||
assert.deepEqual(getAtpAssetStatusDisplay("archived"), { label: "归档", color: "red" });
|
||||
assert.deepEqual(getAtpReleaseStatusDisplay("released"), { label: "已发布", color: "green" });
|
||||
assert.deepEqual(getAtpRunStatusDisplay("running"), { label: "执行中", color: "gold" });
|
||||
});
|
||||
|
||||
test("runner kinds and unknown values fall back safely", () => {
|
||||
assert.equal(getAtpRunnerKindLabel("hybrid"), "混合");
|
||||
assert.deepEqual(getAtpRunStatusDisplay("custom"), { label: "custom", color: "blue" });
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
type StatusDisplay = {
|
||||
label: string;
|
||||
color: string;
|
||||
};
|
||||
|
||||
const ASSET_STATUS_DISPLAYS: Record<string, StatusDisplay> = {
|
||||
draft: { label: "草稿", color: "gold" },
|
||||
enabled: { label: "启用", color: "green" },
|
||||
disabled: { label: "停用", color: "default" },
|
||||
archived: { label: "归档", color: "red" },
|
||||
};
|
||||
|
||||
const RELEASE_STATUS_DISPLAYS: Record<string, StatusDisplay> = {
|
||||
draft: { label: "草稿", color: "gold" },
|
||||
released: { label: "已发布", color: "green" },
|
||||
archived: { label: "归档", color: "red" },
|
||||
};
|
||||
|
||||
const RUN_STATUS_DISPLAYS: Record<string, StatusDisplay> = {
|
||||
pending: { label: "排队中", color: "blue" },
|
||||
running: { label: "执行中", color: "gold" },
|
||||
success: { label: "成功", color: "green" },
|
||||
failed: { label: "失败", color: "red" },
|
||||
};
|
||||
|
||||
const RUNNER_KIND_LABELS: Record<string, string> = {
|
||||
atp: "ATP",
|
||||
egm: "EGM",
|
||||
hybrid: "混合",
|
||||
};
|
||||
|
||||
function getStatusDisplay(mapping: Record<string, StatusDisplay>, value: string): StatusDisplay {
|
||||
return mapping[value] ?? { label: value || "未知", color: "blue" };
|
||||
}
|
||||
|
||||
export function getAtpAssetStatusDisplay(value: string): StatusDisplay {
|
||||
return getStatusDisplay(ASSET_STATUS_DISPLAYS, value);
|
||||
}
|
||||
|
||||
export function getAtpReleaseStatusDisplay(value: string): StatusDisplay {
|
||||
return getStatusDisplay(RELEASE_STATUS_DISPLAYS, value);
|
||||
}
|
||||
|
||||
export function getAtpRunStatusDisplay(value: string): StatusDisplay {
|
||||
return getStatusDisplay(RUN_STATUS_DISPLAYS, value);
|
||||
}
|
||||
|
||||
export function getAtpRunnerKindLabel(value: string): string {
|
||||
return RUNNER_KIND_LABELS[value] ?? (value || "未知");
|
||||
}
|
||||
@@ -1,346 +0,0 @@
|
||||
import type {
|
||||
AtpElementKind,
|
||||
AtpGraphEdge,
|
||||
AtpGraphJson,
|
||||
AtpGraphNode,
|
||||
AtpParseResult,
|
||||
} from "./types";
|
||||
|
||||
const RESERVED_TOKENS = new Set([
|
||||
"BEGIN",
|
||||
"END",
|
||||
"NEW",
|
||||
"DATA",
|
||||
"CASE",
|
||||
"PLOT",
|
||||
"OUTPUT",
|
||||
"REQUEST",
|
||||
"BRANCH",
|
||||
"MODEL",
|
||||
"LIBRARY",
|
||||
"OPTION",
|
||||
"OPTIONS",
|
||||
"PARAM",
|
||||
"PARAMETER",
|
||||
"PARAMETERS",
|
||||
"SOURCE",
|
||||
"TACS",
|
||||
"FOR",
|
||||
"IF",
|
||||
"THEN",
|
||||
"ELSE",
|
||||
"CALL",
|
||||
"RETURN",
|
||||
]);
|
||||
|
||||
const COMMENT_PREFIXES = ["*", "!", "#", "//"];
|
||||
const GROUND_ALIASES = new Set(["0", "GND", "GROUND", "GRD", "REF"]);
|
||||
const NODE_TOKEN_PATTERN = /^[A-Za-z0-9_.:+\-/]+$/;
|
||||
const NAME_TOKEN_PATTERN = /^[A-Za-z][A-Za-z0-9_.:+\-/]*$/;
|
||||
|
||||
type ParsedElement = {
|
||||
name: string;
|
||||
kind: AtpElementKind;
|
||||
source: string;
|
||||
target: string;
|
||||
value: string | null;
|
||||
};
|
||||
|
||||
function isCommentLine(trimmed: string): boolean {
|
||||
if (!trimmed) {
|
||||
return true;
|
||||
}
|
||||
if (/^C\s+/i.test(trimmed)) {
|
||||
return true;
|
||||
}
|
||||
return COMMENT_PREFIXES.some((prefix) => trimmed.startsWith(prefix));
|
||||
}
|
||||
|
||||
function stripInlineComment(line: string): string {
|
||||
const exclamationIndex = line.indexOf(" !");
|
||||
const slashIndex = line.indexOf(" //");
|
||||
|
||||
const indexes = [exclamationIndex, slashIndex].filter((value) => value >= 0);
|
||||
if (indexes.length === 0) {
|
||||
return line;
|
||||
}
|
||||
|
||||
const cutIndex = Math.min(...indexes);
|
||||
return line.slice(0, cutIndex).trimEnd();
|
||||
}
|
||||
|
||||
function normalizeNodeToken(token: string): string | null {
|
||||
const trimmed = token.trim().replace(/^['"]+|['"]+$/g, "");
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const unwrapped = trimmed.replace(/^[\[({]+|[\])}]+$/g, "");
|
||||
if (!unwrapped) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = unwrapped.toUpperCase();
|
||||
if (!NODE_TOKEN_PATTERN.test(normalized)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (GROUND_ALIASES.has(normalized)) {
|
||||
return "0";
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function inferElementKind(token: string): AtpElementKind {
|
||||
const upper = token.trim().toUpperCase();
|
||||
|
||||
if (upper.startsWith("R")) return "R";
|
||||
if (upper.startsWith("L")) return "L";
|
||||
if (upper.startsWith("C")) return "C";
|
||||
if (upper.startsWith("SW") || upper.startsWith("BRK") || upper === "S") return "SW";
|
||||
if (upper.startsWith("V") || upper.startsWith("I") || upper.startsWith("E") || upper.startsWith("F")) return "SRC";
|
||||
if (upper.startsWith("TR") || upper.startsWith("XF") || upper.startsWith("T")) return "XFMR";
|
||||
if (upper.startsWith("LINE") || upper.startsWith("PI") || upper.startsWith("RL")) return "LINE";
|
||||
if (upper.startsWith("CTRL") || upper.startsWith("MEAS") || upper.startsWith("MON")) return "CTRL";
|
||||
return "MISC";
|
||||
}
|
||||
|
||||
function isLikelyTypeToken(token: string): boolean {
|
||||
const upper = token.trim().toUpperCase();
|
||||
if (!upper) {
|
||||
return false;
|
||||
}
|
||||
if (RESERVED_TOKENS.has(upper)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const kind = inferElementKind(upper);
|
||||
if (kind !== "MISC") {
|
||||
return true;
|
||||
}
|
||||
|
||||
return upper.length <= 3 && /^[A-Z]+$/.test(upper);
|
||||
}
|
||||
|
||||
function isLikelyElementName(token: string): boolean {
|
||||
const upper = token.trim().toUpperCase();
|
||||
if (!upper) {
|
||||
return false;
|
||||
}
|
||||
if (RESERVED_TOKENS.has(upper)) {
|
||||
return false;
|
||||
}
|
||||
if (!NAME_TOKEN_PATTERN.test(token)) {
|
||||
return false;
|
||||
}
|
||||
if (/^\d/.test(token)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function extractValue(tokens: string[]): string | null {
|
||||
for (const token of tokens) {
|
||||
const candidate = token.trim();
|
||||
if (!candidate) {
|
||||
continue;
|
||||
}
|
||||
if (candidate === "-" || candidate === "/") {
|
||||
continue;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function sanitizeElementName(name: string, fallbackIndex: number): string {
|
||||
const normalized = name.trim().replace(/[^A-Za-z0-9_.:+\-/]/g, "");
|
||||
if (normalized) {
|
||||
return normalized;
|
||||
}
|
||||
return `E${fallbackIndex}`;
|
||||
}
|
||||
|
||||
function parseElementTokens(tokens: string[], fallbackIndex: number): ParsedElement | null {
|
||||
if (tokens.length < 3) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Format A: BUS_A BUS_B R 10
|
||||
if (tokens.length >= 4) {
|
||||
const sourceByType = normalizeNodeToken(tokens[0]);
|
||||
const targetByType = normalizeNodeToken(tokens[1]);
|
||||
const typeToken = tokens[2];
|
||||
|
||||
if (sourceByType && targetByType && isLikelyTypeToken(typeToken)) {
|
||||
const kind = inferElementKind(typeToken);
|
||||
const generatedName = `${kind === "MISC" ? "E" : kind}${fallbackIndex}`;
|
||||
return {
|
||||
name: generatedName,
|
||||
kind,
|
||||
source: sourceByType,
|
||||
target: targetByType,
|
||||
value: extractValue(tokens.slice(3)),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Format B: R1 BUS_A BUS_B 10
|
||||
const nameToken = tokens[0];
|
||||
const source = normalizeNodeToken(tokens[1]);
|
||||
const target = normalizeNodeToken(tokens[2]);
|
||||
if (!source || !target || !isLikelyElementName(nameToken)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
name: sanitizeElementName(nameToken, fallbackIndex),
|
||||
kind: inferElementKind(nameToken),
|
||||
source,
|
||||
target,
|
||||
value: extractValue(tokens.slice(3)),
|
||||
};
|
||||
}
|
||||
|
||||
function makeUniqueName(rawName: string, seen: Map<string, number>): string {
|
||||
const count = seen.get(rawName) ?? 0;
|
||||
seen.set(rawName, count + 1);
|
||||
if (count === 0) {
|
||||
return rawName;
|
||||
}
|
||||
return `${rawName}_${count + 1}`;
|
||||
}
|
||||
|
||||
function shouldSkipControlLine(tokens: string[]): boolean {
|
||||
if (tokens.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const first = tokens[0].toUpperCase();
|
||||
if (RESERVED_TOKENS.has(first)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (first === "/" || first === "+") {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function parseAtpTextToGraphJson(sourceText: string): AtpParseResult {
|
||||
const normalizedText = sourceText.replace(/\r\n?/g, "\n");
|
||||
const lines = normalizedText.split("\n");
|
||||
|
||||
const warnings: string[] = [];
|
||||
const nodesMap = new Map<string, AtpGraphNode>();
|
||||
const edges: AtpGraphEdge[] = [];
|
||||
const usedNames = new Map<string, number>();
|
||||
|
||||
let parsedLines = 0;
|
||||
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const rawLine = lines[index];
|
||||
const lineNo = index + 1;
|
||||
const trimmed = rawLine.trim();
|
||||
|
||||
if (!trimmed || isCommentLine(trimmed)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const uncommented = stripInlineComment(trimmed);
|
||||
if (!uncommented) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const tokens = uncommented
|
||||
.split(/[\s,;]+/)
|
||||
.map((token) => token.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (shouldSkipControlLine(tokens)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const parsed = parseElementTokens(tokens, edges.length + 1);
|
||||
if (!parsed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const sourceNode = parsed.source;
|
||||
const targetNode = parsed.target;
|
||||
|
||||
if (sourceNode === targetNode) {
|
||||
warnings.push(`第 ${lineNo} 行 ${parsed.name} 的首末节点相同(${sourceNode})。`);
|
||||
}
|
||||
|
||||
const sourceMeta = nodesMap.get(sourceNode) ?? {
|
||||
id: sourceNode,
|
||||
label: sourceNode,
|
||||
kind: sourceNode === "0" ? "ground" : "bus",
|
||||
degree: 0,
|
||||
};
|
||||
sourceMeta.degree += 1;
|
||||
nodesMap.set(sourceNode, sourceMeta);
|
||||
|
||||
const targetMeta = nodesMap.get(targetNode) ?? {
|
||||
id: targetNode,
|
||||
label: targetNode,
|
||||
kind: targetNode === "0" ? "ground" : "bus",
|
||||
degree: 0,
|
||||
};
|
||||
targetMeta.degree += 1;
|
||||
nodesMap.set(targetNode, targetMeta);
|
||||
|
||||
const uniqueName = makeUniqueName(parsed.name, usedNames);
|
||||
edges.push({
|
||||
id: `e_${edges.length + 1}`,
|
||||
name: uniqueName,
|
||||
kind: parsed.kind,
|
||||
source: sourceNode,
|
||||
target: targetNode,
|
||||
value: parsed.value,
|
||||
line_no: lineNo,
|
||||
raw_line: rawLine,
|
||||
});
|
||||
|
||||
parsedLines += 1;
|
||||
}
|
||||
|
||||
const nodes = Array.from(nodesMap.values()).sort((left, right) => {
|
||||
if (right.degree !== left.degree) {
|
||||
return right.degree - left.degree;
|
||||
}
|
||||
return left.id.localeCompare(right.id);
|
||||
});
|
||||
|
||||
if (edges.length === 0) {
|
||||
warnings.push("未解析到可渲染的电路元件,请检查 ATP 文本格式是否为纯文本网表。");
|
||||
}
|
||||
|
||||
const graph: AtpGraphJson = {
|
||||
format: "atp-graph-json-v1",
|
||||
source: "atp-text",
|
||||
created_at: new Date().toISOString(),
|
||||
stats: {
|
||||
total_lines: lines.length,
|
||||
parsed_lines: parsedLines,
|
||||
node_count: nodes.length,
|
||||
element_count: edges.length,
|
||||
warning_count: warnings.length,
|
||||
},
|
||||
nodes,
|
||||
edges,
|
||||
warnings,
|
||||
};
|
||||
|
||||
return {
|
||||
graph,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
export function stringifyAtpGraphJson(graph: AtpGraphJson): string {
|
||||
return JSON.stringify(graph, null, 2);
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
export const ATP_SAMPLE_TEXT = `C SIMPLE ATP SAMPLE
|
||||
R1 BUS_A BUS_B 12.5
|
||||
L1 BUS_B BUS_C 0.003
|
||||
C1 BUS_C 0 1.2e-6
|
||||
SW1 BUS_B BUS_D OPEN
|
||||
V1 BUS_A 0 230
|
||||
R2 BUS_D 0 1500
|
||||
L2 BUS_D BUS_E 0.001
|
||||
C2 BUS_E 0 8.2e-7
|
||||
`;
|
||||
@@ -1,53 +0,0 @@
|
||||
export type AtpNodeKind = "bus" | "ground";
|
||||
|
||||
export type AtpElementKind =
|
||||
| "R"
|
||||
| "L"
|
||||
| "C"
|
||||
| "SW"
|
||||
| "SRC"
|
||||
| "XFMR"
|
||||
| "LINE"
|
||||
| "CTRL"
|
||||
| "MISC";
|
||||
|
||||
export type AtpGraphNode = {
|
||||
id: string;
|
||||
label: string;
|
||||
kind: AtpNodeKind;
|
||||
degree: number;
|
||||
};
|
||||
|
||||
export type AtpGraphEdge = {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: AtpElementKind;
|
||||
source: string;
|
||||
target: string;
|
||||
value: string | null;
|
||||
line_no: number;
|
||||
raw_line: string;
|
||||
};
|
||||
|
||||
export type AtpGraphStats = {
|
||||
total_lines: number;
|
||||
parsed_lines: number;
|
||||
node_count: number;
|
||||
element_count: number;
|
||||
warning_count: number;
|
||||
};
|
||||
|
||||
export type AtpGraphJson = {
|
||||
format: "atp-graph-json-v1";
|
||||
source: "atp-text";
|
||||
created_at: string;
|
||||
stats: AtpGraphStats;
|
||||
nodes: AtpGraphNode[];
|
||||
edges: AtpGraphEdge[];
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
export type AtpParseResult = {
|
||||
graph: AtpGraphJson;
|
||||
warnings: string[];
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
const TASK_NAME_LABELS: Record<string, string> = {
|
||||
"app.tasks.atp_asset_tasks.execute_atp_asset_run_job": "ATP 资料包运行",
|
||||
"app.tasks.atp_asset_tasks.execute_atp_asset_run_job": "ATP 模型运行",
|
||||
"app.tasks.atp_model_tasks.execute_atp_model_run_job": "ATP 模型仿真",
|
||||
"app.tasks.elevation_tasks.analyze_elevation_dataset_job": "高程数据集分析",
|
||||
"app.tasks.elevation_tasks.import_elevation_dataset_data_job": "高程数据导入",
|
||||
|
||||
@@ -833,7 +833,6 @@ export type AtpAssetSummary = {
|
||||
voltage_level: string | null;
|
||||
tower_type: string | null;
|
||||
scene_type: string | null;
|
||||
tags_json: string[];
|
||||
latest_release_no: number;
|
||||
active_release_no: number | null;
|
||||
active_release_id: string | null;
|
||||
|
||||
Reference in New Issue
Block a user