[fix/feat]:[FL-82][ATP模型管理改造]

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
chengkai3
2026-06-11 23:45:57 +08:00
parent fac37ddb8d
commit 4328d9fd34
16 changed files with 640 additions and 2480 deletions
+29 -1
View File
@@ -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),
-3
View File
@@ -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):
+156 -17
View File
@@ -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,
*,
+83
View File
@@ -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)
+158 -204
View File
@@ -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>
+105 -87
View File
@@ -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
-487
View File
@@ -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) ?? "") : "")}
/>
);
}
+21
View File
@@ -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" });
});
+50
View File
@@ -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 || "未知");
}
-346
View File
@@ -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);
}
-10
View File
@@ -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
`;
-53
View File
@@ -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 -1
View File
@@ -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": "高程数据导入",
-1
View File
@@ -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;