[fix/feat]:[FL-8][线路管理-新建线路表单调整]

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
chengkai3
2026-06-07 11:35:02 +08:00
parent 95d6ed9461
commit 5194638af2
7 changed files with 130 additions and 82 deletions
+1 -2
View File
@@ -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
View File
@@ -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):
+1 -19
View File
@@ -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回相序")),
+91
View File
@@ -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()
+35
View File
@@ -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` 数据列仍保留为兼容字段,避免在缺少迁移链路时影响现网插入;当前仅前后端公开契约不再读写/展示这两个字段。
+1 -47
View File
@@ -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>
-4
View File
@@ -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;