feat(tower-models): add tower model management with legacy data seed

This commit is contained in:
chengkai3
2026-05-03 18:15:54 +08:00
parent 7545b56e26
commit 3c4ad99d63
18 changed files with 2242 additions and 201 deletions
+2
View File
@@ -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)
+152
View File
@@ -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)
+57
View File
@@ -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"}
+2 -1
View File
@@ -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",
]
+56
View File
@@ -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)
+97
View File
@@ -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)
+1 -1
View File
@@ -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",
+16 -2
View File
@@ -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",
},
{
+143 -27
View File
@@ -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()
+46 -5
View File
@@ -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()
+1
View File
@@ -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"}),
+748
View File
@@ -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)
+30
View File
@@ -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 提示缺失来源。
+1
View File
@@ -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",
+220 -165
View File
@@ -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
+622
View File
@@ -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>
);
}
+47
View File
@@ -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;