更新属性面板为可拖动,优化工具栏激活逻辑

This commit is contained in:
2026-04-27 11:56:56 +08:00
parent a1442fc062
commit 60181dba54
5 changed files with 268 additions and 239 deletions
+1 -1
View File
@@ -169,7 +169,7 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
{ {
name: "Hydraulic Simulation", name: "Hydraulic Simulation",
meta: { meta: {
icon: <MdWater className="w-6 h-6" />, // icon: <MdWater className="w-6 h-6" />,
label: "事件模拟", label: "事件模拟",
}, },
}, },
@@ -986,7 +986,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
<Box <Box
className="absolute top-20 right-4 bg-white shadow-2xl rounded-lg cursor-pointer hover:shadow-xl transition-all duration-300 opacity-95 hover:opacity-100" className="absolute top-20 right-4 bg-white shadow-2xl rounded-lg cursor-pointer hover:shadow-xl transition-all duration-300 opacity-95 hover:opacity-100"
onClick={() => setIsExpanded(true)} onClick={() => setIsExpanded(true)}
sx={{ zIndex: 1300 }} sx={{ zIndex: 1290 }}
> >
<Box className="flex flex-col items-center py-3 px-3 gap-1"> <Box className="flex flex-col items-center py-3 px-3 gap-1">
<ShowChart className="text-[#257DD4] w-5 h-5" /> <ShowChart className="text-[#257DD4] w-5 h-5" />
@@ -129,14 +129,17 @@ const fetchFromBackend = async (
"raw" "raw"
); );
} else if (type === "scheme") { } else if (type === "scheme") {
// 查询策略模拟值、清洗值和监测值 // 查询策略模拟值、实时模拟值、清洗值和监测值
const [cleanedRes, rawRes, schemeSimRes] = await Promise.all([ const [cleanedRes, rawRes, simulationRes, schemeSimRes] = await Promise.all([
apiFetch(cleanedDataUrl) apiFetch(cleanedDataUrl)
.then((r) => (r.ok ? r.json() : null)) .then((r) => (r.ok ? r.json() : null))
.catch(() => null), .catch(() => null),
apiFetch(rawDataUrl) apiFetch(rawDataUrl)
.then((r) => (r.ok ? r.json() : null)) .then((r) => (r.ok ? r.json() : null))
.catch(() => null), .catch(() => null),
apiFetch(simulationDataUrl)
.then((r) => (r.ok ? r.json() : null))
.catch(() => null),
apiFetch(schemeSimulationDataUrl) apiFetch(schemeSimulationDataUrl)
.then((r) => (r.ok ? r.json() : null)) .then((r) => (r.ok ? r.json() : null))
.catch(() => null), .catch(() => null),
@@ -146,40 +149,18 @@ const fetchFromBackend = async (
// 如果清洗数据有值,则不显示原始监测值 // 如果清洗数据有值,则不显示原始监测值
const rawData = const rawData =
cleanedData.length > 0 ? [] : transformBackendData(rawRes, featureIds); cleanedData.length > 0 ? [] : transformBackendData(rawRes, featureIds);
const simulationData = transformBackendData(simulationRes, featureIds);
const schemeSimData = transformBackendData(schemeSimRes, featureIds); const schemeSimData = transformBackendData(schemeSimRes, featureIds);
// 合并三组数据 return mergeMultipleTimeSeriesData(
const timeMap = new Map<string, Record<string, number | null>>(); [
{ data: cleanedData, suffix: "clean" },
[cleanedData, rawData, schemeSimData].forEach((data, index) => { { data: rawData, suffix: "raw" },
const suffix = ["clean", "raw", "scheme_sim"][index]; { data: simulationData, suffix: "sim" },
data.forEach((point) => { { data: schemeSimData, suffix: "scheme_sim" },
if (!timeMap.has(point.timestamp)) { ],
timeMap.set(point.timestamp, {}); featureIds
}
const values = timeMap.get(point.timestamp)!;
featureIds.forEach((deviceId) => {
const value = point.values[deviceId];
if (value !== undefined) {
values[`${deviceId}_${suffix}`] = value;
}
});
});
});
const result = Array.from(timeMap.entries()).map(
([timestamp, values]) => ({
timestamp,
values,
})
); );
result.sort(
(a, b) =>
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
);
return result;
} else { } else {
// realtime: 查询模拟值、清洗值和监测值 // realtime: 查询模拟值、清洗值和监测值
const [cleanedRes, rawRes, simulationRes] = await Promise.all([ const [cleanedRes, rawRes, simulationRes] = await Promise.all([
@@ -336,6 +317,42 @@ const mergeTimeSeriesData = (
return result; return result;
}; };
const mergeMultipleTimeSeriesData = (
datasets: Array<{
data: TimeSeriesPoint[];
suffix: string;
}>,
deviceIds: string[]
): TimeSeriesPoint[] => {
const timeMap = new Map<string, Record<string, number | null>>();
datasets.forEach(({ data, suffix }) => {
data.forEach((point) => {
if (!timeMap.has(point.timestamp)) {
timeMap.set(point.timestamp, {});
}
const values = timeMap.get(point.timestamp)!;
deviceIds.forEach((deviceId) => {
const value = point.values[deviceId];
if (value !== undefined) {
values[`${deviceId}_${suffix}`] = value;
}
});
});
});
const result = Array.from(timeMap.entries()).map(([timestamp, values]) => ({
timestamp,
values,
}));
result.sort(
(a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
);
return result;
};
const formatTimestamp = (timestamp: string) => const formatTimestamp = (timestamp: string) =>
dayjs(timestamp).tz("Asia/Shanghai").format("YYYY-MM-DD HH:mm"); dayjs(timestamp).tz("Asia/Shanghai").format("YYYY-MM-DD HH:mm");
@@ -537,7 +554,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
const suffixes = [ const suffixes = [
{ key: "clean", name: "清洗值" }, { key: "clean", name: "清洗值" },
{ key: "raw", name: "监测值" }, { key: "raw", name: "监测值" },
{ key: "sim", name: "模拟值" }, { key: "sim", name: "实时模拟值" },
{ key: "scheme_sim", name: "方案模拟值" }, { key: "scheme_sim", name: "方案模拟值" },
]; ];
@@ -643,32 +660,46 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
: suffix === "raw" : suffix === "raw"
? "监测值" ? "监测值"
: suffix === "sim" : suffix === "sim"
? "模拟" ? "实时模拟"
: "方案模拟"; : "方案模拟";
series.push({ series.push({
name: `${id} (${displayName})`, name: `${id} (${displayName})`,
type: "line", type: "line",
symbol: "none", symbol:
suffix === "clean"
? "circle"
: suffix === "raw"
? "diamond"
: "none",
symbolSize: suffix === "clean" || suffix === "raw" ? 7 : 0,
showSymbol: suffix === "clean" || suffix === "raw",
sampling: "lttb", sampling: "lttb",
connectNulls: true, connectNulls: suffix !== "clean" && suffix !== "raw",
itemStyle: { itemStyle: {
color: colors[(index * 4 + sIndex) % colors.length], color: colors[(index * 4 + sIndex) % colors.length],
}, },
data: dataset.map((item) => item[key]), data: dataset.map((item) => item[key]),
areaStyle: { lineStyle:
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [ suffix === "clean" || suffix === "raw"
{ ? { width: 0 }
offset: 0, : undefined,
color: colors[(index * 4 + sIndex) % colors.length], areaStyle:
}, suffix === "clean" || suffix === "raw"
{ ? undefined
offset: 1, : {
color: "rgba(255, 255, 255, 0)", color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
}, {
]), offset: 0,
opacity: 0.3, color: colors[(index * 4 + sIndex) % colors.length],
}, },
{
offset: 1,
color: "rgba(255, 255, 255, 0)",
},
]),
opacity: 0.3,
},
}); });
} }
}); });
@@ -840,7 +871,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
border: "none", border: "none",
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
zIndex: 1300, zIndex: 1290,
backgroundColor: "white", backgroundColor: "white",
overflow: "hidden", overflow: "hidden",
"&:hover": { "&:hover": {
@@ -1,4 +1,7 @@
import React from "react"; "use client";
import React, { useRef } from "react";
import Draggable from "react-draggable";
interface BaseProperty { interface BaseProperty {
label: string; label: string;
@@ -28,6 +31,8 @@ const PropertyPanel: React.FC<PropertyPanelProps> = ({
type = "未知类型", type = "未知类型",
properties = [], properties = [],
}) => { }) => {
const draggableRef = useRef<HTMLDivElement>(null);
const formatValue = (property: BaseProperty) => { const formatValue = (property: BaseProperty) => {
if (property.formatter) { if (property.formatter) {
return property.formatter(property.value); return property.formatter(property.value);
@@ -50,162 +55,16 @@ const PropertyPanel: React.FC<PropertyPanelProps> = ({
: 0; : 0;
return ( return (
<div className="absolute top-4 right-4 bg-white shadow-2xl rounded-xl overflow-hidden w-96 max-h-[850px] flex flex-col backdrop-blur-sm z-1300 opacity-95 hover:opacity-100 transition-all duration-300 "> <Draggable nodeRef={draggableRef} handle=".drag-handle">
{/* 头部 */} <div
<div className="flex justify-between items-center px-5 py-4 bg-[#257DD4] text-white"> ref={draggableRef}
<div className="flex items-center gap-2"> className="absolute top-4 right-4 bg-white shadow-2xl rounded-xl overflow-hidden w-96 max-h-[850px] flex flex-col backdrop-blur-sm z-1300 opacity-95 hover:opacity-100"
<svg >
className="w-5 h-5" {/* 头部 */}
fill="none" <div className="drag-handle flex justify-between items-center px-5 py-4 bg-[#257DD4] text-white cursor-move select-none">
stroke="currentColor" <div className="flex items-center gap-2">
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
<h3 className="text-lg font-semibold"></h3>
</div>
</div>
{/* 内容区域 */}
<div className="flex-1 overflow-y-auto px-4 py-3">
{!id ? (
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
<svg <svg
className="w-16 h-16 mb-3" className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"
/>
</svg>
<p className="text-sm"></p>
<p className="text-xs mt-1"></p>
</div>
) : (
<div className="space-y-2">
{/* ID 属性 */}
<div className="group rounded-lg p-3 transition-all duration-200 bg-blue-50 hover:bg-blue-100 border-l-4 border-blue-500">
<div className="flex justify-between items-start gap-3">
<span className="font-medium text-xs uppercase tracking-wide text-blue-700">
ID
</span>
<span className="text-sm font-semibold text-right flex-1 text-blue-900">
{id}
</span>
</div>
</div>
{/* 类型属性 */}
<div className="group rounded-lg p-3 transition-all duration-200 bg-blue-50 hover:bg-blue-100 border-l-4 border-blue-500">
<div className="flex justify-between items-start gap-3">
<span className="font-medium text-xs uppercase tracking-wide text-blue-700">
</span>
<span className="text-sm font-semibold text-right flex-1 text-blue-900">
{type}
</span>
</div>
</div>
{/* 其他属性(包含二级表格) */}
{properties.map((property, index) => {
// 二级表格
if ("type" in property && property.type === "table") {
return (
<div
key={`table-${index}`}
className="group rounded-lg p-3 transition-all duration-200 bg-gray-50 hover:bg-gray-100"
>
<div className="flex justify-between items-start gap-3">
<span className="font-medium text-xs uppercase tracking-wide text-gray-600">
{property.label}
</span>
</div>
<div className="ml-4 mt-2 border border-gray-300 rounded-md overflow-hidden shadow-sm">
<table className="w-full text-xs">
<thead className="bg-gray-200 text-gray-700">
<tr>
{property.columns.map((col, ci) => (
<th
key={ci}
className="px-3 py-2 text-left font-semibold"
>
{col}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-gray-300">
{property.rows.map((row, ri) => (
<tr key={ri} className="bg-white hover:bg-gray-50">
{row.map((cell, cci) => (
<td
key={cci}
className="px-3 py-2 text-gray-800"
>
{cell}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
// 普通属性
const base = property as BaseProperty;
const isImportant = isImportantKeys.includes(base.label);
return (
<div
key={`prop-${index}`}
className={`group rounded-lg p-3 transition-all duration-200 ${
isImportant
? "bg-blue-50 hover:bg-blue-100 border-l-4 border-blue-500"
: "bg-gray-50 hover:bg-gray-100"
}`}
>
<div className="flex justify-between items-start gap-3">
<span
className={`font-medium text-xs uppercase tracking-wide ${
isImportant ? "text-blue-700" : "text-gray-600"
}`}
>
{base.label}
</span>
<span
className={`text-sm font-semibold text-right flex-1 ${
isImportant ? "text-blue-900" : "text-gray-800"
}`}
>
{formatValue(base)}
</span>
</div>
</div>
);
})}
</div>
)}
</div>
{/* 底部统计区域 */}
<div className="px-5 py-3 bg-gray-50 border-t border-gray-200">
<div className="flex items-center justify-between text-xs">
<span className="text-gray-600 flex items-center gap-1">
<svg
className="w-4 h-4"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@@ -214,20 +73,172 @@ const PropertyPanel: React.FC<PropertyPanelProps> = ({
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth={2} strokeWidth={2}
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/> />
</svg> </svg>
{totalProps} <h3 className="text-lg font-semibold"></h3>
</span> </div>
{id && ( </div>
<span className="text-green-600 flex items-center gap-1 font-medium">
<span className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></span> {/* 内容区域 */}
<div className="flex-1 overflow-y-auto px-4 py-3">
</span> {!id ? (
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
<svg
className="w-16 h-16 mb-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"
/>
</svg>
<p className="text-sm"></p>
<p className="text-xs mt-1"></p>
</div>
) : (
<div className="space-y-2">
{/* ID 属性 */}
<div className="group rounded-lg p-3 transition-all duration-200 bg-blue-50 hover:bg-blue-100 border-l-4 border-blue-500">
<div className="flex justify-between items-start gap-3">
<span className="font-medium text-xs uppercase tracking-wide text-blue-700">
ID
</span>
<span className="text-sm font-semibold text-right flex-1 text-blue-900">
{id}
</span>
</div>
</div>
{/* 类型属性 */}
<div className="group rounded-lg p-3 transition-all duration-200 bg-blue-50 hover:bg-blue-100 border-l-4 border-blue-500">
<div className="flex justify-between items-start gap-3">
<span className="font-medium text-xs uppercase tracking-wide text-blue-700">
</span>
<span className="text-sm font-semibold text-right flex-1 text-blue-900">
{type}
</span>
</div>
</div>
{/* 其他属性(包含二级表格) */}
{properties.map((property, index) => {
// 二级表格
if ("type" in property && property.type === "table") {
return (
<div
key={`table-${index}`}
className="group rounded-lg p-3 transition-all duration-200 bg-gray-50 hover:bg-gray-100"
>
<div className="flex justify-between items-start gap-3">
<span className="font-medium text-xs uppercase tracking-wide text-gray-600">
{property.label}
</span>
</div>
<div className="ml-4 mt-2 border border-gray-300 rounded-md overflow-hidden shadow-sm">
<table className="w-full text-xs">
<thead className="bg-gray-200 text-gray-700">
<tr>
{property.columns.map((col, ci) => (
<th
key={ci}
className="px-3 py-2 text-left font-semibold"
>
{col}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-gray-300">
{property.rows.map((row, ri) => (
<tr key={ri} className="bg-white hover:bg-gray-50">
{row.map((cell, cci) => (
<td
key={cci}
className="px-3 py-2 text-gray-800"
>
{cell}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
// 普通属性
const base = property as BaseProperty;
const isImportant = isImportantKeys.includes(base.label);
return (
<div
key={`prop-${index}`}
className={`group rounded-lg p-3 transition-all duration-200 ${
isImportant
? "bg-blue-50 hover:bg-blue-100 border-l-4 border-blue-500"
: "bg-gray-50 hover:bg-gray-100"
}`}
>
<div className="flex justify-between items-start gap-3">
<span
className={`font-medium text-xs uppercase tracking-wide ${
isImportant ? "text-blue-700" : "text-gray-600"
}`}
>
{base.label}
</span>
<span
className={`text-sm font-semibold text-right flex-1 ${
isImportant ? "text-blue-900" : "text-gray-800"
}`}
>
{formatValue(base)}
</span>
</div>
</div>
);
})}
</div>
)} )}
</div>
{/* 底部统计区域 */}
<div className="px-5 py-3 bg-gray-50 border-t border-gray-200">
<div className="flex items-center justify-between text-xs">
<span className="text-gray-600 flex items-center gap-1">
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
/>
</svg>
{totalProps}
</span>
{id && (
<span className="text-green-600 flex items-center gap-1 font-medium">
<span className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></span>
</span>
)}
</div>
</div> </div>
</div> </div>
</div> </Draggable>
); );
}; };
+8 -21
View File
@@ -402,15 +402,8 @@ const Toolbar: React.FC<ToolbarProps> = ({
deactivateTool(tool); deactivateTool(tool);
setActiveTools((prev) => prev.filter((t) => t !== tool)); setActiveTools((prev) => prev.filter((t) => t !== tool));
} else { } else {
// 如果当前工具未激活,先关闭所有其他工具,然后激活当前工具 // 如果当前工具未激活,保留其他已打开工具,仅新增当前工具
// 关闭所有面板(但保持样式编辑器状态) setActiveTools((prev) => [...prev, tool]);
closeAllPanelsExceptStyle();
// 取消激活所有非样式工具
setActiveTools((prev) => {
const styleActive = prev.includes("style");
return styleActive ? ["style", tool] : [tool];
});
// 激活当前工具并打开对应面板 // 激活当前工具并打开对应面板
activateTool(tool); activateTool(tool);
@@ -422,14 +415,18 @@ const Toolbar: React.FC<ToolbarProps> = ({
switch (tool) { switch (tool) {
case "info": case "info":
setShowPropertyPanel(false); setShowPropertyPanel(false);
setHighlightFeatures([]); if (!activeTools.includes("history")) {
setHighlightFeatures([]);
}
break; break;
case "draw": case "draw":
setShowDrawPanel(false); setShowDrawPanel(false);
break; break;
case "history": case "history":
setShowHistoryPanel(false); setShowHistoryPanel(false);
setHighlightFeatures([]); if (!activeTools.includes("info")) {
setHighlightFeatures([]);
}
setChatPanelFeatureInfos(null); setChatPanelFeatureInfos(null);
setChatPanelTimeRange(null); setChatPanelTimeRange(null);
break; break;
@@ -452,16 +449,6 @@ const Toolbar: React.FC<ToolbarProps> = ({
} }
}; };
// 关闭所有面板(除了样式编辑器)
const closeAllPanelsExceptStyle = () => {
setShowPropertyPanel(false);
setHighlightFeatures([]);
setShowDrawPanel(false);
setShowHistoryPanel(false);
setChatPanelFeatureInfos(null);
setChatPanelTimeRange(null);
// 样式编辑器保持其当前状态,不自动关闭
};
const [computedProperties, setComputedProperties] = useState< const [computedProperties, setComputedProperties] = useState<
Record<string, any> Record<string, any>
>({}); >({});