feat(tower-models): add tower model management with legacy data seed
This commit is contained in:
@@ -11,6 +11,7 @@ from .v1.lines import router as lines_router
|
||||
from .v1.question_bank import router as question_bank_router
|
||||
from .v1.system_params import router as system_params_router
|
||||
from .v1.task_monitor import router as task_monitor_router
|
||||
from .v1.tower_models import router as tower_models_router
|
||||
from .v1.users import router as users_router
|
||||
from .v1.wine import router as wine_router
|
||||
from .v1.ws import router as ws_router
|
||||
@@ -27,6 +28,7 @@ v1_router.include_router(elevation_router)
|
||||
v1_router.include_router(flower_monitor_router)
|
||||
v1_router.include_router(lightning_router)
|
||||
v1_router.include_router(lines_router)
|
||||
v1_router.include_router(tower_models_router)
|
||||
v1_router.include_router(question_bank_router)
|
||||
v1_router.include_router(wine_router)
|
||||
v1_router.include_router(ws_router)
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ...core.database import get_db
|
||||
from ...core.dependencies import CurrentUser, require_any_permission, require_permission
|
||||
from ...schemas.tower_model import (
|
||||
TowerModelCreateRequest,
|
||||
TowerModelImageUploadResponse,
|
||||
TowerModelListResponse,
|
||||
TowerModelSeedResponse,
|
||||
TowerModelSummary,
|
||||
TowerModelUpdateRequest,
|
||||
)
|
||||
from ...services.file_service import download_file_from_path
|
||||
from ...services.tower_model_service import (
|
||||
create_tower_model,
|
||||
delete_tower_model,
|
||||
get_tower_model_by_id,
|
||||
list_tower_models,
|
||||
list_tower_models_for_selector,
|
||||
seed_tower_models_from_legacy,
|
||||
serialize_tower_model,
|
||||
update_tower_model,
|
||||
upload_tower_model_image,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/tower-models", tags=["tower-models"])
|
||||
|
||||
|
||||
@router.get("", response_model=TowerModelListResponse)
|
||||
def get_tower_model_list(
|
||||
keyword: str | None = Query(default=None),
|
||||
enabled: bool | None = Query(default=None),
|
||||
_: CurrentUser = Depends(require_any_permission("tower_model.read", "tower_model.manage", "tower.read", "tower.manage")),
|
||||
db: Session = Depends(get_db),
|
||||
) -> TowerModelListResponse:
|
||||
return list_tower_models(
|
||||
db,
|
||||
keyword=keyword,
|
||||
enabled=enabled,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/selector", response_model=list[TowerModelSummary])
|
||||
def get_tower_model_selector(
|
||||
_: CurrentUser = Depends(require_any_permission("tower_model.read", "tower_model.manage", "tower.read", "tower.manage")),
|
||||
db: Session = Depends(get_db),
|
||||
) -> list[TowerModelSummary]:
|
||||
return list_tower_models_for_selector(db)
|
||||
|
||||
|
||||
@router.post("", response_model=TowerModelSummary)
|
||||
def create_tower_model_endpoint(
|
||||
payload: TowerModelCreateRequest,
|
||||
current_user: CurrentUser = Depends(require_permission("tower_model.manage")),
|
||||
db: Session = Depends(get_db),
|
||||
) -> TowerModelSummary:
|
||||
created = create_tower_model(db, payload, actor=current_user.user)
|
||||
if not created:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="杆塔模型编码已存在")
|
||||
return created
|
||||
|
||||
|
||||
@router.patch("/{model_id}", response_model=TowerModelSummary)
|
||||
def update_tower_model_endpoint(
|
||||
model_id: str,
|
||||
payload: TowerModelUpdateRequest,
|
||||
current_user: CurrentUser = Depends(require_permission("tower_model.manage")),
|
||||
db: Session = Depends(get_db),
|
||||
) -> TowerModelSummary:
|
||||
updated = update_tower_model(db, model_id, payload, actor=current_user.user)
|
||||
if not updated:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="杆塔模型不存在")
|
||||
return updated
|
||||
|
||||
|
||||
@router.delete("/{model_id}")
|
||||
def delete_tower_model_endpoint(
|
||||
model_id: str,
|
||||
_: CurrentUser = Depends(require_permission("tower_model.manage")),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict[str, bool]:
|
||||
deleted = delete_tower_model(db, model_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="杆塔模型不存在")
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@router.post("/{model_id}/image", response_model=TowerModelImageUploadResponse)
|
||||
def upload_tower_model_image_endpoint(
|
||||
model_id: str,
|
||||
mount_code: str = Query(..., min_length=2, max_length=64),
|
||||
file: UploadFile = File(...),
|
||||
current_user: CurrentUser = Depends(require_permission("tower_model.manage")),
|
||||
db: Session = Depends(get_db),
|
||||
) -> TowerModelImageUploadResponse:
|
||||
return upload_tower_model_image(
|
||||
db,
|
||||
model_id=model_id,
|
||||
mount_code=mount_code,
|
||||
file=file,
|
||||
actor=current_user.user,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{model_id}/image")
|
||||
def get_tower_model_image(
|
||||
model_id: str,
|
||||
_: CurrentUser = Depends(require_any_permission("tower_model.read", "tower_model.manage", "tower.read", "tower.manage")),
|
||||
db: Session = Depends(get_db),
|
||||
) -> StreamingResponse:
|
||||
item = get_tower_model_by_id(db, model_id)
|
||||
if not item:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="杆塔模型不存在")
|
||||
if not item.image_mount_code or not item.image_path:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="杆塔模型未配置图片")
|
||||
|
||||
filename, content, content_type = download_file_from_path(
|
||||
db,
|
||||
mount_code=item.image_mount_code,
|
||||
path=item.image_path,
|
||||
)
|
||||
headers = {"Content-Disposition": f'inline; filename="{filename}"'}
|
||||
return StreamingResponse(iter([content]), media_type=content_type or "application/octet-stream", headers=headers)
|
||||
|
||||
|
||||
@router.post("/seed/legacy", response_model=TowerModelSeedResponse)
|
||||
def seed_legacy_tower_models_endpoint(
|
||||
overwrite_existing: bool = Query(default=False),
|
||||
current_user: CurrentUser = Depends(require_permission("tower_model.manage")),
|
||||
db: Session = Depends(get_db),
|
||||
) -> TowerModelSeedResponse:
|
||||
return seed_tower_models_from_legacy(
|
||||
db,
|
||||
actor=current_user.user,
|
||||
overwrite_existing=overwrite_existing,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{model_id}", response_model=TowerModelSummary)
|
||||
def get_tower_model_detail(
|
||||
model_id: str,
|
||||
_: CurrentUser = Depends(require_any_permission("tower_model.read", "tower_model.manage", "tower.read", "tower.manage")),
|
||||
db: Session = Depends(get_db),
|
||||
) -> TowerModelSummary:
|
||||
item = get_tower_model_by_id(db, model_id)
|
||||
if not item:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="杆塔模型不存在")
|
||||
return serialize_tower_model(item)
|
||||
@@ -277,6 +277,61 @@ def _ensure_elevation_dataset_column_compatibility() -> None:
|
||||
)
|
||||
|
||||
|
||||
def _ensure_tower_model_column_compatibility() -> None:
|
||||
"""
|
||||
Keep `tower_model` columns aligned with the current ORM mapping.
|
||||
"""
|
||||
if not database_url.startswith("postgresql"):
|
||||
return
|
||||
|
||||
schema = settings.resolved_db_schema
|
||||
with engine.begin() as connection:
|
||||
db_inspector = inspect(connection)
|
||||
if not db_inspector.has_table("tower_model", schema=schema):
|
||||
return
|
||||
|
||||
column_names = {
|
||||
column["name"]
|
||||
for column in db_inspector.get_columns("tower_model", schema=schema)
|
||||
}
|
||||
|
||||
if "source_tag" not in column_names:
|
||||
connection.execute(
|
||||
text("ALTER TABLE tower_model ADD COLUMN IF NOT EXISTS source_tag VARCHAR(64)"),
|
||||
)
|
||||
logger.warning(
|
||||
"Detected missing tower_model.source_tag; added nullable source tag column.",
|
||||
)
|
||||
|
||||
if "sort_order" not in column_names:
|
||||
connection.execute(
|
||||
text("ALTER TABLE tower_model ADD COLUMN IF NOT EXISTS sort_order INTEGER"),
|
||||
)
|
||||
connection.execute(
|
||||
text("UPDATE tower_model SET sort_order = 0 WHERE sort_order IS NULL"),
|
||||
)
|
||||
connection.execute(
|
||||
text("ALTER TABLE tower_model ALTER COLUMN sort_order SET NOT NULL"),
|
||||
)
|
||||
logger.warning(
|
||||
"Detected missing tower_model.sort_order; added with default 0.",
|
||||
)
|
||||
|
||||
if "default_raw_json" not in column_names:
|
||||
connection.execute(
|
||||
text("ALTER TABLE tower_model ADD COLUMN IF NOT EXISTS default_raw_json JSON"),
|
||||
)
|
||||
connection.execute(
|
||||
text("UPDATE tower_model SET default_raw_json = '{}'::json WHERE default_raw_json IS NULL"),
|
||||
)
|
||||
connection.execute(
|
||||
text("ALTER TABLE tower_model ALTER COLUMN default_raw_json SET NOT NULL"),
|
||||
)
|
||||
logger.warning(
|
||||
"Detected missing tower_model.default_raw_json; added with default empty JSON.",
|
||||
)
|
||||
|
||||
|
||||
def get_db() -> Generator[Session, None, None]:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
@@ -307,6 +362,7 @@ def init_db() -> None:
|
||||
requirement,
|
||||
system_param,
|
||||
todo,
|
||||
tower_model,
|
||||
user,
|
||||
worker_registry,
|
||||
) # noqa: F401
|
||||
@@ -316,6 +372,7 @@ def init_db() -> None:
|
||||
_ensure_user_timestamp_column_compatibility()
|
||||
_ensure_user_audit_column_compatibility()
|
||||
_ensure_elevation_dataset_column_compatibility()
|
||||
_ensure_tower_model_column_compatibility()
|
||||
Base.metadata.create_all(bind=engine)
|
||||
with SessionLocal() as db:
|
||||
local_hosts = {"db", "localhost", "127.0.0.1", "::1"}
|
||||
|
||||
@@ -4,7 +4,7 @@ Import all model modules during package initialization so SQLAlchemy can
|
||||
resolve string-based relationships regardless of route/service import order.
|
||||
"""
|
||||
|
||||
from . import atp_model, audit_log, auth_session, calendar_event, elevation, file_storage, hot_search, lightning_event, lightning_sample, line, line_tower, menu, model_registry, object_group, question_bank, rbac, requirement, system_param, todo, user, worker_registry
|
||||
from . import atp_model, audit_log, auth_session, calendar_event, elevation, file_storage, hot_search, lightning_event, lightning_sample, line, line_tower, menu, model_registry, object_group, question_bank, rbac, requirement, system_param, todo, tower_model, user, worker_registry
|
||||
|
||||
__all__ = [
|
||||
"atp_model",
|
||||
@@ -26,6 +26,7 @@ __all__ = [
|
||||
"requirement",
|
||||
"system_param",
|
||||
"todo",
|
||||
"tower_model",
|
||||
"user",
|
||||
"worker_registry",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import JSON, Boolean, DateTime, Float, Index, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from ..core.database import Base
|
||||
from .base import utcnow
|
||||
|
||||
|
||||
class TowerModel(Base):
|
||||
__tablename__ = "tower_model"
|
||||
__table_args__ = (
|
||||
Index("idx_tower_model_code", "code"),
|
||||
Index("idx_tower_model_name", "name"),
|
||||
Index("idx_tower_model_enabled", "is_enabled"),
|
||||
Index("idx_tower_model_tower_type", "tower_type"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(
|
||||
String(32),
|
||||
primary_key=True,
|
||||
default=lambda: uuid4().hex,
|
||||
)
|
||||
code: Mapped[str] = mapped_column(String(128), unique=True, nullable=False, index=True)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
tower_type: Mapped[str | None] = mapped_column(String(32), index=True)
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
image_mount_code: Mapped[str | None] = mapped_column(String(64), index=True)
|
||||
image_path: Mapped[str | None] = mapped_column(String(2048))
|
||||
source_tag: Mapped[str | None] = mapped_column(String(64), index=True)
|
||||
is_enabled: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
|
||||
sort_order: Mapped[int] = mapped_column(Integer, default=0, index=True)
|
||||
|
||||
default_altitude_m: Mapped[float | None] = mapped_column(Float)
|
||||
default_terrain: Mapped[str | None] = mapped_column(String(64))
|
||||
default_ground_resistance_ohm: Mapped[float | None] = mapped_column(Float)
|
||||
default_lightning_density: Mapped[float | None] = mapped_column(Float)
|
||||
default_span_small_m: Mapped[float | None] = mapped_column(Float)
|
||||
default_span_large_m: Mapped[float | None] = mapped_column(Float)
|
||||
default_slope_1: Mapped[float | None] = mapped_column(Float)
|
||||
default_slope_2: Mapped[float | None] = mapped_column(Float)
|
||||
default_risk_level: Mapped[str | None] = mapped_column(String(32), index=True)
|
||||
default_raw_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
|
||||
|
||||
create_date: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True)
|
||||
create_user: Mapped[str | None] = mapped_column(String(64), index=True)
|
||||
update_date: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=utcnow,
|
||||
onupdate=utcnow,
|
||||
)
|
||||
update_user: Mapped[str | None] = mapped_column(String(64), index=True)
|
||||
@@ -0,0 +1,97 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class TowerModelSummary(BaseModel):
|
||||
id: str
|
||||
code: str
|
||||
name: str
|
||||
tower_type: str | None = None
|
||||
description: str | None = None
|
||||
image_mount_code: str | None = None
|
||||
image_path: str | None = None
|
||||
source_tag: str | None = None
|
||||
is_enabled: bool = True
|
||||
sort_order: int = 0
|
||||
default_altitude_m: float | None = None
|
||||
default_terrain: str | None = None
|
||||
default_ground_resistance_ohm: float | None = None
|
||||
default_lightning_density: float | None = None
|
||||
default_span_small_m: float | None = None
|
||||
default_span_large_m: float | None = None
|
||||
default_slope_1: float | None = None
|
||||
default_slope_2: float | None = None
|
||||
default_risk_level: str | None = None
|
||||
default_raw_json: dict[str, Any] = Field(default_factory=dict)
|
||||
create_date: datetime
|
||||
create_user: str | None = None
|
||||
update_date: datetime
|
||||
update_user: str | None = None
|
||||
|
||||
|
||||
class TowerModelListResponse(BaseModel):
|
||||
items: list[TowerModelSummary]
|
||||
total: int
|
||||
|
||||
|
||||
class TowerModelCreateRequest(BaseModel):
|
||||
code: str = Field(min_length=1, max_length=128)
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
tower_type: str | None = Field(default=None, max_length=32)
|
||||
description: str | None = Field(default=None, max_length=2000)
|
||||
image_mount_code: str | None = Field(default=None, min_length=2, max_length=64)
|
||||
image_path: str | None = Field(default=None, min_length=1, max_length=2048)
|
||||
source_tag: str | None = Field(default=None, max_length=64)
|
||||
is_enabled: bool = True
|
||||
sort_order: int = Field(default=0, ge=0, le=1_000_000)
|
||||
default_altitude_m: float | None = None
|
||||
default_terrain: str | None = Field(default=None, max_length=64)
|
||||
default_ground_resistance_ohm: float | None = None
|
||||
default_lightning_density: float | None = None
|
||||
default_span_small_m: float | None = None
|
||||
default_span_large_m: float | None = None
|
||||
default_slope_1: float | None = None
|
||||
default_slope_2: float | None = None
|
||||
default_risk_level: str | None = Field(default=None, max_length=32)
|
||||
default_raw_json: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class TowerModelUpdateRequest(BaseModel):
|
||||
name: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
tower_type: str | None = Field(default=None, max_length=32)
|
||||
description: str | None = Field(default=None, max_length=2000)
|
||||
image_mount_code: str | None = Field(default=None, min_length=2, max_length=64)
|
||||
image_path: str | None = Field(default=None, min_length=1, max_length=2048)
|
||||
source_tag: str | None = Field(default=None, max_length=64)
|
||||
is_enabled: bool | None = None
|
||||
sort_order: int | None = Field(default=None, ge=0, le=1_000_000)
|
||||
default_altitude_m: float | None = None
|
||||
default_terrain: str | None = Field(default=None, max_length=64)
|
||||
default_ground_resistance_ohm: float | None = None
|
||||
default_lightning_density: float | None = None
|
||||
default_span_small_m: float | None = None
|
||||
default_span_large_m: float | None = None
|
||||
default_slope_1: float | None = None
|
||||
default_slope_2: float | None = None
|
||||
default_risk_level: str | None = Field(default=None, max_length=32)
|
||||
default_raw_json: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class TowerModelImageUploadResponse(BaseModel):
|
||||
model: TowerModelSummary
|
||||
mount_code: str
|
||||
image_path: str
|
||||
|
||||
|
||||
class TowerModelSeedResponse(BaseModel):
|
||||
total_models: int
|
||||
imported_models: int
|
||||
updated_models: int
|
||||
skipped_models: int
|
||||
copied_images: int
|
||||
warnings: list[str] = Field(default_factory=list)
|
||||
|
||||
@@ -404,7 +404,7 @@ def update_menu(db: Session, menu_id: int, payload: MenuUpdateRequest) -> MenuPu
|
||||
|
||||
def delete_menu(db: Session, menu_id: int) -> bool:
|
||||
menu = get_menu_by_id(db, menu_id)
|
||||
if not menu or menu.code in {"admin.users", "admin.roles", "admin.menus", "admin.system_params", "admin.power_lines", "admin.lightning_currents", "admin.lightning_distribution", "admin.workers", "admin.task_monitor", "admin.atp_models", "admin.files", "admin.elevation", "admin.syslog", "admin.wine_runner"}:
|
||||
if not menu or menu.code in {"admin.users", "admin.roles", "admin.menus", "admin.system_params", "admin.power_lines", "admin.lightning_currents", "admin.lightning_distribution", "admin.workers", "admin.task_monitor", "admin.atp_models", "admin.tower_models", "admin.files", "admin.elevation", "admin.syslog", "admin.wine_runner"}:
|
||||
return False
|
||||
child_exists = db.scalar(select(Menu.id).where(Menu.parent_id == menu_id))
|
||||
if child_exists is not None:
|
||||
|
||||
@@ -70,6 +70,7 @@ PROTECTED_MENU_CODES = {
|
||||
"admin.wxapp",
|
||||
"admin.files",
|
||||
"admin.elevation",
|
||||
"admin.tower_models",
|
||||
"admin.filedetector",
|
||||
"admin.baidu_pan",
|
||||
"admin.power_lines",
|
||||
|
||||
@@ -24,6 +24,8 @@ DEFAULT_ADMIN_PERMISSION_CODES: set[str] = {
|
||||
"line.manage",
|
||||
"tower.read",
|
||||
"tower.manage",
|
||||
"tower_model.read",
|
||||
"tower_model.manage",
|
||||
"lightning.read",
|
||||
"lightning.manage",
|
||||
"elevation.read",
|
||||
@@ -92,6 +94,7 @@ MENU_CODE_PERMISSION_MAP: dict[str, set[str]] = {
|
||||
"admin.system_params": {"system_param.read", "system_param.manage"},
|
||||
"admin.files": {"file.read", "file.manage"},
|
||||
"admin.elevation": {"elevation.read", "elevation.manage"},
|
||||
"admin.tower_models": {"tower_model.read", "tower_model.manage"},
|
||||
"admin.workers": {"celery.read", "celery.manage"},
|
||||
"admin.task_monitor": {"celery.read", "celery.manage"},
|
||||
"admin.atp_models": {"atp.read", "atp.manage", "atp.run"},
|
||||
@@ -112,6 +115,17 @@ SYNTHETIC_LEGACY_MENU_ROWS: list[dict[str, Any]] = [
|
||||
"seq": 53,
|
||||
"state": "ENABLED",
|
||||
},
|
||||
{
|
||||
"menu_id": "admin.tower_models",
|
||||
"menu_name": "admin.tower_models",
|
||||
"menu_label": "杆塔模型管理",
|
||||
"menu_type": "MENU",
|
||||
"parent_id": None,
|
||||
"url": "/admin/tower-models",
|
||||
"menu_icon": "Apartment",
|
||||
"seq": 56,
|
||||
"state": "ENABLED",
|
||||
},
|
||||
{
|
||||
"menu_id": "admin.files",
|
||||
"menu_name": "admin.files",
|
||||
@@ -120,7 +134,7 @@ SYNTHETIC_LEGACY_MENU_ROWS: list[dict[str, Any]] = [
|
||||
"parent_id": None,
|
||||
"url": "/admin/files",
|
||||
"menu_icon": "FolderTree",
|
||||
"seq": 56,
|
||||
"seq": 57,
|
||||
"state": "ENABLED",
|
||||
},
|
||||
{
|
||||
@@ -131,7 +145,7 @@ SYNTHETIC_LEGACY_MENU_ROWS: list[dict[str, Any]] = [
|
||||
"parent_id": None,
|
||||
"url": "/admin/elevation",
|
||||
"menu_icon": "Database",
|
||||
"seq": 57,
|
||||
"seq": 58,
|
||||
"state": "ENABLED",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -14,6 +14,7 @@ from sqlalchemy.orm import Session
|
||||
from ..models.base import utcnow
|
||||
from ..models.line import Line
|
||||
from ..models.line_tower import LineTower
|
||||
from ..models.tower_model import TowerModel
|
||||
from ..schemas.line import (
|
||||
LineCreateRequest,
|
||||
LineListResponse,
|
||||
@@ -26,6 +27,7 @@ from ..schemas.line import (
|
||||
LineUpdateRequest,
|
||||
)
|
||||
from .push_service import publish_topic
|
||||
from .tower_model_service import derive_tower_model_code_from_legacy, derive_tower_model_default_values_from_legacy_row
|
||||
|
||||
LINE_TOPIC = "admin.power-lines"
|
||||
CSV_ENCODINGS = ("utf-8-sig", "utf-8", "gbk", "latin-1")
|
||||
@@ -281,27 +283,28 @@ def create_line_tower(
|
||||
if existed:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Tower sequence or tower number already exists")
|
||||
|
||||
model_defaults = _load_tower_model_defaults(db, payload.tower_model)
|
||||
now = utcnow()
|
||||
item = LineTower(
|
||||
line_id=line_id,
|
||||
seq_no=payload.seq_no,
|
||||
tower_no=payload.tower_no.strip(),
|
||||
tower_model=_normalize_str(payload.tower_model),
|
||||
tower_type=_normalize_str(payload.tower_type),
|
||||
tower_model=model_defaults.get("tower_model") if model_defaults else _normalize_str(payload.tower_model),
|
||||
tower_type=_pick_optional_value(payload.tower_type, model_defaults.get("tower_type") if model_defaults else None),
|
||||
longitude=payload.longitude,
|
||||
latitude=payload.latitude,
|
||||
altitude_m=payload.altitude_m,
|
||||
terrain=_normalize_str(payload.terrain),
|
||||
ground_resistance_ohm=payload.ground_resistance_ohm,
|
||||
lightning_density=payload.lightning_density,
|
||||
span_small_m=payload.span_small_m,
|
||||
span_large_m=payload.span_large_m,
|
||||
slope_1=payload.slope_1,
|
||||
slope_2=payload.slope_2,
|
||||
risk_level=_normalize_str(payload.risk_level),
|
||||
circuit_geometry_json=payload.circuit_geometry_json,
|
||||
lightning_result_json=payload.lightning_result_json,
|
||||
raw_extra_json=payload.raw_extra_json,
|
||||
altitude_m=_pick_optional_value(payload.altitude_m, model_defaults.get("altitude_m") if model_defaults else None),
|
||||
terrain=_pick_optional_value(_normalize_str(payload.terrain), model_defaults.get("terrain") if model_defaults else None),
|
||||
ground_resistance_ohm=_pick_optional_value(payload.ground_resistance_ohm, model_defaults.get("ground_resistance_ohm") if model_defaults else None),
|
||||
lightning_density=_pick_optional_value(payload.lightning_density, model_defaults.get("lightning_density") if model_defaults else None),
|
||||
span_small_m=_pick_optional_value(payload.span_small_m, model_defaults.get("span_small_m") if model_defaults else None),
|
||||
span_large_m=_pick_optional_value(payload.span_large_m, model_defaults.get("span_large_m") if model_defaults else None),
|
||||
slope_1=_pick_optional_value(payload.slope_1, model_defaults.get("slope_1") if model_defaults else None),
|
||||
slope_2=_pick_optional_value(payload.slope_2, model_defaults.get("slope_2") if model_defaults else None),
|
||||
risk_level=_pick_optional_value(_normalize_str(payload.risk_level), model_defaults.get("risk_level") if model_defaults else None),
|
||||
circuit_geometry_json=_pick_dict_value(payload.circuit_geometry_json, _extract_model_default_dict(model_defaults, "circuit_geometry_json")),
|
||||
lightning_result_json=_pick_dict_value(payload.lightning_result_json, _extract_model_default_dict(model_defaults, "lightning_result_json")),
|
||||
raw_extra_json=payload.raw_extra_json or {},
|
||||
create_user=actor_user_id,
|
||||
update_user=actor_user_id,
|
||||
create_date=now,
|
||||
@@ -480,21 +483,26 @@ def import_line_towers_from_csv(
|
||||
|
||||
tower.seq_no = seq_no
|
||||
tower.tower_no = tower_no
|
||||
tower.tower_model = _normalize_str(row.get("杆塔模型"))
|
||||
tower.tower_type = _normalize_str(row.get("直线或耐张杆塔"))
|
||||
source_model_name = _normalize_str(row.get("杆塔模型"))
|
||||
model_defaults = _load_tower_model_defaults_from_row(db, source_row=row, source_model_name=source_model_name)
|
||||
tower.tower_model = model_defaults.get("tower_model") if model_defaults else source_model_name
|
||||
tower.tower_type = _pick_optional_value(
|
||||
_normalize_str(row.get("直线或耐张杆塔")),
|
||||
model_defaults.get("tower_type") if model_defaults else None,
|
||||
)
|
||||
tower.longitude = _parse_float(row.get("经度"))
|
||||
tower.latitude = _parse_float(row.get("纬度"))
|
||||
tower.altitude_m = _parse_float(row.get("海拔m"))
|
||||
tower.terrain = _normalize_str(row.get("地形"))
|
||||
tower.ground_resistance_ohm = _parse_float(row.get("接地电阻"))
|
||||
tower.lightning_density = _parse_float(row.get("地闪密度"))
|
||||
tower.span_small_m = _parse_float(row.get("小号侧档距"))
|
||||
tower.span_large_m = _parse_float(row.get("大号侧档距"))
|
||||
tower.slope_1 = _parse_float(row.get("地面倾角1"))
|
||||
tower.slope_2 = _parse_float(row.get("地面倾角2"))
|
||||
tower.risk_level = _normalize_str(row.get("雷击风险等级"))
|
||||
tower.circuit_geometry_json = _build_circuit_geometry(row)
|
||||
tower.lightning_result_json = _build_lightning_result(row)
|
||||
tower.altitude_m = _pick_optional_value(_parse_float(row.get("海拔m")), model_defaults.get("altitude_m") if model_defaults else None)
|
||||
tower.terrain = _pick_optional_value(_normalize_str(row.get("地形")), model_defaults.get("terrain") if model_defaults else None)
|
||||
tower.ground_resistance_ohm = _pick_optional_value(_parse_float(row.get("接地电阻")), model_defaults.get("ground_resistance_ohm") if model_defaults else None)
|
||||
tower.lightning_density = _pick_optional_value(_parse_float(row.get("地闪密度")), model_defaults.get("lightning_density") if model_defaults else None)
|
||||
tower.span_small_m = _pick_optional_value(_parse_float(row.get("小号侧档距")), model_defaults.get("span_small_m") if model_defaults else None)
|
||||
tower.span_large_m = _pick_optional_value(_parse_float(row.get("大号侧档距")), model_defaults.get("span_large_m") if model_defaults else None)
|
||||
tower.slope_1 = _pick_optional_value(_parse_float(row.get("地面倾角1")), model_defaults.get("slope_1") if model_defaults else None)
|
||||
tower.slope_2 = _pick_optional_value(_parse_float(row.get("地面倾角2")), model_defaults.get("slope_2") if model_defaults else None)
|
||||
tower.risk_level = _pick_optional_value(_normalize_str(row.get("雷击风险等级")), model_defaults.get("risk_level") if model_defaults else None)
|
||||
tower.circuit_geometry_json = _pick_dict_value(_build_circuit_geometry(row), _extract_model_default_dict(model_defaults, "circuit_geometry_json"))
|
||||
tower.lightning_result_json = _pick_dict_value(_build_lightning_result(row), _extract_model_default_dict(model_defaults, "lightning_result_json"))
|
||||
tower.raw_extra_json = _extract_extra_values(row, extra_headers)
|
||||
tower.update_user = actor_user_id
|
||||
tower.update_date = utcnow()
|
||||
@@ -811,6 +819,114 @@ def _publish_line_change(event_name: str, payload: dict[str, Any]) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _pick_optional_value(primary: Any, fallback: Any) -> Any:
|
||||
if primary is None:
|
||||
return fallback
|
||||
if isinstance(primary, str) and not primary.strip():
|
||||
return fallback
|
||||
return primary
|
||||
|
||||
|
||||
def _pick_dict_value(primary: dict[str, Any], fallback: dict[str, Any]) -> dict[str, Any]:
|
||||
if _has_meaningful_dict_data(primary):
|
||||
return primary
|
||||
if fallback:
|
||||
return fallback
|
||||
return {}
|
||||
|
||||
|
||||
def _has_meaningful_dict_data(value: dict[str, Any] | None) -> bool:
|
||||
if not value:
|
||||
return False
|
||||
for item in value.values():
|
||||
if item is None:
|
||||
continue
|
||||
if isinstance(item, dict):
|
||||
if _has_meaningful_dict_data(item):
|
||||
return True
|
||||
continue
|
||||
if isinstance(item, list):
|
||||
if len(item) > 0:
|
||||
return True
|
||||
continue
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _extract_model_default_dict(defaults: dict[str, Any] | None, key: str) -> dict[str, Any]:
|
||||
if not defaults:
|
||||
return {}
|
||||
raw_json = defaults.get("raw_json")
|
||||
if not isinstance(raw_json, dict):
|
||||
return {}
|
||||
candidate = raw_json.get(key)
|
||||
if isinstance(candidate, dict):
|
||||
return candidate
|
||||
return {}
|
||||
|
||||
|
||||
def _load_tower_model_defaults(db: Session, model_code: str | None) -> dict[str, Any] | None:
|
||||
normalized = _normalize_str(model_code)
|
||||
if normalized is None:
|
||||
return None
|
||||
model = db.execute(
|
||||
select(TowerModel).where(
|
||||
func.lower(TowerModel.code) == normalized.lower(),
|
||||
TowerModel.is_enabled.is_(True),
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if not model:
|
||||
return None
|
||||
|
||||
return {
|
||||
"tower_model": model.code,
|
||||
"tower_type": model.tower_type,
|
||||
"altitude_m": model.default_altitude_m,
|
||||
"terrain": model.default_terrain,
|
||||
"ground_resistance_ohm": model.default_ground_resistance_ohm,
|
||||
"lightning_density": model.default_lightning_density,
|
||||
"span_small_m": model.default_span_small_m,
|
||||
"span_large_m": model.default_span_large_m,
|
||||
"slope_1": model.default_slope_1,
|
||||
"slope_2": model.default_slope_2,
|
||||
"risk_level": model.default_risk_level,
|
||||
"raw_json": model.default_raw_json or {},
|
||||
}
|
||||
|
||||
|
||||
def _load_tower_model_defaults_from_row(
|
||||
db: Session,
|
||||
*,
|
||||
source_row: dict[str, str],
|
||||
source_model_name: str | None,
|
||||
) -> dict[str, Any] | None:
|
||||
model_code = derive_tower_model_code_from_legacy(source_model_name or "")
|
||||
model_defaults = _load_tower_model_defaults(db, model_code)
|
||||
if model_defaults:
|
||||
return model_defaults
|
||||
|
||||
if not model_code:
|
||||
return None
|
||||
|
||||
fallback = derive_tower_model_default_values_from_legacy_row(source_row)
|
||||
if not fallback:
|
||||
return None
|
||||
return {
|
||||
"tower_model": model_code,
|
||||
"tower_type": fallback.get("tower_type"),
|
||||
"altitude_m": fallback.get("altitude_m"),
|
||||
"terrain": fallback.get("terrain"),
|
||||
"ground_resistance_ohm": fallback.get("ground_resistance_ohm"),
|
||||
"lightning_density": fallback.get("lightning_density"),
|
||||
"span_small_m": fallback.get("span_small_m"),
|
||||
"span_large_m": fallback.get("span_large_m"),
|
||||
"slope_1": fallback.get("slope_1"),
|
||||
"slope_2": fallback.get("slope_2"),
|
||||
"risk_level": fallback.get("risk_level"),
|
||||
"raw_json": fallback.get("raw_json") or {},
|
||||
}
|
||||
|
||||
|
||||
def _fire_and_forget(coro: object) -> None:
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..core.config import get_settings
|
||||
@@ -6,7 +6,9 @@ from ..core.security import hash_password
|
||||
from ..models.file_storage import FileStorageBackend, FileStorageMount
|
||||
from ..models.menu import Menu
|
||||
from ..models.rbac import Permission, Role
|
||||
from ..models.tower_model import TowerModel
|
||||
from ..models.user import User
|
||||
from .tower_model_service import seed_tower_models_from_legacy
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
@@ -28,6 +30,8 @@ DEFAULT_PERMISSIONS: dict[str, str] = {
|
||||
"line.manage": "Manage power lines",
|
||||
"tower.read": "Read line towers",
|
||||
"tower.manage": "Manage line towers",
|
||||
"tower_model.read": "Read tower model library",
|
||||
"tower_model.manage": "Manage tower model library and images",
|
||||
"lightning.read": "Read lightning current events and features",
|
||||
"lightning.manage": "Manage lightning current events and data imports",
|
||||
"elevation.read": "Read elevation datasets and apply jobs",
|
||||
@@ -62,6 +66,8 @@ DEFAULT_ROLES: dict[str, dict[str, object]] = {
|
||||
"line.manage",
|
||||
"tower.read",
|
||||
"tower.manage",
|
||||
"tower_model.read",
|
||||
"tower_model.manage",
|
||||
"lightning.read",
|
||||
"lightning.manage",
|
||||
"elevation.read",
|
||||
@@ -212,6 +218,19 @@ DEFAULT_MENUS: list[dict[str, object]] = [
|
||||
"cacheable": False,
|
||||
"permission_code": "atp.read",
|
||||
},
|
||||
{
|
||||
"code": "admin.tower_models",
|
||||
"name": "杆塔模型管理",
|
||||
"path": "/admin/tower-models",
|
||||
"icon": "Apartment",
|
||||
"parent_code": None,
|
||||
"type": "menu",
|
||||
"sort_order": 56,
|
||||
"status": "enabled",
|
||||
"visible": True,
|
||||
"cacheable": False,
|
||||
"permission_code": "tower_model.read",
|
||||
},
|
||||
{
|
||||
"code": "admin.files",
|
||||
"name": "文件管理",
|
||||
@@ -219,7 +238,7 @@ DEFAULT_MENUS: list[dict[str, object]] = [
|
||||
"icon": "FolderTree",
|
||||
"parent_code": None,
|
||||
"type": "menu",
|
||||
"sort_order": 56,
|
||||
"sort_order": 57,
|
||||
"status": "enabled",
|
||||
"visible": True,
|
||||
"cacheable": False,
|
||||
@@ -232,7 +251,7 @@ DEFAULT_MENUS: list[dict[str, object]] = [
|
||||
"icon": "Database",
|
||||
"parent_code": None,
|
||||
"type": "menu",
|
||||
"sort_order": 57,
|
||||
"sort_order": 58,
|
||||
"status": "enabled",
|
||||
"visible": True,
|
||||
"cacheable": False,
|
||||
@@ -245,7 +264,7 @@ DEFAULT_MENUS: list[dict[str, object]] = [
|
||||
"icon": "FileText",
|
||||
"parent_code": None,
|
||||
"type": "menu",
|
||||
"sort_order": 58,
|
||||
"sort_order": 59,
|
||||
"status": "enabled",
|
||||
"visible": True,
|
||||
"cacheable": False,
|
||||
@@ -267,7 +286,7 @@ DEFAULT_MENUS: list[dict[str, object]] = [
|
||||
]
|
||||
|
||||
ROLE_MENU_BINDINGS: dict[str, list[str]] = {
|
||||
"admin": ["admin.users", "admin.roles", "admin.menus", "admin.system_params", "admin.power_lines", "admin.lightning_currents", "admin.lightning_distribution", "admin.workers", "admin.task_monitor", "admin.atp_models", "admin.files", "admin.elevation", "admin.syslog", "admin.wine_runner"],
|
||||
"admin": ["admin.users", "admin.roles", "admin.menus", "admin.system_params", "admin.power_lines", "admin.lightning_currents", "admin.lightning_distribution", "admin.workers", "admin.task_monitor", "admin.atp_models", "admin.tower_models", "admin.files", "admin.elevation", "admin.syslog", "admin.wine_runner"],
|
||||
"user": [],
|
||||
}
|
||||
|
||||
@@ -322,6 +341,7 @@ def seed_defaults(db: Session) -> None:
|
||||
_seed_file_storage(db)
|
||||
_seed_initial_admin(db)
|
||||
db.commit()
|
||||
_seed_legacy_tower_models_if_empty(db)
|
||||
|
||||
|
||||
def _seed_permissions(db: Session) -> dict[str, Permission]:
|
||||
@@ -482,3 +502,24 @@ def _seed_initial_admin(db: Session) -> None:
|
||||
role_codes = {role.code for role in user.roles}
|
||||
if "admin" not in role_codes:
|
||||
user.roles.append(admin_role)
|
||||
|
||||
|
||||
def _seed_legacy_tower_models_if_empty(db: Session) -> None:
|
||||
existing_count = int(db.scalar(select(func.count()).select_from(TowerModel)) or 0)
|
||||
if existing_count > 0:
|
||||
return
|
||||
|
||||
actor = db.scalar(select(User).where(User.username == settings.initial_admin_username))
|
||||
if actor is None:
|
||||
actor = db.scalar(select(User).order_by(User.created_at.asc()))
|
||||
if actor is None:
|
||||
return
|
||||
|
||||
try:
|
||||
seed_tower_models_from_legacy(
|
||||
db,
|
||||
actor=actor,
|
||||
overwrite_existing=False,
|
||||
)
|
||||
except Exception:
|
||||
db.rollback()
|
||||
|
||||
@@ -22,6 +22,7 @@ TOPIC_RULES: dict[str, TopicRule] = {
|
||||
"admin.menus": TopicRule(any_permission_codes={"menu.read", "menu.manage"}),
|
||||
"admin.system-params": TopicRule(any_permission_codes={"system_param.read", "system_param.manage"}),
|
||||
"admin.files": TopicRule(any_permission_codes={"file.read", "file.manage"}),
|
||||
"admin.tower-models": TopicRule(any_permission_codes={"tower_model.read", "tower_model.manage", "tower.read", "tower.manage"}),
|
||||
"admin.elevation": TopicRule(any_permission_codes={"elevation.read", "elevation.manage"}),
|
||||
"admin.atp-models": TopicRule(any_permission_codes={"atp.read", "atp.run", "atp.manage"}),
|
||||
"admin.audit_logs": TopicRule(any_permission_codes={"menu.read", "menu.manage"}),
|
||||
|
||||
@@ -0,0 +1,748 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import csv
|
||||
import io
|
||||
import mimetypes
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException, UploadFile, status
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..models.base import utcnow
|
||||
from ..models.tower_model import TowerModel
|
||||
from ..models.user import User
|
||||
from ..schemas.tower_model import (
|
||||
TowerModelCreateRequest,
|
||||
TowerModelImageUploadResponse,
|
||||
TowerModelListResponse,
|
||||
TowerModelSeedResponse,
|
||||
TowerModelSummary,
|
||||
TowerModelUpdateRequest,
|
||||
)
|
||||
from .file_service import _build_driver_or_400, _require_mount, list_enabled_mounts
|
||||
from .push_service import publish_topic
|
||||
from .storage_driver import (
|
||||
StorageDriverError,
|
||||
StorageInvalidPathError,
|
||||
StoragePathNotFoundError,
|
||||
join_virtual_path,
|
||||
normalize_virtual_path,
|
||||
)
|
||||
|
||||
TOWER_MODEL_TOPIC = "admin.tower-models"
|
||||
DEFAULT_TOWER_MODEL_IMAGE_DIR = "/tower-models/images"
|
||||
DEFAULT_SEED_SOURCE_TAG = "legacy-fl"
|
||||
LEGACY_WORKSPACE_ROOT = Path("/root/.openclaw/workspace/fl")
|
||||
LEGACY_SETTING_PATH = LEGACY_WORKSPACE_ROOT / "执行目录-2025-11-20" / "Primary" / "LP_Setting.txt"
|
||||
LEGACY_GANTA_PATH = LEGACY_WORKSPACE_ROOT / "执行目录-2025-11-20" / "Primary" / "LP_GanTa.txt"
|
||||
LEGACY_MODELS_IMAGE_DIR = LEGACY_WORKSPACE_ROOT / "执行目录-2025-11-20" / "Models"
|
||||
|
||||
|
||||
def serialize_tower_model(item: TowerModel) -> TowerModelSummary:
|
||||
return TowerModelSummary(
|
||||
id=item.id,
|
||||
code=item.code,
|
||||
name=item.name,
|
||||
tower_type=item.tower_type,
|
||||
description=item.description,
|
||||
image_mount_code=item.image_mount_code,
|
||||
image_path=item.image_path,
|
||||
source_tag=item.source_tag,
|
||||
is_enabled=item.is_enabled,
|
||||
sort_order=item.sort_order,
|
||||
default_altitude_m=item.default_altitude_m,
|
||||
default_terrain=item.default_terrain,
|
||||
default_ground_resistance_ohm=item.default_ground_resistance_ohm,
|
||||
default_lightning_density=item.default_lightning_density,
|
||||
default_span_small_m=item.default_span_small_m,
|
||||
default_span_large_m=item.default_span_large_m,
|
||||
default_slope_1=item.default_slope_1,
|
||||
default_slope_2=item.default_slope_2,
|
||||
default_risk_level=item.default_risk_level,
|
||||
default_raw_json=item.default_raw_json or {},
|
||||
create_date=item.create_date,
|
||||
create_user=item.create_user,
|
||||
update_date=item.update_date,
|
||||
update_user=item.update_user,
|
||||
)
|
||||
|
||||
|
||||
def list_tower_models(
|
||||
db: Session,
|
||||
*,
|
||||
keyword: str | None,
|
||||
enabled: bool | None,
|
||||
) -> TowerModelListResponse:
|
||||
stmt = select(TowerModel)
|
||||
total_stmt = select(func.count()).select_from(TowerModel)
|
||||
|
||||
normalized_keyword = (keyword or "").strip()
|
||||
if normalized_keyword:
|
||||
like = f"%{normalized_keyword}%"
|
||||
predicate = or_(
|
||||
TowerModel.code.ilike(like),
|
||||
TowerModel.name.ilike(like),
|
||||
TowerModel.tower_type.ilike(like),
|
||||
)
|
||||
stmt = stmt.where(predicate)
|
||||
total_stmt = total_stmt.where(predicate)
|
||||
|
||||
if enabled is not None:
|
||||
stmt = stmt.where(TowerModel.is_enabled == enabled)
|
||||
total_stmt = total_stmt.where(TowerModel.is_enabled == enabled)
|
||||
|
||||
total = int(db.scalar(total_stmt) or 0)
|
||||
items = db.execute(
|
||||
stmt.order_by(TowerModel.sort_order.asc(), TowerModel.code.asc())
|
||||
).scalars().all()
|
||||
return TowerModelListResponse(
|
||||
items=[serialize_tower_model(item) for item in items],
|
||||
total=total,
|
||||
)
|
||||
|
||||
|
||||
def list_tower_models_for_selector(db: Session) -> list[TowerModelSummary]:
|
||||
items = db.execute(
|
||||
select(TowerModel)
|
||||
.where(TowerModel.is_enabled.is_(True))
|
||||
.order_by(TowerModel.sort_order.asc(), TowerModel.code.asc())
|
||||
).scalars().all()
|
||||
return [serialize_tower_model(item) for item in items]
|
||||
|
||||
|
||||
def get_tower_model_by_id(db: Session, model_id: str) -> TowerModel | None:
|
||||
return db.execute(
|
||||
select(TowerModel).where(TowerModel.id == model_id)
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def get_tower_model_by_code(db: Session, code: str) -> TowerModel | None:
|
||||
normalized = code.strip()
|
||||
if not normalized:
|
||||
return None
|
||||
return db.execute(
|
||||
select(TowerModel).where(func.lower(TowerModel.code) == normalized.lower())
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def create_tower_model(
|
||||
db: Session,
|
||||
payload: TowerModelCreateRequest,
|
||||
*,
|
||||
actor: User,
|
||||
) -> TowerModelSummary | None:
|
||||
normalized_code = payload.code.strip()
|
||||
if get_tower_model_by_code(db, normalized_code):
|
||||
return None
|
||||
|
||||
now = utcnow()
|
||||
item = TowerModel(
|
||||
code=normalized_code,
|
||||
name=payload.name.strip(),
|
||||
tower_type=_normalize_str(payload.tower_type),
|
||||
description=_normalize_str(payload.description),
|
||||
image_mount_code=_normalize_str(payload.image_mount_code),
|
||||
image_path=_normalize_path(payload.image_path),
|
||||
source_tag=_normalize_str(payload.source_tag),
|
||||
is_enabled=payload.is_enabled,
|
||||
sort_order=payload.sort_order,
|
||||
default_altitude_m=payload.default_altitude_m,
|
||||
default_terrain=_normalize_str(payload.default_terrain),
|
||||
default_ground_resistance_ohm=payload.default_ground_resistance_ohm,
|
||||
default_lightning_density=payload.default_lightning_density,
|
||||
default_span_small_m=payload.default_span_small_m,
|
||||
default_span_large_m=payload.default_span_large_m,
|
||||
default_slope_1=payload.default_slope_1,
|
||||
default_slope_2=payload.default_slope_2,
|
||||
default_risk_level=_normalize_str(payload.default_risk_level),
|
||||
default_raw_json=payload.default_raw_json or {},
|
||||
create_date=now,
|
||||
create_user=actor.id,
|
||||
update_date=now,
|
||||
update_user=actor.id,
|
||||
)
|
||||
db.add(item)
|
||||
db.commit()
|
||||
saved = get_tower_model_by_id(db, item.id)
|
||||
if not saved:
|
||||
return None
|
||||
_publish_tower_model_change(
|
||||
"tower-model.created",
|
||||
{"action": "tower_model_created", "model_id": saved.id},
|
||||
)
|
||||
return serialize_tower_model(saved)
|
||||
|
||||
|
||||
def update_tower_model(
|
||||
db: Session,
|
||||
model_id: str,
|
||||
payload: TowerModelUpdateRequest,
|
||||
*,
|
||||
actor: User,
|
||||
) -> TowerModelSummary | None:
|
||||
item = get_tower_model_by_id(db, model_id)
|
||||
if not item:
|
||||
return None
|
||||
|
||||
update_data = payload.model_dump(exclude_unset=True)
|
||||
if "name" in update_data and update_data["name"] is not None:
|
||||
item.name = str(update_data["name"]).strip()
|
||||
if "tower_type" in update_data:
|
||||
item.tower_type = _normalize_str(update_data["tower_type"])
|
||||
if "description" in update_data:
|
||||
item.description = _normalize_str(update_data["description"])
|
||||
if "image_mount_code" in update_data:
|
||||
item.image_mount_code = _normalize_str(update_data["image_mount_code"])
|
||||
if "image_path" in update_data:
|
||||
item.image_path = _normalize_path(update_data["image_path"])
|
||||
if "source_tag" in update_data:
|
||||
item.source_tag = _normalize_str(update_data["source_tag"])
|
||||
if "is_enabled" in update_data and update_data["is_enabled"] is not None:
|
||||
item.is_enabled = bool(update_data["is_enabled"])
|
||||
if "sort_order" in update_data and update_data["sort_order"] is not None:
|
||||
item.sort_order = int(update_data["sort_order"])
|
||||
|
||||
for field in (
|
||||
"default_altitude_m",
|
||||
"default_ground_resistance_ohm",
|
||||
"default_lightning_density",
|
||||
"default_span_small_m",
|
||||
"default_span_large_m",
|
||||
"default_slope_1",
|
||||
"default_slope_2",
|
||||
):
|
||||
if field in update_data:
|
||||
setattr(item, field, update_data[field])
|
||||
|
||||
if "default_terrain" in update_data:
|
||||
item.default_terrain = _normalize_str(update_data["default_terrain"])
|
||||
if "default_risk_level" in update_data:
|
||||
item.default_risk_level = _normalize_str(update_data["default_risk_level"])
|
||||
if "default_raw_json" in update_data and update_data["default_raw_json"] is not None:
|
||||
item.default_raw_json = dict(update_data["default_raw_json"])
|
||||
|
||||
item.update_user = actor.id
|
||||
item.update_date = utcnow()
|
||||
db.commit()
|
||||
|
||||
saved = get_tower_model_by_id(db, model_id)
|
||||
if not saved:
|
||||
return None
|
||||
_publish_tower_model_change(
|
||||
"tower-model.updated",
|
||||
{"action": "tower_model_updated", "model_id": model_id},
|
||||
)
|
||||
return serialize_tower_model(saved)
|
||||
|
||||
|
||||
def delete_tower_model(db: Session, model_id: str) -> bool:
|
||||
item = get_tower_model_by_id(db, model_id)
|
||||
if not item:
|
||||
return False
|
||||
|
||||
db.delete(item)
|
||||
db.commit()
|
||||
_publish_tower_model_change(
|
||||
"tower-model.deleted",
|
||||
{"action": "tower_model_deleted", "model_id": model_id},
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def upload_tower_model_image(
|
||||
db: Session,
|
||||
*,
|
||||
model_id: str,
|
||||
mount_code: str,
|
||||
file: UploadFile,
|
||||
actor: User,
|
||||
) -> TowerModelImageUploadResponse:
|
||||
item = get_tower_model_by_id(db, model_id)
|
||||
if not item:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="杆塔模型不存在")
|
||||
|
||||
filename = (file.filename or "").strip()
|
||||
if not filename:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="文件名不能为空")
|
||||
suffix = Path(filename).suffix.lower()
|
||||
if suffix not in {".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp"}:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="图片格式不支持,仅支持 jpg/jpeg/png/webp/gif/bmp")
|
||||
|
||||
try:
|
||||
content = file.file.read()
|
||||
finally:
|
||||
try:
|
||||
file.file.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not content:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="上传图片为空")
|
||||
|
||||
mount = _require_mount(db, mount_code)
|
||||
driver = _build_driver_or_400(mount)
|
||||
image_dir = normalize_virtual_path(DEFAULT_TOWER_MODEL_IMAGE_DIR)
|
||||
_ensure_directory(driver, image_dir)
|
||||
safe_basename = _sanitize_filename(item.code) or "tower_model"
|
||||
target_name = f"{safe_basename}{suffix}"
|
||||
target_path = join_virtual_path(image_dir, target_name)
|
||||
|
||||
content_type = file.content_type or mimetypes.guess_type(filename)[0]
|
||||
try:
|
||||
driver.write_file(target_path, content=content, content_type=content_type)
|
||||
except StoragePathNotFoundError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
except StorageInvalidPathError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||
except StorageDriverError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
|
||||
|
||||
item.image_mount_code = mount.code
|
||||
item.image_path = target_path
|
||||
item.update_user = actor.id
|
||||
item.update_date = utcnow()
|
||||
db.commit()
|
||||
|
||||
saved = get_tower_model_by_id(db, model_id)
|
||||
if not saved:
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="保存图片关联失败")
|
||||
|
||||
_publish_tower_model_change(
|
||||
"tower-model.image-uploaded",
|
||||
{"action": "tower_model_image_uploaded", "model_id": model_id},
|
||||
)
|
||||
return TowerModelImageUploadResponse(
|
||||
model=serialize_tower_model(saved),
|
||||
mount_code=mount.code,
|
||||
image_path=target_path,
|
||||
)
|
||||
|
||||
|
||||
def seed_tower_models_from_legacy(
|
||||
db: Session,
|
||||
*,
|
||||
actor: User,
|
||||
overwrite_existing: bool,
|
||||
) -> TowerModelSeedResponse:
|
||||
if not LEGACY_SETTING_PATH.exists():
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"老系统配置文件不存在: {LEGACY_SETTING_PATH}")
|
||||
if not LEGACY_GANTA_PATH.exists():
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"老系统杆塔文件不存在: {LEGACY_GANTA_PATH}")
|
||||
if not LEGACY_MODELS_IMAGE_DIR.exists():
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"老系统图片目录不存在: {LEGACY_MODELS_IMAGE_DIR}")
|
||||
|
||||
mount = _resolve_default_mount(db)
|
||||
driver = _build_driver_or_400(mount)
|
||||
image_dir = normalize_virtual_path(DEFAULT_TOWER_MODEL_IMAGE_DIR)
|
||||
_ensure_directory(driver, image_dir)
|
||||
|
||||
model_codes = _load_legacy_model_codes(LEGACY_SETTING_PATH)
|
||||
defaults_by_model = _load_legacy_defaults_by_model(LEGACY_GANTA_PATH)
|
||||
if not model_codes:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="老系统配置未解析到杆塔模型清单")
|
||||
|
||||
imported_models = 0
|
||||
updated_models = 0
|
||||
skipped_models = 0
|
||||
copied_images = 0
|
||||
warnings: list[str] = []
|
||||
|
||||
for sort_index, model_code in enumerate(model_codes, start=1):
|
||||
existing = get_tower_model_by_code(db, model_code)
|
||||
defaults = defaults_by_model.get(model_code, {})
|
||||
|
||||
image_path = None
|
||||
source_image = _find_legacy_image_file(model_code)
|
||||
if source_image is not None:
|
||||
target_name = f"{_sanitize_filename(model_code) or model_code}.jpg"
|
||||
target_path = join_virtual_path(image_dir, target_name)
|
||||
try:
|
||||
driver.write_file(
|
||||
target_path,
|
||||
content=source_image.read_bytes(),
|
||||
content_type=mimetypes.guess_type(source_image.name)[0] or "image/jpeg",
|
||||
)
|
||||
image_path = target_path
|
||||
copied_images += 1
|
||||
except Exception as exc:
|
||||
warnings.append(f"模型 {model_code} 图片复制失败: {exc}")
|
||||
else:
|
||||
warnings.append(f"模型 {model_code} 未找到匹配图片")
|
||||
|
||||
if existing and not overwrite_existing:
|
||||
skipped_models += 1
|
||||
continue
|
||||
|
||||
if existing is None:
|
||||
now = utcnow()
|
||||
existing = TowerModel(
|
||||
code=model_code,
|
||||
name=model_code,
|
||||
source_tag=DEFAULT_SEED_SOURCE_TAG,
|
||||
is_enabled=True,
|
||||
sort_order=sort_index,
|
||||
create_date=now,
|
||||
create_user=actor.id,
|
||||
update_date=now,
|
||||
update_user=actor.id,
|
||||
)
|
||||
db.add(existing)
|
||||
imported_models += 1
|
||||
else:
|
||||
updated_models += 1
|
||||
|
||||
existing.source_tag = DEFAULT_SEED_SOURCE_TAG
|
||||
existing.is_enabled = True
|
||||
existing.sort_order = sort_index
|
||||
existing.tower_type = _normalize_str(str(defaults.get("tower_type") or ""))
|
||||
existing.default_altitude_m = _coerce_optional_float(defaults.get("altitude_m"))
|
||||
existing.default_terrain = _normalize_str(str(defaults.get("terrain") or ""))
|
||||
existing.default_ground_resistance_ohm = _coerce_optional_float(defaults.get("ground_resistance_ohm"))
|
||||
existing.default_lightning_density = _coerce_optional_float(defaults.get("lightning_density"))
|
||||
existing.default_span_small_m = _coerce_optional_float(defaults.get("span_small_m"))
|
||||
existing.default_span_large_m = _coerce_optional_float(defaults.get("span_large_m"))
|
||||
existing.default_slope_1 = _coerce_optional_float(defaults.get("slope_1"))
|
||||
existing.default_slope_2 = _coerce_optional_float(defaults.get("slope_2"))
|
||||
existing.default_risk_level = _normalize_str(str(defaults.get("risk_level") or ""))
|
||||
existing.default_raw_json = dict(defaults.get("raw_json") or {})
|
||||
if image_path:
|
||||
existing.image_mount_code = mount.code
|
||||
existing.image_path = image_path
|
||||
existing.update_user = actor.id
|
||||
existing.update_date = utcnow()
|
||||
|
||||
db.commit()
|
||||
|
||||
_publish_tower_model_change(
|
||||
"tower-model.seeded",
|
||||
{
|
||||
"action": "tower_model_seeded",
|
||||
"imported_models": imported_models,
|
||||
"updated_models": updated_models,
|
||||
"skipped_models": skipped_models,
|
||||
},
|
||||
)
|
||||
return TowerModelSeedResponse(
|
||||
total_models=len(model_codes),
|
||||
imported_models=imported_models,
|
||||
updated_models=updated_models,
|
||||
skipped_models=skipped_models,
|
||||
copied_images=copied_images,
|
||||
warnings=warnings,
|
||||
)
|
||||
|
||||
|
||||
def resolve_tower_model_defaults(
|
||||
db: Session,
|
||||
*,
|
||||
model_code: str,
|
||||
) -> TowerModelSummary | None:
|
||||
item = get_tower_model_by_code(db, model_code)
|
||||
if not item or not item.is_enabled:
|
||||
return None
|
||||
return serialize_tower_model(item)
|
||||
|
||||
|
||||
def _resolve_default_mount(db: Session):
|
||||
mounts = list_enabled_mounts(db)
|
||||
if not mounts:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="当前未配置可用文件挂载点")
|
||||
return mounts[0]
|
||||
|
||||
|
||||
def _ensure_directory(driver: Any, path: str) -> None:
|
||||
try:
|
||||
driver.ensure_directory(path)
|
||||
except StorageInvalidPathError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||
except StorageDriverError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
|
||||
|
||||
|
||||
def _load_legacy_model_codes(setting_path: Path) -> list[str]:
|
||||
text = setting_path.read_text(encoding="utf-8", errors="ignore")
|
||||
start_tag = "<GanTaType_Models>"
|
||||
end_tag = "</GanTaType_Models>"
|
||||
start = text.find(start_tag)
|
||||
end = text.find(end_tag)
|
||||
if start < 0 or end < 0 or end <= start:
|
||||
return []
|
||||
body = text[start + len(start_tag):end]
|
||||
items = [line.strip() for line in body.splitlines()]
|
||||
return [item for item in items if item]
|
||||
|
||||
|
||||
def _load_legacy_defaults_by_model(ganta_path: Path) -> dict[str, dict[str, Any]]:
|
||||
content = ganta_path.read_bytes()
|
||||
decoded = _decode_csv_bytes(content)
|
||||
rows = list(csv.DictReader(io.StringIO(decoded)))
|
||||
result: dict[str, dict[str, Any]] = {}
|
||||
for row in rows:
|
||||
values = derive_tower_model_default_values_from_legacy_row(row)
|
||||
model_code = _normalize_str(values.get("model_code"))
|
||||
if not model_code:
|
||||
continue
|
||||
if model_code in result:
|
||||
continue
|
||||
result[model_code] = values
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _find_legacy_image_file(model_code: str) -> Path | None:
|
||||
for suffix in (".jpg", ".jpeg", ".png", ".webp", ".bmp", ".gif"):
|
||||
candidate = LEGACY_MODELS_IMAGE_DIR / f"{model_code}{suffix}"
|
||||
if candidate.exists() and candidate.is_file():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def _decode_csv_bytes(content: bytes) -> str:
|
||||
for encoding in ("utf-8-sig", "utf-8", "gbk", "latin-1"):
|
||||
try:
|
||||
return content.decode(encoding)
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="老系统 CSV 编码无法识别")
|
||||
|
||||
|
||||
def _parse_float_value(value: Any) -> float | None:
|
||||
normalized = _normalize_str(value)
|
||||
if normalized is None:
|
||||
return None
|
||||
try:
|
||||
return float(normalized)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _build_circuit_geometry_from_row(row: dict[str, Any]) -> dict[str, Any]:
|
||||
def value(key: str) -> float | None:
|
||||
return _parse_float_value(row.get(key))
|
||||
|
||||
return {
|
||||
"I": {
|
||||
"phase_spacing_m": {
|
||||
"upper": value("I回上相中距m"),
|
||||
"middle": value("I回中相中距m"),
|
||||
"lower": value("I回下相中距m"),
|
||||
},
|
||||
"phase_height_m": {
|
||||
"upper": value("I回上相高度m"),
|
||||
"middle": value("I回中相高度m"),
|
||||
"lower": value("I回下相高度m"),
|
||||
},
|
||||
},
|
||||
"II": {
|
||||
"phase_spacing_m": {
|
||||
"upper": value("II回上相中距m"),
|
||||
"middle": value("II回中相中距m"),
|
||||
"lower": value("II回下相中距m"),
|
||||
},
|
||||
"phase_height_m": {
|
||||
"upper": value("II回上相高度m"),
|
||||
"middle": value("II回中相高度m"),
|
||||
"lower": value("II回下相高度m"),
|
||||
},
|
||||
},
|
||||
"III": {
|
||||
"phase_spacing_m": {
|
||||
"upper": value("III回上相中距m"),
|
||||
"middle": value("III回中相中距m"),
|
||||
"lower": value("III回下相中距m"),
|
||||
},
|
||||
"phase_height_m": {
|
||||
"upper": value("III回上相高度m"),
|
||||
"middle": value("III回中相高度m"),
|
||||
"lower": value("III回下相高度m"),
|
||||
},
|
||||
},
|
||||
"IV": {
|
||||
"phase_spacing_m": {
|
||||
"upper": value("IV回上相中距m"),
|
||||
"middle": value("IV回中相中距m"),
|
||||
"lower": value("IV回下相中距m"),
|
||||
},
|
||||
"phase_height_m": {
|
||||
"upper": value("IV回上相高度m"),
|
||||
"middle": value("IV回中相高度m"),
|
||||
"lower": value("IV回下相高度m"),
|
||||
},
|
||||
},
|
||||
"insulator_length_mm": _parse_float_value(row.get("绝缘子串长度mm")),
|
||||
"tower_height_m": _parse_float_value(row.get("杆塔呼高m")),
|
||||
"lightning_wire": {
|
||||
"left_mid_distance_m": _parse_float_value(row.get("左避雷中距m")),
|
||||
"right_mid_distance_m": _parse_float_value(row.get("右避雷中距m")),
|
||||
"height_m": _parse_float_value(row.get("避雷线高度m")),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _build_lightning_result_from_row(row: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"counterstroke_indicator": _parse_float_value(row.get("绕击反击")),
|
||||
"counterstroke_withstand_ka": _parse_float_value(row.get("反击耐雷水平kA")),
|
||||
"counterstroke_trip_rate": _parse_float_value(row.get("反击跳闸率(次/100km.a)")),
|
||||
"shielding_withstand_ka": _parse_float_value(row.get("绕击耐雷水平kA")),
|
||||
"shielding_trip_rate": _parse_float_value(row.get("绕击跳闸率(次/100km.a)")),
|
||||
"risk_level": _normalize_str(row.get("雷击风险等级")),
|
||||
}
|
||||
|
||||
|
||||
def _to_bool_from_text(value: Any) -> bool | None:
|
||||
normalized = _normalize_str(value)
|
||||
if normalized is None:
|
||||
return None
|
||||
lowered = normalized.lower()
|
||||
if lowered in {"是", "true", "1", "yes", "y"}:
|
||||
return True
|
||||
if lowered in {"否", "false", "0", "no", "n"}:
|
||||
return False
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_str(value: Any) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value).strip()
|
||||
if not text:
|
||||
return None
|
||||
if text in {"-1", "-1.0"}:
|
||||
return None
|
||||
return text
|
||||
|
||||
|
||||
def _normalize_path(value: Any) -> str | None:
|
||||
normalized = _normalize_str(value)
|
||||
if normalized is None:
|
||||
return None
|
||||
try:
|
||||
return normalize_virtual_path(normalized)
|
||||
except StorageInvalidPathError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||
|
||||
|
||||
def _safe_float(value: Any) -> float | None:
|
||||
normalized = _normalize_str(value)
|
||||
if normalized is None:
|
||||
return None
|
||||
try:
|
||||
return float(normalized)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _safe_int(value: Any) -> int | None:
|
||||
normalized = _normalize_str(value)
|
||||
if normalized is None:
|
||||
return None
|
||||
try:
|
||||
return int(float(normalized))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _coerce_optional_float(value: Any) -> float | None:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, (int, float)):
|
||||
return float(value)
|
||||
return _safe_float(value)
|
||||
|
||||
|
||||
def _sanitize_filename(value: str) -> str:
|
||||
sanitized = value.strip()
|
||||
if not sanitized:
|
||||
return ""
|
||||
for char in ('\\', '/', ':', '*', '?', '"', "<", ">", "|", " "):
|
||||
sanitized = sanitized.replace(char, "_")
|
||||
return sanitized
|
||||
|
||||
|
||||
def derive_tower_model_code_from_legacy(model_name: str) -> str:
|
||||
normalized = _normalize_str(model_name)
|
||||
if normalized is None:
|
||||
return ""
|
||||
return normalized
|
||||
|
||||
|
||||
def derive_tower_model_default_values_from_legacy_row(row: dict[str, Any]) -> dict[str, Any]:
|
||||
model_code = derive_tower_model_code_from_legacy(row.get("杆塔模型"))
|
||||
default_dsmd = _safe_float(row.get("地闪密度"))
|
||||
default_ground_resistance = _safe_float(row.get("接地电阻"))
|
||||
if default_dsmd is None:
|
||||
default_dsmd = 2.8
|
||||
if default_ground_resistance is None:
|
||||
default_ground_resistance = 15.0
|
||||
|
||||
default_raw_json = {
|
||||
"legacy_defaults": {
|
||||
"voltage_kv": _safe_int(row.get("电压等级")),
|
||||
"phase_sequence": {
|
||||
"I": _normalize_str(row.get("I回相序")),
|
||||
"II": _normalize_str(row.get("II回相序")),
|
||||
"III": _normalize_str(row.get("III回相序")),
|
||||
"IV": _normalize_str(row.get("IV回相序")),
|
||||
},
|
||||
"arrester_install": {
|
||||
"A": _to_bool_from_text(row.get("A相是否安装避雷器")),
|
||||
"B": _to_bool_from_text(row.get("B相是否安装避雷器")),
|
||||
"C": _to_bool_from_text(row.get("C相是否安装避雷器")),
|
||||
},
|
||||
"left_lightning_mid_distance_m": _safe_float(row.get("左避雷中距m")),
|
||||
"right_lightning_mid_distance_m": _safe_float(row.get("右避雷中距m")),
|
||||
"lightning_wire_height_m": _safe_float(row.get("避雷线高度m")),
|
||||
"insulator_length_mm": _safe_float(row.get("绝缘子串长度mm")),
|
||||
"tower_height_m": _safe_float(row.get("杆塔呼高m")),
|
||||
"electric_angle": _safe_float(row.get("电角度")),
|
||||
"current_a": _safe_float(row.get("雷电流幅值a")) or 31.0,
|
||||
"current_b": _safe_float(row.get("雷电流幅值b")) or 2.6,
|
||||
"rao_ji_fan_ji": _safe_int(row.get("绕击反击")) or 3,
|
||||
"counterstrike_level_ka": _safe_float(row.get("反击耐雷水平kA")),
|
||||
"counterstrike_trip_rate": _safe_float(row.get("反击跳闸率(次/100km.a)")),
|
||||
"shielding_level_ka": _safe_float(row.get("绕击耐雷水平kA")),
|
||||
"shielding_trip_rate": _safe_float(row.get("绕击跳闸率(次/100km.a)")),
|
||||
"reason_analysis": _normalize_str(row.get("原因分析")),
|
||||
"measure_recommend": _normalize_str(row.get("措施推荐")),
|
||||
},
|
||||
"circuit_geometry_json": _build_circuit_geometry_from_row(row),
|
||||
"lightning_result_json": _build_lightning_result_from_row(row),
|
||||
}
|
||||
return {
|
||||
"model_code": model_code,
|
||||
"tower_type": _normalize_str(row.get("直线或耐张杆塔")),
|
||||
"altitude_m": _safe_float(row.get("海拔m")),
|
||||
"terrain": _normalize_str(row.get("地形")),
|
||||
"ground_resistance_ohm": default_ground_resistance,
|
||||
"lightning_density": default_dsmd,
|
||||
"span_small_m": _safe_float(row.get("小号侧档距")),
|
||||
"span_large_m": _safe_float(row.get("大号侧档距")),
|
||||
"slope_1": _safe_float(row.get("地面倾角1")),
|
||||
"slope_2": _safe_float(row.get("地面倾角2")),
|
||||
"risk_level": _normalize_str(row.get("雷击风险等级")),
|
||||
"raw_json": default_raw_json,
|
||||
}
|
||||
|
||||
|
||||
def _publish_tower_model_change(event_name: str, payload: dict[str, Any]) -> None:
|
||||
_fire_and_forget(
|
||||
publish_topic(
|
||||
TOWER_MODEL_TOPIC,
|
||||
name=event_name,
|
||||
payload=payload,
|
||||
requires_refetch=["/api/v1/tower-models"],
|
||||
dedupe_key=f"{event_name}:{payload.get('model_id', 'all')}",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _fire_and_forget(coro: object) -> None:
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
return
|
||||
loop.create_task(coro)
|
||||
@@ -400,3 +400,33 @@
|
||||
- 后端 `Line` 模型当前仅存储 `voltage_kv` 数值,不存“交流/直流/四回路文案”维度,因此:
|
||||
- `500/800/1000`、`110/220` 四回路等在持久化后会折叠为同一数值;
|
||||
- 编辑回显时仅能按默认映射回一个选项(当前优先映射到 `dc_500/dc_800/dc_1000` 与 `ac_110/ac_220`)。
|
||||
|
||||
## Work Log - 杆塔模型管理闭环与初始化数据兜底(2026-05-03)
|
||||
|
||||
- 背景:
|
||||
- 用户要求在当前系统新增“杆塔模型管理”,并明确要求初始化数据(模型+默认参数+图片)随功能一并落地,不留人工处理。
|
||||
- 前一版改动已覆盖主链路,但存在两处阻塞:
|
||||
- 前端 `tower-models` 页面 `Card` 组件类型报错,`tsc` 无法通过;
|
||||
- 后端老系统默认值解析函数存在作用域错误,可能导致初始化时模型默认参数构建失败。
|
||||
|
||||
- 本次修复:
|
||||
- 文件:`web/src/app/admin/tower-models/page.tsx`
|
||||
- `Card` 改为使用项目统一封装 `@/components/ui-antd`,消除 Antd `CardInterface` 在 React 19 下的 JSX 类型不兼容。
|
||||
- 初始化确认弹窗增加 `closable: false`、`maskClosable: false`、`keyboard: false`,避免误触关闭导致初始化分支被误走。
|
||||
- 文件:`api/app/services/tower_model_service.py`
|
||||
- 修复 `derive_tower_model_default_values_from_legacy_row` 中 `default_raw_json` 的缩进/作用域问题:
|
||||
- 变量改为始终构建,避免在接地电阻有值时出现未定义风险;
|
||||
- 保证 `raw_json`(含相序、避雷器、几何参数、雷电结果)稳定写入 `tower_model.default_raw_json`。
|
||||
|
||||
- 验证:
|
||||
- 后端语法:`python3 -m compileall api/app` 通过。
|
||||
- 前端类型:`./web/node_modules/.bin/tsc -p web/tsconfig.json --noEmit` 通过。
|
||||
- 前端生产构建:`npm run build:web` 通过(含 `/admin/tower-models` 页面产物)。
|
||||
|
||||
- 风险与影响:
|
||||
- 影响范围限定在“杆塔模型管理页 + 老系统初始化默认值解析”,未改动既有业务接口契约。
|
||||
- 初始化导入仍依赖老系统目录存在:
|
||||
- `fl/执行目录-2025-11-20/Primary/LP_Setting.txt`
|
||||
- `fl/执行目录-2025-11-20/Primary/LP_GanTa.txt`
|
||||
- `fl/执行目录-2025-11-20/Models`
|
||||
- 若运行环境无上述目录,`/api/v1/tower-models/seed/legacy` 将按设计返回 404 提示缺失来源。
|
||||
|
||||
@@ -61,6 +61,7 @@ const PROTECTED_MENU_CODES = new Set([
|
||||
"admin.roles",
|
||||
"admin.menus",
|
||||
"admin.system_params",
|
||||
"admin.tower_models",
|
||||
"admin.files",
|
||||
"admin.elevation",
|
||||
"admin.wxapp",
|
||||
|
||||
@@ -34,6 +34,7 @@ import type {
|
||||
LineTowerImportResponse,
|
||||
LineTowerListResponse,
|
||||
LineTowerSummary,
|
||||
TowerModelSummary,
|
||||
} from "@/types/auth";
|
||||
|
||||
type LineFormValues = {
|
||||
@@ -178,6 +179,7 @@ export default function AdminPowerLinesPage() {
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<(typeof STATUS_OPTIONS)[number]["value"]>("all");
|
||||
const [selectedLineId, setSelectedLineId] = useState<string | null>(null);
|
||||
const [selectedLineTouched, setSelectedLineTouched] = useState(false);
|
||||
const [towerKeyword, setTowerKeyword] = useState("");
|
||||
const [towerTypeFilter, setTowerTypeFilter] = useState("");
|
||||
const [towerRiskFilter, setTowerRiskFilter] = useState("");
|
||||
@@ -267,6 +269,18 @@ export default function AdminPowerLinesPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const towerModelOptionsQuery = useQuery({
|
||||
queryKey: ["/api/v1/tower-models/selector"],
|
||||
enabled: !!user && canTowerRead,
|
||||
queryFn: async () => {
|
||||
const response = await fetchWithAuth("/api/v1/tower-models/selector");
|
||||
if (!response.ok) {
|
||||
throw new Error(await readApiError(response));
|
||||
}
|
||||
return (await response.json()) as TowerModelSummary[];
|
||||
},
|
||||
});
|
||||
|
||||
const refreshLines = useCallback(async () => {
|
||||
await queryClient.invalidateQueries({
|
||||
predicate: (query) =>
|
||||
@@ -289,32 +303,58 @@ export default function AdminPowerLinesPage() {
|
||||
void refreshLines();
|
||||
void refreshTowers();
|
||||
}, [refreshLines, refreshTowers]));
|
||||
useTopicSubscription("admin.tower-models", useCallback(() => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["/api/v1/tower-models/selector"] });
|
||||
}, [queryClient]));
|
||||
|
||||
const lines = linesQuery.data?.items ?? [];
|
||||
const towers = towersQuery.data?.items ?? [];
|
||||
|
||||
const towerModels = towerModelOptionsQuery.data ?? [];
|
||||
const towerModelOptions = towerModels.map((item) => ({ value: item.code, label: `${item.code} - ${item.name}` }));
|
||||
const effectiveSelectedLineId = useMemo(() => {
|
||||
if (selectedLineTouched) {
|
||||
if (selectedLineId && lines.some((item) => item.id === selectedLineId)) {
|
||||
return selectedLineId;
|
||||
}
|
||||
return lines.length > 0 ? lines[0].id : null;
|
||||
}
|
||||
return selectedLineId ?? (lines.length > 0 ? lines[0].id : null);
|
||||
}, [lines, selectedLineId, selectedLineTouched]);
|
||||
const towerQueryCurrent = towerPagination.current;
|
||||
const shouldResetTowerPage = towerQueryCurrent !== 1 && (
|
||||
selectedLineId !== effectiveSelectedLineId
|
||||
|| towerKeyword.trim().length > 0
|
||||
|| towerTypeFilter.length > 0
|
||||
|| towerRiskFilter.trim().length > 0
|
||||
);
|
||||
const effectiveTowerPageCurrent = shouldResetTowerPage ? 1 : towerQueryCurrent;
|
||||
const selectedLine = useMemo(
|
||||
() => lines.find((item) => item.id === selectedLineId) ?? null,
|
||||
[lines, selectedLineId],
|
||||
() => lines.find((item) => item.id === effectiveSelectedLineId) ?? null,
|
||||
[lines, effectiveSelectedLineId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedLineId && lines.length > 0) {
|
||||
setSelectedLineId(lines[0].id);
|
||||
const applyTowerModelDefaults = useCallback((modelCode: string | null | undefined) => {
|
||||
if (!modelCode) {
|
||||
return;
|
||||
}
|
||||
if (selectedLineId && !lines.some((item) => item.id === selectedLineId)) {
|
||||
setSelectedLineId(lines.length > 0 ? lines[0].id : null);
|
||||
const matched = towerModels.find((item) => item.code === modelCode);
|
||||
if (!matched) {
|
||||
return;
|
||||
}
|
||||
}, [lines, selectedLineId]);
|
||||
|
||||
useEffect(() => {
|
||||
setTowerPagination((prev) => {
|
||||
if (prev.current === 1) {
|
||||
return prev;
|
||||
}
|
||||
return { ...prev, current: 1 };
|
||||
towerForm.setFieldsValue({
|
||||
tower_type: matched.tower_type ?? "",
|
||||
altitude_m: matched.default_altitude_m ?? null,
|
||||
terrain: matched.default_terrain ?? "",
|
||||
ground_resistance_ohm: matched.default_ground_resistance_ohm ?? null,
|
||||
lightning_density: matched.default_lightning_density ?? null,
|
||||
span_small_m: matched.default_span_small_m ?? null,
|
||||
span_large_m: matched.default_span_large_m ?? null,
|
||||
slope_1: matched.default_slope_1 ?? null,
|
||||
slope_2: matched.default_slope_2 ?? null,
|
||||
risk_level: matched.default_risk_level ?? "",
|
||||
});
|
||||
}, [selectedLineId, towerKeyword, towerTypeFilter, towerRiskFilter]);
|
||||
}, [towerForm, towerModels]);
|
||||
|
||||
const saveLineMutation = useMutation({
|
||||
mutationFn: async (values: LineFormValues) => {
|
||||
@@ -378,7 +418,8 @@ export default function AdminPowerLinesPage() {
|
||||
return lineId;
|
||||
},
|
||||
onSuccess: async (lineId) => {
|
||||
if (selectedLineId === lineId) {
|
||||
if (effectiveSelectedLineId === lineId) {
|
||||
setSelectedLineTouched(false);
|
||||
setSelectedLineId(null);
|
||||
}
|
||||
setError("");
|
||||
@@ -393,7 +434,7 @@ export default function AdminPowerLinesPage() {
|
||||
|
||||
const saveTowerMutation = useMutation({
|
||||
mutationFn: async (values: TowerFormValues) => {
|
||||
if (!selectedLineId) {
|
||||
if (!effectiveSelectedLineId) {
|
||||
throw new Error("请先选择线路");
|
||||
}
|
||||
if (!canTowerManage) {
|
||||
@@ -430,7 +471,7 @@ export default function AdminPowerLinesPage() {
|
||||
return "updated" as const;
|
||||
}
|
||||
|
||||
const response = await fetchWithAuth(`/api/v1/lines/${selectedLineId}/towers`, {
|
||||
const response = await fetchWithAuth(`/api/v1/lines/${effectiveSelectedLineId}/towers`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
@@ -475,13 +516,13 @@ export default function AdminPowerLinesPage() {
|
||||
|
||||
const importMutation = useMutation({
|
||||
mutationFn: async (file: File) => {
|
||||
if (!selectedLineId) {
|
||||
if (!effectiveSelectedLineId) {
|
||||
throw new Error("请先选择线路");
|
||||
}
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
const response = await fetchWithAuth(`/api/v1/lines/${selectedLineId}/towers/import`, {
|
||||
const response = await fetchWithAuth(`/api/v1/lines/${effectiveSelectedLineId}/towers/import`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
@@ -505,10 +546,10 @@ export default function AdminPowerLinesPage() {
|
||||
|
||||
const exportMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!selectedLineId) {
|
||||
if (!effectiveSelectedLineId) {
|
||||
throw new Error("请先选择线路");
|
||||
}
|
||||
const response = await fetchWithAuth(`/api/v1/lines/${selectedLineId}/towers/export`);
|
||||
const response = await fetchWithAuth(`/api/v1/lines/${effectiveSelectedLineId}/towers/export`);
|
||||
if (!response.ok) {
|
||||
throw new Error(await readApiError(response));
|
||||
}
|
||||
@@ -557,6 +598,13 @@ export default function AdminPowerLinesPage() {
|
||||
setEditingTower(null);
|
||||
towerForm.setFieldsValue(EMPTY_TOWER_FORM);
|
||||
setTowerModalOpen(true);
|
||||
if (towerModels.length > 0) {
|
||||
const preferred = towerModels[0]?.code;
|
||||
if (preferred) {
|
||||
towerForm.setFieldsValue({ tower_model: preferred });
|
||||
applyTowerModelDefaults(preferred);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const openEditTowerModal = (item: LineTowerSummary) => {
|
||||
@@ -581,141 +629,137 @@ export default function AdminPowerLinesPage() {
|
||||
setTowerModalOpen(true);
|
||||
};
|
||||
|
||||
const lineCards = useMemo(
|
||||
() =>
|
||||
lines.map((line) => {
|
||||
const selected = line.id === selectedLineId;
|
||||
return (
|
||||
<Card
|
||||
key={line.id}
|
||||
size="small"
|
||||
hoverable
|
||||
onClick={() => setSelectedLineId(line.id)}
|
||||
style={selected
|
||||
? {
|
||||
borderColor: "var(--ant-color-primary)",
|
||||
background: "var(--ant-color-primary-bg)",
|
||||
}
|
||||
: undefined}
|
||||
title={(
|
||||
<Space size={8} wrap>
|
||||
<Typography.Text strong>{line.name}</Typography.Text>
|
||||
<Tag color={line.status === "enabled" ? "success" : "default"}>{formatStatus(line.status)}</Tag>
|
||||
</Space>
|
||||
)}
|
||||
extra={canLineManage ? (
|
||||
<Space size={4}>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
openEditLineModal(line);
|
||||
}}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="删除线路"
|
||||
description={`确认删除线路 ${line.code} 吗?`}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
okButtonProps={{ danger: true }}
|
||||
onConfirm={async () => {
|
||||
await deleteLineMutation.mutateAsync(line.id);
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
loading={deleteLineMutation.isPending}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
) : null}
|
||||
>
|
||||
<Space direction="vertical" size={4} className="w-full">
|
||||
<Typography.Text type="secondary">
|
||||
编码:<Typography.Text code>{line.code}</Typography.Text>
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary">电压等级:{line.voltage_kv ?? "-"} kV</Typography.Text>
|
||||
<Typography.Text type="secondary">塔形:{line.tower_shape || "-"}</Typography.Text>
|
||||
<Typography.Text type="secondary">杆塔总数:{line.tower_count}</Typography.Text>
|
||||
<Typography.Text type="secondary">
|
||||
更新时间:{new Date(line.update_date).toLocaleString()}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
</Card>
|
||||
);
|
||||
}),
|
||||
[canLineManage, deleteLineMutation, lines, selectedLineId],
|
||||
);
|
||||
|
||||
const towerColumns = useMemo<ColumnsType<LineTowerSummary>>(
|
||||
() => [
|
||||
{ title: "序号", dataIndex: "seq_no", width: 80 },
|
||||
{
|
||||
title: "塔号",
|
||||
dataIndex: "tower_no",
|
||||
width: 120,
|
||||
render: (value: string) => <Typography.Text code>{value}</Typography.Text>,
|
||||
},
|
||||
{ title: "模型", dataIndex: "tower_model", width: 180, render: (value: string | null) => value || "-" },
|
||||
{ title: "塔型", dataIndex: "tower_type", width: 100, render: (value: string | null) => value || "-" },
|
||||
{
|
||||
title: "坐标",
|
||||
key: "geo",
|
||||
width: 200,
|
||||
render: (_: unknown, row) =>
|
||||
row.longitude !== null && row.latitude !== null
|
||||
? `${row.longitude.toFixed(6)}, ${row.latitude.toFixed(6)}`
|
||||
: "-",
|
||||
},
|
||||
{ title: "接地电阻", dataIndex: "ground_resistance_ohm", width: 100, render: (value: number | null) => value ?? "-" },
|
||||
{ title: "地闪密度", dataIndex: "lightning_density", width: 100, render: (value: number | null) => value ?? "-" },
|
||||
{ title: "风险等级", dataIndex: "risk_level", width: 100, render: (value: string | null) => value || "-" },
|
||||
{
|
||||
title: "更新时间",
|
||||
dataIndex: "update_date",
|
||||
width: 180,
|
||||
render: (value: string) => new Date(value).toLocaleString(),
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "actions",
|
||||
width: 160,
|
||||
fixed: "right",
|
||||
render: (_: unknown, row) => (
|
||||
<Space size={8}>
|
||||
{canTowerManage && (
|
||||
<Button size="small" onClick={() => openEditTowerModal(row)}>
|
||||
编辑
|
||||
</Button>
|
||||
)}
|
||||
{canTowerManage && (
|
||||
<Popconfirm
|
||||
title="删除杆塔"
|
||||
description={`确认删除杆塔 ${row.tower_no} 吗?`}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
okButtonProps={{ danger: true }}
|
||||
onConfirm={async () => {
|
||||
await deleteTowerMutation.mutateAsync(row.id);
|
||||
}}
|
||||
>
|
||||
<Button size="small" danger loading={deleteTowerMutation.isPending}>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
const lineCards = lines.map((line) => {
|
||||
const selected = line.id === effectiveSelectedLineId;
|
||||
return (
|
||||
<Card
|
||||
key={line.id}
|
||||
size="small"
|
||||
hoverable
|
||||
onClick={() => {
|
||||
setSelectedLineTouched(true);
|
||||
setSelectedLineId(line.id);
|
||||
}}
|
||||
style={selected
|
||||
? {
|
||||
borderColor: "var(--ant-color-primary)",
|
||||
background: "var(--ant-color-primary-bg)",
|
||||
}
|
||||
: undefined}
|
||||
title={(
|
||||
<Space size={8} wrap>
|
||||
<Typography.Text strong>{line.name}</Typography.Text>
|
||||
<Tag color={line.status === "enabled" ? "success" : "default"}>{formatStatus(line.status)}</Tag>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
],
|
||||
[canTowerManage, deleteTowerMutation],
|
||||
);
|
||||
)}
|
||||
extra={canLineManage ? (
|
||||
<Space size={4}>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
openEditLineModal(line);
|
||||
}}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="删除线路"
|
||||
description={`确认删除线路 ${line.code} 吗?`}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
okButtonProps={{ danger: true }}
|
||||
onConfirm={async () => {
|
||||
await deleteLineMutation.mutateAsync(line.id);
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
loading={deleteLineMutation.isPending}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
) : null}
|
||||
>
|
||||
<Space direction="vertical" size={4} className="w-full">
|
||||
<Typography.Text type="secondary">
|
||||
编码:<Typography.Text code>{line.code}</Typography.Text>
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary">电压等级:{line.voltage_kv ?? "-"} kV</Typography.Text>
|
||||
<Typography.Text type="secondary">塔形:{line.tower_shape || "-"}</Typography.Text>
|
||||
<Typography.Text type="secondary">杆塔总数:{line.tower_count}</Typography.Text>
|
||||
<Typography.Text type="secondary">
|
||||
更新时间:{new Date(line.update_date).toLocaleString()}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
|
||||
const towerColumns: ColumnsType<LineTowerSummary> = [
|
||||
{ title: "序号", dataIndex: "seq_no", width: 80 },
|
||||
{
|
||||
title: "塔号",
|
||||
dataIndex: "tower_no",
|
||||
width: 120,
|
||||
render: (value: string) => <Typography.Text code>{value}</Typography.Text>,
|
||||
},
|
||||
{ title: "模型", dataIndex: "tower_model", width: 180, render: (value: string | null) => value || "-" },
|
||||
{ title: "塔型", dataIndex: "tower_type", width: 100, render: (value: string | null) => value || "-" },
|
||||
{
|
||||
title: "坐标",
|
||||
key: "geo",
|
||||
width: 200,
|
||||
render: (_: unknown, row) =>
|
||||
row.longitude !== null && row.latitude !== null
|
||||
? `${row.longitude.toFixed(6)}, ${row.latitude.toFixed(6)}`
|
||||
: "-",
|
||||
},
|
||||
{ title: "接地电阻", dataIndex: "ground_resistance_ohm", width: 100, render: (value: number | null) => value ?? "-" },
|
||||
{ title: "地闪密度", dataIndex: "lightning_density", width: 100, render: (value: number | null) => value ?? "-" },
|
||||
{ title: "风险等级", dataIndex: "risk_level", width: 100, render: (value: string | null) => value || "-" },
|
||||
{
|
||||
title: "更新时间",
|
||||
dataIndex: "update_date",
|
||||
width: 180,
|
||||
render: (value: string) => new Date(value).toLocaleString(),
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "actions",
|
||||
width: 160,
|
||||
fixed: "right",
|
||||
render: (_: unknown, row) => (
|
||||
<Space size={8}>
|
||||
{canTowerManage && (
|
||||
<Button size="small" onClick={() => openEditTowerModal(row)}>
|
||||
编辑
|
||||
</Button>
|
||||
)}
|
||||
{canTowerManage && (
|
||||
<Popconfirm
|
||||
title="删除杆塔"
|
||||
description={`确认删除杆塔 ${row.tower_no} 吗?`}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
okButtonProps={{ danger: true }}
|
||||
onConfirm={async () => {
|
||||
await deleteTowerMutation.mutateAsync(row.id);
|
||||
}}
|
||||
>
|
||||
<Button size="small" danger loading={deleteTowerMutation.isPending}>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
if (initializing || linesQuery.isLoading) {
|
||||
return (
|
||||
@@ -805,13 +849,13 @@ export default function AdminPowerLinesPage() {
|
||||
{ label: "塔杆列表", value: "table" },
|
||||
]}
|
||||
onChange={(value) => setTowerViewMode(value as "table" | "map")}
|
||||
disabled={!selectedLineId}
|
||||
disabled={!effectiveSelectedLineId}
|
||||
/>
|
||||
{canTowerManage && (
|
||||
<Button
|
||||
onClick={() => importInputRef.current?.click()}
|
||||
loading={importMutation.isPending}
|
||||
disabled={!selectedLineId}
|
||||
disabled={!effectiveSelectedLineId}
|
||||
>
|
||||
导入 CSV
|
||||
</Button>
|
||||
@@ -829,19 +873,19 @@ export default function AdminPowerLinesPage() {
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<Button onClick={() => exportMutation.mutate()} loading={exportMutation.isPending} disabled={!selectedLineId}>
|
||||
<Button onClick={() => exportMutation.mutate()} loading={exportMutation.isPending} disabled={!effectiveSelectedLineId}>
|
||||
导出 CSV
|
||||
</Button>
|
||||
{canTowerManage && (
|
||||
<Button type="primary" onClick={openCreateTowerModal} disabled={!selectedLineId}>
|
||||
<Button type="primary" onClick={openCreateTowerModal} disabled={!effectiveSelectedLineId}>
|
||||
新建杆塔
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
)}
|
||||
>
|
||||
{!selectedLineId || !selectedLine ? (
|
||||
<Empty description={selectedLineId ? "所选线路不存在,请重新选择" : "请先选择一条线路"} />
|
||||
{!effectiveSelectedLineId || !selectedLine ? (
|
||||
<Empty description={effectiveSelectedLineId ? "所选线路不存在,请重新选择" : "请先选择一条线路"} />
|
||||
) : (
|
||||
<Space direction="vertical" size={12} className="w-full">
|
||||
<Typography.Text type="secondary">
|
||||
@@ -897,7 +941,7 @@ export default function AdminPowerLinesPage() {
|
||||
dataSource={towers}
|
||||
loading={towersQuery.isFetching}
|
||||
pagination={{
|
||||
current: towerPagination.current,
|
||||
current: effectiveTowerPageCurrent,
|
||||
pageSize: towerPagination.pageSize,
|
||||
total: towersQuery.data?.total ?? 0,
|
||||
showSizeChanger: true,
|
||||
@@ -984,7 +1028,18 @@ export default function AdminPowerLinesPage() {
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="tower_model" label="杆塔模型">
|
||||
<Input />
|
||||
<Select
|
||||
showSearch
|
||||
allowClear
|
||||
loading={towerModelOptionsQuery.isFetching}
|
||||
options={towerModelOptions}
|
||||
placeholder="请选择杆塔模型"
|
||||
onChange={(value) => {
|
||||
applyTowerModelDefaults(value);
|
||||
}}
|
||||
filterOption={(input, option) =>
|
||||
String(option?.label ?? "").toLowerCase().includes(input.toLowerCase())}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="tower_type" label="塔型">
|
||||
<Select
|
||||
|
||||
@@ -0,0 +1,622 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Alert,
|
||||
App,
|
||||
Button,
|
||||
Empty,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Select,
|
||||
Space,
|
||||
Switch,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
} from "antd";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import Image from "next/image";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { useAuth } from "@/components/auth-provider";
|
||||
import { Card } from "@/components/ui-antd";
|
||||
import { useTopicSubscription } from "@/hooks/use-topic-subscription";
|
||||
import { getApiBaseUrl, readApiError } from "@/lib/api";
|
||||
import type {
|
||||
FileListResponse,
|
||||
FileStorageMount,
|
||||
TowerModelImageUploadResponse,
|
||||
TowerModelListResponse,
|
||||
TowerModelSeedResponse,
|
||||
TowerModelSummary,
|
||||
} from "@/types/auth";
|
||||
|
||||
type TowerModelFormValues = {
|
||||
code: string;
|
||||
name: string;
|
||||
tower_type: string;
|
||||
description: string;
|
||||
is_enabled: boolean;
|
||||
sort_order: number;
|
||||
default_altitude_m: number | null;
|
||||
default_terrain: string;
|
||||
default_ground_resistance_ohm: number | null;
|
||||
default_lightning_density: number | null;
|
||||
default_span_small_m: number | null;
|
||||
default_span_large_m: number | null;
|
||||
default_slope_1: number | null;
|
||||
default_slope_2: number | null;
|
||||
default_risk_level: string;
|
||||
};
|
||||
|
||||
const EMPTY_FORM: TowerModelFormValues = {
|
||||
code: "",
|
||||
name: "",
|
||||
tower_type: "",
|
||||
description: "",
|
||||
is_enabled: true,
|
||||
sort_order: 0,
|
||||
default_altitude_m: null,
|
||||
default_terrain: "",
|
||||
default_ground_resistance_ohm: null,
|
||||
default_lightning_density: null,
|
||||
default_span_small_m: null,
|
||||
default_span_large_m: null,
|
||||
default_slope_1: null,
|
||||
default_slope_2: null,
|
||||
default_risk_level: "",
|
||||
};
|
||||
|
||||
function toEditValues(item: TowerModelSummary): TowerModelFormValues {
|
||||
return {
|
||||
code: item.code,
|
||||
name: item.name,
|
||||
tower_type: item.tower_type ?? "",
|
||||
description: item.description ?? "",
|
||||
is_enabled: item.is_enabled,
|
||||
sort_order: item.sort_order,
|
||||
default_altitude_m: item.default_altitude_m,
|
||||
default_terrain: item.default_terrain ?? "",
|
||||
default_ground_resistance_ohm: item.default_ground_resistance_ohm,
|
||||
default_lightning_density: item.default_lightning_density,
|
||||
default_span_small_m: item.default_span_small_m,
|
||||
default_span_large_m: item.default_span_large_m,
|
||||
default_slope_1: item.default_slope_1,
|
||||
default_slope_2: item.default_slope_2,
|
||||
default_risk_level: item.default_risk_level ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
function buildPayload(values: TowerModelFormValues): Record<string, unknown> {
|
||||
return {
|
||||
code: values.code.trim(),
|
||||
name: values.name.trim(),
|
||||
tower_type: values.tower_type.trim() || null,
|
||||
description: values.description.trim() || null,
|
||||
is_enabled: values.is_enabled,
|
||||
sort_order: values.sort_order ?? 0,
|
||||
default_altitude_m: values.default_altitude_m ?? null,
|
||||
default_terrain: values.default_terrain.trim() || null,
|
||||
default_ground_resistance_ohm: values.default_ground_resistance_ohm ?? null,
|
||||
default_lightning_density: values.default_lightning_density ?? null,
|
||||
default_span_small_m: values.default_span_small_m ?? null,
|
||||
default_span_large_m: values.default_span_large_m ?? null,
|
||||
default_slope_1: values.default_slope_1 ?? null,
|
||||
default_slope_2: values.default_slope_2 ?? null,
|
||||
default_risk_level: values.default_risk_level.trim() || null,
|
||||
};
|
||||
}
|
||||
|
||||
export default function AdminTowerModelsPage() {
|
||||
const { user, initializing, fetchWithAuth, hasPermission } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
const { message: messageApi, modal } = App.useApp();
|
||||
const [form] = Form.useForm<TowerModelFormValues>();
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [enabledFilter, setEnabledFilter] = useState<"all" | "enabled" | "disabled">("all");
|
||||
const [error, setError] = useState("");
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingModel, setEditingModel] = useState<TowerModelSummary | null>(null);
|
||||
const [uploadModel, setUploadModel] = useState<TowerModelSummary | null>(null);
|
||||
const [seedRunning, setSeedRunning] = useState(false);
|
||||
|
||||
const canRead = hasPermission("tower_model.read") || hasPermission("tower_model.manage") || hasPermission("tower.read") || hasPermission("tower.manage");
|
||||
const canManage = hasPermission("tower_model.manage");
|
||||
|
||||
const listPath = useMemo(() => {
|
||||
const params = new URLSearchParams();
|
||||
if (keyword.trim()) {
|
||||
params.set("keyword", keyword.trim());
|
||||
}
|
||||
if (enabledFilter !== "all") {
|
||||
params.set("enabled", enabledFilter === "enabled" ? "true" : "false");
|
||||
}
|
||||
const query = params.toString();
|
||||
return `/api/v1/tower-models${query ? `?${query}` : ""}`;
|
||||
}, [keyword, enabledFilter]);
|
||||
|
||||
const mountsQuery = useQuery({
|
||||
queryKey: ["/api/v1/admin/files?path=/"],
|
||||
enabled: !!user && canManage,
|
||||
queryFn: async () => {
|
||||
const response = await fetchWithAuth("/api/v1/admin/files?path=/");
|
||||
if (!response.ok) {
|
||||
throw new Error(await readApiError(response));
|
||||
}
|
||||
return (await response.json()) as FileListResponse;
|
||||
},
|
||||
});
|
||||
|
||||
const towerModelsQuery = useQuery({
|
||||
queryKey: [listPath],
|
||||
enabled: !!user && canRead,
|
||||
queryFn: async () => {
|
||||
const response = await fetchWithAuth(listPath);
|
||||
if (!response.ok) {
|
||||
throw new Error(await readApiError(response));
|
||||
}
|
||||
return (await response.json()) as TowerModelListResponse;
|
||||
},
|
||||
});
|
||||
|
||||
const refreshList = useCallback(async () => {
|
||||
await queryClient.invalidateQueries({
|
||||
predicate: (query) =>
|
||||
Array.isArray(query.queryKey)
|
||||
&& typeof query.queryKey[0] === "string"
|
||||
&& query.queryKey[0].startsWith("/api/v1/tower-models"),
|
||||
});
|
||||
}, [queryClient]);
|
||||
|
||||
useTopicSubscription("admin.tower-models", useCallback(() => {
|
||||
void refreshList();
|
||||
}, [refreshList]));
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async (values: TowerModelFormValues) => {
|
||||
const payload = buildPayload(values);
|
||||
if (editingModel) {
|
||||
const response = await fetchWithAuth(`/api/v1/tower-models/${editingModel.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(await readApiError(response));
|
||||
}
|
||||
return "updated" as const;
|
||||
}
|
||||
|
||||
const response = await fetchWithAuth("/api/v1/tower-models", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(await readApiError(response));
|
||||
}
|
||||
return "created" as const;
|
||||
},
|
||||
onSuccess: async (mode) => {
|
||||
setError("");
|
||||
messageApi.success(mode === "created" ? "杆塔模型已创建" : "杆塔模型已更新");
|
||||
setDialogOpen(false);
|
||||
setEditingModel(null);
|
||||
form.resetFields();
|
||||
await refreshList();
|
||||
},
|
||||
onError: (candidate) => {
|
||||
setError(candidate instanceof Error ? candidate.message : "保存杆塔模型失败");
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (modelId: string) => {
|
||||
const response = await fetchWithAuth(`/api/v1/tower-models/${modelId}`, { method: "DELETE" });
|
||||
if (!response.ok) {
|
||||
throw new Error(await readApiError(response));
|
||||
}
|
||||
},
|
||||
onSuccess: async () => {
|
||||
setError("");
|
||||
messageApi.success("杆塔模型已删除");
|
||||
await refreshList();
|
||||
},
|
||||
onError: (candidate) => {
|
||||
setError(candidate instanceof Error ? candidate.message : "删除杆塔模型失败");
|
||||
},
|
||||
});
|
||||
|
||||
const uploadImageMutation = useMutation({
|
||||
mutationFn: async (payload: { modelId: string; mountCode: string; file: File }) => {
|
||||
const formData = new FormData();
|
||||
formData.append("file", payload.file);
|
||||
const params = new URLSearchParams({ mount_code: payload.mountCode });
|
||||
const response = await fetchWithAuth(`/api/v1/tower-models/${payload.modelId}/image?${params.toString()}`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(await readApiError(response));
|
||||
}
|
||||
return (await response.json()) as TowerModelImageUploadResponse;
|
||||
},
|
||||
onSuccess: async () => {
|
||||
setError("");
|
||||
messageApi.success("模型图片上传成功");
|
||||
setUploadModel(null);
|
||||
await refreshList();
|
||||
},
|
||||
onError: (candidate) => {
|
||||
setError(candidate instanceof Error ? candidate.message : "图片上传失败");
|
||||
},
|
||||
});
|
||||
|
||||
const openCreate = () => {
|
||||
setEditingModel(null);
|
||||
form.setFieldsValue(EMPTY_FORM);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = useCallback((item: TowerModelSummary) => {
|
||||
setEditingModel(item);
|
||||
form.setFieldsValue(toEditValues(item));
|
||||
setDialogOpen(true);
|
||||
}, [form]);
|
||||
|
||||
const triggerSeed = async (overwrite: boolean) => {
|
||||
setSeedRunning(true);
|
||||
try {
|
||||
const params = new URLSearchParams({ overwrite_existing: overwrite ? "true" : "false" });
|
||||
const response = await fetchWithAuth(`/api/v1/tower-models/seed/legacy?${params.toString()}`, {
|
||||
method: "POST",
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(await readApiError(response));
|
||||
}
|
||||
const payload = (await response.json()) as TowerModelSeedResponse;
|
||||
messageApi.success(
|
||||
`初始化完成:新增 ${payload.imported_models},更新 ${payload.updated_models},跳过 ${payload.skipped_models},图片 ${payload.copied_images}`,
|
||||
);
|
||||
if (payload.warnings.length > 0) {
|
||||
setError(payload.warnings.slice(0, 8).join("; "));
|
||||
} else {
|
||||
setError("");
|
||||
}
|
||||
await refreshList();
|
||||
} catch (candidate) {
|
||||
setError(candidate instanceof Error ? candidate.message : "初始化失败");
|
||||
} finally {
|
||||
setSeedRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const tableColumns = useMemo<ColumnsType<TowerModelSummary>>(
|
||||
() => [
|
||||
{
|
||||
title: "模型编码",
|
||||
dataIndex: "code",
|
||||
width: 160,
|
||||
render: (value: string) => <Typography.Text code>{value}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
title: "模型名称",
|
||||
dataIndex: "name",
|
||||
width: 200,
|
||||
},
|
||||
{
|
||||
title: "塔型",
|
||||
dataIndex: "tower_type",
|
||||
width: 100,
|
||||
render: (value: string | null) => value || "-",
|
||||
},
|
||||
{
|
||||
title: "默认参数",
|
||||
key: "defaults",
|
||||
width: 320,
|
||||
render: (_: unknown, row) => (
|
||||
<Space size={[8, 4]} wrap>
|
||||
<Tag>接地电阻 {row.default_ground_resistance_ohm ?? "-"}Ω</Tag>
|
||||
<Tag>地闪密度 {row.default_lightning_density ?? "-"}</Tag>
|
||||
<Tag>档距 {row.default_span_small_m ?? "-"} / {row.default_span_large_m ?? "-"}</Tag>
|
||||
<Tag>倾角 {row.default_slope_1 ?? "-"} / {row.default_slope_2 ?? "-"}</Tag>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "图片",
|
||||
key: "image",
|
||||
width: 200,
|
||||
render: (_: unknown, row) => {
|
||||
if (!row.image_path) {
|
||||
return <Typography.Text type="secondary">未上传</Typography.Text>;
|
||||
}
|
||||
return (
|
||||
<Space size={8}>
|
||||
<Image
|
||||
src={`${getApiBaseUrl()}/api/v1/tower-models/${row.id}/image`}
|
||||
alt={row.name}
|
||||
width={56}
|
||||
height={56}
|
||||
style={{ objectFit: "cover", borderRadius: 6, border: "1px solid #ddd" }}
|
||||
/>
|
||||
<Button size="small" onClick={() => window.open(`${getApiBaseUrl()}/api/v1/tower-models/${row.id}/image`, "_blank")}>
|
||||
查看
|
||||
</Button>
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "状态",
|
||||
dataIndex: "is_enabled",
|
||||
width: 80,
|
||||
render: (value: boolean) => <Tag color={value ? "success" : "default"}>{value ? "启用" : "禁用"}</Tag>,
|
||||
},
|
||||
{
|
||||
title: "排序",
|
||||
dataIndex: "sort_order",
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "actions",
|
||||
width: 240,
|
||||
fixed: "right",
|
||||
render: (_: unknown, row) => (
|
||||
<Space size={8}>
|
||||
{canManage && <Button size="small" onClick={() => openEdit(row)}>编辑</Button>}
|
||||
{canManage && (
|
||||
<Button size="small" onClick={() => setUploadModel(row)}>
|
||||
上传图片
|
||||
</Button>
|
||||
)}
|
||||
{canManage && (
|
||||
<Popconfirm
|
||||
title="删除杆塔模型"
|
||||
description={`确认删除模型 ${row.code} 吗?`}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
okButtonProps={{ danger: true }}
|
||||
onConfirm={async () => {
|
||||
await deleteMutation.mutateAsync(row.id);
|
||||
}}
|
||||
>
|
||||
<Button size="small" danger loading={deleteMutation.isPending}>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
],
|
||||
[canManage, deleteMutation, openEdit],
|
||||
);
|
||||
|
||||
const mounts = mountsQuery.data?.mounts ?? [];
|
||||
|
||||
if (initializing || towerModelsQuery.isLoading) {
|
||||
return <Card><Typography.Text type="secondary">加载杆塔模型中...</Typography.Text></Card>;
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<Card>
|
||||
<Space direction="vertical" size={12}>
|
||||
<Typography.Text type="secondary">请先登录后再访问杆塔模型管理页面。</Typography.Text>
|
||||
<Button><Link href="/">返回首页</Link></Button>
|
||||
</Space>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!canRead) {
|
||||
return (
|
||||
<Card>
|
||||
<Space direction="vertical" size={12}>
|
||||
<Typography.Text type="secondary">你没有访问该页面的权限(需要 `tower_model.read`)。</Typography.Text>
|
||||
<Button><Link href="/">返回首页</Link></Button>
|
||||
</Space>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const listError = towerModelsQuery.error instanceof Error ? towerModelsQuery.error.message : "";
|
||||
const listData = towerModelsQuery.data;
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size={16} className="w-full">
|
||||
{(error || listError) && (
|
||||
<Alert type="error" showIcon message="操作失败" description={error || listError} />
|
||||
)}
|
||||
|
||||
<Card
|
||||
title="杆塔模型管理"
|
||||
extra={canManage ? (
|
||||
<Space size={8} wrap>
|
||||
<Button onClick={openCreate} type="primary">新建模型</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
modal.confirm({
|
||||
title: "初始化老系统模型数据",
|
||||
content: "从老系统 LP_Setting/LP_GanTa/Models 导入模型、默认参数和图片。",
|
||||
closable: false,
|
||||
maskClosable: false,
|
||||
keyboard: false,
|
||||
okText: "覆盖初始化",
|
||||
cancelText: "仅新增",
|
||||
onOk: async () => {
|
||||
await triggerSeed(true);
|
||||
},
|
||||
onCancel: async () => {
|
||||
await triggerSeed(false);
|
||||
},
|
||||
});
|
||||
}}
|
||||
loading={seedRunning}
|
||||
>
|
||||
初始化老系统数据
|
||||
</Button>
|
||||
</Space>
|
||||
) : null}
|
||||
>
|
||||
<Space direction="vertical" size={12} className="w-full">
|
||||
<Typography.Text type="secondary">
|
||||
模型图片由文件服务挂载路径管理;新建线路添加杆塔时会按模型自动带出默认参数。
|
||||
</Typography.Text>
|
||||
<div className="grid gap-3 md:grid-cols-[1fr_160px]">
|
||||
<Input
|
||||
value={keyword}
|
||||
allowClear
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
placeholder="按模型编码/名称/塔型筛选"
|
||||
/>
|
||||
<Select
|
||||
value={enabledFilter}
|
||||
options={[
|
||||
{ value: "all", label: "全部状态" },
|
||||
{ value: "enabled", label: "启用" },
|
||||
{ value: "disabled", label: "禁用" },
|
||||
]}
|
||||
onChange={(value) => setEnabledFilter(value)}
|
||||
/>
|
||||
</div>
|
||||
{listData && listData.items.length === 0 ? (
|
||||
<Empty description="暂无杆塔模型数据" image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
) : (
|
||||
<Table<TowerModelSummary>
|
||||
rowKey={(row) => row.id}
|
||||
columns={tableColumns}
|
||||
dataSource={listData?.items ?? []}
|
||||
pagination={{ pageSize: 20, showSizeChanger: true }}
|
||||
scroll={{ x: 1450 }}
|
||||
/>
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title={editingModel ? "编辑杆塔模型" : "新建杆塔模型"}
|
||||
open={dialogOpen}
|
||||
width={960}
|
||||
okText={editingModel ? "保存" : "创建"}
|
||||
confirmLoading={saveMutation.isPending}
|
||||
onCancel={() => {
|
||||
if (saveMutation.isPending) return;
|
||||
setDialogOpen(false);
|
||||
}}
|
||||
onOk={async () => {
|
||||
const values = await form.validateFields();
|
||||
saveMutation.mutate(values);
|
||||
}}
|
||||
>
|
||||
<Form<TowerModelFormValues> form={form} layout="vertical" initialValues={EMPTY_FORM}>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<Form.Item name="code" label="模型编码" rules={[{ required: true, message: "请输入模型编码" }]}>
|
||||
<Input disabled={!!editingModel} />
|
||||
</Form.Item>
|
||||
<Form.Item name="name" label="模型名称" rules={[{ required: true, message: "请输入模型名称" }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="tower_type" label="塔型">
|
||||
<Select
|
||||
allowClear
|
||||
options={[
|
||||
{ value: "直线", label: "直线" },
|
||||
{ value: "耐张", label: "耐张" },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="sort_order" label="排序">
|
||||
<InputNumber min={0} max={1_000_000} className="w-full" />
|
||||
</Form.Item>
|
||||
<Form.Item name="default_altitude_m" label="默认海拔(m)">
|
||||
<InputNumber precision={4} className="w-full" />
|
||||
</Form.Item>
|
||||
<Form.Item name="default_terrain" label="默认地形">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="default_ground_resistance_ohm" label="默认接地电阻(Ω)">
|
||||
<InputNumber precision={4} className="w-full" />
|
||||
</Form.Item>
|
||||
<Form.Item name="default_lightning_density" label="默认地闪密度">
|
||||
<InputNumber precision={8} className="w-full" />
|
||||
</Form.Item>
|
||||
<Form.Item name="default_span_small_m" label="默认小号侧档距(m)">
|
||||
<InputNumber precision={4} className="w-full" />
|
||||
</Form.Item>
|
||||
<Form.Item name="default_span_large_m" label="默认大号侧档距(m)">
|
||||
<InputNumber precision={4} className="w-full" />
|
||||
</Form.Item>
|
||||
<Form.Item name="default_slope_1" label="默认地面倾角1">
|
||||
<InputNumber precision={8} className="w-full" />
|
||||
</Form.Item>
|
||||
<Form.Item name="default_slope_2" label="默认地面倾角2">
|
||||
<InputNumber precision={8} className="w-full" />
|
||||
</Form.Item>
|
||||
<Form.Item name="default_risk_level" label="默认风险等级">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="is_enabled" label="启用状态" valuePropName="checked">
|
||||
<Switch checkedChildren="启用" unCheckedChildren="禁用" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<Form.Item name="description" label="描述">
|
||||
<Input.TextArea rows={3} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="上传模型图片"
|
||||
open={!!uploadModel}
|
||||
okText="上传"
|
||||
confirmLoading={uploadImageMutation.isPending}
|
||||
onCancel={() => {
|
||||
if (uploadImageMutation.isPending) return;
|
||||
setUploadModel(null);
|
||||
}}
|
||||
onOk={() => {
|
||||
if (!uploadModel) return;
|
||||
const file = fileInputRef.current?.files?.[0];
|
||||
if (!file) {
|
||||
setError("请先选择图片文件");
|
||||
return;
|
||||
}
|
||||
const mount = mounts[0];
|
||||
if (!mount) {
|
||||
setError("未查询到可用文件挂载点");
|
||||
return;
|
||||
}
|
||||
uploadImageMutation.mutate({
|
||||
modelId: uploadModel.id,
|
||||
mountCode: mount.code,
|
||||
file,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Space direction="vertical" size={12} className="w-full">
|
||||
<Typography.Text type="secondary">
|
||||
当前模型:{uploadModel?.code} / {uploadModel?.name}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary">
|
||||
目标挂载:{(mounts[0] as FileStorageMount | undefined)?.code ?? "-"}(默认第一个可用挂载)
|
||||
</Typography.Text>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".jpg,.jpeg,.png,.webp,.gif,.bmp,image/*"
|
||||
className="block w-full"
|
||||
/>
|
||||
</Space>
|
||||
</Modal>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
@@ -322,6 +322,53 @@ export type ElevationDatasetAnalyzeResponse = {
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
export type TowerModelSummary = {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
tower_type: string | null;
|
||||
description: string | null;
|
||||
image_mount_code: string | null;
|
||||
image_path: string | null;
|
||||
source_tag: string | null;
|
||||
is_enabled: boolean;
|
||||
sort_order: number;
|
||||
default_altitude_m: number | null;
|
||||
default_terrain: string | null;
|
||||
default_ground_resistance_ohm: number | null;
|
||||
default_lightning_density: number | null;
|
||||
default_span_small_m: number | null;
|
||||
default_span_large_m: number | null;
|
||||
default_slope_1: number | null;
|
||||
default_slope_2: number | null;
|
||||
default_risk_level: string | null;
|
||||
default_raw_json: Record<string, unknown>;
|
||||
create_date: string;
|
||||
create_user: string | null;
|
||||
update_date: string;
|
||||
update_user: string | null;
|
||||
};
|
||||
|
||||
export type TowerModelListResponse = {
|
||||
items: TowerModelSummary[];
|
||||
total: number;
|
||||
};
|
||||
|
||||
export type TowerModelImageUploadResponse = {
|
||||
model: TowerModelSummary;
|
||||
mount_code: string;
|
||||
image_path: string;
|
||||
};
|
||||
|
||||
export type TowerModelSeedResponse = {
|
||||
total_models: number;
|
||||
imported_models: number;
|
||||
updated_models: number;
|
||||
skipped_models: number;
|
||||
copied_images: number;
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
export type ElevationDatasetBatchImportResponse = {
|
||||
imported_count: number;
|
||||
analyzed_count: number;
|
||||
|
||||
Reference in New Issue
Block a user