Files
fquiz/web/src/app/admin/power-lines/page.tsx
T
chengkai3 cfe7624de3 [fix]:[FL-11][删除线路后报错]
Co-authored-by: multica-agent <github@multica.ai>
2026-06-07 12:31:12 +08:00

1362 lines
47 KiB
TypeScript

"use client";
import Link from "next/link";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
App,
Alert,
Button,
Empty,
Form,
Input,
InputNumber,
Modal,
Popconfirm,
Segmented,
Select,
Space,
Table,
Typography,
} from "antd";
import type { ColumnsType } from "antd/es/table";
import type { CSSProperties } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useAuth } from "@/components/auth-provider";
import { PowerLineCesiumMap } from "@/components/power-line-cesium-map";
import { Card } from "@/components/ui-antd";
import { useTopicSubscription } from "@/hooks/use-topic-subscription";
import { readApiError } from "@/lib/api";
import type {
LineListResponse,
LineSummary,
LineTowerImportResponse,
LineTowerListResponse,
LineTowerSummary,
TowerModelSummary,
TowerProfileDetail,
} from "@/types/auth";
type LineFormValues = {
name: string;
voltage_level: string | null;
};
type TowerFormValues = {
seq_no: number;
tower_no: string;
tower_model: string;
tower_type: string;
longitude: number | null;
latitude: number | null;
altitude_m: number | null;
terrain: string;
ground_resistance_ohm: number | null;
lightning_density: number | null;
span_small_m: number | null;
span_large_m: number | null;
slope_1: number | null;
slope_2: number | null;
risk_level: string;
};
type TowerProfileFormValues = {
structure_kind: string;
stroke_mode: string;
phase_sequence_1: string;
phase_sequence_2: string;
phase_sequence_3: string;
phase_sequence_4: string;
arrester_a: string;
arrester_b: string;
arrester_c: string;
geometry_layers_json: string;
};
const TOWER_TYPE_OPTIONS = [
{ value: "", label: "全部塔型" },
{ value: "直线", label: "直线" },
{ value: "耐张", label: "耐张" },
] as const;
const LINE_VOLTAGE_OPTIONS = [
{ value: "dc_500", label: "直流500kV", voltage_kv: 500 },
{ value: "dc_800", label: "直流800kV", voltage_kv: 800 },
{ value: "dc_1000", label: "直流1000kV", voltage_kv: 1000 },
{ value: "ac_35", label: "交流35kV", voltage_kv: 35 },
{ value: "ac_66", label: "交流66kV", voltage_kv: 66 },
{ value: "ac_110", label: "交流110kV", voltage_kv: 110 },
{ value: "ac_220", label: "交流220kV", voltage_kv: 220 },
{ value: "ac_330", label: "交流330kV", voltage_kv: 330 },
{ value: "ac_500", label: "交流500kV", voltage_kv: 500 },
{ value: "ac_750", label: "交流750kV", voltage_kv: 750 },
{ value: "ac_800", label: "交流800kV", voltage_kv: 800 },
{ value: "ac_1000", label: "交流1000kV", voltage_kv: 1000 },
{ value: "ac_110_x4", label: "交流110kV|交流110kV|交流110kV|交流110kV", voltage_kv: 110 },
{ value: "ac_220_x4", label: "交流220kV|交流220kV|交流220kV|交流220kV", voltage_kv: 220 },
] as const;
const LINE_VOLTAGE_VALUE_TO_KV: Record<(typeof LINE_VOLTAGE_OPTIONS)[number]["value"], number> = {
dc_500: 500,
dc_800: 800,
dc_1000: 1000,
ac_35: 35,
ac_66: 66,
ac_110: 110,
ac_220: 220,
ac_330: 330,
ac_500: 500,
ac_750: 750,
ac_800: 800,
ac_1000: 1000,
ac_110_x4: 110,
ac_220_x4: 220,
};
const LINE_VOLTAGE_KV_TO_DEFAULT_OPTION: Partial<Record<number, (typeof LINE_VOLTAGE_OPTIONS)[number]["value"]>> = {
35: "ac_35",
66: "ac_66",
110: "ac_110",
220: "ac_220",
330: "ac_330",
500: "dc_500",
750: "ac_750",
800: "dc_800",
1000: "dc_1000",
};
const TOWER_TABLE_DEFAULT_PAGE_SIZE = 20;
const TOWER_MAP_QUERY_LIMIT = 500;
const POWER_LINES_PANEL_MIN_HEIGHT = 360;
const POWER_LINES_PANEL_FALLBACK_RESERVE = 220;
const POWER_LINES_PANEL_VIEWPORT_GAP = 10;
const POWER_LINES_PANEL_BODY_GAP = 16;
const POWER_LINES_FILTERS_ESTIMATE_HEIGHT = 86;
const POWER_LINES_STATUS_ESTIMATE_HEIGHT = 34;
const POWER_LINES_MAP_HEADER_ESTIMATE_HEIGHT = 112;
const POWER_LINES_MAP_MIN_HEIGHT = 240;
const POWER_LINES_TABLE_MIN_SCROLL_Y = 180;
const EMPTY_LINE_FORM: LineFormValues = {
name: "",
voltage_level: null,
};
const EMPTY_TOWER_FORM: TowerFormValues = {
seq_no: 1,
tower_no: "",
tower_model: "",
tower_type: "",
longitude: null,
latitude: null,
altitude_m: null,
terrain: "",
ground_resistance_ohm: null,
lightning_density: null,
span_small_m: null,
span_large_m: null,
slope_1: null,
slope_2: null,
risk_level: "",
};
const EMPTY_TOWER_PROFILE_FORM: TowerProfileFormValues = {
structure_kind: "",
stroke_mode: "",
phase_sequence_1: "",
phase_sequence_2: "",
phase_sequence_3: "",
phase_sequence_4: "",
arrester_a: "",
arrester_b: "",
arrester_c: "",
geometry_layers_json: "{}",
};
function resolveVoltageOptionFromKv(voltageKv: number | null): LineFormValues["voltage_level"] {
if (voltageKv === null) {
return null;
}
return LINE_VOLTAGE_KV_TO_DEFAULT_OPTION[voltageKv] ?? null;
}
export default function AdminPowerLinesPage() {
const { user, initializing, fetchWithAuth, hasPermission } = useAuth();
const queryClient = useQueryClient();
const { message: messageApi } = App.useApp();
const importInputRef = useRef<HTMLInputElement | null>(null);
const panelScrollAnchorRef = useRef<HTMLDivElement | null>(null);
const [lineForm] = Form.useForm<LineFormValues>();
const [towerForm] = Form.useForm<TowerFormValues>();
const [towerProfileForm] = Form.useForm<TowerProfileFormValues>();
const [keyword, setKeyword] = useState("");
const [selectedLineId, setSelectedLineId] = useState<string | null>(null);
const [lineIdPendingDeletion, setLineIdPendingDeletion] = useState<string | null>(null);
const [selectedLineTouched, setSelectedLineTouched] = useState(false);
const [towerKeyword, setTowerKeyword] = useState("");
const [towerTypeFilter, setTowerTypeFilter] = useState("");
const [towerRiskFilter, setTowerRiskFilter] = useState("");
const [towerPagination, setTowerPagination] = useState({ current: 1, pageSize: TOWER_TABLE_DEFAULT_PAGE_SIZE });
const [lineModalOpen, setLineModalOpen] = useState(false);
const [towerModalOpen, setTowerModalOpen] = useState(false);
const [towerProfileModalOpen, setTowerProfileModalOpen] = useState(false);
const [editingLine, setEditingLine] = useState<LineSummary | null>(null);
const [editingTower, setEditingTower] = useState<LineTowerSummary | null>(null);
const [editingTowerProfileTower, setEditingTowerProfileTower] = useState<LineTowerSummary | null>(null);
const [towerViewMode, setTowerViewMode] = useState<"table" | "map">("map");
const [error, setError] = useState("");
const [panelBodyHeight, setPanelBodyHeight] = useState(POWER_LINES_PANEL_MIN_HEIGHT);
const canLineRead = hasPermission("line.read") || hasPermission("line.manage");
const canLineManage = hasPermission("line.manage");
const canTowerRead = hasPermission("tower.read") || hasPermission("tower.manage");
const canTowerManage = hasPermission("tower.manage");
const canRead = canLineRead || canTowerRead;
const lineListPath = useMemo(() => {
const params = new URLSearchParams();
if (keyword.trim()) {
params.set("keyword", keyword.trim());
}
const query = params.toString();
return `/api/v1/lines${query ? `?${query}` : ""}`;
}, [keyword]);
const activeTowerLineId = useMemo(() => {
if (!selectedLineId || selectedLineId === lineIdPendingDeletion) {
return null;
}
return selectedLineId;
}, [lineIdPendingDeletion, selectedLineId]);
const towerQueryCurrent = towerPagination.current;
const towerQueryPageSize = towerPagination.pageSize;
const towerListPath = useMemo(() => {
if (!activeTowerLineId) {
return "";
}
const params = new URLSearchParams();
if (towerKeyword.trim()) {
params.set("keyword", towerKeyword.trim());
}
if (towerTypeFilter) {
params.set("tower_type", towerTypeFilter);
}
if (towerRiskFilter.trim()) {
params.set("risk_level", towerRiskFilter.trim());
}
if (towerViewMode === "table") {
params.set("limit", String(towerQueryPageSize));
params.set("offset", String((towerQueryCurrent - 1) * towerQueryPageSize));
} else {
params.set("limit", String(TOWER_MAP_QUERY_LIMIT));
params.set("offset", "0");
}
const query = params.toString();
return `/api/v1/lines/${activeTowerLineId}/towers?${query}`;
}, [
activeTowerLineId,
towerKeyword,
towerTypeFilter,
towerRiskFilter,
towerViewMode,
towerQueryCurrent,
towerQueryPageSize,
]);
const linesQuery = useQuery({
queryKey: [lineListPath],
enabled: !!user && canRead,
queryFn: async () => {
const response = await fetchWithAuth(lineListPath);
if (!response.ok) {
throw new Error(await readApiError(response));
}
return (await response.json()) as LineListResponse;
},
});
const towersQuery = useQuery({
queryKey: [towerListPath],
enabled: !!user && !!activeTowerLineId && canRead,
queryFn: async () => {
if (!towerListPath) {
return { items: [], total: 0 } satisfies LineTowerListResponse;
}
const response = await fetchWithAuth(towerListPath);
if (!response.ok) {
throw new Error(await readApiError(response));
}
return (await response.json()) as LineTowerListResponse;
},
});
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 towerProfileQuery = useQuery({
queryKey: ["tower-profile", editingTowerProfileTower?.id],
enabled: !!user && !!editingTowerProfileTower && towerProfileModalOpen && canTowerRead,
queryFn: async () => {
const response = await fetchWithAuth(`/api/v1/tower-profiles/${editingTowerProfileTower?.id}`);
if (!response.ok) {
throw new Error(await readApiError(response));
}
return (await response.json()) as TowerProfileDetail;
},
});
const refreshLines = useCallback(async () => {
await queryClient.invalidateQueries({
predicate: (query) =>
Array.isArray(query.queryKey)
&& typeof query.queryKey[0] === "string"
&& query.queryKey[0].startsWith("/api/v1/lines"),
});
}, [queryClient]);
const refreshTowers = useCallback(async () => {
await queryClient.invalidateQueries({
predicate: (query) =>
Array.isArray(query.queryKey)
&& typeof query.queryKey[0] === "string"
&& query.queryKey[0].includes("/towers"),
});
}, [queryClient]);
useEffect(() => {
const profile = towerProfileQuery.data;
if (!profile) {
return;
}
towerProfileForm.setFieldsValue({
structure_kind: profile.structure_kind ?? "",
stroke_mode: profile.stroke_mode ?? "",
phase_sequence_1: profile.phase_sequence_1 ?? "",
phase_sequence_2: profile.phase_sequence_2 ?? "",
phase_sequence_3: profile.phase_sequence_3 ?? "",
phase_sequence_4: profile.phase_sequence_4 ?? "",
arrester_a: profile.arrester_a ?? "",
arrester_b: profile.arrester_b ?? "",
arrester_c: profile.arrester_c ?? "",
geometry_layers_json: JSON.stringify(profile.geometry_layers_json ?? {}, null, 2),
});
}, [towerProfileForm, towerProfileQuery.data]);
useTopicSubscription("admin.power-lines", useCallback(() => {
void refreshLines();
void refreshTowers();
}, [refreshLines, refreshTowers]));
useTopicSubscription("admin.tower-models", useCallback(() => {
void queryClient.invalidateQueries({ queryKey: ["/api/v1/tower-models/selector"] });
}, [queryClient]));
const lines = useMemo(() => linesQuery.data?.items ?? [], [linesQuery.data?.items]);
const towers = useMemo(() => towersQuery.data?.items ?? [], [towersQuery.data?.items]);
const towerModels = useMemo(() => towerModelOptionsQuery.data ?? [], [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 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 === effectiveSelectedLineId) ?? null,
[lines, effectiveSelectedLineId],
);
useEffect(() => {
if (selectedLineId !== effectiveSelectedLineId) {
const frameId = window.requestAnimationFrame(() => {
setSelectedLineId(effectiveSelectedLineId);
});
return () => {
window.cancelAnimationFrame(frameId);
};
}
}, [selectedLineId, effectiveSelectedLineId]);
const applyTowerModelDefaults = useCallback((modelCode: string | null | undefined) => {
if (!modelCode) {
return;
}
const matched = towerModels.find((item) => item.code === modelCode);
if (!matched) {
return;
}
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 ?? "",
});
}, [towerForm, towerModels]);
const saveLineMutation = useMutation({
mutationFn: async (values: LineFormValues) => {
if (!canLineManage) {
throw new Error("缺少 line.manage 权限");
}
const payload = {
name: values.name.trim(),
voltage_kv: values.voltage_level ? LINE_VOLTAGE_VALUE_TO_KV[values.voltage_level] : null,
};
if (editingLine) {
const response = await fetchWithAuth(`/api/v1/lines/${editingLine.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: payload.name,
voltage_kv: payload.voltage_kv,
}),
});
if (!response.ok) {
throw new Error(await readApiError(response));
}
return "updated" as const;
}
const response = await fetchWithAuth("/api/v1/lines", {
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" ? "线路已创建" : "线路已更新");
setLineModalOpen(false);
setEditingLine(null);
lineForm.resetFields();
await refreshLines();
},
onError: (candidate) => {
setError(candidate instanceof Error ? candidate.message : "保存线路失败");
},
});
const deleteLineMutation = useMutation({
mutationFn: async (lineId: string) => {
const response = await fetchWithAuth(`/api/v1/lines/${lineId}`, { method: "DELETE" });
if (!response.ok) {
throw new Error(await readApiError(response));
}
return lineId;
},
onMutate: (lineId) => {
if (effectiveSelectedLineId === lineId) {
setLineIdPendingDeletion(lineId);
}
},
onSuccess: async (lineId) => {
if (effectiveSelectedLineId === lineId) {
setSelectedLineTouched(false);
setSelectedLineId(null);
}
setError("");
messageApi.success("线路已删除");
await refreshLines();
},
onError: (candidate) => {
setError(candidate instanceof Error ? candidate.message : "删除线路失败");
},
onSettled: () => {
setLineIdPendingDeletion(null);
},
});
const saveTowerMutation = useMutation({
mutationFn: async (values: TowerFormValues) => {
if (!effectiveSelectedLineId) {
throw new Error("请先选择线路");
}
if (!canTowerManage) {
throw new Error("缺少 tower.manage 权限");
}
const payload = {
seq_no: Number(values.seq_no),
tower_no: values.tower_no.trim(),
tower_model: values.tower_model.trim() || null,
tower_type: values.tower_type.trim() || null,
longitude: values.longitude ?? null,
latitude: values.latitude ?? null,
altitude_m: values.altitude_m ?? null,
terrain: values.terrain.trim() || null,
ground_resistance_ohm: values.ground_resistance_ohm ?? null,
lightning_density: values.lightning_density ?? null,
span_small_m: values.span_small_m ?? null,
span_large_m: values.span_large_m ?? null,
slope_1: values.slope_1 ?? null,
slope_2: values.slope_2 ?? null,
risk_level: values.risk_level.trim() || null,
};
if (editingTower) {
const response = await fetchWithAuth(`/api/v1/lines/towers/${editingTower.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/lines/${effectiveSelectedLineId}/towers`, {
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" ? "杆塔已创建" : "杆塔已更新");
setTowerModalOpen(false);
setEditingTower(null);
towerForm.resetFields();
await refreshTowers();
await refreshLines();
},
onError: (candidate) => {
setError(candidate instanceof Error ? candidate.message : "保存杆塔失败");
},
});
const deleteTowerMutation = useMutation({
mutationFn: async (towerId: string) => {
const response = await fetchWithAuth(`/api/v1/lines/towers/${towerId}`, { method: "DELETE" });
if (!response.ok) {
throw new Error(await readApiError(response));
}
return towerId;
},
onSuccess: async () => {
setError("");
messageApi.success("杆塔已删除");
await refreshTowers();
await refreshLines();
},
onError: (candidate) => {
setError(candidate instanceof Error ? candidate.message : "删除杆塔失败");
},
});
const saveTowerProfileMutation = useMutation({
mutationFn: async (values: TowerProfileFormValues) => {
if (!editingTowerProfileTower) {
throw new Error("未选择杆塔");
}
const geometryLayers = values.geometry_layers_json.trim()
? JSON.parse(values.geometry_layers_json)
: {};
const response = await fetchWithAuth(`/api/v1/tower-profiles/${editingTowerProfileTower.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
structure_kind: values.structure_kind.trim() || null,
stroke_mode: values.stroke_mode.trim() || null,
phase_sequence_1: values.phase_sequence_1.trim() || null,
phase_sequence_2: values.phase_sequence_2.trim() || null,
phase_sequence_3: values.phase_sequence_3.trim() || null,
phase_sequence_4: values.phase_sequence_4.trim() || null,
arrester_a: values.arrester_a.trim() || null,
arrester_b: values.arrester_b.trim() || null,
arrester_c: values.arrester_c.trim() || null,
geometry_layers_json: geometryLayers,
}),
});
if (!response.ok) {
throw new Error(await readApiError(response));
}
return (await response.json()) as TowerProfileDetail;
},
onSuccess: async () => {
setError("");
messageApi.success("专业参数已保存");
setTowerProfileModalOpen(false);
setEditingTowerProfileTower(null);
towerProfileForm.resetFields();
await queryClient.invalidateQueries({ queryKey: ["tower-profile"] });
},
onError: (candidate) => {
setError(candidate instanceof Error ? candidate.message : "保存专业参数失败");
},
});
const importMutation = useMutation({
mutationFn: async (file: File) => {
if (!effectiveSelectedLineId) {
throw new Error("请先选择线路");
}
const formData = new FormData();
formData.append("file", file);
const response = await fetchWithAuth(`/api/v1/lines/${effectiveSelectedLineId}/towers/import`, {
method: "POST",
body: formData,
});
if (!response.ok) {
throw new Error(await readApiError(response));
}
return (await response.json()) as LineTowerImportResponse;
},
onSuccess: async (result) => {
setError("");
messageApi.success(
`导入完成:新增 ${result.imported_count} 条,更新 ${result.updated_count} 条,跳过 ${result.skipped_count} 条`,
);
await refreshLines();
await refreshTowers();
},
onError: (candidate) => {
setError(candidate instanceof Error ? candidate.message : "导入失败");
},
});
const exportMutation = useMutation({
mutationFn: async () => {
if (!effectiveSelectedLineId) {
throw new Error("请先选择线路");
}
const response = await fetchWithAuth(`/api/v1/lines/${effectiveSelectedLineId}/towers/export`);
if (!response.ok) {
throw new Error(await readApiError(response));
}
const blob = await response.blob();
const contentDisposition = response.headers.get("Content-Disposition") || "";
const matched = contentDisposition.match(/filename=\"([^\"]+)\"/i);
const filename = matched?.[1] ?? "towers_export.csv";
return { blob, filename };
},
onSuccess: ({ blob, filename }) => {
const url = window.URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
window.URL.revokeObjectURL(url);
setError("");
messageApi.success("导出成功");
},
onError: (candidate) => {
setError(candidate instanceof Error ? candidate.message : "导出失败");
},
});
const openCreateLineModal = () => {
setEditingLine(null);
lineForm.setFieldsValue(EMPTY_LINE_FORM);
setLineModalOpen(true);
};
const openEditLineModal = (line: LineSummary) => {
setEditingLine(line);
lineForm.setFieldsValue({
name: line.name,
voltage_level: resolveVoltageOptionFromKv(line.voltage_kv),
});
setLineModalOpen(true);
};
const openCreateTowerModal = () => {
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) => {
setEditingTower(item);
towerForm.setFieldsValue({
seq_no: item.seq_no,
tower_no: item.tower_no,
tower_model: item.tower_model ?? "",
tower_type: item.tower_type ?? "",
longitude: item.longitude,
latitude: item.latitude,
altitude_m: item.altitude_m,
terrain: item.terrain ?? "",
ground_resistance_ohm: item.ground_resistance_ohm,
lightning_density: item.lightning_density,
span_small_m: item.span_small_m,
span_large_m: item.span_large_m,
slope_1: item.slope_1,
slope_2: item.slope_2,
risk_level: item.risk_level ?? "",
});
setTowerModalOpen(true);
};
const openTowerProfileModal = (item: LineTowerSummary) => {
setEditingTowerProfileTower(item);
towerProfileForm.setFieldsValue(EMPTY_TOWER_PROFILE_FORM);
setTowerProfileModalOpen(true);
};
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>
</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_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 && (
<Button size="small" onClick={() => openTowerProfileModal(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>
),
},
];
const updatePanelBodyHeight = useCallback(() => {
if (typeof window === "undefined") {
return;
}
const anchor = panelScrollAnchorRef.current;
if (!anchor) {
return;
}
const anchorTop = anchor.getBoundingClientRect().top;
let nextHeight = Math.floor(window.innerHeight - anchorTop - POWER_LINES_PANEL_FALLBACK_RESERVE);
const rightCard = anchor.querySelector<HTMLElement>(".power-lines-right-card");
if (rightCard) {
const cardRect = rightCard.getBoundingClientRect();
const body = rightCard.querySelector<HTMLElement>(".ant-card-body");
const bodyHeight = body?.getBoundingClientRect().height ?? POWER_LINES_PANEL_MIN_HEIGHT;
const nonBodyHeight = Math.max(0, cardRect.height - bodyHeight);
const topGap = Math.max(0, cardRect.top - anchorTop);
nextHeight = Math.floor(
window.innerHeight - anchorTop - topGap - nonBodyHeight - POWER_LINES_PANEL_VIEWPORT_GAP,
);
}
const clampedHeight = Math.max(POWER_LINES_PANEL_MIN_HEIGHT, nextHeight);
setPanelBodyHeight((previous) => (Math.abs(previous - clampedHeight) <= 1 ? previous : clampedHeight));
}, []);
useEffect(() => {
const frameId = window.requestAnimationFrame(updatePanelBodyHeight);
return () => {
window.cancelAnimationFrame(frameId);
};
}, [
lineCards.length,
towerViewMode,
towers.length,
linesQuery.isFetching,
towersQuery.isFetching,
updatePanelBodyHeight,
]);
useEffect(() => {
if (typeof window === "undefined") {
return;
}
const onViewportChange = () => {
window.requestAnimationFrame(updatePanelBodyHeight);
};
window.addEventListener("resize", onViewportChange);
return () => {
window.removeEventListener("resize", onViewportChange);
};
}, [updatePanelBodyHeight]);
useEffect(() => {
if (typeof window === "undefined" || typeof ResizeObserver === "undefined") {
return;
}
const anchor = panelScrollAnchorRef.current;
if (!anchor) {
return;
}
const resizeObserver = new ResizeObserver(() => {
window.requestAnimationFrame(updatePanelBodyHeight);
});
resizeObserver.observe(anchor);
return () => {
resizeObserver.disconnect();
};
}, [updatePanelBodyHeight]);
const leftListHeight = Math.max(
180,
panelBodyHeight - POWER_LINES_FILTERS_ESTIMATE_HEIGHT - POWER_LINES_STATUS_ESTIMATE_HEIGHT - POWER_LINES_PANEL_BODY_GAP,
);
const rightContentHeight = Math.max(
220,
panelBodyHeight - POWER_LINES_MAP_HEADER_ESTIMATE_HEIGHT - POWER_LINES_PANEL_BODY_GAP,
);
const mapHeight = Math.max(POWER_LINES_MAP_MIN_HEIGHT, rightContentHeight - 32);
const towerTableScrollY = Math.max(POWER_LINES_TABLE_MIN_SCROLL_Y, rightContentHeight - 54);
if (initializing || linesQuery.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">你没有访问该页面的权限(需要 `line.read` `tower.read`)。</Typography.Text>
<Button>
<Link href="/">返回首页</Link>
</Button>
</Space>
</Card>
);
}
const lineError = linesQuery.error instanceof Error ? linesQuery.error.message : "";
const towerError = towersQuery.error instanceof Error ? towersQuery.error.message : "";
return (
<>
<Space direction="vertical" size={16} className="w-full">
{(error || lineError || towerError) && (
<Alert type="error" showIcon message="操作失败" description={error || lineError || towerError} />
)}
<div
ref={panelScrollAnchorRef}
className="grid gap-4 xl:grid-cols-[360px_minmax(0,1fr)]"
style={{ "--admin-power-lines-panel-body-height": `${panelBodyHeight}px` } as CSSProperties}
>
<Card
title="线路管理"
className="power-lines-left-card"
styles={{ body: { height: panelBodyHeight, overflow: "hidden" } }}
extra={canLineManage ? (
<Button type="primary" onClick={openCreateLineModal}>
新建线路
</Button>
) : null}
>
<Space direction="vertical" size={12} className="w-full">
<Typography.Text type="secondary">
左侧选择线路,右侧查看线路分布图或塔杆列表。
</Typography.Text>
<Input
value={keyword}
allowClear
onChange={(event) => setKeyword(event.target.value)}
placeholder="按线路编码/名称筛选"
/>
<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} />
) : (
lineCards
)}
</Space>
</Space>
</Card>
<Card
className="power-lines-right-card"
styles={{ body: { height: panelBodyHeight, overflow: "hidden" } }}
title={selectedLine ? `${selectedLine.name} - 杆塔管理` : "杆塔管理"}
extra={(
<Space size={8} wrap>
<Segmented
value={towerViewMode}
options={[
{ label: "分布图", value: "map" },
{ label: "塔杆列表", value: "table" },
]}
onChange={(value) => setTowerViewMode(value as "table" | "map")}
disabled={!effectiveSelectedLineId}
/>
{canTowerManage && (
<Button
onClick={() => importInputRef.current?.click()}
loading={importMutation.isPending}
disabled={!effectiveSelectedLineId}
>
导入 CSV
</Button>
)}
<input
ref={importInputRef}
type="file"
accept=".csv,text/csv"
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0];
if (file) {
importMutation.mutate(file);
}
event.target.value = "";
}}
/>
<Button onClick={() => exportMutation.mutate()} loading={exportMutation.isPending} disabled={!effectiveSelectedLineId}>
导出 CSV
</Button>
{canTowerManage && (
<Button type="primary" onClick={openCreateTowerModal} disabled={!effectiveSelectedLineId}>
新建杆塔
</Button>
)}
</Space>
)}
>
{!effectiveSelectedLineId || !selectedLine ? (
<Empty description={effectiveSelectedLineId ? "所选线路不存在,请重新选择" : "请先选择一条线路"} />
) : (
<Space direction="vertical" size={12} className="w-full">
<Typography.Text type="secondary">
当前线路编码:{selectedLine.code},杆塔总数:{selectedLine.tower_count ?? 0},当前视图:{towerViewMode === "table" ? "塔杆列表" : "分布图"}
</Typography.Text>
<div className="grid gap-3 md:grid-cols-3">
<Input
value={towerKeyword}
allowClear
onChange={(event) => setTowerKeyword(event.target.value)}
placeholder="按塔号/模型筛选"
/>
<Select
value={towerTypeFilter}
options={[...TOWER_TYPE_OPTIONS]}
onChange={(value) => setTowerTypeFilter(value)}
/>
<Input
value={towerRiskFilter}
allowClear
onChange={(event) => setTowerRiskFilter(event.target.value)}
placeholder="按风险等级筛选"
/>
</div>
<div className="relative overflow-y-auto" style={{ height: rightContentHeight }}>
<div
aria-hidden={towerViewMode !== "map"}
className={`transition-all duration-300 ease-out motion-reduce:transition-none ${
towerViewMode === "map"
? "relative translate-y-0 opacity-100"
: "pointer-events-none absolute inset-0 translate-y-1 opacity-0"
}`}
>
<PowerLineCesiumMap
lineCode={selectedLine.code}
lineName={selectedLine.name}
towers={towers}
loading={towersQuery.isFetching}
height={mapHeight}
/>
</div>
<div
aria-hidden={towerViewMode !== "table"}
className={`transition-all duration-300 ease-out motion-reduce:transition-none ${
towerViewMode === "table"
? "relative translate-y-0 opacity-100"
: "pointer-events-none absolute inset-0 -translate-y-1 opacity-0"
}`}
>
<Table<LineTowerSummary>
rowKey={(row) => row.id}
columns={towerColumns}
dataSource={towers}
loading={towersQuery.isFetching}
pagination={{
current: effectiveTowerPageCurrent,
pageSize: towerPagination.pageSize,
total: towersQuery.data?.total ?? 0,
showSizeChanger: true,
showTotal: (total) => `共 ${total} 条`,
onChange: (page, pageSize) => {
setTowerPagination({ current: page, pageSize });
},
}}
scroll={{ x: 1520, y: towerTableScrollY }}
/>
</div>
</div>
</Space>
)}
</Card>
</div>
</Space>
<Modal
title={editingLine ? "编辑线路" : "新建线路"}
open={lineModalOpen}
okText={editingLine ? "保存" : "创建"}
confirmLoading={saveLineMutation.isPending}
onCancel={() => {
if (saveLineMutation.isPending) return;
setLineModalOpen(false);
}}
onOk={async () => {
const values = await lineForm.validateFields();
saveLineMutation.mutate(values);
}}
>
<Form<LineFormValues> form={lineForm} layout="vertical" initialValues={EMPTY_LINE_FORM}>
{!editingLine ? (
<Alert
showIcon
type="info"
className="mb-4"
message="线路编码将由系统自动生成"
/>
) : null}
{editingLine ? (
<Form.Item label="线路编码">
<Input value={editingLine.code} disabled />
</Form.Item>
) : null}
<Form.Item
name="name"
label="线路名称"
rules={[{ required: true, message: "请输入线路名称" }]}
>
<Input />
</Form.Item>
<Form.Item name="voltage_level" label="电压等级">
<Select
allowClear
placeholder="请选择电压等级"
options={[...LINE_VOLTAGE_OPTIONS].map((item) => ({ value: item.value, label: item.label }))}
/>
</Form.Item>
</Form>
</Modal>
<Modal
title={editingTower ? "编辑杆塔" : "新建杆塔"}
open={towerModalOpen}
width={860}
okText={editingTower ? "保存" : "创建"}
confirmLoading={saveTowerMutation.isPending}
onCancel={() => {
if (saveTowerMutation.isPending) return;
setTowerModalOpen(false);
}}
onOk={async () => {
const values = await towerForm.validateFields();
saveTowerMutation.mutate(values);
}}
>
<Form<TowerFormValues> form={towerForm} layout="vertical" initialValues={EMPTY_TOWER_FORM}>
<div className="grid gap-3 md:grid-cols-2">
<Form.Item name="seq_no" label="序号" rules={[{ required: true, message: "请输入序号" }]}>
<InputNumber min={1} max={1000000} className="w-full" />
</Form.Item>
<Form.Item name="tower_no" label="塔号" rules={[{ required: true, message: "请输入塔号" }]}>
<Input />
</Form.Item>
<Form.Item name="tower_model" label="杆塔模型">
<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
options={[
{ value: "", label: "未设置" },
{ value: "直线", label: "直线" },
{ value: "耐张", label: "耐张" },
]}
/>
</Form.Item>
<Form.Item name="longitude" label="经度">
<InputNumber className="w-full" precision={8} />
</Form.Item>
<Form.Item name="latitude" label="纬度">
<InputNumber className="w-full" precision={8} />
</Form.Item>
<Form.Item name="altitude_m" label="海拔(m)">
<InputNumber className="w-full" precision={4} />
</Form.Item>
<Form.Item name="terrain" label="地形">
<Input />
</Form.Item>
<Form.Item name="ground_resistance_ohm" label="接地电阻(Ω)">
<InputNumber className="w-full" precision={4} />
</Form.Item>
<Form.Item name="lightning_density" label="地闪密度">
<InputNumber className="w-full" precision={8} />
</Form.Item>
<Form.Item name="span_small_m" label="小号侧档距(m)">
<InputNumber className="w-full" precision={4} />
</Form.Item>
<Form.Item name="span_large_m" label="大号侧档距(m)">
<InputNumber className="w-full" precision={4} />
</Form.Item>
<Form.Item name="slope_1" label="地面倾角1">
<InputNumber className="w-full" precision={8} />
</Form.Item>
<Form.Item name="slope_2" label="地面倾角2">
<InputNumber className="w-full" precision={8} />
</Form.Item>
<Form.Item name="risk_level" label="风险等级">
<Input />
</Form.Item>
</div>
</Form>
</Modal>
<Modal
title={editingTowerProfileTower ? `专业参数 - ${editingTowerProfileTower.tower_no}` : "专业参数"}
open={towerProfileModalOpen}
width={920}
okText="保存"
confirmLoading={saveTowerProfileMutation.isPending}
onCancel={() => {
if (saveTowerProfileMutation.isPending) return;
setTowerProfileModalOpen(false);
setEditingTowerProfileTower(null);
}}
onOk={async () => {
const values = await towerProfileForm.validateFields();
saveTowerProfileMutation.mutate(values);
}}
>
<Form<TowerProfileFormValues> form={towerProfileForm} layout="vertical" initialValues={EMPTY_TOWER_PROFILE_FORM}>
<div className="grid gap-3 md:grid-cols-2">
<Form.Item name="structure_kind" label="直线/耐张">
<Input />
</Form.Item>
<Form.Item name="stroke_mode" label="绕击/反击模式">
<Input />
</Form.Item>
<Form.Item name="phase_sequence_1" label="I回相序">
<Input />
</Form.Item>
<Form.Item name="phase_sequence_2" label="II回相序">
<Input />
</Form.Item>
<Form.Item name="phase_sequence_3" label="III回相序">
<Input />
</Form.Item>
<Form.Item name="phase_sequence_4" label="IV回相序">
<Input />
</Form.Item>
<Form.Item name="arrester_a" label="A相避雷器">
<Input />
</Form.Item>
<Form.Item name="arrester_b" label="B相避雷器">
<Input />
</Form.Item>
<Form.Item name="arrester_c" label="C相避雷器">
<Input />
</Form.Item>
</div>
<Form.Item
name="geometry_layers_json"
label="回路几何 JSON"
rules={[{
validator: async (_, value) => {
if (!value || !String(value).trim()) {
return;
}
JSON.parse(String(value));
},
message: "请输入合法 JSON",
}]}
>
<Input.TextArea rows={12} spellCheck={false} />
</Form.Item>
</Form>
</Modal>
</>
);
}