[fix/feat]:[FL-8][线路管理-新建线路表单调整]
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -40,11 +40,10 @@ router = APIRouter(prefix="/lines", tags=["lines"])
|
||||
@router.get("", response_model=LineListResponse)
|
||||
def get_line_list(
|
||||
keyword: str | None = Query(default=None),
|
||||
status_filter: str | None = Query(default=None, alias="status"),
|
||||
_: CurrentUser = Depends(require_any_permission("line.read", "line.manage", "tower.read", "tower.manage")),
|
||||
db: Session = Depends(get_db),
|
||||
) -> LineListResponse:
|
||||
return list_lines(db, keyword=keyword, status_filter=status_filter)
|
||||
return list_lines(db, keyword=keyword)
|
||||
|
||||
|
||||
@router.post("", response_model=LineSummary)
|
||||
|
||||
+1
-10
@@ -1,23 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
LineStatus = Literal["enabled", "disabled"]
|
||||
|
||||
|
||||
class LineSummary(BaseModel):
|
||||
id: str
|
||||
code: str
|
||||
name: str
|
||||
voltage_kv: int | None = None
|
||||
tower_shape: str | None = None
|
||||
phase_sequence_json: dict[str, Any] = Field(default_factory=dict)
|
||||
arrester_install_json: dict[str, Any] = Field(default_factory=dict)
|
||||
lightning_param_json: dict[str, Any] = Field(default_factory=dict)
|
||||
status: LineStatus
|
||||
tower_count: int = 0
|
||||
create_date: datetime
|
||||
create_user: str | None = None
|
||||
@@ -34,21 +29,17 @@ class LineCreateRequest(BaseModel):
|
||||
code: str = Field(min_length=1, max_length=64)
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
voltage_kv: int | None = Field(default=None, ge=1, le=2000)
|
||||
tower_shape: str | None = Field(default=None, max_length=64)
|
||||
phase_sequence_json: dict[str, Any] = Field(default_factory=dict)
|
||||
arrester_install_json: dict[str, Any] = Field(default_factory=dict)
|
||||
lightning_param_json: dict[str, Any] = Field(default_factory=dict)
|
||||
status: LineStatus = "enabled"
|
||||
|
||||
|
||||
class LineUpdateRequest(BaseModel):
|
||||
name: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
voltage_kv: int | None = Field(default=None, ge=1, le=2000)
|
||||
tower_shape: str | None = Field(default=None, max_length=64)
|
||||
phase_sequence_json: dict[str, Any] | None = None
|
||||
arrester_install_json: dict[str, Any] | None = None
|
||||
lightning_param_json: dict[str, Any] | None = None
|
||||
status: LineStatus | None = None
|
||||
|
||||
|
||||
class LineTowerSummary(BaseModel):
|
||||
|
||||
@@ -52,11 +52,9 @@ def serialize_line(line: Line, *, tower_count: int = 0) -> LineSummary:
|
||||
code=line.code,
|
||||
name=line.name,
|
||||
voltage_kv=line.voltage_kv,
|
||||
tower_shape=line.tower_shape,
|
||||
phase_sequence_json=line.phase_sequence_json or {},
|
||||
arrester_install_json=line.arrester_install_json or {},
|
||||
lightning_param_json=line.lightning_param_json or {},
|
||||
status=line.status, # type: ignore[arg-type]
|
||||
tower_count=tower_count,
|
||||
create_date=line.create_date,
|
||||
create_user=line.create_user,
|
||||
@@ -98,7 +96,6 @@ def list_lines(
|
||||
db: Session,
|
||||
*,
|
||||
keyword: str | None,
|
||||
status_filter: str | None,
|
||||
) -> LineListResponse:
|
||||
stmt = select(Line)
|
||||
total_stmt = select(func.count()).select_from(Line)
|
||||
@@ -110,10 +107,6 @@ def list_lines(
|
||||
stmt = stmt.where(predicate)
|
||||
total_stmt = total_stmt.where(predicate)
|
||||
|
||||
if status_filter in {"enabled", "disabled"}:
|
||||
stmt = stmt.where(Line.status == status_filter)
|
||||
total_stmt = total_stmt.where(Line.status == status_filter)
|
||||
|
||||
total = int(db.scalar(total_stmt) or 0)
|
||||
items = db.execute(stmt.order_by(Line.update_date.desc(), Line.code.asc())).scalars().all()
|
||||
line_ids = [item.id for item in items]
|
||||
@@ -150,11 +143,10 @@ def create_line(
|
||||
code=payload.code.strip(),
|
||||
name=payload.name.strip(),
|
||||
voltage_kv=payload.voltage_kv,
|
||||
tower_shape=_normalize_str(payload.tower_shape),
|
||||
phase_sequence_json=payload.phase_sequence_json,
|
||||
arrester_install_json=payload.arrester_install_json,
|
||||
lightning_param_json=payload.lightning_param_json,
|
||||
status=payload.status,
|
||||
status="enabled",
|
||||
create_user=actor_user_id,
|
||||
update_user=actor_user_id,
|
||||
create_date=now,
|
||||
@@ -187,16 +179,12 @@ def update_line(
|
||||
line.name = str(update_data["name"]).strip()
|
||||
if "voltage_kv" in update_data:
|
||||
line.voltage_kv = update_data["voltage_kv"]
|
||||
if "tower_shape" in update_data:
|
||||
line.tower_shape = _normalize_str(update_data["tower_shape"])
|
||||
if "phase_sequence_json" in update_data and update_data["phase_sequence_json"] is not None:
|
||||
line.phase_sequence_json = dict(update_data["phase_sequence_json"])
|
||||
if "arrester_install_json" in update_data and update_data["arrester_install_json"] is not None:
|
||||
line.arrester_install_json = dict(update_data["arrester_install_json"])
|
||||
if "lightning_param_json" in update_data and update_data["lightning_param_json"] is not None:
|
||||
line.lightning_param_json = dict(update_data["lightning_param_json"])
|
||||
if "status" in update_data and update_data["status"] is not None:
|
||||
line.status = str(update_data["status"])
|
||||
|
||||
line.update_user = actor_user_id
|
||||
line.update_date = utcnow()
|
||||
@@ -564,7 +552,6 @@ def export_line_towers_to_csv(db: Session, *, line: Line) -> tuple[str, bytes]:
|
||||
"线路编号",
|
||||
"线路名称",
|
||||
"电压等级",
|
||||
"塔形",
|
||||
"序号",
|
||||
"塔号",
|
||||
"杆塔模型",
|
||||
@@ -595,7 +582,6 @@ def export_line_towers_to_csv(db: Session, *, line: Line) -> tuple[str, bytes]:
|
||||
line.code,
|
||||
line.name,
|
||||
line.voltage_kv or "",
|
||||
line.tower_shape or "",
|
||||
tower.seq_no,
|
||||
tower.tower_no,
|
||||
tower.tower_model or "",
|
||||
@@ -654,10 +640,6 @@ def _apply_line_metadata_from_csv(
|
||||
if voltage is not None:
|
||||
line.voltage_kv = voltage
|
||||
|
||||
tower_shape = _normalize_str(row.get("塔形"))
|
||||
if tower_shape:
|
||||
line.tower_shape = tower_shape
|
||||
|
||||
line.phase_sequence_json = {
|
||||
"I": _normalize_str(row.get("I回相序")),
|
||||
"II": _normalize_str(row.get("II回相序")),
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SCHEMA_FILE = ROOT / "app" / "schemas" / "line.py"
|
||||
SERVICE_FILE = ROOT / "app" / "services" / "line_service.py"
|
||||
API_FILE = ROOT / "app" / "api" / "v1" / "lines.py"
|
||||
|
||||
|
||||
def _load_module(path: Path) -> ast.Module:
|
||||
return ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
||||
|
||||
|
||||
def _class_field_names(module: ast.Module, class_name: str) -> set[str]:
|
||||
for node in module.body:
|
||||
if isinstance(node, ast.ClassDef) and node.name == class_name:
|
||||
names: set[str] = set()
|
||||
for item in node.body:
|
||||
if isinstance(item, ast.AnnAssign) and isinstance(item.target, ast.Name):
|
||||
names.add(item.target.id)
|
||||
return names
|
||||
raise AssertionError(f"class {class_name} not found")
|
||||
|
||||
|
||||
def _function_node(module: ast.Module, function_name: str) -> ast.FunctionDef:
|
||||
for node in module.body:
|
||||
if isinstance(node, ast.FunctionDef) and node.name == function_name:
|
||||
return node
|
||||
raise AssertionError(f"function {function_name} not found")
|
||||
|
||||
|
||||
class LineContractTest(unittest.TestCase):
|
||||
def test_line_schemas_do_not_expose_removed_fields(self) -> None:
|
||||
module = _load_module(SCHEMA_FILE)
|
||||
|
||||
for class_name in ("LineSummary", "LineCreateRequest", "LineUpdateRequest"):
|
||||
fields = _class_field_names(module, class_name)
|
||||
self.assertNotIn("tower_shape", fields)
|
||||
self.assertNotIn("status", fields)
|
||||
|
||||
def test_line_list_endpoint_no_longer_accepts_status_filter(self) -> None:
|
||||
module = _load_module(API_FILE)
|
||||
function = _function_node(module, "get_line_list")
|
||||
arg_names = [arg.arg for arg in function.args.args]
|
||||
|
||||
self.assertNotIn("status_filter", arg_names)
|
||||
|
||||
source = ast.unparse(function)
|
||||
self.assertIn("return list_lines(db, keyword=keyword)", source)
|
||||
|
||||
def test_line_service_drops_removed_fields_from_public_contract(self) -> None:
|
||||
module = _load_module(SERVICE_FILE)
|
||||
serialize_line = _function_node(module, "serialize_line")
|
||||
serialize_source = ast.unparse(serialize_line)
|
||||
|
||||
self.assertNotIn("tower_shape=", serialize_source)
|
||||
self.assertNotIn("status=", serialize_source)
|
||||
|
||||
export_source = SCHEMA_FILE.read_text(encoding="utf-8")
|
||||
self.assertNotIn("tower_shape:", export_source)
|
||||
self.assertNotIn("status:", export_source)
|
||||
|
||||
def test_line_tower_csv_export_header_excludes_tower_shape(self) -> None:
|
||||
module = _load_module(SERVICE_FILE)
|
||||
function = _function_node(module, "export_line_towers_to_csv")
|
||||
header_values: list[str] | None = None
|
||||
|
||||
for node in function.body:
|
||||
if isinstance(node, ast.Assign):
|
||||
for target in node.targets:
|
||||
if isinstance(target, ast.Name) and target.id == "headers":
|
||||
if isinstance(node.value, ast.List):
|
||||
header_values = [
|
||||
item.value
|
||||
for item in node.value.elts
|
||||
if isinstance(item, ast.Constant) and isinstance(item.value, str)
|
||||
]
|
||||
break
|
||||
if header_values is not None:
|
||||
break
|
||||
|
||||
self.assertIsNotNone(header_values)
|
||||
self.assertNotIn("塔形", header_values)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,35 @@
|
||||
## Work Log - 线路管理新建线路表单去掉塔型/状态(2026-06-07)
|
||||
|
||||
- 背景:
|
||||
- Issue `FL-8` 要求调整 `/admin/power-lines` 新建线路表单,去掉“塔型”“状态”表单项,并删除对应字段契约。
|
||||
- 代码排查确认影响不仅在前端表单,还包括线路公开 schema、线路列表筛选、线路卡片展示,以及 CSV 导入导出的线路元数据字段。
|
||||
- 仓库当前无显式 Alembic 迁移链路,因此本次不直接做数据库删列迁移,优先收口前后端公开契约与页面行为。
|
||||
|
||||
- 本次改动:
|
||||
- `web/src/app/admin/power-lines/page.tsx`
|
||||
- 删除线路表单中的 `tower_shape`、`status` 字段。
|
||||
- 删除线路列表状态筛选、线路卡片状态 Tag、线路卡片塔形展示。
|
||||
- 调整创建/更新请求体,仅提交 `code`、`name`、`voltage_kv`。
|
||||
- `web/src/types/auth.ts`
|
||||
- 删除 `LineStatus` 类型与 `LineSummary` 中的 `tower_shape`、`status` 字段。
|
||||
- `api/app/schemas/line.py`
|
||||
- 删除 `LineSummary`、`LineCreateRequest`、`LineUpdateRequest` 中的 `tower_shape`、`status` 字段。
|
||||
- `api/app/api/v1/lines.py`
|
||||
- 删除线路列表接口的 `status` 查询参数。
|
||||
- `api/app/services/line_service.py`
|
||||
- 删除线路序列化输出中的 `tower_shape`、`status`。
|
||||
- 删除线路列表状态筛选逻辑。
|
||||
- 删除创建/更新线路时对 `tower_shape`、`status` 的公开读写。
|
||||
- 删除杆塔 CSV 导出中的“塔形”列,以及 CSV 导入元数据对“塔形”的回填。
|
||||
- `api/tests/test_line_contract.py`
|
||||
- 新增 AST 级最小回归测试,校验线路公开 schema/接口/CSV 导出头中不再暴露上述字段。
|
||||
|
||||
- 验证:
|
||||
- `python3 -m unittest api/tests/test_line_contract.py` 通过。
|
||||
- `npm --workspace web run lint -- src/app/admin/power-lines/page.tsx` 仍失败,但失败项与修改前一致:
|
||||
- `react-hooks/set-state-in-effect` 2 处
|
||||
- `react-hooks/exhaustive-deps` warning 4 处
|
||||
- 本次未新增新的 lint 问题。
|
||||
|
||||
- 风险与关注点:
|
||||
- `power_line` 表中的 `tower_shape` / `status` 数据列仍保留为兼容字段,避免在缺少迁移链路时影响现网插入;当前仅前后端公开契约不再读写/展示这两个字段。
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
} from "antd";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
@@ -30,7 +29,6 @@ import { useTopicSubscription } from "@/hooks/use-topic-subscription";
|
||||
import { readApiError } from "@/lib/api";
|
||||
import type {
|
||||
LineListResponse,
|
||||
LineStatus,
|
||||
LineSummary,
|
||||
LineTowerImportResponse,
|
||||
LineTowerListResponse,
|
||||
@@ -43,8 +41,6 @@ type LineFormValues = {
|
||||
code: string;
|
||||
name: string;
|
||||
voltage_level: string | null;
|
||||
tower_shape: string;
|
||||
status: LineStatus;
|
||||
};
|
||||
|
||||
type TowerFormValues = {
|
||||
@@ -78,17 +74,6 @@ type TowerProfileFormValues = {
|
||||
geometry_layers_json: string;
|
||||
};
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: "all", label: "全部状态" },
|
||||
{ value: "enabled", label: "启用" },
|
||||
{ value: "disabled", label: "禁用" },
|
||||
] as const;
|
||||
|
||||
const LINE_STATUS_OPTIONS = [
|
||||
{ value: "enabled", label: "启用" },
|
||||
{ value: "disabled", label: "禁用" },
|
||||
] as const;
|
||||
|
||||
const TOWER_TYPE_OPTIONS = [
|
||||
{ value: "", label: "全部塔型" },
|
||||
{ value: "直线", label: "直线" },
|
||||
@@ -157,8 +142,6 @@ const EMPTY_LINE_FORM: LineFormValues = {
|
||||
code: "",
|
||||
name: "",
|
||||
voltage_level: null,
|
||||
tower_shape: "",
|
||||
status: "enabled",
|
||||
};
|
||||
|
||||
const EMPTY_TOWER_FORM: TowerFormValues = {
|
||||
@@ -192,12 +175,6 @@ const EMPTY_TOWER_PROFILE_FORM: TowerProfileFormValues = {
|
||||
geometry_layers_json: "{}",
|
||||
};
|
||||
|
||||
function formatStatus(status: string): string {
|
||||
if (status === "enabled") return "启用";
|
||||
if (status === "disabled") return "禁用";
|
||||
return status || "-";
|
||||
}
|
||||
|
||||
function resolveVoltageOptionFromKv(voltageKv: number | null): LineFormValues["voltage_level"] {
|
||||
if (voltageKv === null) {
|
||||
return null;
|
||||
@@ -216,7 +193,6 @@ export default function AdminPowerLinesPage() {
|
||||
const [towerProfileForm] = Form.useForm<TowerProfileFormValues>();
|
||||
|
||||
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("");
|
||||
@@ -244,12 +220,9 @@ export default function AdminPowerLinesPage() {
|
||||
if (keyword.trim()) {
|
||||
params.set("keyword", keyword.trim());
|
||||
}
|
||||
if (statusFilter !== "all") {
|
||||
params.set("status", statusFilter);
|
||||
}
|
||||
const query = params.toString();
|
||||
return `/api/v1/lines${query ? `?${query}` : ""}`;
|
||||
}, [keyword, statusFilter]);
|
||||
}, [keyword]);
|
||||
|
||||
const towerListPath = useMemo(() => {
|
||||
if (!selectedLineId) {
|
||||
@@ -443,8 +416,6 @@ export default function AdminPowerLinesPage() {
|
||||
code: values.code.trim(),
|
||||
name: values.name.trim(),
|
||||
voltage_kv: values.voltage_level ? LINE_VOLTAGE_VALUE_TO_KV[values.voltage_level] : null,
|
||||
tower_shape: values.tower_shape.trim() || null,
|
||||
status: values.status,
|
||||
};
|
||||
|
||||
if (editingLine) {
|
||||
@@ -454,8 +425,6 @@ export default function AdminPowerLinesPage() {
|
||||
body: JSON.stringify({
|
||||
name: payload.name,
|
||||
voltage_kv: payload.voltage_kv,
|
||||
tower_shape: payload.tower_shape,
|
||||
status: payload.status,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
@@ -708,8 +677,6 @@ export default function AdminPowerLinesPage() {
|
||||
code: line.code,
|
||||
name: line.name,
|
||||
voltage_level: resolveVoltageOptionFromKv(line.voltage_kv),
|
||||
tower_shape: line.tower_shape ?? "",
|
||||
status: line.status,
|
||||
});
|
||||
setLineModalOpen(true);
|
||||
};
|
||||
@@ -775,7 +742,6 @@ export default function AdminPowerLinesPage() {
|
||||
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 ? (
|
||||
@@ -816,7 +782,6 @@ export default function AdminPowerLinesPage() {
|
||||
编码:<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()}
|
||||
@@ -1046,11 +1011,6 @@ export default function AdminPowerLinesPage() {
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
placeholder="按线路编码/名称筛选"
|
||||
/>
|
||||
<Select
|
||||
value={statusFilter}
|
||||
options={[...STATUS_OPTIONS]}
|
||||
onChange={(value) => setStatusFilter(value)}
|
||||
/>
|
||||
<Space direction="vertical" size={10} className="w-full overflow-y-auto pr-1" style={{ height: leftListHeight }}>
|
||||
{lines.length === 0 ? (
|
||||
<Empty description="暂无线路数据" image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
@@ -1222,12 +1182,6 @@ export default function AdminPowerLinesPage() {
|
||||
options={[...LINE_VOLTAGE_OPTIONS].map((item) => ({ value: item.value, label: item.label }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="tower_shape" label="塔形">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="状态" rules={[{ required: true, message: "请选择状态" }]}>
|
||||
<Select options={[...LINE_STATUS_OPTIONS]} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
|
||||
@@ -513,18 +513,14 @@ export type ElevationApplyJobCreateResponse = {
|
||||
queued: boolean;
|
||||
};
|
||||
|
||||
export type LineStatus = "enabled" | "disabled";
|
||||
|
||||
export type LineSummary = {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
voltage_kv: number | null;
|
||||
tower_shape: string | null;
|
||||
phase_sequence_json: Record<string, unknown>;
|
||||
arrester_install_json: Record<string, unknown>;
|
||||
lightning_param_json: Record<string, unknown>;
|
||||
status: LineStatus;
|
||||
tower_count: number;
|
||||
create_date: string;
|
||||
create_user: string | null;
|
||||
|
||||
Reference in New Issue
Block a user