Compare commits
2
Commits
master
...
v2026.08.26.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0dad61ff1f | ||
|
|
fee4fc6ce1 |
+3
-3
@@ -7,8 +7,8 @@ NEXTAUTH_URL="https://frontend.example.com/"
|
|||||||
BACKEND_URL="https://server.example.com"
|
BACKEND_URL="https://server.example.com"
|
||||||
AGENT_URL="https://agent.example.com"
|
AGENT_URL="https://agent.example.com"
|
||||||
MAP_URL="https://geoserver.example.com/geoserver"
|
MAP_URL="https://geoserver.example.com/geoserver"
|
||||||
MAP_WORKSPACE="tjwater"
|
MAP_WORKSPACE="tjwater_next"
|
||||||
MAP_EXTENT="13490131,3630016,13525879,3666968.25"
|
MAP_EXTENT="13508801.93,3608163.35,13555650.64,3633685.14"
|
||||||
NETWORK_NAME="tjwater"
|
NETWORK_NAME="tjwater_next"
|
||||||
MAPBOX_TOKEN="replace-with-public-mapbox-token"
|
MAPBOX_TOKEN="replace-with-public-mapbox-token"
|
||||||
TIANDITU_TOKEN="replace-with-public-tianditu-token"
|
TIANDITU_TOKEN="replace-with-public-tianditu-token"
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
},
|
},
|
||||||
"server": {
|
"server": {
|
||||||
"file": "server-v1.openapi.json",
|
"file": "server-v1.openapi.json",
|
||||||
"sha256": "ac9b6fac185dfd999f1791cba51eb482df17a427b361963250aafa5fb1a276b4"
|
"sha256": "404a196c0177faed2aa5b46ee86430a034dfe990e0a77a43428a727748a882b6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1347
-11732
File diff suppressed because it is too large
Load Diff
@@ -9,22 +9,22 @@ loadEnvConfig(projectDir, process.env.NODE_ENV !== "production");
|
|||||||
|
|
||||||
const parseExtent = (value) => {
|
const parseExtent = (value) => {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return [13508849, 3608036, 13555781, 3633813];
|
return [13508801.93, 3608163.35, 13555650.64, 3633685.14];
|
||||||
}
|
}
|
||||||
|
|
||||||
const extent = value.split(",").map(Number);
|
const extent = value.split(",").map(Number);
|
||||||
return extent.length === 4 && extent.every(Number.isFinite)
|
return extent.length === 4 && extent.every(Number.isFinite)
|
||||||
? extent
|
? extent
|
||||||
: [13508849, 3608036, 13555781, 3633813];
|
: [13508801.93, 3608163.35, 13555650.64, 3633685.14];
|
||||||
};
|
};
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
BACKEND_URL: process.env.BACKEND_URL || "http://127.0.0.1:8000",
|
BACKEND_URL: process.env.BACKEND_URL || "http://127.0.0.1:8000",
|
||||||
AGENT_URL: process.env.AGENT_URL || "http://127.0.0.1:8788",
|
AGENT_URL: process.env.AGENT_URL || "http://127.0.0.1:8788",
|
||||||
MAP_URL: process.env.MAP_URL || "http://127.0.0.1:8080/geoserver",
|
MAP_URL: process.env.MAP_URL || "http://127.0.0.1:8080/geoserver",
|
||||||
MAP_WORKSPACE: process.env.MAP_WORKSPACE || "tjwater",
|
MAP_WORKSPACE: process.env.MAP_WORKSPACE || "tjwater_next",
|
||||||
MAP_EXTENT: parseExtent(process.env.MAP_EXTENT),
|
MAP_EXTENT: parseExtent(process.env.MAP_EXTENT),
|
||||||
NETWORK_NAME: process.env.NETWORK_NAME || "tjwater",
|
NETWORK_NAME: process.env.NETWORK_NAME || "tjwater_next",
|
||||||
MAPBOX_TOKEN: process.env.MAPBOX_TOKEN || "",
|
MAPBOX_TOKEN: process.env.MAPBOX_TOKEN || "",
|
||||||
TIANDITU_TOKEN: process.env.TIANDITU_TOKEN || "",
|
TIANDITU_TOKEN: process.env.TIANDITU_TOKEN || "",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,384 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Collapse,
|
||||||
|
IconButton,
|
||||||
|
LinearProgress,
|
||||||
|
Stack,
|
||||||
|
Typography,
|
||||||
|
alpha,
|
||||||
|
useMediaQuery,
|
||||||
|
useTheme,
|
||||||
|
} from "@mui/material";
|
||||||
|
import AutoAwesomeRounded from "@mui/icons-material/AutoAwesomeRounded";
|
||||||
|
import CheckCircleRounded from "@mui/icons-material/CheckCircleRounded";
|
||||||
|
import ErrorOutlineRounded from "@mui/icons-material/ErrorOutlineRounded";
|
||||||
|
import KeyboardArrowDownRounded from "@mui/icons-material/KeyboardArrowDownRounded";
|
||||||
|
import KeyboardArrowUpRounded from "@mui/icons-material/KeyboardArrowUpRounded";
|
||||||
|
import RadioButtonUncheckedRounded from "@mui/icons-material/RadioButtonUncheckedRounded";
|
||||||
|
import StopCircleRounded from "@mui/icons-material/StopCircleRounded";
|
||||||
|
|
||||||
|
import type { AgentActivity, AgentActivityAction } from "@/lib/chatStream";
|
||||||
|
|
||||||
|
const activityAccent = "#0097a7";
|
||||||
|
|
||||||
|
type TimedActivityItem = {
|
||||||
|
status: "running" | "completed" | "error" | "cancelled";
|
||||||
|
startedAt: number;
|
||||||
|
endedAt?: number;
|
||||||
|
elapsedMs?: number;
|
||||||
|
elapsedSnapshotAt?: number;
|
||||||
|
durationMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDuration = (durationMs: number | undefined) => {
|
||||||
|
if (durationMs === undefined || !Number.isFinite(durationMs)) return undefined;
|
||||||
|
if (durationMs < 10_000) return `${(durationMs / 1000).toFixed(1)}s`;
|
||||||
|
const seconds = Math.round(durationMs / 1000);
|
||||||
|
return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getElapsedMs = (
|
||||||
|
item: TimedActivityItem,
|
||||||
|
now: number,
|
||||||
|
) => {
|
||||||
|
if (item.durationMs !== undefined) return item.durationMs;
|
||||||
|
if (item.status === "running") {
|
||||||
|
if (item.elapsedMs !== undefined && item.elapsedSnapshotAt !== undefined) {
|
||||||
|
return Math.max(0, item.elapsedMs + now - item.elapsedSnapshotAt);
|
||||||
|
}
|
||||||
|
return Math.max(0, now - item.startedAt);
|
||||||
|
}
|
||||||
|
return item.endedAt ? Math.max(0, item.endedAt - item.startedAt) : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const StatusIcon = ({
|
||||||
|
status,
|
||||||
|
size = 18,
|
||||||
|
}: {
|
||||||
|
status: AgentActivity["status"];
|
||||||
|
size?: number;
|
||||||
|
}) => {
|
||||||
|
if (status === "completed") {
|
||||||
|
return <CheckCircleRounded color="success" sx={{ fontSize: size }} />;
|
||||||
|
}
|
||||||
|
if (status === "error") {
|
||||||
|
return <ErrorOutlineRounded color="error" sx={{ fontSize: size }} />;
|
||||||
|
}
|
||||||
|
if (status === "cancelled") {
|
||||||
|
return <StopCircleRounded color="disabled" sx={{ fontSize: size }} />;
|
||||||
|
}
|
||||||
|
return <AutoAwesomeRounded sx={{ fontSize: size, color: activityAccent }} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ActionStatusIcon = ({ status }: { status: AgentActivityAction["status"] }) => {
|
||||||
|
if (status === "error") {
|
||||||
|
return <ErrorOutlineRounded sx={{ mt: "2px", fontSize: 14, color: "error.main" }} />;
|
||||||
|
}
|
||||||
|
if (status === "completed") {
|
||||||
|
return <CheckCircleRounded sx={{ mt: "2px", fontSize: 14, color: "success.main" }} />;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<RadioButtonUncheckedRounded
|
||||||
|
sx={{ mt: "2px", fontSize: 14, color: activityAccent }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ActionRow = ({ action, now }: { action: AgentActivityAction; now: number }) => {
|
||||||
|
const elapsed = getElapsedMs(action, now);
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
spacing={1}
|
||||||
|
alignItems="flex-start"
|
||||||
|
sx={{ minWidth: 0, py: 0.55 }}
|
||||||
|
>
|
||||||
|
<ActionStatusIcon status={action.status} />
|
||||||
|
<Box sx={{ minWidth: 0, flex: 1 }}>
|
||||||
|
<Stack
|
||||||
|
direction={{ xs: "column", sm: "row" }}
|
||||||
|
spacing={{ xs: 0.2, sm: 1 }}
|
||||||
|
justifyContent="space-between"
|
||||||
|
>
|
||||||
|
<Typography variant="body2" fontWeight={650} sx={{ lineHeight: 1.45 }}>
|
||||||
|
{action.title}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ flexShrink: 0 }}>
|
||||||
|
{formatDuration(elapsed)}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
{action.target ? (
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
color="text.secondary"
|
||||||
|
sx={{
|
||||||
|
display: "block",
|
||||||
|
mt: 0.2,
|
||||||
|
fontFamily: "monospace",
|
||||||
|
wordBreak: "break-word",
|
||||||
|
whiteSpace: "pre-wrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{action.target}
|
||||||
|
</Typography>
|
||||||
|
) : null}
|
||||||
|
{action.error ? (
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
color="error.main"
|
||||||
|
sx={{ display: "block", mt: 0.2, wordBreak: "break-word" }}
|
||||||
|
>
|
||||||
|
{action.error}
|
||||||
|
</Typography>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AgentActivityTimeline = ({
|
||||||
|
activities,
|
||||||
|
}: {
|
||||||
|
activities: AgentActivity[];
|
||||||
|
}) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const reduceMotion = useMediaQuery("(prefers-reduced-motion: reduce)");
|
||||||
|
const hasRunning = activities.some((activity) => activity.status === "running");
|
||||||
|
const hasError = activities.some((activity) => activity.status === "error");
|
||||||
|
const hasCancelled = activities.some((activity) => activity.status === "cancelled");
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const [now, setNow] = useState(() => Date.now());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasRunning) return;
|
||||||
|
const timer = window.setInterval(() => setNow(Date.now()), 500);
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
}, [hasRunning]);
|
||||||
|
|
||||||
|
const current = [...activities]
|
||||||
|
.reverse()
|
||||||
|
.find((activity) => activity.status === "running") ?? activities.at(-1);
|
||||||
|
const totalDuration = useMemo(() => {
|
||||||
|
if (!activities.length) return undefined;
|
||||||
|
const start = Math.min(...activities.map((activity) => activity.startedAt));
|
||||||
|
const end = hasRunning
|
||||||
|
? now
|
||||||
|
: Math.max(
|
||||||
|
...activities.map((activity) => activity.endedAt ?? activity.startedAt),
|
||||||
|
);
|
||||||
|
return formatDuration(Math.max(0, end - start));
|
||||||
|
}, [activities, hasRunning, now]);
|
||||||
|
const overallStatus: AgentActivity["status"] = hasRunning
|
||||||
|
? "running"
|
||||||
|
: hasError
|
||||||
|
? "error"
|
||||||
|
: hasCancelled
|
||||||
|
? "cancelled"
|
||||||
|
: "completed";
|
||||||
|
const statusLabel = {
|
||||||
|
running: "进行中",
|
||||||
|
completed: "已完成",
|
||||||
|
error: "失败",
|
||||||
|
cancelled: "已停止",
|
||||||
|
}[overallStatus];
|
||||||
|
const statusColor = {
|
||||||
|
running: activityAccent,
|
||||||
|
completed: theme.palette.success.main,
|
||||||
|
error: theme.palette.error.main,
|
||||||
|
cancelled: theme.palette.text.secondary,
|
||||||
|
}[overallStatus];
|
||||||
|
const summary = hasRunning
|
||||||
|
? (current?.title ?? "正在分析")
|
||||||
|
: hasError
|
||||||
|
? "分析未完成"
|
||||||
|
: hasCancelled
|
||||||
|
? "分析已停止"
|
||||||
|
: `已完成 ${activities.length} 个阶段`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
overflow: "hidden",
|
||||||
|
borderRadius: 3,
|
||||||
|
border: `1px solid ${alpha(activityAccent, 0.16)}`,
|
||||||
|
bgcolor: alpha(activityAccent, 0.035),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
spacing={1}
|
||||||
|
alignItems="center"
|
||||||
|
sx={{ px: 1.4, py: 1.05 }}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
flex: "0 0 auto",
|
||||||
|
display: "grid",
|
||||||
|
placeItems: "center",
|
||||||
|
borderRadius: 2,
|
||||||
|
color: activityAccent,
|
||||||
|
bgcolor: alpha(activityAccent, 0.1),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AutoAwesomeRounded sx={{ fontSize: 17 }} />
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ minWidth: 0, flex: 1 }}>
|
||||||
|
<Stack direction="row" spacing={0.75} alignItems="center">
|
||||||
|
<Typography variant="body2" fontWeight={750}>
|
||||||
|
分析过程
|
||||||
|
</Typography>
|
||||||
|
<Stack
|
||||||
|
component="span"
|
||||||
|
direction="row"
|
||||||
|
spacing={0.45}
|
||||||
|
alignItems="center"
|
||||||
|
sx={{ color: statusColor }}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
component="span"
|
||||||
|
sx={{
|
||||||
|
width: 5,
|
||||||
|
height: 5,
|
||||||
|
borderRadius: "50%",
|
||||||
|
bgcolor: "currentColor",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Typography
|
||||||
|
component="span"
|
||||||
|
variant="caption"
|
||||||
|
fontWeight={700}
|
||||||
|
color="inherit"
|
||||||
|
>
|
||||||
|
{statusLabel}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
color="text.secondary"
|
||||||
|
noWrap
|
||||||
|
sx={{ display: "block", mt: 0.1 }}
|
||||||
|
>
|
||||||
|
{summary}
|
||||||
|
{totalDuration ? ` · ${totalDuration}` : ""}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
aria-label={expanded ? "收起分析过程" : "展开分析过程"}
|
||||||
|
onClick={() => setExpanded((current) => !current)}
|
||||||
|
sx={{
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
flex: "0 0 auto",
|
||||||
|
color: "text.secondary",
|
||||||
|
bgcolor: alpha("#000", 0.035),
|
||||||
|
"&:hover": { bgcolor: alpha("#000", 0.07) },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{expanded ? (
|
||||||
|
<KeyboardArrowUpRounded sx={{ fontSize: 18 }} />
|
||||||
|
) : (
|
||||||
|
<KeyboardArrowDownRounded sx={{ fontSize: 18 }} />
|
||||||
|
)}
|
||||||
|
</IconButton>
|
||||||
|
</Stack>
|
||||||
|
{hasRunning ? (
|
||||||
|
<LinearProgress
|
||||||
|
sx={{
|
||||||
|
height: 2,
|
||||||
|
bgcolor: alpha(activityAccent, 0.08),
|
||||||
|
"& .MuiLinearProgress-bar": { bgcolor: activityAccent },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<Collapse in={expanded} timeout={reduceMotion ? 0 : 180}>
|
||||||
|
<Stack
|
||||||
|
spacing={1.1}
|
||||||
|
sx={{
|
||||||
|
px: 1.4,
|
||||||
|
py: 1.15,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{activities.map((activity, index) => {
|
||||||
|
const elapsed = formatDuration(getElapsedMs(activity, now));
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
key={activity.id}
|
||||||
|
direction="row"
|
||||||
|
spacing={1}
|
||||||
|
sx={{
|
||||||
|
position: "relative",
|
||||||
|
minWidth: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 18,
|
||||||
|
flex: "0 0 18px",
|
||||||
|
position: "relative",
|
||||||
|
pt: "2px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{index < activities.length - 1 ? (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 19,
|
||||||
|
bottom: -14,
|
||||||
|
left: 8.5,
|
||||||
|
width: "1px",
|
||||||
|
bgcolor: alpha(activityAccent, 0.18),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<StatusIcon status={activity.status} size={17} />
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ minWidth: 0, flex: 1 }}>
|
||||||
|
<Stack direction="row" spacing={1} justifyContent="space-between">
|
||||||
|
<Typography variant="body2" fontWeight={700} sx={{ lineHeight: 1.45 }}>
|
||||||
|
{activity.title}
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
color="text.secondary"
|
||||||
|
sx={{ flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
{elapsed}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
color="text.secondary"
|
||||||
|
sx={{ display: "block", mt: 0.25, lineHeight: 1.5 }}
|
||||||
|
>
|
||||||
|
{activity.reason}
|
||||||
|
</Typography>
|
||||||
|
{activity.actions.length ? (
|
||||||
|
<Stack
|
||||||
|
spacing={0}
|
||||||
|
sx={{
|
||||||
|
mt: 0.6,
|
||||||
|
pl: 1,
|
||||||
|
borderLeft: `1px solid ${alpha(activityAccent, 0.2)}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{activity.actions.map((action) => (
|
||||||
|
<ActionRow key={action.id} action={action} now={now} />
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
</Collapse>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -118,19 +118,10 @@ const PermissionRequestCard = ({
|
|||||||
sx={{
|
sx={{
|
||||||
borderRadius: 3,
|
borderRadius: 3,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
border: `1px solid ${alpha("#fff", 0.72)}`,
|
border: `1px solid ${alpha(accentColor, 0.18)}`,
|
||||||
bgcolor: alpha("#fff", 0.5),
|
bgcolor: alpha(accentColor, 0.035),
|
||||||
boxShadow: `0 8px 24px ${alpha("#000", 0.05)}`,
|
boxShadow: `0 6px 18px ${alpha("#000", 0.04)}`,
|
||||||
backdropFilter: "blur(20px)",
|
|
||||||
position: "relative",
|
position: "relative",
|
||||||
"&::before": {
|
|
||||||
content: '""',
|
|
||||||
position: "absolute",
|
|
||||||
inset: "10px auto 10px 0",
|
|
||||||
width: 3,
|
|
||||||
borderRadius: "0 999px 999px 0",
|
|
||||||
bgcolor: accentColor,
|
|
||||||
},
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Stack
|
<Stack
|
||||||
@@ -180,6 +171,22 @@ const PermissionRequestCard = ({
|
|||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Stack spacing={1.15} sx={{ px: 1.5, pt: 1.25, pb: 1.35, pl: 1.75 }}>
|
<Stack spacing={1.15} sx={{ px: 1.5, pt: 1.25, pb: 1.35, pl: 1.75 }}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
px: 1.25,
|
||||||
|
py: 1,
|
||||||
|
borderRadius: 2.5,
|
||||||
|
bgcolor: alpha(accentColor, 0.055),
|
||||||
|
border: `1px solid ${alpha(accentColor, 0.12)}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="caption" color="text.secondary" fontWeight={800}>
|
||||||
|
执行目的
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ mt: 0.25, lineHeight: 1.55 }}>
|
||||||
|
{permission.reason?.trim() || "Agent 未提供执行目的"}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
px: 1.25,
|
px: 1.25,
|
||||||
|
|||||||
@@ -29,7 +29,9 @@ export const TodoPlanCard = ({
|
|||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const total = todoUpdate.todos.length;
|
const total = todoUpdate.todos.length;
|
||||||
const completed = todoUpdate.todos.filter((todo) => todo.status === "completed").length;
|
const completed = todoUpdate.todos.filter((todo) => todo.status === "completed").length;
|
||||||
const running = todoUpdate.todos.find((todo) => todo.status === "in_progress");
|
const runningCount = todoUpdate.todos.filter(
|
||||||
|
(todo) => todo.status === "in_progress",
|
||||||
|
).length;
|
||||||
const cancelled = todoUpdate.todos.filter((todo) => todo.status === "cancelled").length;
|
const cancelled = todoUpdate.todos.filter((todo) => todo.status === "cancelled").length;
|
||||||
const pending = todoUpdate.todos.filter((todo) => todo.status === "pending").length;
|
const pending = todoUpdate.todos.filter((todo) => todo.status === "pending").length;
|
||||||
const progress = total > 0 ? Math.round((completed / total) * 100) : 0;
|
const progress = total > 0 ? Math.round((completed / total) * 100) : 0;
|
||||||
@@ -77,7 +79,7 @@ export const TodoPlanCard = ({
|
|||||||
? `${completed} 完成 / ${cancelled} 中止`
|
? `${completed} 完成 / ${cancelled} 中止`
|
||||||
: [
|
: [
|
||||||
completed ? `${completed} 完成` : null,
|
completed ? `${completed} 完成` : null,
|
||||||
running ? "1 进行中" : null,
|
runningCount ? `${runningCount} 进行中` : null,
|
||||||
pending ? `${pending} 待办` : null,
|
pending ? `${pending} 待办` : null,
|
||||||
cancelled ? `${cancelled} 中止` : null,
|
cancelled ? `${cancelled} 中止` : null,
|
||||||
].filter(Boolean).join(" / ") || "等待任务";
|
].filter(Boolean).join(" / ") || "等待任务";
|
||||||
@@ -221,14 +223,14 @@ export const TodoPlanCard = ({
|
|||||||
</Typography>
|
</Typography>
|
||||||
<Chip
|
<Chip
|
||||||
size="small"
|
size="small"
|
||||||
label={running ? "执行中" : isAborted ? "已中止" : completed === total ? "已完成" : "已同步"}
|
label={runningCount ? "执行中" : isAborted ? "已中止" : completed === total ? "已完成" : "已同步"}
|
||||||
sx={{
|
sx={{
|
||||||
height: 20,
|
height: 20,
|
||||||
borderRadius: "10px",
|
borderRadius: "10px",
|
||||||
fontSize: "0.66rem",
|
fontSize: "0.66rem",
|
||||||
fontWeight: 800,
|
fontWeight: 800,
|
||||||
color: running ? "#0277bd" : isAborted ? "text.secondary" : "#00838f",
|
color: runningCount ? "#0277bd" : isAborted ? "text.secondary" : "#00838f",
|
||||||
bgcolor: alpha(running ? "#0288d1" : isAborted ? "#64748b" : "#00838f", 0.08),
|
bgcolor: alpha(runningCount ? "#0288d1" : isAborted ? "#64748b" : "#00838f", 0.08),
|
||||||
"& .MuiChip-label": { px: 0.75 },
|
"& .MuiChip-label": { px: 0.75 },
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -305,4 +307,3 @@ export const TodoPlanCard = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ jest.mock("next/image", () => ({
|
|||||||
|
|
||||||
jest.mock("framer-motion", () => ({
|
jest.mock("framer-motion", () => ({
|
||||||
AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||||
|
useReducedMotion: () => false,
|
||||||
motion: {
|
motion: {
|
||||||
div: ({
|
div: ({
|
||||||
children,
|
children,
|
||||||
@@ -44,6 +45,47 @@ jest.mock("./AgentMarkdownBlock", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
describe("AgentTurn speech selection", () => {
|
describe("AgentTurn speech selection", () => {
|
||||||
|
it("mounts the answer only after the complete response is available", () => {
|
||||||
|
const sharedProps = {
|
||||||
|
messageSpeechState: "idle" as const,
|
||||||
|
onSpeak: jest.fn(),
|
||||||
|
onPause: jest.fn(),
|
||||||
|
onResume: jest.fn(),
|
||||||
|
onStopSpeech: jest.fn(),
|
||||||
|
isTtsSupported: true,
|
||||||
|
onCreateBranch: jest.fn(),
|
||||||
|
onReplyPermission: jest.fn(),
|
||||||
|
onReplyQuestion: jest.fn(),
|
||||||
|
onRejectQuestion: jest.fn(),
|
||||||
|
};
|
||||||
|
const { rerender } = render(
|
||||||
|
<AgentTurn
|
||||||
|
{...sharedProps}
|
||||||
|
message={{ id: "assistant-buffered", role: "assistant", content: "" }}
|
||||||
|
isStreaming
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByTestId("agent-answer-content")).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText("正在生成")).toBeInTheDocument();
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<AgentTurn
|
||||||
|
{...sharedProps}
|
||||||
|
message={{
|
||||||
|
id: "assistant-buffered",
|
||||||
|
role: "assistant",
|
||||||
|
content: "完整分析结果已生成。",
|
||||||
|
}}
|
||||||
|
isStreaming
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId("agent-answer-content")).toHaveTextContent(
|
||||||
|
"完整分析结果已生成。",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("shows a floating action and reads from the selected text", async () => {
|
it("shows a floating action and reads from the selected text", async () => {
|
||||||
const content = "第一段内容。\n\n第二段内容。";
|
const content = "第一段内容。\n\n第二段内容。";
|
||||||
const speechText = "第一段内容。\n第二段内容。";
|
const speechText = "第一段内容。\n第二段内容。";
|
||||||
@@ -136,6 +178,7 @@ describe("AgentTurn speech selection", () => {
|
|||||||
permission: "bash",
|
permission: "bash",
|
||||||
patterns: ["npm test"],
|
patterns: ["npm test"],
|
||||||
target: "npm test",
|
target: "npm test",
|
||||||
|
reason: "需要运行测试确认本次改动没有引入回归。",
|
||||||
always: ["npm test"],
|
always: ["npm test"],
|
||||||
createdAt: 1,
|
createdAt: 1,
|
||||||
status: "pending",
|
status: "pending",
|
||||||
@@ -158,10 +201,151 @@ describe("AgentTurn speech selection", () => {
|
|||||||
|
|
||||||
expect(screen.getByRole("button", { name: "允许一次" })).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: "允许一次" })).toBeInTheDocument();
|
||||||
expect(screen.getByText("保存授权范围")).toBeInTheDocument();
|
expect(screen.getByText("保存授权范围")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("执行目的")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("需要运行测试确认本次改动没有引入回归。")).toBeInTheDocument();
|
||||||
expect(screen.getAllByText("npm test")).toHaveLength(2);
|
expect(screen.getAllByText("npm test")).toHaveLength(2);
|
||||||
expect(screen.getByTestId("GppGoodRoundedIcon")).toBeInTheDocument();
|
expect(screen.getByTestId("GppGoodRoundedIcon")).toBeInTheDocument();
|
||||||
fireEvent.click(screen.getByRole("button", { name: "保存授权" }));
|
fireEvent.click(screen.getByRole("button", { name: "保存授权" }));
|
||||||
expect(onReplyPermission).toHaveBeenCalledWith("permission-1", "always");
|
expect(onReplyPermission).toHaveBeenCalledWith("permission-1", "always");
|
||||||
expect(screen.getByRole("button", { name: "拒绝" })).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: "拒绝" })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("groups concrete actions under a business activity", () => {
|
||||||
|
render(
|
||||||
|
<AgentTurn
|
||||||
|
message={{
|
||||||
|
id: "assistant-activity",
|
||||||
|
role: "assistant",
|
||||||
|
content: "",
|
||||||
|
activities: [
|
||||||
|
{
|
||||||
|
id: "activity-1",
|
||||||
|
title: "准备供水分区数据",
|
||||||
|
reason: "需要确认拓扑与水库属性完整,才能计算服务范围。",
|
||||||
|
status: "running",
|
||||||
|
startedAt: Date.now(),
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
id: "action-1",
|
||||||
|
tool: "tjwater_cli",
|
||||||
|
title: "查询后端数据",
|
||||||
|
status: "completed",
|
||||||
|
target: "network get-all-reservoirs-properties",
|
||||||
|
startedAt: Date.now() - 100,
|
||||||
|
endedAt: Date.now(),
|
||||||
|
durationMs: 100,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
isStreaming
|
||||||
|
messageSpeechState="idle"
|
||||||
|
onSpeak={jest.fn()}
|
||||||
|
onPause={jest.fn()}
|
||||||
|
onResume={jest.fn()}
|
||||||
|
onStopSpeech={jest.fn()}
|
||||||
|
isTtsSupported
|
||||||
|
onCreateBranch={jest.fn()}
|
||||||
|
onReplyPermission={jest.fn()}
|
||||||
|
onReplyQuestion={jest.fn()}
|
||||||
|
onRejectQuestion={jest.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("分析过程")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("进行中")).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText("准备供水分区数据").length).toBeGreaterThan(0);
|
||||||
|
expect(screen.getByTestId("KeyboardArrowDownRoundedIcon")).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.queryByText("需要确认拓扑与水库属性完整,才能计算服务范围。"),
|
||||||
|
).not.toBeVisible();
|
||||||
|
expect(screen.queryByText("查询后端数据")).not.toBeVisible();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "展开分析过程" }));
|
||||||
|
expect(screen.getByTestId("KeyboardArrowUpRoundedIcon")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("需要确认拓扑与水库属性完整,才能计算服务范围。")).toBeVisible();
|
||||||
|
expect(screen.getByText("查询后端数据")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the actual number of in-progress session tasks", () => {
|
||||||
|
render(
|
||||||
|
<AgentTurn
|
||||||
|
message={{
|
||||||
|
id: "assistant-todos",
|
||||||
|
role: "assistant",
|
||||||
|
content: "",
|
||||||
|
todos: {
|
||||||
|
sessionId: "session-1",
|
||||||
|
createdAt: 1,
|
||||||
|
todos: [
|
||||||
|
{ id: "todo-1", content: "准备数据", status: "completed" },
|
||||||
|
{ id: "todo-2", content: "分析结果", status: "completed" },
|
||||||
|
{ id: "todo-3", content: "生成建议", status: "in_progress" },
|
||||||
|
{ id: "todo-4", content: "生成图表", status: "in_progress" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
isStreaming
|
||||||
|
messageSpeechState="idle"
|
||||||
|
onSpeak={jest.fn()}
|
||||||
|
onPause={jest.fn()}
|
||||||
|
onResume={jest.fn()}
|
||||||
|
onStopSpeech={jest.fn()}
|
||||||
|
isTtsSupported
|
||||||
|
onCreateBranch={jest.fn()}
|
||||||
|
onReplyPermission={jest.fn()}
|
||||||
|
onReplyQuestion={jest.fn()}
|
||||||
|
onRejectQuestion={jest.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText(/2 完成 \/ 2 进行中/u)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a failed activity compact until the user expands it", () => {
|
||||||
|
render(
|
||||||
|
<AgentTurn
|
||||||
|
message={{
|
||||||
|
id: "assistant-activity-error",
|
||||||
|
role: "assistant",
|
||||||
|
content: "⚠️ **错误:** 模型请求失败",
|
||||||
|
activities: [
|
||||||
|
{
|
||||||
|
id: "activity-error",
|
||||||
|
title: "正在准备分析",
|
||||||
|
reason: "正在理解请求并确定本次分析需要完成的业务步骤。",
|
||||||
|
status: "error",
|
||||||
|
startedAt: Date.now() - 4100,
|
||||||
|
endedAt: Date.now(),
|
||||||
|
durationMs: 4100,
|
||||||
|
actions: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
isStreaming={false}
|
||||||
|
messageSpeechState="idle"
|
||||||
|
onSpeak={jest.fn()}
|
||||||
|
onPause={jest.fn()}
|
||||||
|
onResume={jest.fn()}
|
||||||
|
onStopSpeech={jest.fn()}
|
||||||
|
isTtsSupported
|
||||||
|
onCreateBranch={jest.fn()}
|
||||||
|
onReplyPermission={jest.fn()}
|
||||||
|
onReplyQuestion={jest.fn()}
|
||||||
|
onRejectQuestion={jest.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("失败")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("分析未完成 · 4.1s")).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.queryByText("正在理解请求并确定本次分析需要完成的业务步骤。"),
|
||||||
|
).not.toBeVisible();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "展开分析过程" }));
|
||||||
|
expect(
|
||||||
|
screen.getByText("正在理解请求并确定本次分析需要完成的业务步骤。"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import React, { useMemo } from "react";
|
import React, { useMemo } from "react";
|
||||||
import { motion } from "framer-motion";
|
import { motion, useReducedMotion } from "framer-motion";
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
Box,
|
Box,
|
||||||
@@ -33,6 +33,7 @@ import type {
|
|||||||
import { stripMarkdown } from "./globalChatboxUtils";
|
import { stripMarkdown } from "./globalChatboxUtils";
|
||||||
import { findSpeechSelectionStartOffset } from "./speechStartOptions";
|
import { findSpeechSelectionStartOffset } from "./speechStartOptions";
|
||||||
import { AgentProgressTimeline } from "./AgentProgressTimeline";
|
import { AgentProgressTimeline } from "./AgentProgressTimeline";
|
||||||
|
import { AgentActivityTimeline } from "./AgentActivityTimeline";
|
||||||
import { ChartGenerationSkeleton, ChatInlineChart } from "./ChatInlineChart";
|
import { ChartGenerationSkeleton, ChatInlineChart } from "./ChatInlineChart";
|
||||||
import { ChatToolCallBlock } from "./ChatToolCallBlock";
|
import { ChatToolCallBlock } from "./ChatToolCallBlock";
|
||||||
import { MarkdownBlock, normalizeClipboardText } from "./AgentMarkdownBlock";
|
import { MarkdownBlock, normalizeClipboardText } from "./AgentMarkdownBlock";
|
||||||
@@ -149,61 +150,6 @@ const StreamingStatus = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const StreamingMarkdownBlock = ({
|
|
||||||
text,
|
|
||||||
isStreaming,
|
|
||||||
segmentKey,
|
|
||||||
}: {
|
|
||||||
text: string;
|
|
||||||
isStreaming: boolean;
|
|
||||||
segmentKey: string;
|
|
||||||
}) => {
|
|
||||||
const [streamTextState, setStreamTextState] = React.useState<{
|
|
||||||
displayText: string;
|
|
||||||
animatedTailLength: number;
|
|
||||||
}>({
|
|
||||||
displayText: text,
|
|
||||||
animatedTailLength: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
React.useLayoutEffect(() => {
|
|
||||||
setStreamTextState((current) => {
|
|
||||||
if (current.displayText === text) {
|
|
||||||
return current;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isStreaming) {
|
|
||||||
return {
|
|
||||||
displayText: text,
|
|
||||||
animatedTailLength: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (current.displayText === text) {
|
|
||||||
return current;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
displayText: text,
|
|
||||||
animatedTailLength:
|
|
||||||
text.length > current.displayText.length &&
|
|
||||||
text.startsWith(current.displayText)
|
|
||||||
? Math.min(48, text.length - current.displayText.length)
|
|
||||||
: 0,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}, [isStreaming, text]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<MarkdownBlock
|
|
||||||
streamFadeKey={`${segmentKey}-${streamTextState.displayText.length}`}
|
|
||||||
streamFadeLength={streamTextState.animatedTailLength}
|
|
||||||
>
|
|
||||||
{streamTextState.displayText}
|
|
||||||
</MarkdownBlock>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const AgentTurn = React.memo(
|
export const AgentTurn = React.memo(
|
||||||
({
|
({
|
||||||
message,
|
message,
|
||||||
@@ -220,9 +166,11 @@ export const AgentTurn = React.memo(
|
|||||||
onRejectQuestion,
|
onRejectQuestion,
|
||||||
}: AgentTurnProps) => {
|
}: AgentTurnProps) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
const reduceMotion = useReducedMotion();
|
||||||
const isUser = message.role === "user";
|
const isUser = message.role === "user";
|
||||||
const isErrorMessage = Boolean(message.isError);
|
const isErrorMessage = Boolean(message.isError);
|
||||||
const isStreamingAssistant = !isUser && !isErrorMessage && isStreaming;
|
const isStreamingAssistant = !isUser && !isErrorMessage && isStreaming;
|
||||||
|
const hasFinalAnswer = message.content.trim().length > 0;
|
||||||
const [isHovered, setIsHovered] = React.useState(false);
|
const [isHovered, setIsHovered] = React.useState(false);
|
||||||
const answerContentRef = React.useRef<HTMLDivElement | null>(null);
|
const answerContentRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
const [speechSelection, setSpeechSelection] = React.useState<SpeechSelection | null>(null);
|
const [speechSelection, setSpeechSelection] = React.useState<SpeechSelection | null>(null);
|
||||||
@@ -230,7 +178,8 @@ export const AgentTurn = React.memo(
|
|||||||
(item) => item.phase === "complete" && item.status === "completed",
|
(item) => item.phase === "complete" && item.status === "completed",
|
||||||
) ?? false;
|
) ?? false;
|
||||||
const isProgressRunning = !isErrorMessage && !isProgressComplete && (
|
const isProgressRunning = !isErrorMessage && !isProgressComplete && (
|
||||||
message.progress?.some((item) => item.status === "running") ?? false
|
(message.activities?.some((item) => item.status === "running") ?? false) ||
|
||||||
|
(message.progress?.some((item) => item.status === "running") ?? false)
|
||||||
);
|
);
|
||||||
|
|
||||||
const parsedAssistantSections = useMemo(
|
const parsedAssistantSections = useMemo(
|
||||||
@@ -456,7 +405,9 @@ export const AgentTurn = React.memo(
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Stack spacing={1.5}>
|
<Stack spacing={1.5}>
|
||||||
{message.progress?.length ? (
|
{message.activities?.length ? (
|
||||||
|
<AgentActivityTimeline activities={message.activities} />
|
||||||
|
) : message.progress?.length ? (
|
||||||
<AgentProgressTimeline progress={message.progress} isAborted={isErrorMessage} />
|
<AgentProgressTimeline progress={message.progress} isAborted={isErrorMessage} />
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -493,23 +444,45 @@ export const AgentTurn = React.memo(
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Stack spacing={1.2}>
|
<Stack spacing={1.2}>
|
||||||
<Stack direction="row" alignItems="center" justifyContent="space-between" spacing={1}>
|
<Stack
|
||||||
<Typography variant="caption" color="text.secondary" fontWeight={800} sx={{ letterSpacing: 0.5 }}>
|
direction="row"
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="space-between"
|
||||||
|
spacing={1}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
color="text.secondary"
|
||||||
|
fontWeight={800}
|
||||||
|
sx={{ letterSpacing: 0.5 }}
|
||||||
|
>
|
||||||
分析结果
|
分析结果
|
||||||
</Typography>
|
</Typography>
|
||||||
{isStreamingAssistant ? <StreamingStatus /> : null}
|
{isStreamingAssistant ? <StreamingStatus /> : null}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
{hasFinalAnswer || !isStreamingAssistant ? (
|
||||||
|
<motion.div
|
||||||
|
data-testid="agent-answer-content"
|
||||||
|
initial={
|
||||||
|
reduceMotion
|
||||||
|
? false
|
||||||
|
: { opacity: 0, y: 6, filter: "blur(2px)" }
|
||||||
|
}
|
||||||
|
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
|
||||||
|
transition={{
|
||||||
|
duration: reduceMotion ? 0 : 0.24,
|
||||||
|
ease: [0.16, 1, 0.3, 1],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack spacing={1.2}>
|
||||||
{contentSegments.map((segment, segIdx) => {
|
{contentSegments.map((segment, segIdx) => {
|
||||||
if (segment.type === "text") {
|
if (segment.type === "text") {
|
||||||
const text = segment.content.trim();
|
const text = segment.content.trim();
|
||||||
if (!text && contentSegments.length > 1) return null;
|
if (!text && contentSegments.length > 1) return null;
|
||||||
return (
|
return (
|
||||||
<StreamingMarkdownBlock
|
<MarkdownBlock key={segIdx}>
|
||||||
key={segIdx}
|
{text || "..."}
|
||||||
text={text || "..."}
|
</MarkdownBlock>
|
||||||
isStreaming={isStreamingAssistant}
|
|
||||||
segmentKey={`${message.id}-${segIdx}`}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (segment.type === "tool_call") {
|
if (segment.type === "tool_call") {
|
||||||
@@ -523,12 +496,22 @@ export const AgentTurn = React.memo(
|
|||||||
key={segment.toolCall.id}
|
key={segment.toolCall.id}
|
||||||
title={(p.title as string) ?? undefined}
|
title={(p.title as string) ?? undefined}
|
||||||
chart_type={
|
chart_type={
|
||||||
(p.chart_type as "line" | "bar" | "pie") ?? "line"
|
(p.chart_type as "line" | "bar" | "pie") ??
|
||||||
|
"line"
|
||||||
|
}
|
||||||
|
x_data={
|
||||||
|
p.x_data ??
|
||||||
|
p.xData ??
|
||||||
|
p.labels ??
|
||||||
|
p.categories
|
||||||
}
|
}
|
||||||
x_data={p.x_data ?? p.xData ?? p.labels ?? p.categories}
|
|
||||||
series={p.series}
|
series={p.series}
|
||||||
x_axis_name={(p.x_axis_name as string) ?? undefined}
|
x_axis_name={
|
||||||
y_axis_name={(p.y_axis_name as string) ?? undefined}
|
(p.x_axis_name as string) ?? undefined
|
||||||
|
}
|
||||||
|
y_axis_name={
|
||||||
|
(p.y_axis_name as string) ?? undefined
|
||||||
|
}
|
||||||
isStreaming={isStreamingAssistant}
|
isStreaming={isStreamingAssistant}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -551,6 +534,9 @@ export const AgentTurn = React.memo(
|
|||||||
return null;
|
return null;
|
||||||
})}
|
})}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
</motion.div>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Popper
|
<Popper
|
||||||
|
|||||||
@@ -45,12 +45,12 @@ type ToolMeta = {
|
|||||||
|
|
||||||
const LOCATE_TOOL_TO_LAYER: Record<string, string> = {
|
const LOCATE_TOOL_TO_LAYER: Record<string, string> = {
|
||||||
locate_features: "",
|
locate_features: "",
|
||||||
locate_junctions: "geo_junctions_mat",
|
locate_junctions: "junctions",
|
||||||
locate_pipes: "geo_pipes_mat",
|
locate_pipes: "pipes",
|
||||||
locate_valves: "geo_valves",
|
locate_valves: "valves",
|
||||||
locate_reservoirs: "geo_reservoirs",
|
locate_reservoirs: "reservoirs",
|
||||||
locate_pumps: "geo_pumps",
|
locate_pumps: "pumps",
|
||||||
locate_tanks: "geo_tanks",
|
locate_tanks: "tanks",
|
||||||
};
|
};
|
||||||
|
|
||||||
const LOCATE_LINE_TOOLS = new Set<string>(["locate_pipes"]);
|
const LOCATE_LINE_TOOLS = new Set<string>(["locate_pipes"]);
|
||||||
@@ -672,22 +672,22 @@ export const ChatToolCallBlock: React.FC<ChatToolCallBlockProps> = ({
|
|||||||
switch (featureType) {
|
switch (featureType) {
|
||||||
case "junction":
|
case "junction":
|
||||||
case "junctions":
|
case "junctions":
|
||||||
return { layer: "geo_junctions_mat", geometryKind: "point" };
|
return { layer: "junctions", geometryKind: "point" };
|
||||||
case "pipe":
|
case "pipe":
|
||||||
case "pipes":
|
case "pipes":
|
||||||
return { layer: "geo_pipes_mat", geometryKind: "line" };
|
return { layer: "pipes", geometryKind: "line" };
|
||||||
case "valve":
|
case "valve":
|
||||||
case "valves":
|
case "valves":
|
||||||
return { layer: "geo_valves", geometryKind: "point" };
|
return { layer: "valves", geometryKind: "point" };
|
||||||
case "reservoir":
|
case "reservoir":
|
||||||
case "reservoirs":
|
case "reservoirs":
|
||||||
return { layer: "geo_reservoirs", geometryKind: "point" };
|
return { layer: "reservoirs", geometryKind: "point" };
|
||||||
case "pump":
|
case "pump":
|
||||||
case "pumps":
|
case "pumps":
|
||||||
return { layer: "geo_pumps", geometryKind: "point" };
|
return { layer: "pumps", geometryKind: "point" };
|
||||||
case "tank":
|
case "tank":
|
||||||
case "tanks":
|
case "tanks":
|
||||||
return { layer: "geo_tanks", geometryKind: "point" };
|
return { layer: "tanks", geometryKind: "point" };
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -229,18 +229,23 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isStreaming) {
|
if (isStreaming) {
|
||||||
|
const latestAssistant = [...messages]
|
||||||
|
.reverse()
|
||||||
|
.find((message) => message.role === "assistant");
|
||||||
|
if (latestAssistant?.content.trim()) {
|
||||||
|
cancelStreamingScroll();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!isNearBottomRef.current) return;
|
if (!isNearBottomRef.current) return;
|
||||||
scheduleStreamingScrollToBottom();
|
scheduleStreamingScrollToBottom();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
cancelStreamingScroll();
|
cancelStreamingScroll();
|
||||||
scrollToBottom("smooth");
|
|
||||||
}, [
|
}, [
|
||||||
cancelStreamingScroll,
|
cancelStreamingScroll,
|
||||||
isStreaming,
|
isStreaming,
|
||||||
messages,
|
messages,
|
||||||
scheduleStreamingScrollToBottom,
|
scheduleStreamingScrollToBottom,
|
||||||
scrollToBottom,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type {
|
import type {
|
||||||
|
AgentActivity,
|
||||||
AgentQuestionRequest,
|
AgentQuestionRequest,
|
||||||
AgentTodoUpdate,
|
AgentTodoUpdate,
|
||||||
} from "@/lib/chatStream";
|
} from "@/lib/chatStream";
|
||||||
@@ -42,6 +43,8 @@ export type AgentPermissionRequest = {
|
|||||||
permission: string;
|
permission: string;
|
||||||
patterns: string[];
|
patterns: string[];
|
||||||
target?: string;
|
target?: string;
|
||||||
|
activityId?: string;
|
||||||
|
reason?: string;
|
||||||
always: string[];
|
always: string[];
|
||||||
tool?: {
|
tool?: {
|
||||||
messageID: string;
|
messageID: string;
|
||||||
@@ -59,6 +62,7 @@ export type Message = {
|
|||||||
content: string;
|
content: string;
|
||||||
isError?: boolean;
|
isError?: boolean;
|
||||||
progress?: ChatProgress[];
|
progress?: ChatProgress[];
|
||||||
|
activities?: AgentActivity[];
|
||||||
artifacts?: AgentArtifact[];
|
artifacts?: AgentArtifact[];
|
||||||
permissions?: AgentPermissionRequest[];
|
permissions?: AgentPermissionRequest[];
|
||||||
questions?: AgentQuestionRequest[];
|
questions?: AgentQuestionRequest[];
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type {
|
import type {
|
||||||
|
AgentActivity,
|
||||||
AgentQuestionRequest,
|
AgentQuestionRequest,
|
||||||
AgentTodoUpdate,
|
AgentTodoUpdate,
|
||||||
PermissionReply,
|
PermissionReply,
|
||||||
@@ -79,6 +80,48 @@ export const completeRunningProgress = (progress: ChatProgress[] | undefined) =>
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const upsertActivity = (
|
||||||
|
activities: AgentActivity[] | undefined,
|
||||||
|
event: StreamEvent & { type: "activity_update" },
|
||||||
|
) => {
|
||||||
|
const next = [...(activities ?? [])];
|
||||||
|
const index = next.findIndex((activity) => activity.id === event.activity.id);
|
||||||
|
if (index >= 0) next[index] = event.activity;
|
||||||
|
else next.push(event.activity);
|
||||||
|
return next;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const completeRunningActivities = (
|
||||||
|
activities: AgentActivity[] | undefined,
|
||||||
|
status: "completed" | "error" | "cancelled" = "completed",
|
||||||
|
) => activities?.map((activity) => {
|
||||||
|
if (activity.status !== "running") return activity;
|
||||||
|
const endedAt = Date.now();
|
||||||
|
return {
|
||||||
|
...activity,
|
||||||
|
status,
|
||||||
|
actions: activity.actions.map((action) =>
|
||||||
|
action.status === "running"
|
||||||
|
? {
|
||||||
|
...action,
|
||||||
|
status: status === "error" ? "error" as const : "completed" as const,
|
||||||
|
endedAt,
|
||||||
|
elapsedMs: undefined,
|
||||||
|
elapsedSnapshotAt: undefined,
|
||||||
|
durationMs: Math.max(0, endedAt - action.startedAt),
|
||||||
|
...(status === "error"
|
||||||
|
? { error: action.error ?? "活动执行失败" }
|
||||||
|
: {}),
|
||||||
|
}
|
||||||
|
: action,
|
||||||
|
),
|
||||||
|
endedAt,
|
||||||
|
elapsedMs: undefined,
|
||||||
|
elapsedSnapshotAt: undefined,
|
||||||
|
durationMs: Math.max(0, endedAt - activity.startedAt),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
export const cancelRunningTodos = (todoUpdate: AgentTodoUpdate | undefined) =>
|
export const cancelRunningTodos = (todoUpdate: AgentTodoUpdate | undefined) =>
|
||||||
todoUpdate
|
todoUpdate
|
||||||
? {
|
? {
|
||||||
@@ -107,6 +150,8 @@ export const upsertPermission = (
|
|||||||
permission: event.permission,
|
permission: event.permission,
|
||||||
patterns: event.patterns,
|
patterns: event.patterns,
|
||||||
target: event.target,
|
target: event.target,
|
||||||
|
activityId: event.activityId,
|
||||||
|
reason: event.reason,
|
||||||
always: event.always,
|
always: event.always,
|
||||||
tool: event.tool,
|
tool: event.tool,
|
||||||
createdAt: event.createdAt,
|
createdAt: event.createdAt,
|
||||||
@@ -405,6 +450,7 @@ export const rejectOpenQuestionsAfterAbort = (
|
|||||||
|
|
||||||
export const finalizeAssistantMessageAfterAbort = (message: Message): Message => {
|
export const finalizeAssistantMessageAfterAbort = (message: Message): Message => {
|
||||||
const completedProgress = completeRunningProgress(message.progress);
|
const completedProgress = completeRunningProgress(message.progress);
|
||||||
|
const cancelledActivities = completeRunningActivities(message.activities, "cancelled");
|
||||||
const cancelledTodos = cancelRunningTodos(message.todos);
|
const cancelledTodos = cancelRunningTodos(message.todos);
|
||||||
const abortedPermissions = abortOpenPermissionsAfterAbort(message.permissions);
|
const abortedPermissions = abortOpenPermissionsAfterAbort(message.permissions);
|
||||||
const rejectedQuestions = rejectOpenQuestionsAfterAbort(message.questions);
|
const rejectedQuestions = rejectOpenQuestionsAfterAbort(message.questions);
|
||||||
@@ -414,6 +460,7 @@ export const finalizeAssistantMessageAfterAbort = (message: Message): Message =>
|
|||||||
Boolean(abortedPermissions?.length) ||
|
Boolean(abortedPermissions?.length) ||
|
||||||
Boolean(rejectedQuestions?.length) ||
|
Boolean(rejectedQuestions?.length) ||
|
||||||
Boolean(completedProgress?.length) ||
|
Boolean(completedProgress?.length) ||
|
||||||
|
Boolean(cancelledActivities?.length) ||
|
||||||
Boolean(cancelledTodos);
|
Boolean(cancelledTodos);
|
||||||
|
|
||||||
if (!hasVisibleOutput) {
|
if (!hasVisibleOutput) {
|
||||||
@@ -425,6 +472,7 @@ export const finalizeAssistantMessageAfterAbort = (message: Message): Message =>
|
|||||||
content: message.content || "⚠️ **请求已中断**",
|
content: message.content || "⚠️ **请求已中断**",
|
||||||
isError: true,
|
isError: true,
|
||||||
progress: completedProgress,
|
progress: completedProgress,
|
||||||
|
activities: cancelledActivities,
|
||||||
permissions: abortedPermissions,
|
permissions: abortedPermissions,
|
||||||
questions: rejectedQuestions,
|
questions: rejectedQuestions,
|
||||||
todos: cancelledTodos,
|
todos: cancelledTodos,
|
||||||
|
|||||||
@@ -132,6 +132,80 @@ describe("useAgentChatSession actions", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("applies an activity phase and todo snapshot atomically before revealing the final answer", async () => {
|
||||||
|
listChatSessions.mockResolvedValue([]);
|
||||||
|
jest.mocked(streamAgentChat).mockImplementationOnce(async ({ onEvent }) => {
|
||||||
|
onEvent({
|
||||||
|
type: "activity_update",
|
||||||
|
sessionId: "session-1",
|
||||||
|
activity: {
|
||||||
|
id: "activity-analyze",
|
||||||
|
title: "分析管网数据",
|
||||||
|
reason: "需要识别影响供水能力的关键管段。",
|
||||||
|
status: "running",
|
||||||
|
actions: [],
|
||||||
|
startedAt: 1000,
|
||||||
|
},
|
||||||
|
todos: [
|
||||||
|
{
|
||||||
|
id: "todo-data",
|
||||||
|
content: "准备管网数据",
|
||||||
|
status: "completed",
|
||||||
|
priority: "high",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "todo-analysis",
|
||||||
|
content: "识别瓶颈管段",
|
||||||
|
status: "in_progress",
|
||||||
|
priority: "high",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
todosCreatedAt: 1001,
|
||||||
|
});
|
||||||
|
onEvent({
|
||||||
|
type: "final_answer",
|
||||||
|
sessionId: "session-1",
|
||||||
|
content: "已识别关键瓶颈管段。",
|
||||||
|
});
|
||||||
|
onEvent({ type: "done", sessionId: "session-1" });
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useAgentChatSession({
|
||||||
|
projectId: "project-1",
|
||||||
|
onToolCall: jest.fn(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isHydrating).toBe(false));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.sendPrompt("分析管网瓶颈");
|
||||||
|
});
|
||||||
|
|
||||||
|
const assistantMessage = result.current.messages.at(-1);
|
||||||
|
expect(assistantMessage).toMatchObject({
|
||||||
|
role: "assistant",
|
||||||
|
content: "已识别关键瓶颈管段。",
|
||||||
|
todos: {
|
||||||
|
sessionId: "session-1",
|
||||||
|
createdAt: 1001,
|
||||||
|
todos: [
|
||||||
|
expect.objectContaining({ id: "todo-data", status: "completed" }),
|
||||||
|
expect.objectContaining({ id: "todo-analysis", status: "completed" }),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
assistantMessage?.activities?.find(
|
||||||
|
(activity) => activity.id === "activity-analyze",
|
||||||
|
),
|
||||||
|
).toMatchObject({
|
||||||
|
title: "分析管网数据",
|
||||||
|
status: "completed",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("finalizes running progress when aborting an active prompt", async () => {
|
it("finalizes running progress when aborting an active prompt", async () => {
|
||||||
listChatSessions.mockResolvedValue([]);
|
listChatSessions.mockResolvedValue([]);
|
||||||
jest.mocked(streamAgentChat).mockImplementationOnce(
|
jest.mocked(streamAgentChat).mockImplementationOnce(
|
||||||
|
|||||||
@@ -340,7 +340,7 @@ describe("useAgentChatSession lifecycle and resume", () => {
|
|||||||
}),
|
}),
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
id: "todo-2",
|
id: "todo-2",
|
||||||
status: "in_progress",
|
status: "completed",
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
applyQuestionResponse,
|
applyQuestionResponse,
|
||||||
cancelRunningTodos,
|
cancelRunningTodos,
|
||||||
|
completeRunningActivities,
|
||||||
completeRunningProgress,
|
completeRunningProgress,
|
||||||
createAssistantMessage,
|
createAssistantMessage,
|
||||||
createTodoUpdateFromEvent,
|
createTodoUpdateFromEvent,
|
||||||
@@ -40,6 +41,7 @@ import {
|
|||||||
normalizeSessionTodos,
|
normalizeSessionTodos,
|
||||||
toPermissionStatus,
|
toPermissionStatus,
|
||||||
upsertPermission,
|
upsertPermission,
|
||||||
|
upsertActivity,
|
||||||
upsertProgress,
|
upsertProgress,
|
||||||
upsertQuestionAcrossMessages,
|
upsertQuestionAcrossMessages,
|
||||||
} from "./agentChatSessionState";
|
} from "./agentChatSessionState";
|
||||||
@@ -48,68 +50,21 @@ import type {
|
|||||||
UseAgentChatSessionOptions,
|
UseAgentChatSessionOptions,
|
||||||
} from "./useAgentChatSession.types";
|
} from "./useAgentChatSession.types";
|
||||||
|
|
||||||
const TOKEN_PLAYBACK_INTERVAL_MS = 16;
|
const completeTodos = (todoUpdate: Message["todos"]) =>
|
||||||
const TOKEN_PLAYBACK_BASE_CHARS = 28;
|
todoUpdate
|
||||||
const TOKEN_PLAYBACK_MAX_CHARS = 160;
|
? {
|
||||||
|
...todoUpdate,
|
||||||
const sliceCodePoints = (value: string, count: number) =>
|
todos: todoUpdate.todos.map((todo) =>
|
||||||
Array.from(value).slice(0, count).join("");
|
todo.status === "pending" || todo.status === "in_progress"
|
||||||
|
? {
|
||||||
let cachedSegmenter: Intl.Segmenter | null | undefined;
|
...todo,
|
||||||
|
status: "completed" as const,
|
||||||
const getSegmenter = () => {
|
updatedAt: Date.now(),
|
||||||
if (cachedSegmenter !== undefined) return cachedSegmenter;
|
|
||||||
cachedSegmenter =
|
|
||||||
typeof Intl !== "undefined" && "Segmenter" in Intl
|
|
||||||
? new Intl.Segmenter("zh", { granularity: "word" })
|
|
||||||
: null;
|
|
||||||
return cachedSegmenter;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getPlaybackChunkSize = (bufferLength: number) => {
|
|
||||||
if (bufferLength >= 600) return TOKEN_PLAYBACK_MAX_CHARS;
|
|
||||||
if (bufferLength >= 300) return 112;
|
|
||||||
if (bufferLength >= 140) return 72;
|
|
||||||
if (bufferLength >= 64) return 44;
|
|
||||||
return TOKEN_PLAYBACK_BASE_CHARS;
|
|
||||||
};
|
|
||||||
|
|
||||||
const takeNextTokenPlaybackChunk = (content: string, maxChars: number) => {
|
|
||||||
if (content.length <= maxChars) return content;
|
|
||||||
const targetChars = Math.max(12, Math.floor(maxChars * 0.68));
|
|
||||||
|
|
||||||
const segmenter = getSegmenter();
|
|
||||||
if (segmenter) {
|
|
||||||
let chunk = "";
|
|
||||||
for (const segment of segmenter.segment(content)) {
|
|
||||||
chunk += segment.segment;
|
|
||||||
if (
|
|
||||||
chunk.length >= maxChars ||
|
|
||||||
(chunk.length >= targetChars &&
|
|
||||||
/[\s,。!?、;:,.!?;:]/u.test(segment.segment))
|
|
||||||
) {
|
|
||||||
return chunk;
|
|
||||||
}
|
}
|
||||||
|
: todo,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
}
|
: undefined;
|
||||||
|
|
||||||
const phrase = content.match(/^.{1,12}?[\s,。!?、;:,.!?;:]+/u)?.[0];
|
|
||||||
if (phrase) return phrase;
|
|
||||||
|
|
||||||
const cjkChunk = content.match(
|
|
||||||
/^[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]+/u,
|
|
||||||
)?.[0];
|
|
||||||
if (cjkChunk) return sliceCodePoints(cjkChunk, Math.min(maxChars, 18));
|
|
||||||
|
|
||||||
const wordChunk = content.match(/^\S+\s*/u)?.[0];
|
|
||||||
if (wordChunk) {
|
|
||||||
return wordChunk.length <= maxChars
|
|
||||||
? wordChunk
|
|
||||||
: sliceCodePoints(wordChunk, maxChars);
|
|
||||||
}
|
|
||||||
|
|
||||||
return sliceCodePoints(content, Math.min(maxChars, 12));
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useAgentChatSession = ({
|
export const useAgentChatSession = ({
|
||||||
projectId,
|
projectId,
|
||||||
@@ -136,11 +91,6 @@ export const useAgentChatSession = ({
|
|||||||
const isSessionTitleManuallyEditedRef = useRef(false);
|
const isSessionTitleManuallyEditedRef = useRef(false);
|
||||||
const cancelPromiseRef = useRef<Promise<void> | null>(null);
|
const cancelPromiseRef = useRef<Promise<void> | null>(null);
|
||||||
const titleUpdateNonceRef = useRef(0);
|
const titleUpdateNonceRef = useRef(0);
|
||||||
const pendingTokenRef = useRef<{
|
|
||||||
assistantMessageId: string;
|
|
||||||
content: string;
|
|
||||||
} | null>(null);
|
|
||||||
const tokenPlaybackIntervalRef = useRef<number | null>(null);
|
|
||||||
const credentialRefreshRequestIdsRef = useRef(new Set<string>());
|
const credentialRefreshRequestIdsRef = useRef(new Set<string>());
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -168,83 +118,6 @@ export const useAgentChatSession = ({
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const cancelTokenPlayback = useCallback(() => {
|
|
||||||
const intervalId = tokenPlaybackIntervalRef.current;
|
|
||||||
if (intervalId === null) return;
|
|
||||||
window.clearInterval(intervalId);
|
|
||||||
tokenPlaybackIntervalRef.current = null;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const flushPendingTokens = useCallback(() => {
|
|
||||||
const pending = pendingTokenRef.current;
|
|
||||||
pendingTokenRef.current = null;
|
|
||||||
cancelTokenPlayback();
|
|
||||||
if (!pending) return;
|
|
||||||
applyTokenContent(pending.assistantMessageId, pending.content);
|
|
||||||
}, [applyTokenContent, cancelTokenPlayback]);
|
|
||||||
|
|
||||||
const scheduleTokenPlayback = useCallback(() => {
|
|
||||||
if (tokenPlaybackIntervalRef.current !== null) return;
|
|
||||||
const id = window.setInterval(() => {
|
|
||||||
const pending = pendingTokenRef.current;
|
|
||||||
if (!pending) {
|
|
||||||
window.clearInterval(id);
|
|
||||||
tokenPlaybackIntervalRef.current = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const chunk = takeNextTokenPlaybackChunk(
|
|
||||||
pending.content,
|
|
||||||
getPlaybackChunkSize(pending.content.length),
|
|
||||||
);
|
|
||||||
if (!chunk) {
|
|
||||||
window.clearInterval(id);
|
|
||||||
tokenPlaybackIntervalRef.current = null;
|
|
||||||
pendingTokenRef.current = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const remaining = pending.content.slice(chunk.length);
|
|
||||||
pendingTokenRef.current = remaining
|
|
||||||
? { assistantMessageId: pending.assistantMessageId, content: remaining }
|
|
||||||
: null;
|
|
||||||
applyTokenContent(pending.assistantMessageId, chunk);
|
|
||||||
|
|
||||||
if (!remaining) {
|
|
||||||
window.clearInterval(id);
|
|
||||||
tokenPlaybackIntervalRef.current = null;
|
|
||||||
}
|
|
||||||
}, TOKEN_PLAYBACK_INTERVAL_MS);
|
|
||||||
tokenPlaybackIntervalRef.current = id;
|
|
||||||
}, [applyTokenContent]);
|
|
||||||
|
|
||||||
const queueTokenContent = useCallback(
|
|
||||||
(assistantMessageId: string, content: string) => {
|
|
||||||
const pending = pendingTokenRef.current;
|
|
||||||
if (pending && pending.assistantMessageId !== assistantMessageId) {
|
|
||||||
flushPendingTokens();
|
|
||||||
}
|
|
||||||
pendingTokenRef.current = {
|
|
||||||
assistantMessageId,
|
|
||||||
content:
|
|
||||||
pending?.assistantMessageId === assistantMessageId
|
|
||||||
? pending.content + content
|
|
||||||
: content,
|
|
||||||
};
|
|
||||||
scheduleTokenPlayback();
|
|
||||||
},
|
|
||||||
[flushPendingTokens, scheduleTokenPlayback],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(
|
|
||||||
() => () => {
|
|
||||||
pendingTokenRef.current = null;
|
|
||||||
cancelTokenPlayback();
|
|
||||||
},
|
|
||||||
[cancelTokenPlayback],
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
isSessionTitleManuallyEditedRef.current = isSessionTitleManuallyEdited;
|
isSessionTitleManuallyEditedRef.current = isSessionTitleManuallyEdited;
|
||||||
}, [isSessionTitleManuallyEdited]);
|
}, [isSessionTitleManuallyEdited]);
|
||||||
@@ -391,10 +264,6 @@ export const useAgentChatSession = ({
|
|||||||
assistantMessageId?: string;
|
assistantMessageId?: string;
|
||||||
},
|
},
|
||||||
) => {
|
) => {
|
||||||
if (event.type !== "token") {
|
|
||||||
flushPendingTokens();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
event.type !== "session_title" &&
|
event.type !== "session_title" &&
|
||||||
"sessionId" in event &&
|
"sessionId" in event &&
|
||||||
@@ -447,7 +316,36 @@ export const useAgentChatSession = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (event.type === "token") {
|
if (event.type === "token") {
|
||||||
queueTokenContent(assistantMessageId, event.content);
|
applyTokenContent(assistantMessageId, event.content);
|
||||||
|
} else if (event.type === "final_answer") {
|
||||||
|
setMessages((prev) => {
|
||||||
|
const next = prev.map((message) =>
|
||||||
|
message.id === assistantMessageId
|
||||||
|
? { ...message, content: event.content, isError: false }
|
||||||
|
: message,
|
||||||
|
);
|
||||||
|
messagesRef.current = next;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
} else if (event.type === "activity_update") {
|
||||||
|
setMessages((prev) => {
|
||||||
|
const next = prev.map((message) =>
|
||||||
|
message.id === assistantMessageId
|
||||||
|
? { ...message, activities: upsertActivity(message.activities, event) }
|
||||||
|
: message,
|
||||||
|
);
|
||||||
|
return event.todos
|
||||||
|
? normalizeSessionTodos(
|
||||||
|
next,
|
||||||
|
{
|
||||||
|
sessionId: event.sessionId,
|
||||||
|
todos: event.todos,
|
||||||
|
createdAt: event.todosCreatedAt ?? Date.now(),
|
||||||
|
},
|
||||||
|
assistantMessageId,
|
||||||
|
)
|
||||||
|
: next;
|
||||||
|
});
|
||||||
} else if (event.type === "progress") {
|
} else if (event.type === "progress") {
|
||||||
setMessages((prev) =>
|
setMessages((prev) =>
|
||||||
prev.map((message) =>
|
prev.map((message) =>
|
||||||
@@ -583,6 +481,7 @@ export const useAgentChatSession = ({
|
|||||||
prev.map((message) => {
|
prev.map((message) => {
|
||||||
if (message.id !== assistantMessageId) return message;
|
if (message.id !== assistantMessageId) return message;
|
||||||
const completedProgress = completeRunningProgress(message.progress);
|
const completedProgress = completeRunningProgress(message.progress);
|
||||||
|
const completedActivities = completeRunningActivities(message.activities);
|
||||||
if (
|
if (
|
||||||
message.content.trim().length === 0 &&
|
message.content.trim().length === 0 &&
|
||||||
!(message.artifacts?.length)
|
!(message.artifacts?.length)
|
||||||
@@ -592,9 +491,16 @@ export const useAgentChatSession = ({
|
|||||||
content:
|
content:
|
||||||
"Agent 已完成处理,但没有生成文本回答。请查看过程记录,或换个更具体的问题重试。",
|
"Agent 已完成处理,但没有生成文本回答。请查看过程记录,或换个更具体的问题重试。",
|
||||||
progress: completedProgress,
|
progress: completedProgress,
|
||||||
|
activities: completedActivities,
|
||||||
|
todos: completeTodos(message.todos),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return { ...message, progress: completedProgress };
|
return {
|
||||||
|
...message,
|
||||||
|
progress: completedProgress,
|
||||||
|
activities: completedActivities,
|
||||||
|
todos: completeTodos(message.todos),
|
||||||
|
};
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
setIsStreaming(false);
|
setIsStreaming(false);
|
||||||
@@ -607,6 +513,7 @@ export const useAgentChatSession = ({
|
|||||||
content: message.content || `⚠️ **错误:** ${event.message}`,
|
content: message.content || `⚠️ **错误:** ${event.message}`,
|
||||||
isError: true,
|
isError: true,
|
||||||
progress: completeRunningProgress(message.progress),
|
progress: completeRunningProgress(message.progress),
|
||||||
|
activities: completeRunningActivities(message.activities, "error"),
|
||||||
todos: cancelRunningTodos(message.todos),
|
todos: cancelRunningTodos(message.todos),
|
||||||
}
|
}
|
||||||
: message,
|
: message,
|
||||||
@@ -623,6 +530,7 @@ export const useAgentChatSession = ({
|
|||||||
content: message.content || `⚠️ **${event.message}**`,
|
content: message.content || `⚠️ **${event.message}**`,
|
||||||
isError: true,
|
isError: true,
|
||||||
progress: completeRunningProgress(message.progress),
|
progress: completeRunningProgress(message.progress),
|
||||||
|
activities: completeRunningActivities(message.activities, "error"),
|
||||||
todos: cancelRunningTodos(message.todos),
|
todos: cancelRunningTodos(message.todos),
|
||||||
}
|
}
|
||||||
: message,
|
: message,
|
||||||
@@ -632,12 +540,11 @@ export const useAgentChatSession = ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
|
applyTokenContent,
|
||||||
appendArtifact,
|
appendArtifact,
|
||||||
flushPendingTokens,
|
|
||||||
getLastAssistantMessageId,
|
getLastAssistantMessageId,
|
||||||
handleCredentialRefresh,
|
handleCredentialRefresh,
|
||||||
onToolCall,
|
onToolCall,
|
||||||
queueTokenContent,
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -654,20 +561,18 @@ export const useAgentChatSession = ({
|
|||||||
onEvent: (event) => applyStreamEvent(event),
|
onEvent: (event) => applyStreamEvent(event),
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
flushPendingTokens();
|
|
||||||
if (!controller.signal.aborted) {
|
if (!controller.signal.aborted) {
|
||||||
console.error("[GlobalChatbox] Failed to resume chat stream:", error);
|
console.error("[GlobalChatbox] Failed to resume chat stream:", error);
|
||||||
setIsStreaming(false);
|
setIsStreaming(false);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
flushPendingTokens();
|
|
||||||
if (abortRef.current === controller) {
|
if (abortRef.current === controller) {
|
||||||
abortRef.current = null;
|
abortRef.current = null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[applyStreamEvent, flushPendingTokens],
|
[applyStreamEvent],
|
||||||
);
|
);
|
||||||
resumeStreamingSessionRef.current = resumeStreamingSession;
|
resumeStreamingSessionRef.current = resumeStreamingSession;
|
||||||
|
|
||||||
@@ -716,7 +621,6 @@ export const useAgentChatSession = ({
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
flushPendingTokens();
|
|
||||||
if (controller.signal.aborted) {
|
if (controller.signal.aborted) {
|
||||||
setMessages((prev) =>
|
setMessages((prev) =>
|
||||||
prev
|
prev
|
||||||
@@ -733,6 +637,7 @@ export const useAgentChatSession = ({
|
|||||||
message.content.trim().length === 0 &&
|
message.content.trim().length === 0 &&
|
||||||
!(message.artifacts?.length) &&
|
!(message.artifacts?.length) &&
|
||||||
!(message.progress?.length) &&
|
!(message.progress?.length) &&
|
||||||
|
!(message.activities?.length) &&
|
||||||
!message.todos
|
!message.todos
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -747,20 +652,19 @@ export const useAgentChatSession = ({
|
|||||||
content: `⚠️ **错误:** ${String(error)}`,
|
content: `⚠️ **错误:** ${String(error)}`,
|
||||||
isError: true,
|
isError: true,
|
||||||
progress: completeRunningProgress(message.progress),
|
progress: completeRunningProgress(message.progress),
|
||||||
|
activities: completeRunningActivities(message.activities, "error"),
|
||||||
}
|
}
|
||||||
: message,
|
: message,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
setIsStreaming(false);
|
setIsStreaming(false);
|
||||||
} finally {
|
} finally {
|
||||||
flushPendingTokens();
|
|
||||||
abortRef.current = null;
|
abortRef.current = null;
|
||||||
setIsStreaming(false);
|
setIsStreaming(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
applyStreamEvent,
|
applyStreamEvent,
|
||||||
flushPendingTokens,
|
|
||||||
getApprovalMode,
|
getApprovalMode,
|
||||||
getModel,
|
getModel,
|
||||||
isHydrating,
|
isHydrating,
|
||||||
@@ -773,7 +677,6 @@ export const useAgentChatSession = ({
|
|||||||
const abort = useCallback(() => {
|
const abort = useCallback(() => {
|
||||||
const controller = abortRef.current;
|
const controller = abortRef.current;
|
||||||
controller?.abort();
|
controller?.abort();
|
||||||
flushPendingTokens();
|
|
||||||
setIsStreaming(false);
|
setIsStreaming(false);
|
||||||
const assistantMessageId = getLastAssistantMessageId();
|
const assistantMessageId = getLastAssistantMessageId();
|
||||||
|
|
||||||
@@ -796,7 +699,7 @@ export const useAgentChatSession = ({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
cancelPromiseRef.current = trackedCancelPromise;
|
cancelPromiseRef.current = trackedCancelPromise;
|
||||||
}, [flushPendingTokens, getLastAssistantMessageId]);
|
}, [getLastAssistantMessageId]);
|
||||||
|
|
||||||
const replyPermission = useCallback(
|
const replyPermission = useCallback(
|
||||||
async (requestId: string, reply: PermissionDecision) => {
|
async (requestId: string, reply: PermissionDecision) => {
|
||||||
@@ -1009,7 +912,6 @@ export const useAgentChatSession = ({
|
|||||||
const createSession = useCallback(() => {
|
const createSession = useCallback(() => {
|
||||||
if (isHydrating || isStreaming) return;
|
if (isHydrating || isStreaming) return;
|
||||||
|
|
||||||
flushPendingTokens();
|
|
||||||
const controller = abortRef.current;
|
const controller = abortRef.current;
|
||||||
controller?.abort();
|
controller?.abort();
|
||||||
hydrationNonceRef.current += 1;
|
hydrationNonceRef.current += 1;
|
||||||
@@ -1020,7 +922,7 @@ export const useAgentChatSession = ({
|
|||||||
setIsSessionTitleManuallyEdited(false);
|
setIsSessionTitleManuallyEdited(false);
|
||||||
setSessionId(undefined);
|
setSessionId(undefined);
|
||||||
setIsStreaming(false);
|
setIsStreaming(false);
|
||||||
}, [flushPendingTokens, isHydrating, isStreaming]);
|
}, [isHydrating, isStreaming]);
|
||||||
|
|
||||||
const switchSession = useCallback(
|
const switchSession = useCallback(
|
||||||
async (nextSessionId: string, optimisticTitle?: string) => {
|
async (nextSessionId: string, optimisticTitle?: string) => {
|
||||||
|
|||||||
@@ -22,30 +22,30 @@ const FEATURE_TYPE_MAP: Record<
|
|||||||
string,
|
string,
|
||||||
{ layer: string; geometryKind: "point" | "line"; label: string }
|
{ layer: string; geometryKind: "point" | "line"; label: string }
|
||||||
> = {
|
> = {
|
||||||
junction: { layer: "geo_junctions_mat", geometryKind: "point", label: "节点" },
|
junction: { layer: "junctions", geometryKind: "point", label: "节点" },
|
||||||
junctions: { layer: "geo_junctions_mat", geometryKind: "point", label: "节点" },
|
junctions: { layer: "junctions", geometryKind: "point", label: "节点" },
|
||||||
pipe: { layer: "geo_pipes_mat", geometryKind: "line", label: "管道" },
|
pipe: { layer: "pipes", geometryKind: "line", label: "管道" },
|
||||||
pipes: { layer: "geo_pipes_mat", geometryKind: "line", label: "管道" },
|
pipes: { layer: "pipes", geometryKind: "line", label: "管道" },
|
||||||
valve: { layer: "geo_valves", geometryKind: "point", label: "阀门" },
|
valve: { layer: "valves", geometryKind: "point", label: "阀门" },
|
||||||
valves: { layer: "geo_valves", geometryKind: "point", label: "阀门" },
|
valves: { layer: "valves", geometryKind: "point", label: "阀门" },
|
||||||
reservoir: { layer: "geo_reservoirs", geometryKind: "point", label: "水源" },
|
reservoir: { layer: "reservoirs", geometryKind: "point", label: "水源" },
|
||||||
reservoirs: { layer: "geo_reservoirs", geometryKind: "point", label: "水源" },
|
reservoirs: { layer: "reservoirs", geometryKind: "point", label: "水源" },
|
||||||
pump: { layer: "geo_pumps", geometryKind: "point", label: "泵站" },
|
pump: { layer: "pumps", geometryKind: "point", label: "泵站" },
|
||||||
pumps: { layer: "geo_pumps", geometryKind: "point", label: "泵站" },
|
pumps: { layer: "pumps", geometryKind: "point", label: "泵站" },
|
||||||
tank: { layer: "geo_tanks", geometryKind: "point", label: "水池" },
|
tank: { layer: "tanks", geometryKind: "point", label: "水池" },
|
||||||
tanks: { layer: "geo_tanks", geometryKind: "point", label: "水池" },
|
tanks: { layer: "tanks", geometryKind: "point", label: "水池" },
|
||||||
};
|
};
|
||||||
|
|
||||||
const LOCATE_TOOL_CONFIG: Record<
|
const LOCATE_TOOL_CONFIG: Record<
|
||||||
string,
|
string,
|
||||||
{ layer: string; geometryKind: "point" | "line"; label: string }
|
{ layer: string; geometryKind: "point" | "line"; label: string }
|
||||||
> = {
|
> = {
|
||||||
locate_pipes: { layer: "geo_pipes_mat", geometryKind: "line", label: "管道" },
|
locate_pipes: { layer: "pipes", geometryKind: "line", label: "管道" },
|
||||||
locate_junctions: { layer: "geo_junctions_mat", geometryKind: "point", label: "节点" },
|
locate_junctions: { layer: "junctions", geometryKind: "point", label: "节点" },
|
||||||
locate_valves: { layer: "geo_valves", geometryKind: "point", label: "阀门" },
|
locate_valves: { layer: "valves", geometryKind: "point", label: "阀门" },
|
||||||
locate_reservoirs: { layer: "geo_reservoirs", geometryKind: "point", label: "水源" },
|
locate_reservoirs: { layer: "reservoirs", geometryKind: "point", label: "水源" },
|
||||||
locate_pumps: { layer: "geo_pumps", geometryKind: "point", label: "泵站" },
|
locate_pumps: { layer: "pumps", geometryKind: "point", label: "泵站" },
|
||||||
locate_tanks: { layer: "geo_tanks", geometryKind: "point", label: "水池" },
|
locate_tanks: { layer: "tanks", geometryKind: "point", label: "水池" },
|
||||||
};
|
};
|
||||||
|
|
||||||
const LOCATE_ID_PARAM_KEYS = [
|
const LOCATE_ID_PARAM_KEYS = [
|
||||||
|
|||||||
@@ -179,10 +179,7 @@ const DetectionResults: React.FC<Props> = ({ result, state, onStateChange }) =>
|
|||||||
|
|
||||||
const locateSensors = async (sensorIds: string[]) => {
|
const locateSensors = async (sensorIds: string[]) => {
|
||||||
if (!map || sensorIds.length === 0) return;
|
if (!map || sensorIds.length === 0) return;
|
||||||
let features = await queryFeaturesByIds(sensorIds, "geo_junctions_mat");
|
const features = await queryFeaturesByIds(sensorIds, "junctions");
|
||||||
if (features.length === 0) {
|
|
||||||
features = await queryFeaturesByIds(sensorIds, "geo_junctions");
|
|
||||||
}
|
|
||||||
if (features.length === 0) return;
|
if (features.length === 0) return;
|
||||||
setHighlightFeatures(features);
|
setHighlightFeatures(features);
|
||||||
const format = new GeoJSON();
|
const format = new GeoJSON();
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
|
|||||||
import dayjs, { Dayjs } from "dayjs";
|
import dayjs, { Dayjs } from "dayjs";
|
||||||
import "dayjs/locale/zh-cn";
|
import "dayjs/locale/zh-cn";
|
||||||
import { useNotification } from "@refinedev/core";
|
import { useNotification } from "@refinedev/core";
|
||||||
import { api } from "@/lib/api";
|
import { getAnalysisScheme, listAnalysisSchemes } from "@/lib/analysisRuns";
|
||||||
import { NETWORK_NAME } from "@config/config";
|
import { NETWORK_NAME } from "@config/config";
|
||||||
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
|
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
|
||||||
import { SchemeQueryEmptyState } from "@components/olmap/common/PanelEmptyState";
|
import { SchemeQueryEmptyState } from "@components/olmap/common/PanelEmptyState";
|
||||||
@@ -42,7 +42,7 @@ interface Props {
|
|||||||
export interface BurstDetectionSchemeQueryState {
|
export interface BurstDetectionSchemeQueryState {
|
||||||
queryAll: boolean;
|
queryAll: boolean;
|
||||||
queryDate: Dayjs | null;
|
queryDate: Dayjs | null;
|
||||||
expandedId: number | null;
|
expandedId: string | null;
|
||||||
hasQueried: boolean;
|
hasQueried: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,15 +130,12 @@ const SchemeQuery: React.FC<Props> = ({
|
|||||||
const handleQuery = async () => {
|
const handleQuery = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const params: Record<string, string> = {
|
const nextSchemes = (await listAnalysisSchemes({
|
||||||
scheme_type: "burst_detection",
|
runType: "burst_detection",
|
||||||
};
|
queryDate: !queryAll && queryDate
|
||||||
if (!queryAll && queryDate) {
|
? queryDate.format("YYYY-MM-DD")
|
||||||
params.query_date = queryDate.startOf("day").toISOString();
|
: undefined,
|
||||||
}
|
})) as BurstDetectionSchemeRecord[];
|
||||||
|
|
||||||
const response = await api.get("/api/v1/schemes", { params });
|
|
||||||
const nextSchemes = response.data as BurstDetectionSchemeRecord[];
|
|
||||||
setSchemes(nextSchemes);
|
setSchemes(nextSchemes);
|
||||||
setQueryField("hasQueried", true);
|
setQueryField("hasQueried", true);
|
||||||
open?.({
|
open?.({
|
||||||
@@ -157,13 +154,9 @@ const SchemeQuery: React.FC<Props> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleViewSchemeResult = async (schemeName: string) => {
|
const handleViewSchemeResult = async (runId: string) => {
|
||||||
try {
|
try {
|
||||||
const response = await api.get(
|
const schemeRecord = (await getAnalysisScheme(runId)) as BurstDetectionSchemeRecord & {
|
||||||
`/api/v1/schemes/${encodeURIComponent(schemeName)}`,
|
|
||||||
{ params: { scheme_type: "burst_detection" } },
|
|
||||||
);
|
|
||||||
const schemeRecord = response.data as BurstDetectionSchemeRecord & {
|
|
||||||
result_payload?: BurstDetectionResult;
|
result_payload?: BurstDetectionResult;
|
||||||
};
|
};
|
||||||
const normalizedResult =
|
const normalizedResult =
|
||||||
@@ -185,7 +178,7 @@ const SchemeQuery: React.FC<Props> = ({
|
|||||||
open?.({
|
open?.({
|
||||||
type: "success",
|
type: "success",
|
||||||
message: "方案加载成功",
|
message: "方案加载成功",
|
||||||
description: `已加载方案:${schemeName}`,
|
description: `已加载方案:${schemeRecord.scheme_name}`,
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
open?.({
|
open?.({
|
||||||
@@ -398,7 +391,7 @@ const SchemeQuery: React.FC<Props> = ({
|
|||||||
size="small"
|
size="small"
|
||||||
className="bg-blue-600 hover:bg-blue-700"
|
className="bg-blue-600 hover:bg-blue-700"
|
||||||
sx={{ textTransform: "none", fontWeight: 500 }}
|
sx={{ textTransform: "none", fontWeight: 500 }}
|
||||||
onClick={() => handleViewSchemeResult(scheme.scheme_name)}
|
onClick={() => handleViewSchemeResult(scheme.scheme_id)}
|
||||||
>
|
>
|
||||||
查看侦测结果
|
查看侦测结果
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ export interface BurstDetectionSchemeDetail {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface BurstDetectionSchemeRecord {
|
export interface BurstDetectionSchemeRecord {
|
||||||
scheme_id: number;
|
scheme_id: string;
|
||||||
scheme_name: string;
|
scheme_name: string;
|
||||||
scheme_type?: string;
|
scheme_type?: string;
|
||||||
create_time: string;
|
create_time: string;
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { useNotification } from "@refinedev/core";
|
|||||||
import dayjs, { Dayjs } from "dayjs";
|
import dayjs, { Dayjs } from "dayjs";
|
||||||
import "dayjs/locale/zh-cn";
|
import "dayjs/locale/zh-cn";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
|
import { listAnalysisSchemes } from "@/lib/analysisRuns";
|
||||||
import { NETWORK_NAME, config } from "@config/config";
|
import { NETWORK_NAME, config } from "@config/config";
|
||||||
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
|
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
|
||||||
import { useSessionRecoveryDraft } from "@/lib/sessionRecoveryDraft";
|
import { useSessionRecoveryDraft } from "@/lib/sessionRecoveryDraft";
|
||||||
@@ -46,7 +47,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface SchemeItem {
|
export interface SchemeItem {
|
||||||
scheme_id: number;
|
scheme_id: string;
|
||||||
scheme_name: string;
|
scheme_name: string;
|
||||||
scheme_type: string;
|
scheme_type: string;
|
||||||
create_time: string;
|
create_time: string;
|
||||||
@@ -63,7 +64,7 @@ export interface BurstLocationAnalysisParametersState {
|
|||||||
schemeName: string;
|
schemeName: string;
|
||||||
dataSource: DataSource;
|
dataSource: DataSource;
|
||||||
schemes: SchemeItem[];
|
schemes: SchemeItem[];
|
||||||
selectedSchemeId: number | "";
|
selectedSchemeId: string;
|
||||||
burstLeakage: number;
|
burstLeakage: number;
|
||||||
enableFlow: boolean;
|
enableFlow: boolean;
|
||||||
burstStartTime: Dayjs | null;
|
burstStartTime: Dayjs | null;
|
||||||
@@ -141,12 +142,9 @@ const AnalysisParameters: React.FC<Props> = ({
|
|||||||
|
|
||||||
setSchemeLoading(true);
|
setSchemeLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await api.get(`${config.BACKEND_URL}/api/v1/schemes`, {
|
const burstSchemes = (await listAnalysisSchemes({
|
||||||
params: { scheme_type: "burst_analysis" },
|
runType: "burst_analysis",
|
||||||
});
|
}) as unknown as SchemeItem[]).sort(
|
||||||
const burstSchemes = (response.data as SchemeItem[]).filter(
|
|
||||||
(scheme) => scheme.scheme_type === "burst_analysis",
|
|
||||||
).sort(
|
|
||||||
(a, b) =>
|
(a, b) =>
|
||||||
dayjs(b.create_time).valueOf() - dayjs(a.create_time).valueOf(),
|
dayjs(b.create_time).valueOf() - dayjs(a.create_time).valueOf(),
|
||||||
);
|
);
|
||||||
@@ -194,7 +192,7 @@ const AnalysisParameters: React.FC<Props> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSchemeSelect = (schemeId: number) => {
|
const handleSchemeSelect = (schemeId: string) => {
|
||||||
setFormField("selectedSchemeId", schemeId);
|
setFormField("selectedSchemeId", schemeId);
|
||||||
const scheme = schemes.find((item) => item.scheme_id === schemeId);
|
const scheme = schemes.find((item) => item.scheme_id === schemeId);
|
||||||
if (scheme) {
|
if (scheme) {
|
||||||
@@ -253,8 +251,7 @@ const AnalysisParameters: React.FC<Props> = ({
|
|||||||
scada_burst_start: burstStartTime.toISOString(),
|
scada_burst_start: burstStartTime.toISOString(),
|
||||||
scada_burst_end: burstEndTime.toISOString(),
|
scada_burst_end: burstEndTime.toISOString(),
|
||||||
use_scada_flow: enableFlow || undefined,
|
use_scada_flow: enableFlow || undefined,
|
||||||
simulation_scheme_name: selectedScheme?.scheme_name,
|
simulation_run_id: selectedScheme?.scheme_id,
|
||||||
simulation_scheme_type: selectedScheme?.scheme_type,
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -370,7 +367,7 @@ const AnalysisParameters: React.FC<Props> = ({
|
|||||||
<FormControl fullWidth size="small">
|
<FormControl fullWidth size="small">
|
||||||
<Select
|
<Select
|
||||||
value={selectedSchemeId}
|
value={selectedSchemeId}
|
||||||
onChange={(e) => handleSchemeSelect(Number(e.target.value))}
|
onChange={(e) => handleSchemeSelect(String(e.target.value))}
|
||||||
disabled={schemeLoading}
|
disabled={schemeLoading}
|
||||||
displayEmpty
|
displayEmpty
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -202,10 +202,7 @@ const LocationResults: React.FC<Props> = ({ result }) => {
|
|||||||
if (!pipeIds.length || !map) return;
|
if (!pipeIds.length || !map) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let features = await queryFeaturesByIds(pipeIds, "geo_pipes_mat");
|
const features = await queryFeaturesByIds(pipeIds, "pipes");
|
||||||
if (features.length === 0) {
|
|
||||||
features = await queryFeaturesByIds(pipeIds, "geo_pipes");
|
|
||||||
}
|
|
||||||
if (features.length === 0) return;
|
if (features.length === 0) return;
|
||||||
|
|
||||||
setHighlightFeatures(features);
|
setHighlightFeatures(features);
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
|
|||||||
import "dayjs/locale/zh-cn";
|
import "dayjs/locale/zh-cn";
|
||||||
import dayjs, { Dayjs } from "dayjs";
|
import dayjs, { Dayjs } from "dayjs";
|
||||||
import { useNotification } from "@refinedev/core";
|
import { useNotification } from "@refinedev/core";
|
||||||
import { api } from "@/lib/api";
|
import { getAnalysisScheme, listAnalysisSchemes } from "@/lib/analysisRuns";
|
||||||
import { NETWORK_NAME, config } from "@config/config";
|
import { NETWORK_NAME } from "@config/config";
|
||||||
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
|
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
|
||||||
import { useMap } from "@components/olmap/core/MapComponent";
|
import { useMap } from "@components/olmap/core/MapComponent";
|
||||||
import { queryFeaturesByIds } from "@/utils/mapQueryService";
|
import { queryFeaturesByIds } from "@/utils/mapQueryService";
|
||||||
@@ -56,7 +56,7 @@ interface Props {
|
|||||||
export interface BurstLocationSchemeQueryState {
|
export interface BurstLocationSchemeQueryState {
|
||||||
queryAll: boolean;
|
queryAll: boolean;
|
||||||
queryDate: Dayjs | null;
|
queryDate: Dayjs | null;
|
||||||
expandedId: number | null;
|
expandedId: string | null;
|
||||||
simulationBurstIdsByName: Record<string, string[]>;
|
simulationBurstIdsByName: Record<string, string[]>;
|
||||||
hasQueried: boolean;
|
hasQueried: boolean;
|
||||||
}
|
}
|
||||||
@@ -147,10 +147,7 @@ const SchemeQuery: React.FC<Props> = ({
|
|||||||
if (!uniquePipeIds.length || !map) return;
|
if (!uniquePipeIds.length || !map) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let features = await queryFeaturesByIds(uniquePipeIds, "geo_pipes_mat");
|
const features = await queryFeaturesByIds(uniquePipeIds, "pipes");
|
||||||
if (features.length === 0) {
|
|
||||||
features = await queryFeaturesByIds(uniquePipeIds, "geo_pipes");
|
|
||||||
}
|
|
||||||
if (features.length === 0) return;
|
if (features.length === 0) return;
|
||||||
|
|
||||||
setHighlightFeatures(features);
|
setHighlightFeatures(features);
|
||||||
@@ -227,23 +224,17 @@ const SchemeQuery: React.FC<Props> = ({
|
|||||||
const handleQuery = async () => {
|
const handleQuery = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const params: Record<string, string> = {
|
const [nextSchemes, simulationSchemes] = await Promise.all([
|
||||||
scheme_type: "burst_location",
|
listAnalysisSchemes({
|
||||||
};
|
runType: "burst_location",
|
||||||
if (!queryAll && queryDate) {
|
queryDate: !queryAll && queryDate
|
||||||
params.query_date = queryDate.startOf("day").toISOString();
|
? queryDate.format("YYYY-MM-DD")
|
||||||
}
|
: undefined,
|
||||||
|
|
||||||
const [response, simulationResponse] = await Promise.all([
|
|
||||||
api.get(`${config.BACKEND_URL}/api/v1/schemes`, { params }),
|
|
||||||
api.get(`${config.BACKEND_URL}/api/v1/schemes`, {
|
|
||||||
params: { scheme_type: "burst_analysis" },
|
|
||||||
}),
|
}),
|
||||||
|
listAnalysisSchemes({ runType: "burst_analysis" }),
|
||||||
]);
|
]);
|
||||||
const nextSchemes = response.data as BurstSchemeRecord[];
|
|
||||||
const nextSimulationBurstIdsByName = Object.fromEntries(
|
const nextSimulationBurstIdsByName = Object.fromEntries(
|
||||||
(simulationResponse.data as BurstSimulationSchemeItem[])
|
(simulationSchemes as BurstSimulationSchemeItem[])
|
||||||
.filter((scheme) => scheme.scheme_type === "burst_analysis")
|
|
||||||
.map((scheme) => [
|
.map((scheme) => [
|
||||||
scheme.scheme_name,
|
scheme.scheme_name,
|
||||||
normalizeBurstIds(scheme.scheme_detail?.burst_ID),
|
normalizeBurstIds(scheme.scheme_detail?.burst_ID),
|
||||||
@@ -253,7 +244,7 @@ const SchemeQuery: React.FC<Props> = ({
|
|||||||
setSchemes(
|
setSchemes(
|
||||||
nextSchemes.map((scheme) =>
|
nextSchemes.map((scheme) =>
|
||||||
enrichSchemeWithSimulationBurstIds(
|
enrichSchemeWithSimulationBurstIds(
|
||||||
scheme,
|
scheme as BurstSchemeRecord,
|
||||||
nextSimulationBurstIdsByName,
|
nextSimulationBurstIdsByName,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -276,13 +267,9 @@ const SchemeQuery: React.FC<Props> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleViewSchemeResult = async (schemeName: string) => {
|
const handleViewSchemeResult = async (runId: string) => {
|
||||||
try {
|
try {
|
||||||
const response = await api.get(
|
const schemeRecord = (await getAnalysisScheme(runId)) as BurstSchemeRecord & {
|
||||||
`${config.BACKEND_URL}/api/v1/schemes/${encodeURIComponent(schemeName)}`,
|
|
||||||
{ params: { scheme_type: "burst_location" } },
|
|
||||||
);
|
|
||||||
const schemeRecord = response.data as BurstSchemeRecord & {
|
|
||||||
result_payload?: BurstLocationResult;
|
result_payload?: BurstLocationResult;
|
||||||
};
|
};
|
||||||
const normalizedResult =
|
const normalizedResult =
|
||||||
@@ -302,7 +289,7 @@ const SchemeQuery: React.FC<Props> = ({
|
|||||||
open?.({
|
open?.({
|
||||||
type: "success",
|
type: "success",
|
||||||
message: "方案加载成功",
|
message: "方案加载成功",
|
||||||
description: `已加载方案: ${schemeName}`,
|
description: `已加载方案: ${schemeRecord.scheme_name}`,
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
open?.({
|
open?.({
|
||||||
@@ -516,7 +503,7 @@ const SchemeQuery: React.FC<Props> = ({
|
|||||||
size="small"
|
size="small"
|
||||||
className="bg-blue-600 hover:bg-blue-700"
|
className="bg-blue-600 hover:bg-blue-700"
|
||||||
sx={{ textTransform: "none", fontWeight: 500 }}
|
sx={{ textTransform: "none", fontWeight: 500 }}
|
||||||
onClick={() => handleViewSchemeResult(scheme.scheme_name)}
|
onClick={() => handleViewSchemeResult(scheme.scheme_id)}
|
||||||
>
|
>
|
||||||
查看定位结果
|
查看定位结果
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ export interface BurstLocationSchemeDetail {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface BurstSchemeRecord {
|
export interface BurstSchemeRecord {
|
||||||
scheme_id: number;
|
scheme_id: string;
|
||||||
scheme_name: string;
|
scheme_name: string;
|
||||||
scheme_type?: string;
|
scheme_type?: string;
|
||||||
create_time: string;
|
create_time: string;
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
|
|||||||
if (!feature) return;
|
if (!feature) return;
|
||||||
if (
|
if (
|
||||||
feature.getGeometry()?.getType() === "Point" ||
|
feature.getGeometry()?.getType() === "Point" ||
|
||||||
(layer !== "geo_pipes_mat" && layer !== "geo_pipes")
|
layer !== "pipes"
|
||||||
) {
|
) {
|
||||||
open?.({
|
open?.({
|
||||||
type: "error",
|
type: "error",
|
||||||
@@ -248,7 +248,7 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
|
|||||||
if (highlightFeatures.length > 0 || pipePoints.length === 0) return;
|
if (highlightFeatures.length > 0 || pipePoints.length === 0) return;
|
||||||
queryFeaturesByIds(
|
queryFeaturesByIds(
|
||||||
pipePoints.map((pipe) => pipe.id),
|
pipePoints.map((pipe) => pipe.id),
|
||||||
"geo_pipes_mat",
|
"pipes",
|
||||||
).then((features) => {
|
).then((features) => {
|
||||||
if (features.length > 0) {
|
if (features.length > 0) {
|
||||||
setHighlightFeatures(features);
|
setHighlightFeatures(features);
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ jest.mock("@/utils/mapQueryService", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const scheme: SchemeRecord = {
|
const scheme: SchemeRecord = {
|
||||||
id: 17,
|
id: "17",
|
||||||
schemeName: "burst-report-demo",
|
schemeName: "burst-report-demo",
|
||||||
type: "burst_analysis",
|
type: "burst_analysis",
|
||||||
username: "operator",
|
username: "operator",
|
||||||
@@ -52,10 +52,12 @@ const feature = (id: string, diameter: number) => ({
|
|||||||
describe("AnalysisReport", () => {
|
describe("AnalysisReport", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
(queryFeaturesByIds as jest.Mock).mockImplementation(
|
(queryFeaturesByIds as jest.Mock).mockImplementation(
|
||||||
async (_ids: string[], layerName: string) =>
|
async (ids: string[], layerName: string) =>
|
||||||
layerName === "geo_pipes_mat"
|
layerName === "pipes"
|
||||||
? [feature("P-1", 315)]
|
? ids.map((id) =>
|
||||||
: [feature("P-2", 800)],
|
id === "P-1" ? feature(id, 315) : feature(id, 800),
|
||||||
|
)
|
||||||
|
: [],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -109,8 +111,8 @@ describe("AnalysisReport", () => {
|
|||||||
expect(preview.getByText("800 mm")).toBeInTheDocument();
|
expect(preview.getByText("800 mm")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
expect(queryFeaturesByIds).toHaveBeenCalledWith(
|
expect(queryFeaturesByIds).toHaveBeenCalledWith(
|
||||||
["P-2"],
|
["P-1", "P-2"],
|
||||||
"geo_pipes",
|
"pipes",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -263,7 +265,7 @@ describe("AnalysisReport", () => {
|
|||||||
),
|
),
|
||||||
).toBeInTheDocument(),
|
).toBeInTheDocument(),
|
||||||
);
|
);
|
||||||
expect(queryFeaturesByIds).toHaveBeenCalledTimes(2);
|
expect(queryFeaturesByIds).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
firstRender.unmount();
|
firstRender.unmount();
|
||||||
render(<AnalysisReport {...props} />);
|
render(<AnalysisReport {...props} />);
|
||||||
@@ -273,6 +275,6 @@ describe("AnalysisReport", () => {
|
|||||||
"800 mm",
|
"800 mm",
|
||||||
),
|
),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
expect(queryFeaturesByIds).toHaveBeenCalledTimes(2);
|
expect(queryFeaturesByIds).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,9 +27,9 @@ import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
|
|||||||
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
|
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
|
||||||
import "dayjs/locale/zh-cn"; // 引入中文包
|
import "dayjs/locale/zh-cn"; // 引入中文包
|
||||||
import dayjs, { Dayjs } from "dayjs";
|
import dayjs, { Dayjs } from "dayjs";
|
||||||
import { api } from "@/lib/api";
|
import { listAnalysisSchemes } from "@/lib/analysisRuns";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import { config, NETWORK_NAME } from "@config/config";
|
import { NETWORK_NAME } from "@config/config";
|
||||||
import { useNotification } from "@refinedev/core";
|
import { useNotification } from "@refinedev/core";
|
||||||
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
|
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
|
||||||
|
|
||||||
@@ -51,7 +51,7 @@ import {
|
|||||||
import { Point } from "ol/geom";
|
import { Point } from "ol/geom";
|
||||||
import { toLonLat } from "ol/proj";
|
import { toLonLat } from "ol/proj";
|
||||||
import Timeline from "@components/olmap/core/Controls/Timeline";
|
import Timeline from "@components/olmap/core/Controls/Timeline";
|
||||||
import { SchemaItem, SchemeRecord } from "./types";
|
import { SchemeRecord } from "./types";
|
||||||
import {
|
import {
|
||||||
getCachedPipeDiameters,
|
getCachedPipeDiameters,
|
||||||
getPipeDiameterDisplay,
|
getPipeDiameterDisplay,
|
||||||
@@ -78,7 +78,7 @@ export interface BurstSchemeQueryState {
|
|||||||
showTimeline: boolean;
|
showTimeline: boolean;
|
||||||
selectedDate: Date | undefined;
|
selectedDate: Date | undefined;
|
||||||
timeRange: { start: Date; end: Date } | undefined;
|
timeRange: { start: Date; end: Date } | undefined;
|
||||||
expandedId: number | null;
|
expandedId: string | null;
|
||||||
hasQueried: boolean;
|
hasQueried: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,17 +123,17 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
const creatorName = useSchemeCreatorName();
|
const creatorName = useSchemeCreatorName();
|
||||||
const [mapContainer, setMapContainer] = useState<HTMLElement | null>(null); // 地图容器元素
|
const [mapContainer, setMapContainer] = useState<HTMLElement | null>(null); // 地图容器元素
|
||||||
const [pipeDiametersByScheme, setPipeDiametersByScheme] = useState<
|
const [pipeDiametersByScheme, setPipeDiametersByScheme] = useState<
|
||||||
Record<number, PipeDiameterMap>
|
Record<string, PipeDiameterMap>
|
||||||
>({});
|
>({});
|
||||||
const [loadingDiameterByScheme, setLoadingDiameterByScheme] = useState<
|
const [loadingDiameterByScheme, setLoadingDiameterByScheme] = useState<
|
||||||
Record<number, boolean>
|
Record<string, boolean>
|
||||||
>({});
|
>({});
|
||||||
|
|
||||||
const { open } = useNotification();
|
const { open } = useNotification();
|
||||||
|
|
||||||
const map = useMap();
|
const map = useMap();
|
||||||
const data = useData();
|
const data = useData();
|
||||||
const { schemeName, setSchemeName } = data || {};
|
const { schemeName, setSchemeName, schemeRunId, setSchemeRunId } = data || {};
|
||||||
|
|
||||||
// 使用外部提供的 schemes 或内部状态
|
// 使用外部提供的 schemes 或内部状态
|
||||||
const schemes =
|
const schemes =
|
||||||
@@ -161,38 +161,16 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const params: Record<string, string> = {
|
const nextSchemes = (await listAnalysisSchemes({
|
||||||
scheme_type: SCHEME_TYPE,
|
runType: SCHEME_TYPE,
|
||||||
};
|
queryDate: !queryAll && queryDate
|
||||||
if (!queryAll && queryDate) {
|
? queryDate.format("YYYY-MM-DD")
|
||||||
params.query_date = queryDate.startOf("day").toISOString();
|
: undefined,
|
||||||
}
|
})) as unknown as SchemeRecord[];
|
||||||
const response = await api.get(`${config.BACKEND_URL}/api/v1/schemes`, {
|
|
||||||
params,
|
|
||||||
});
|
|
||||||
let filteredResults = response.data;
|
|
||||||
|
|
||||||
if (!queryAll) {
|
|
||||||
const formattedDate = queryDate!.format("YYYY-MM-DD");
|
|
||||||
filteredResults = response.data.filter((item: SchemaItem) => {
|
|
||||||
const itemDate = moment(item.create_time).format("YYYY-MM-DD");
|
|
||||||
return itemDate === formattedDate;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextSchemes = filteredResults.map((item: SchemaItem) => ({
|
|
||||||
id: item.scheme_id,
|
|
||||||
schemeName: item.scheme_name,
|
|
||||||
type: item.scheme_type,
|
|
||||||
username: item.username,
|
|
||||||
create_time: item.create_time,
|
|
||||||
startTime: item.scheme_start_time,
|
|
||||||
schemeDetail: item.scheme_detail,
|
|
||||||
}));
|
|
||||||
setSchemes(nextSchemes);
|
setSchemes(nextSchemes);
|
||||||
setQueryField("hasQueried", true);
|
setQueryField("hasQueried", true);
|
||||||
|
|
||||||
if (filteredResults.length === 0) {
|
if (nextSchemes.length === 0) {
|
||||||
open?.({
|
open?.({
|
||||||
type: "success",
|
type: "success",
|
||||||
message: "查询结果",
|
message: "查询结果",
|
||||||
@@ -204,7 +182,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
open?.({
|
open?.({
|
||||||
type: "success",
|
type: "success",
|
||||||
message: "查询成功",
|
message: "查询成功",
|
||||||
description: `共找到 ${filteredResults.length} 条方案记录`,
|
description: `共找到 ${nextSchemes.length} 条方案记录`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -221,7 +199,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
|
|
||||||
const handleLocatePipes = (pipeIds: string[]) => {
|
const handleLocatePipes = (pipeIds: string[]) => {
|
||||||
if (pipeIds.length > 0) {
|
if (pipeIds.length > 0) {
|
||||||
queryFeaturesByIds(pipeIds, "geo_pipes_mat").then((features) => {
|
queryFeaturesByIds(pipeIds, "pipes").then((features) => {
|
||||||
if (features.length > 0) {
|
if (features.length > 0) {
|
||||||
// 设置高亮要素
|
// 设置高亮要素
|
||||||
setHighlightFeatures(features);
|
setHighlightFeatures(features);
|
||||||
@@ -299,7 +277,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
}, [expandedId, filteredSchemes, pipeDiametersByScheme]);
|
}, [expandedId, filteredSchemes, pipeDiametersByScheme]);
|
||||||
|
|
||||||
// 内部的方案查询函数
|
// 内部的方案查询函数
|
||||||
const handleViewDetails = (id: number) => {
|
const handleViewDetails = (id: string) => {
|
||||||
const scheme = filteredSchemes.find((s) => s.id === id);
|
const scheme = filteredSchemes.find((s) => s.id === id);
|
||||||
if (!scheme) return;
|
if (!scheme) return;
|
||||||
|
|
||||||
@@ -321,6 +299,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
setSchemeName?.(scheme.schemeName);
|
setSchemeName?.(scheme.schemeName);
|
||||||
|
setSchemeRunId?.(scheme.id);
|
||||||
handleLocatePipes(scheme.schemeDetail?.burst_ID || []);
|
handleLocatePipes(scheme.schemeDetail?.burst_ID || []);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -438,6 +417,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
timeRange={timeRange}
|
timeRange={timeRange}
|
||||||
disableDateSelection={!!timeRange}
|
disableDateSelection={!!timeRange}
|
||||||
schemeName={schemeName}
|
schemeName={schemeName}
|
||||||
|
schemeRunId={schemeRunId}
|
||||||
schemeType={SCHEME_TYPE}
|
schemeType={SCHEME_TYPE}
|
||||||
/>,
|
/>,
|
||||||
mapContainer, // 渲染到地图容器中,而不是 body
|
mapContainer, // 渲染到地图容器中,而不是 body
|
||||||
|
|||||||
@@ -210,7 +210,7 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedPipeId || highlightFeature) return;
|
if (!selectedPipeId || highlightFeature) return;
|
||||||
queryFeaturesByIds([selectedPipeId], "geo_pipes_mat").then((features) => {
|
queryFeaturesByIds([selectedPipeId], "pipes").then((features) => {
|
||||||
setHighlightFeature(features[0] ?? null);
|
setHighlightFeature(features[0] ?? null);
|
||||||
});
|
});
|
||||||
}, [highlightFeature, selectedPipeId]);
|
}, [highlightFeature, selectedPipeId]);
|
||||||
@@ -245,7 +245,7 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
|
|||||||
|
|
||||||
const handleLocatePipes = (pipeIds: string[], highlight: boolean = true) => {
|
const handleLocatePipes = (pipeIds: string[], highlight: boolean = true) => {
|
||||||
if (pipeIds.length > 0) {
|
if (pipeIds.length > 0) {
|
||||||
queryFeaturesByIds(pipeIds, "geo_pipes_mat").then((features) => {
|
queryFeaturesByIds(pipeIds, "pipes").then((features) => {
|
||||||
if (features.length > 0) {
|
if (features.length > 0) {
|
||||||
if (highlight) {
|
if (highlight) {
|
||||||
// 设置高亮类型为管段
|
// 设置高亮类型为管段
|
||||||
@@ -270,7 +270,7 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
|
|||||||
|
|
||||||
const handleLocateNodes = (nodeIds: string[]) => {
|
const handleLocateNodes = (nodeIds: string[]) => {
|
||||||
if (nodeIds.length > 0) {
|
if (nodeIds.length > 0) {
|
||||||
queryFeaturesByIds(nodeIds, "geo_junctions").then((features) => {
|
queryFeaturesByIds(nodeIds, "junctions").then((features) => {
|
||||||
if (features.length > 0) {
|
if (features.length > 0) {
|
||||||
// 设置高亮类型为受影响节点
|
// 设置高亮类型为受影响节点
|
||||||
setHighlightType("affected_node");
|
setHighlightType("affected_node");
|
||||||
@@ -294,7 +294,7 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
|
|||||||
|
|
||||||
const handleLocateMustCloseValves = (valveIds: string[]) => {
|
const handleLocateMustCloseValves = (valveIds: string[]) => {
|
||||||
if (valveIds.length > 0) {
|
if (valveIds.length > 0) {
|
||||||
queryFeaturesByIds(valveIds, "geo_valves").then((features) => {
|
queryFeaturesByIds(valveIds, "valves").then((features) => {
|
||||||
if (features.length > 0) {
|
if (features.length > 0) {
|
||||||
// 设置高亮类型为必关阀门
|
// 设置高亮类型为必关阀门
|
||||||
setHighlightType("must_close");
|
setHighlightType("must_close");
|
||||||
@@ -318,7 +318,7 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
|
|||||||
|
|
||||||
const handleLocateOptionalValves = (valveIds: string[]) => {
|
const handleLocateOptionalValves = (valveIds: string[]) => {
|
||||||
if (valveIds.length > 0) {
|
if (valveIds.length > 0) {
|
||||||
queryFeaturesByIds(valveIds, "geo_valves").then((features) => {
|
queryFeaturesByIds(valveIds, "valves").then((features) => {
|
||||||
if (features.length > 0) {
|
if (features.length > 0) {
|
||||||
// 设置高亮类型为可选阀门
|
// 设置高亮类型为可选阀门
|
||||||
setHighlightType("optional");
|
setHighlightType("optional");
|
||||||
@@ -411,7 +411,7 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
|
|||||||
setSelectedPipeId(initialPipeIds[0]);
|
setSelectedPipeId(initialPipeIds[0]);
|
||||||
|
|
||||||
// 尝试获取Feature以高亮 (可选)
|
// 尝试获取Feature以高亮 (可选)
|
||||||
queryFeaturesByIds(initialPipeIds, "geo_pipes_mat").then((features) => {
|
queryFeaturesByIds(initialPipeIds, "pipes").then((features) => {
|
||||||
if (features && features.length > 0) {
|
if (features && features.length > 0) {
|
||||||
setHighlightFeature(features[0]);
|
setHighlightFeature(features[0]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,24 +44,10 @@ export const loadPipeDiameters = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const request = (async () => {
|
const request = (async () => {
|
||||||
let features = await queryFeaturesByIds(
|
const features = await queryFeaturesByIds(
|
||||||
normalizedPipeIds,
|
normalizedPipeIds,
|
||||||
"geo_pipes_mat",
|
"pipes",
|
||||||
);
|
);
|
||||||
const foundPipeIds = new Set(
|
|
||||||
features.map((feature) => String(feature.getProperties().id)),
|
|
||||||
);
|
|
||||||
const missingPipeIds = normalizedPipeIds.filter(
|
|
||||||
(pipeId) => !foundPipeIds.has(pipeId),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (missingPipeIds.length > 0) {
|
|
||||||
const fallbackFeatures = await queryFeaturesByIds(
|
|
||||||
missingPipeIds,
|
|
||||||
"geo_pipes",
|
|
||||||
);
|
|
||||||
features = [...features, ...fallbackFeatures];
|
|
||||||
}
|
|
||||||
|
|
||||||
const diameters: PipeDiameterMap = Object.fromEntries(
|
const diameters: PipeDiameterMap = Object.fromEntries(
|
||||||
normalizedPipeIds.map((pipeId) => [pipeId, null]),
|
normalizedPipeIds.map((pipeId) => [pipeId, null]),
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export interface SchemeDetail {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface SchemeRecord {
|
export interface SchemeRecord {
|
||||||
id: number;
|
id: string;
|
||||||
schemeName: string;
|
schemeName: string;
|
||||||
type: string;
|
type: string;
|
||||||
username: string;
|
username: string;
|
||||||
@@ -18,16 +18,6 @@ export interface SchemeRecord {
|
|||||||
schemeDetail?: SchemeDetail;
|
schemeDetail?: SchemeDetail;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SchemaItem {
|
|
||||||
scheme_id: number;
|
|
||||||
scheme_name: string;
|
|
||||||
scheme_type: string;
|
|
||||||
username: string;
|
|
||||||
create_time: string;
|
|
||||||
scheme_start_time: string;
|
|
||||||
scheme_detail?: SchemeDetail;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ValveIsolationResult {
|
export interface ValveIsolationResult {
|
||||||
accident_elements: string[];
|
accident_elements: string[];
|
||||||
affected_nodes: string[];
|
affected_nodes: string[];
|
||||||
|
|||||||
@@ -202,7 +202,7 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (highlightFeature) return;
|
if (highlightFeature) return;
|
||||||
queryFeaturesByIds([sourceNode], "geo_junctions_mat").then((features) => {
|
queryFeaturesByIds([sourceNode], "junctions").then((features) => {
|
||||||
setHighlightFeature(features[0] ?? null);
|
setHighlightFeature(features[0] ?? null);
|
||||||
});
|
});
|
||||||
}, [highlightFeature, sourceNode]);
|
}, [highlightFeature, sourceNode]);
|
||||||
|
|||||||
@@ -25,10 +25,10 @@ import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
|
|||||||
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
|
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
|
||||||
import "dayjs/locale/zh-cn";
|
import "dayjs/locale/zh-cn";
|
||||||
import dayjs, { Dayjs } from "dayjs";
|
import dayjs, { Dayjs } from "dayjs";
|
||||||
import { api } from "@/lib/api";
|
import { listAnalysisSchemes } from "@/lib/analysisRuns";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import { useNotification } from "@refinedev/core";
|
import { useNotification } from "@refinedev/core";
|
||||||
import { config, NETWORK_NAME } from "@config/config";
|
import { NETWORK_NAME } from "@config/config";
|
||||||
import { queryFeaturesByIds } from "@/utils/mapQueryService";
|
import { queryFeaturesByIds } from "@/utils/mapQueryService";
|
||||||
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
|
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
|
||||||
import { useData, useMap } from "@components/olmap/core/MapComponent";
|
import { useData, useMap } from "@components/olmap/core/MapComponent";
|
||||||
@@ -39,7 +39,7 @@ import { Style, Icon, Circle, Fill, Stroke } from "ol/style";
|
|||||||
import Feature from "ol/Feature";
|
import Feature from "ol/Feature";
|
||||||
import { bbox, featureCollection } from "@turf/turf";
|
import { bbox, featureCollection } from "@turf/turf";
|
||||||
import Timeline from "@components/olmap/core/Controls/Timeline";
|
import Timeline from "@components/olmap/core/Controls/Timeline";
|
||||||
import { ContaminantSchemaItem, ContaminantSchemeRecord } from "./types";
|
import { ContaminantSchemeRecord } from "./types";
|
||||||
import { useSchemeCreatorName } from "@components/olmap/core/useSchemeCreatorName";
|
import { useSchemeCreatorName } from "@components/olmap/core/useSchemeCreatorName";
|
||||||
import { SchemeQueryEmptyState } from "@components/olmap/common/PanelEmptyState";
|
import { SchemeQueryEmptyState } from "@components/olmap/common/PanelEmptyState";
|
||||||
|
|
||||||
@@ -60,7 +60,7 @@ export interface ContaminantSchemeQueryState {
|
|||||||
showTimeline: boolean;
|
showTimeline: boolean;
|
||||||
selectedDate: Date | undefined;
|
selectedDate: Date | undefined;
|
||||||
timeRange: { start: Date; end: Date } | undefined;
|
timeRange: { start: Date; end: Date } | undefined;
|
||||||
expandedId: number | null;
|
expandedId: string | null;
|
||||||
hasQueried: boolean;
|
hasQueried: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,7 +111,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
const { open } = useNotification();
|
const { open } = useNotification();
|
||||||
const map = useMap();
|
const map = useMap();
|
||||||
const data = useData();
|
const data = useData();
|
||||||
const { schemeName, setSchemeName } = data || {};
|
const { schemeName, setSchemeName, schemeRunId, setSchemeRunId } = data || {};
|
||||||
|
|
||||||
const schemes =
|
const schemes =
|
||||||
externalSchemes !== undefined ? externalSchemes : internalSchemes;
|
externalSchemes !== undefined ? externalSchemes : internalSchemes;
|
||||||
@@ -222,40 +222,16 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
if (!queryAll && !queryDate) return;
|
if (!queryAll && !queryDate) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const params: Record<string, string> = {
|
const nextSchemes = (await listAnalysisSchemes({
|
||||||
scheme_type: SCHEME_TYPE,
|
runType: SCHEME_TYPE,
|
||||||
};
|
queryDate: !queryAll && queryDate
|
||||||
if (!queryAll && queryDate) {
|
? queryDate.format("YYYY-MM-DD")
|
||||||
params.query_date = queryDate.startOf("day").toISOString();
|
: undefined,
|
||||||
}
|
})) as unknown as ContaminantSchemeRecord[];
|
||||||
const response = await api.get(`${config.BACKEND_URL}/api/v1/schemes`, {
|
|
||||||
params,
|
|
||||||
});
|
|
||||||
let filteredResults = response.data;
|
|
||||||
|
|
||||||
if (!queryAll) {
|
|
||||||
const formattedDate = queryDate!.format("YYYY-MM-DD");
|
|
||||||
filteredResults = response.data.filter(
|
|
||||||
(item: ContaminantSchemaItem) => {
|
|
||||||
const itemDate = moment(item.create_time).format("YYYY-MM-DD");
|
|
||||||
return itemDate === formattedDate;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextSchemes = filteredResults.map((item: ContaminantSchemaItem) => ({
|
|
||||||
id: item.scheme_id,
|
|
||||||
schemeName: item.scheme_name,
|
|
||||||
type: item.scheme_type,
|
|
||||||
username: item.username,
|
|
||||||
create_time: item.create_time,
|
|
||||||
startTime: item.scheme_start_time,
|
|
||||||
schemeDetail: item.scheme_detail,
|
|
||||||
}));
|
|
||||||
setSchemes(nextSchemes);
|
setSchemes(nextSchemes);
|
||||||
setQueryField("hasQueried", true);
|
setQueryField("hasQueried", true);
|
||||||
|
|
||||||
if (filteredResults.length === 0) {
|
if (nextSchemes.length === 0) {
|
||||||
open?.({
|
open?.({
|
||||||
type: "success",
|
type: "success",
|
||||||
message: "查询结果",
|
message: "查询结果",
|
||||||
@@ -267,7 +243,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
open?.({
|
open?.({
|
||||||
type: "success",
|
type: "success",
|
||||||
message: "查询成功",
|
message: "查询成功",
|
||||||
description: `共找到 ${filteredResults.length} 条方案记录`,
|
description: `共找到 ${nextSchemes.length} 条方案记录`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -284,7 +260,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
|
|
||||||
const handleLocateSource = (sourceIds: string[]) => {
|
const handleLocateSource = (sourceIds: string[]) => {
|
||||||
if (sourceIds.length > 0) {
|
if (sourceIds.length > 0) {
|
||||||
queryFeaturesByIds(sourceIds, "geo_junctions_mat").then((features) => {
|
queryFeaturesByIds(sourceIds, "junctions").then((features) => {
|
||||||
if (features.length > 0) {
|
if (features.length > 0) {
|
||||||
// 设置高亮要素
|
// 设置高亮要素
|
||||||
setHighlightFeatures(features);
|
setHighlightFeatures(features);
|
||||||
@@ -307,7 +283,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleViewDetails = (id: number) => {
|
const handleViewDetails = (id: string) => {
|
||||||
const scheme = filteredSchemes.find((s) => s.id === id);
|
const scheme = filteredSchemes.find((s) => s.id === id);
|
||||||
if (!scheme) return;
|
if (!scheme) return;
|
||||||
|
|
||||||
@@ -328,6 +304,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
setSchemeName?.(scheme.schemeName);
|
setSchemeName?.(scheme.schemeName);
|
||||||
|
setSchemeRunId?.(scheme.id);
|
||||||
if (scheme.schemeDetail?.source) {
|
if (scheme.schemeDetail?.source) {
|
||||||
handleLocateSource([scheme.schemeDetail.source]);
|
handleLocateSource([scheme.schemeDetail.source]);
|
||||||
}
|
}
|
||||||
@@ -343,6 +320,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
timeRange={timeRange}
|
timeRange={timeRange}
|
||||||
disableDateSelection={!!timeRange}
|
disableDateSelection={!!timeRange}
|
||||||
schemeName={schemeName}
|
schemeName={schemeName}
|
||||||
|
schemeRunId={schemeRunId}
|
||||||
schemeType={SCHEME_TYPE}
|
schemeType={SCHEME_TYPE}
|
||||||
/>,
|
/>,
|
||||||
mapContainer,
|
mapContainer,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export interface ContaminantSchemeDetail {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ContaminantSchemeRecord {
|
export interface ContaminantSchemeRecord {
|
||||||
id: number;
|
id: string;
|
||||||
schemeName: string;
|
schemeName: string;
|
||||||
type: string;
|
type: string;
|
||||||
username: string;
|
username: string;
|
||||||
@@ -14,13 +14,3 @@ export interface ContaminantSchemeRecord {
|
|||||||
startTime: string;
|
startTime: string;
|
||||||
schemeDetail?: ContaminantSchemeDetail;
|
schemeDetail?: ContaminantSchemeDetail;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ContaminantSchemaItem {
|
|
||||||
scheme_id: number;
|
|
||||||
scheme_name: string;
|
|
||||||
scheme_type: string;
|
|
||||||
username: string;
|
|
||||||
create_time: string;
|
|
||||||
scheme_start_time: string;
|
|
||||||
scheme_detail?: ContaminantSchemeDetail;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -21,8 +21,11 @@ import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
|
|||||||
import "dayjs/locale/zh-cn";
|
import "dayjs/locale/zh-cn";
|
||||||
import dayjs, { Dayjs } from "dayjs";
|
import dayjs, { Dayjs } from "dayjs";
|
||||||
import { useNotification } from "@refinedev/core";
|
import { useNotification } from "@refinedev/core";
|
||||||
import { api } from "@/lib/api";
|
import {
|
||||||
import { NETWORK_NAME, config } from "@config/config";
|
getAnalysisResults,
|
||||||
|
getAnalysisScheme,
|
||||||
|
listAnalysisSchemes,
|
||||||
|
} from "@/lib/analysisRuns";
|
||||||
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
|
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
|
||||||
import { LeakageResultDetail, LeakageSchemeRecord } from "./types";
|
import { LeakageResultDetail, LeakageSchemeRecord } from "./types";
|
||||||
import { FLOW_DISPLAY_UNIT, toM3h } from "@utils/units";
|
import { FLOW_DISPLAY_UNIT, toM3h } from "@utils/units";
|
||||||
@@ -40,7 +43,7 @@ interface Props {
|
|||||||
export interface DMALeakSchemeQueryState {
|
export interface DMALeakSchemeQueryState {
|
||||||
queryAll: boolean;
|
queryAll: boolean;
|
||||||
queryDate: Dayjs | null;
|
queryDate: Dayjs | null;
|
||||||
expandedId: number | null;
|
expandedId: string | null;
|
||||||
hasQueried: boolean;
|
hasQueried: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,16 +87,12 @@ const SchemeQuery: React.FC<Props> = ({
|
|||||||
const handleQuery = async () => {
|
const handleQuery = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const params: Record<string, string> = {
|
const nextSchemes = (await listAnalysisSchemes({
|
||||||
scheme_type: "dma_leak_identification",
|
runType: "dma_leak_identification",
|
||||||
};
|
queryDate: !queryAll && queryDate
|
||||||
if (!queryAll && queryDate) {
|
? queryDate.format("YYYY-MM-DD")
|
||||||
params.query_date = queryDate.startOf("day").toISOString();
|
: undefined,
|
||||||
}
|
})) as LeakageSchemeRecord[];
|
||||||
const response = await api.get(`${config.BACKEND_URL}/api/v1/schemes`, {
|
|
||||||
params,
|
|
||||||
});
|
|
||||||
const nextSchemes = response.data as LeakageSchemeRecord[];
|
|
||||||
setSchemes(nextSchemes);
|
setSchemes(nextSchemes);
|
||||||
setQueryField("hasQueried", true);
|
setQueryField("hasQueried", true);
|
||||||
if (nextSchemes.length === 0) {
|
if (nextSchemes.length === 0) {
|
||||||
@@ -122,22 +121,30 @@ const SchemeQuery: React.FC<Props> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleViewSchemeResult = async (schemeName: string) => {
|
const handleViewSchemeResult = async (runId: string) => {
|
||||||
try {
|
try {
|
||||||
const response = await api.get(
|
const [scheme, results] = await Promise.all([
|
||||||
`${config.BACKEND_URL}/api/v1/schemes/${encodeURIComponent(schemeName)}`,
|
getAnalysisScheme(runId),
|
||||||
{
|
getAnalysisResults(runId, "leakage_identification"),
|
||||||
params: {
|
]);
|
||||||
scheme_type: "dma_leak_identification",
|
const result = results[0]?.payload;
|
||||||
},
|
if (!result) {
|
||||||
},
|
throw new Error("方案详情缺少漏损识别结果");
|
||||||
);
|
}
|
||||||
onViewResult(response.data as LeakageResultDetail);
|
onViewResult({
|
||||||
|
...result,
|
||||||
|
scheme_name: scheme.scheme_name,
|
||||||
|
scheme_detail: scheme.scheme_detail,
|
||||||
|
scheme_start_time: scheme.scheme_start_time,
|
||||||
|
create_time: scheme.create_time,
|
||||||
|
username: scheme.username,
|
||||||
|
} as LeakageResultDetail);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
open?.({
|
open?.({
|
||||||
type: "error",
|
type: "error",
|
||||||
message: "查看详情失败",
|
message: "查看详情失败",
|
||||||
description: error?.response?.data?.detail ?? "无法获取方案详情",
|
description:
|
||||||
|
error?.response?.data?.detail ?? error?.message ?? "无法获取方案详情",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -293,7 +300,7 @@ const SchemeQuery: React.FC<Props> = ({
|
|||||||
size="small"
|
size="small"
|
||||||
className="bg-blue-600 hover:bg-blue-700"
|
className="bg-blue-600 hover:bg-blue-700"
|
||||||
sx={{ textTransform: "none", fontWeight: 500 }}
|
sx={{ textTransform: "none", fontWeight: 500 }}
|
||||||
onClick={() => handleViewSchemeResult(scheme.scheme_name)}
|
onClick={() => handleViewSchemeResult(scheme.scheme_id)}
|
||||||
>
|
>
|
||||||
查看识别结果
|
查看识别结果
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export interface LeakageRow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface LeakageSchemeRecord {
|
export interface LeakageSchemeRecord {
|
||||||
scheme_id: number;
|
scheme_id: string;
|
||||||
scheme_name: string;
|
scheme_name: string;
|
||||||
scheme_type: string;
|
scheme_type: string;
|
||||||
username: string;
|
username: string;
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
|
|||||||
const featureId = String(feature.getProperties().id);
|
const featureId = String(feature.getProperties().id);
|
||||||
|
|
||||||
if (selectionMode === 'valve') {
|
if (selectionMode === 'valve') {
|
||||||
if (layer !== 'geo_valves') {
|
if (layer !== 'valves') {
|
||||||
open?.({
|
open?.({
|
||||||
type: "error",
|
type: "error",
|
||||||
message: "请选择阀门要素",
|
message: "请选择阀门要素",
|
||||||
@@ -173,7 +173,7 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
} else if (selectionMode === 'drainage') {
|
} else if (selectionMode === 'drainage') {
|
||||||
if (layer !== 'geo_junctions') {
|
if (layer !== 'junctions') {
|
||||||
open?.({
|
open?.({
|
||||||
type: "error",
|
type: "error",
|
||||||
message: "请选择节点要素作为排水点",
|
message: "请选择节点要素作为排水点",
|
||||||
@@ -277,7 +277,7 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
|
|||||||
if (valveFeatures.length > 0) return;
|
if (valveFeatures.length > 0) return;
|
||||||
queryFeaturesByIds(
|
queryFeaturesByIds(
|
||||||
valves.map((valve) => valve.id),
|
valves.map((valve) => valve.id),
|
||||||
"geo_valves",
|
"valves",
|
||||||
).then((features) => {
|
).then((features) => {
|
||||||
setValveFeatures(features);
|
setValveFeatures(features);
|
||||||
});
|
});
|
||||||
@@ -374,7 +374,7 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (drainageFeature) return;
|
if (drainageFeature) return;
|
||||||
queryFeaturesByIds([drainageNode], "geo_junctions").then((features) => {
|
queryFeaturesByIds([drainageNode], "junctions").then((features) => {
|
||||||
setDrainageFeature(features[0] ?? null);
|
setDrainageFeature(features[0] ?? null);
|
||||||
});
|
});
|
||||||
}, [drainageFeature, drainageNode]);
|
}, [drainageFeature, drainageNode]);
|
||||||
|
|||||||
@@ -26,9 +26,9 @@ import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
|
|||||||
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
|
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
|
||||||
import "dayjs/locale/zh-cn";
|
import "dayjs/locale/zh-cn";
|
||||||
import dayjs, { Dayjs } from "dayjs";
|
import dayjs, { Dayjs } from "dayjs";
|
||||||
import { api } from "@/lib/api";
|
import { listAnalysisSchemes } from "@/lib/analysisRuns";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import { config, NETWORK_NAME } from "@config/config";
|
import { NETWORK_NAME } from "@config/config";
|
||||||
import { useNotification } from "@refinedev/core";
|
import { useNotification } from "@refinedev/core";
|
||||||
import { useData, useMap } from "@components/olmap/core/MapComponent";
|
import { useData, useMap } from "@components/olmap/core/MapComponent";
|
||||||
import { queryFeaturesByIds } from "@/utils/mapQueryService";
|
import { queryFeaturesByIds } from "@/utils/mapQueryService";
|
||||||
@@ -40,7 +40,7 @@ import { Style, Icon, Circle, Fill, Stroke } from "ol/style";
|
|||||||
import Feature, { FeatureLike } from "ol/Feature";
|
import Feature, { FeatureLike } from "ol/Feature";
|
||||||
import { bbox, featureCollection } from "@turf/turf";
|
import { bbox, featureCollection } from "@turf/turf";
|
||||||
import Timeline from "@components/olmap/core/Controls/Timeline";
|
import Timeline from "@components/olmap/core/Controls/Timeline";
|
||||||
import { SchemeRecord, SchemaItem } from "./types";
|
import { SchemeRecord } from "./types";
|
||||||
import { FLOW_DISPLAY_UNIT } from "@utils/units";
|
import { FLOW_DISPLAY_UNIT } from "@utils/units";
|
||||||
import { useSchemeCreatorName } from "@components/olmap/core/useSchemeCreatorName";
|
import { useSchemeCreatorName } from "@components/olmap/core/useSchemeCreatorName";
|
||||||
import { SchemeQueryEmptyState } from "@components/olmap/common/PanelEmptyState";
|
import { SchemeQueryEmptyState } from "@components/olmap/common/PanelEmptyState";
|
||||||
@@ -59,7 +59,7 @@ const SCHEME_TYPE = "flushing_analysis";
|
|||||||
export interface FlushingSchemeQueryState {
|
export interface FlushingSchemeQueryState {
|
||||||
queryAll: boolean;
|
queryAll: boolean;
|
||||||
queryDate: Dayjs | null;
|
queryDate: Dayjs | null;
|
||||||
expandedId: number | null;
|
expandedId: string | null;
|
||||||
showTimeline: boolean;
|
showTimeline: boolean;
|
||||||
selectedDate: Date | undefined;
|
selectedDate: Date | undefined;
|
||||||
timeRange: { start: Date; end: Date } | undefined;
|
timeRange: { start: Date; end: Date } | undefined;
|
||||||
@@ -111,7 +111,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
const { open } = useNotification();
|
const { open } = useNotification();
|
||||||
const map = useMap();
|
const map = useMap();
|
||||||
const data = useData();
|
const data = useData();
|
||||||
const { schemeName, setSchemeName } = data || {};
|
const { schemeName, setSchemeName, schemeRunId, setSchemeRunId } = data || {};
|
||||||
|
|
||||||
const schemes = externalSchemes !== undefined ? externalSchemes : internalSchemes;
|
const schemes = externalSchemes !== undefined ? externalSchemes : internalSchemes;
|
||||||
const setSchemes = onSchemesChange || setInternalSchemes;
|
const setSchemes = onSchemesChange || setInternalSchemes;
|
||||||
@@ -214,7 +214,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
|
|
||||||
const handleLocateDrainageNode = (nodeId: string) => {
|
const handleLocateDrainageNode = (nodeId: string) => {
|
||||||
if (!nodeId) return;
|
if (!nodeId) return;
|
||||||
queryFeaturesByIds([nodeId], "geo_junctions_mat").then((features) => {
|
queryFeaturesByIds([nodeId], "junctions").then((features) => {
|
||||||
if (features.length > 0) {
|
if (features.length > 0) {
|
||||||
// Add type property to distinguish styling
|
// Add type property to distinguish styling
|
||||||
features.forEach(f => f.set("type", "drainage"));
|
features.forEach(f => f.set("type", "drainage"));
|
||||||
@@ -231,7 +231,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
|
|
||||||
const handleLocateValves = (valveIds: string[]) => {
|
const handleLocateValves = (valveIds: string[]) => {
|
||||||
if (!valveIds || valveIds.length === 0) return;
|
if (!valveIds || valveIds.length === 0) return;
|
||||||
queryFeaturesByIds(valveIds, "geo_valves").then((features) => {
|
queryFeaturesByIds(valveIds, "valves").then((features) => {
|
||||||
if (features.length > 0) {
|
if (features.length > 0) {
|
||||||
features.forEach(f => f.set("type", "valve"));
|
features.forEach(f => f.set("type", "valve"));
|
||||||
setHighlightFeatures(features);
|
setHighlightFeatures(features);
|
||||||
@@ -269,42 +269,16 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const params: Record<string, string> = {
|
const nextSchemes = (await listAnalysisSchemes({
|
||||||
scheme_type: SCHEME_TYPE,
|
runType: SCHEME_TYPE,
|
||||||
};
|
queryDate: !queryAll && queryDate
|
||||||
if (!queryAll && queryDate) {
|
? queryDate.format("YYYY-MM-DD")
|
||||||
params.query_date = queryDate.startOf("day").toISOString();
|
: undefined,
|
||||||
}
|
})) as unknown as SchemeRecord[];
|
||||||
const response = await api.get(`${config.BACKEND_URL}/api/v1/schemes`, {
|
|
||||||
params,
|
|
||||||
});
|
|
||||||
|
|
||||||
let filteredResults = response.data;
|
|
||||||
|
|
||||||
// Filter by type
|
|
||||||
filteredResults = filteredResults.filter((item: SchemaItem) => item.scheme_type === SCHEME_TYPE);
|
|
||||||
|
|
||||||
if (!queryAll && queryDate) {
|
|
||||||
const formattedDate = queryDate.format("YYYY-MM-DD");
|
|
||||||
filteredResults = filteredResults.filter((item: SchemaItem) => {
|
|
||||||
const itemDate = moment(item.create_time).format("YYYY-MM-DD");
|
|
||||||
return itemDate === formattedDate;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextSchemes = filteredResults.map((item: SchemaItem) => ({
|
|
||||||
id: item.scheme_id,
|
|
||||||
schemeName: item.scheme_name,
|
|
||||||
type: item.scheme_type,
|
|
||||||
username: item.username,
|
|
||||||
create_time: item.create_time,
|
|
||||||
startTime: item.scheme_start_time,
|
|
||||||
schemeDetail: item.scheme_detail,
|
|
||||||
}));
|
|
||||||
setSchemes(nextSchemes);
|
setSchemes(nextSchemes);
|
||||||
setQueryField("hasQueried", true);
|
setQueryField("hasQueried", true);
|
||||||
|
|
||||||
if (filteredResults.length === 0) {
|
if (nextSchemes.length === 0) {
|
||||||
open?.({
|
open?.({
|
||||||
type: "success",
|
type: "success",
|
||||||
message: "未找到相关方案",
|
message: "未找到相关方案",
|
||||||
@@ -314,7 +288,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
open?.({
|
open?.({
|
||||||
type: "success",
|
type: "success",
|
||||||
message: "查询成功",
|
message: "查询成功",
|
||||||
description: `共找到 ${filteredResults.length} 条方案记录`,
|
description: `共找到 ${nextSchemes.length} 条方案记录`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -346,6 +320,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
setSchemeName?.(scheme.schemeName);
|
setSchemeName?.(scheme.schemeName);
|
||||||
|
setSchemeRunId?.(scheme.id);
|
||||||
|
|
||||||
// Locate drainage node by default if available
|
// Locate drainage node by default if available
|
||||||
if (scheme.schemeDetail?.drainage_node_ID) {
|
if (scheme.schemeDetail?.drainage_node_ID) {
|
||||||
@@ -363,6 +338,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
timeRange={timeRange}
|
timeRange={timeRange}
|
||||||
disableDateSelection={!!timeRange}
|
disableDateSelection={!!timeRange}
|
||||||
schemeName={schemeName}
|
schemeName={schemeName}
|
||||||
|
schemeRunId={schemeRunId}
|
||||||
schemeType={SCHEME_TYPE}
|
schemeType={SCHEME_TYPE}
|
||||||
/>,
|
/>,
|
||||||
mapContainer,
|
mapContainer,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export interface SchemeDetail {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface SchemeRecord {
|
export interface SchemeRecord {
|
||||||
id: number;
|
id: string;
|
||||||
schemeName: string;
|
schemeName: string;
|
||||||
type: string;
|
type: string;
|
||||||
username: string;
|
username: string;
|
||||||
@@ -23,13 +23,3 @@ export interface SchemeRecord {
|
|||||||
// 详情信息
|
// 详情信息
|
||||||
schemeDetail?: SchemeDetail;
|
schemeDetail?: SchemeDetail;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SchemaItem {
|
|
||||||
scheme_id: number;
|
|
||||||
scheme_name: string;
|
|
||||||
scheme_type: string;
|
|
||||||
username: string;
|
|
||||||
create_time: string;
|
|
||||||
scheme_start_time: string;
|
|
||||||
scheme_detail?: SchemeDetail;
|
|
||||||
}
|
|
||||||
|
|||||||
+3
-3
@@ -26,11 +26,11 @@ jest.mock("./SchemeQuery", () => ({
|
|||||||
onEdit,
|
onEdit,
|
||||||
active,
|
active,
|
||||||
}: {
|
}: {
|
||||||
onEdit: (schemeId: number) => void;
|
onEdit: (schemeId: string) => void;
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
mockSchemeQueryRender(active);
|
mockSchemeQueryRender(active);
|
||||||
return <button onClick={() => onEdit(7)}>打开测试方案</button>;
|
return <button onClick={() => onEdit("run-7")}>打开测试方案</button>;
|
||||||
},
|
},
|
||||||
createMonitoringSchemeQueryState: () => ({}),
|
createMonitoringSchemeQueryState: () => ({}),
|
||||||
}));
|
}));
|
||||||
@@ -61,7 +61,7 @@ jest.mock("@components/olmap/common/PanelEmptyState", () => ({
|
|||||||
const mockGetSensorPlacementScheme = jest.mocked(getSensorPlacementScheme);
|
const mockGetSensorPlacementScheme = jest.mocked(getSensorPlacementScheme);
|
||||||
|
|
||||||
const scheme: SensorPlacementScheme = {
|
const scheme: SensorPlacementScheme = {
|
||||||
id: 7,
|
id: "run-7",
|
||||||
scheme_name: "测试方案",
|
scheme_name: "测试方案",
|
||||||
sensor_number: 1,
|
sensor_number: 1,
|
||||||
min_diameter: 100,
|
min_diameter: 100,
|
||||||
|
|||||||
+2
-2
@@ -103,7 +103,7 @@ const MonitoringPlaceOptimizationPanel: React.FC<
|
|||||||
const [activeScheme, setActiveScheme] =
|
const [activeScheme, setActiveScheme] =
|
||||||
useState<SensorPlacementScheme | null>(null);
|
useState<SensorPlacementScheme | null>(null);
|
||||||
const [loadingScheme, setLoadingScheme] = useState(false);
|
const [loadingScheme, setLoadingScheme] = useState(false);
|
||||||
const [pendingOpenSchemeId, setPendingOpenSchemeId] = useState<number | null>(
|
const [pendingOpenSchemeId, setPendingOpenSchemeId] = useState<string | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const { open: notify } = useNotification();
|
const { open: notify } = useNotification();
|
||||||
@@ -138,7 +138,7 @@ const MonitoringPlaceOptimizationPanel: React.FC<
|
|||||||
setPendingOpenSchemeId(null);
|
setPendingOpenSchemeId(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleOpenScheme = async (schemeId: number) => {
|
const handleOpenScheme = async (schemeId: string) => {
|
||||||
setLoadingScheme(true);
|
setLoadingScheme(true);
|
||||||
try {
|
try {
|
||||||
const scheme = await getSensorPlacementScheme(schemeId);
|
const scheme = await getSensorPlacementScheme(schemeId);
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ const OptimizationParameters: React.FC<OptimizationParametersProps> = ({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const created = await optimizeSensorPlacement({
|
const created = await optimizeSensorPlacement({
|
||||||
scheme_name: normalizeSchemeName(schemeName),
|
run_name: normalizeSchemeName(schemeName),
|
||||||
sensor_type: "pressure",
|
sensor_type: "pressure",
|
||||||
method: method as "sensitivity" | "kmeans",
|
method: method as "sensitivity" | "kmeans",
|
||||||
sensor_count: sensorCount,
|
sensor_count: sensorCount,
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ jest.mock("./schemeApi", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const scheme: SensorPlacementScheme = {
|
const scheme: SensorPlacementScheme = {
|
||||||
id: 1,
|
id: "run-1",
|
||||||
scheme_name: "测试方案",
|
scheme_name: "测试方案",
|
||||||
sensor_number: 1,
|
sensor_number: 1,
|
||||||
min_diameter: 300,
|
min_diameter: 300,
|
||||||
|
|||||||
@@ -24,9 +24,8 @@ import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
|
|||||||
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
|
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
|
||||||
import "dayjs/locale/zh-cn"; // 引入中文包
|
import "dayjs/locale/zh-cn"; // 引入中文包
|
||||||
import dayjs, { Dayjs } from "dayjs";
|
import dayjs, { Dayjs } from "dayjs";
|
||||||
import { api } from "@/lib/api";
|
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import { config, NETWORK_NAME } from "@config/config";
|
import { NETWORK_NAME } from "@config/config";
|
||||||
import { useNotification } from "@refinedev/core";
|
import { useNotification } from "@refinedev/core";
|
||||||
import { useMap } from "@components/olmap/core/MapComponent";
|
import { useMap } from "@components/olmap/core/MapComponent";
|
||||||
import { queryFeaturesByIds } from "@/utils/mapQueryService";
|
import { queryFeaturesByIds } from "@/utils/mapQueryService";
|
||||||
@@ -40,22 +39,13 @@ import { bbox, featureCollection } from "@turf/turf";
|
|||||||
import type { SchemeRecord } from "./types";
|
import type { SchemeRecord } from "./types";
|
||||||
import { useSchemeCreatorName } from "@components/olmap/core/useSchemeCreatorName";
|
import { useSchemeCreatorName } from "@components/olmap/core/useSchemeCreatorName";
|
||||||
import { SchemeQueryEmptyState } from "@components/olmap/common/PanelEmptyState";
|
import { SchemeQueryEmptyState } from "@components/olmap/common/PanelEmptyState";
|
||||||
|
import { listSensorPlacementSchemes } from "./schemeApi";
|
||||||
interface SchemaItem {
|
|
||||||
id: number;
|
|
||||||
scheme_name: string;
|
|
||||||
sensor_number: number;
|
|
||||||
min_diameter: number;
|
|
||||||
username: string;
|
|
||||||
create_time: string;
|
|
||||||
sensor_location?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SchemeQueryProps {
|
interface SchemeQueryProps {
|
||||||
schemes?: SchemeRecord[];
|
schemes?: SchemeRecord[];
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
onSchemesChange?: (schemes: SchemeRecord[]) => void;
|
onSchemesChange?: (schemes: SchemeRecord[]) => void;
|
||||||
onEdit?: (id: number) => void;
|
onEdit?: (id: string) => void;
|
||||||
network?: string;
|
network?: string;
|
||||||
state?: MonitoringSchemeQueryState;
|
state?: MonitoringSchemeQueryState;
|
||||||
onStateChange?: (state: MonitoringSchemeQueryState) => void;
|
onStateChange?: (state: MonitoringSchemeQueryState) => void;
|
||||||
@@ -64,7 +54,7 @@ interface SchemeQueryProps {
|
|||||||
export interface MonitoringSchemeQueryState {
|
export interface MonitoringSchemeQueryState {
|
||||||
queryAll: boolean;
|
queryAll: boolean;
|
||||||
queryDate: Dayjs | null;
|
queryDate: Dayjs | null;
|
||||||
expandedId: number | null;
|
expandedId: string | null;
|
||||||
hasQueried: boolean;
|
hasQueried: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,22 +175,18 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await api.get(
|
let filteredResults = await listSensorPlacementSchemes();
|
||||||
`${config.BACKEND_URL}/api/v1/sensor-placement-schemes`,
|
|
||||||
);
|
|
||||||
|
|
||||||
let filteredResults = response.data;
|
|
||||||
|
|
||||||
// 按日期过滤
|
// 按日期过滤
|
||||||
if (!queryAll && queryDate) {
|
if (!queryAll && queryDate) {
|
||||||
const formattedDate = queryDate.format("YYYY-MM-DD");
|
const formattedDate = queryDate.format("YYYY-MM-DD");
|
||||||
filteredResults = filteredResults.filter((item: SchemaItem) => {
|
filteredResults = filteredResults.filter((item) => {
|
||||||
const itemDate = moment(item.create_time).format("YYYY-MM-DD");
|
const itemDate = moment(item.create_time).format("YYYY-MM-DD");
|
||||||
return itemDate === formattedDate;
|
return itemDate === formattedDate;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextSchemes = filteredResults.map((item: SchemaItem) => ({
|
const nextSchemes = filteredResults.map((item) => ({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
schemeName: item.scheme_name,
|
schemeName: item.scheme_name,
|
||||||
sensorNumber: item.sensor_number,
|
sensorNumber: item.sensor_number,
|
||||||
@@ -250,7 +236,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (sensorIds.length > 0) {
|
if (sensorIds.length > 0) {
|
||||||
queryFeaturesByIds(sensorIds, "geo_junctions_mat").then((features) => {
|
queryFeaturesByIds(sensorIds, "junctions").then((features) => {
|
||||||
if (features.length > 0) {
|
if (features.length > 0) {
|
||||||
// 设置高亮要素
|
// 设置高亮要素
|
||||||
setHighlightFeatures(features);
|
setHighlightFeatures(features);
|
||||||
@@ -271,7 +257,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 查看详情(展开/收起)
|
// 查看详情(展开/收起)
|
||||||
const handleViewDetails = (id: number) => {
|
const handleViewDetails = (id: string) => {
|
||||||
setQueryField("expandedId", expandedId === id ? null : id);
|
setQueryField("expandedId", expandedId === id ? null : id);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ const point = (
|
|||||||
const rows = [point("A", 1, 400, 200), point("B", 2, 500, 300)];
|
const rows = [point("A", 1, 400, 200), point("B", 2, 500, 300)];
|
||||||
|
|
||||||
const scheme: SensorPlacementScheme = {
|
const scheme: SensorPlacementScheme = {
|
||||||
id: 1,
|
id: "run-1",
|
||||||
scheme_name: "测试方案",
|
scheme_name: "测试方案",
|
||||||
sensor_number: rows.length,
|
sensor_number: rows.length,
|
||||||
min_diameter: 300,
|
min_diameter: 300,
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { api } from "@/lib/api";
|
||||||
|
import {
|
||||||
|
exportSensorPlacementExcel,
|
||||||
|
getSensorPlacementScheme,
|
||||||
|
listSensorPlacementSchemes,
|
||||||
|
optimizeSensorPlacement,
|
||||||
|
overwriteSensorPlacementScheme,
|
||||||
|
} from "./schemeApi";
|
||||||
|
|
||||||
|
jest.mock("@/lib/api", () => ({
|
||||||
|
api: {
|
||||||
|
get: jest.fn(),
|
||||||
|
post: jest.fn(),
|
||||||
|
put: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const run = {
|
||||||
|
run_id: "9fb546cb-5f72-4ddd-8a46-d13e9aee2c6b",
|
||||||
|
name: "sensor-plan-a",
|
||||||
|
sensor_count: 2,
|
||||||
|
min_diameter: 300,
|
||||||
|
created_by: "operator",
|
||||||
|
created_at: "2026-08-25T08:00:00Z",
|
||||||
|
sensor_locations: ["J-1", "J-2"],
|
||||||
|
sensor_points: [],
|
||||||
|
can_edit: true,
|
||||||
|
status: "completed",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("sensor placement runs API adapter", () => {
|
||||||
|
beforeEach(() => jest.clearAllMocks());
|
||||||
|
|
||||||
|
it("lists paged runs and maps run fields to the existing screen model", async () => {
|
||||||
|
jest.mocked(api.get).mockResolvedValue({
|
||||||
|
data: { items: [run], total: 1, limit: 1000, offset: 0 },
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(listSensorPlacementSchemes()).resolves.toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
id: run.run_id,
|
||||||
|
scheme_name: run.name,
|
||||||
|
sensor_number: 2,
|
||||||
|
sensor_location: ["J-1", "J-2"],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(api.get).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("/api/v1/sensor-placement-runs"),
|
||||||
|
{ params: { limit: 1000, offset: 0 } },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses run endpoints for creation and detail lookup", async () => {
|
||||||
|
jest.mocked(api.post).mockResolvedValue({ data: run });
|
||||||
|
jest.mocked(api.get).mockResolvedValue({ data: run });
|
||||||
|
|
||||||
|
await optimizeSensorPlacement({
|
||||||
|
run_name: run.name,
|
||||||
|
sensor_type: "pressure",
|
||||||
|
method: "sensitivity",
|
||||||
|
sensor_count: 2,
|
||||||
|
min_diameter: 300,
|
||||||
|
});
|
||||||
|
await getSensorPlacementScheme(run.run_id);
|
||||||
|
|
||||||
|
expect(api.post).toHaveBeenCalledWith(
|
||||||
|
expect.stringMatching(/\/api\/v1\/sensor-placement-runs$/),
|
||||||
|
expect.objectContaining({ run_name: run.name }),
|
||||||
|
);
|
||||||
|
expect(api.get).toHaveBeenLastCalledWith(
|
||||||
|
expect.stringContaining(`/api/v1/sensor-placement-runs/${run.run_id}`),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the plural result fields required by update and export", async () => {
|
||||||
|
jest.mocked(api.put).mockResolvedValue({ data: run });
|
||||||
|
jest.mocked(api.post).mockResolvedValue({ data: new Blob() });
|
||||||
|
|
||||||
|
await overwriteSensorPlacementScheme(run.run_id, ["J-1"], ["J-2"]);
|
||||||
|
await exportSensorPlacementExcel(run.run_id, ["J-2"], { "J-2": "added" });
|
||||||
|
|
||||||
|
expect(api.put).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining(`/sensor-placement-runs/${run.run_id}`),
|
||||||
|
{
|
||||||
|
expected_sensor_locations: ["J-1"],
|
||||||
|
sensor_locations: ["J-2"],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(api.post).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining(`/sensor-placement-runs/${run.run_id}/exports/excel`),
|
||||||
|
{
|
||||||
|
sensor_locations: ["J-2"],
|
||||||
|
adjustment_status: { "J-2": "added" },
|
||||||
|
},
|
||||||
|
{ responseType: "blob" },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,30 +7,67 @@ import type {
|
|||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
export interface OptimizeSchemeInput {
|
export interface OptimizeSchemeInput {
|
||||||
scheme_name: string;
|
run_name: string;
|
||||||
sensor_type: "pressure";
|
sensor_type: "pressure";
|
||||||
method: "sensitivity" | "kmeans";
|
method: "sensitivity" | "kmeans";
|
||||||
sensor_count: number;
|
sensor_count: number;
|
||||||
min_diameter: number;
|
min_diameter: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SensorPlacementRunResponse = {
|
||||||
|
run_id: string;
|
||||||
|
name: string;
|
||||||
|
sensor_count: number;
|
||||||
|
min_diameter: number;
|
||||||
|
created_by: string;
|
||||||
|
created_at: string;
|
||||||
|
sensor_locations: string[];
|
||||||
|
sensor_points: SensorPoint[];
|
||||||
|
can_edit: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toSensorPlacementScheme = (
|
||||||
|
run: SensorPlacementRunResponse,
|
||||||
|
): SensorPlacementScheme => ({
|
||||||
|
id: run.run_id,
|
||||||
|
scheme_name: run.name,
|
||||||
|
sensor_number: run.sensor_count,
|
||||||
|
min_diameter: run.min_diameter,
|
||||||
|
username: run.created_by,
|
||||||
|
create_time: run.created_at,
|
||||||
|
sensor_location: run.sensor_locations,
|
||||||
|
sensor_points: run.sensor_points,
|
||||||
|
can_edit: run.can_edit,
|
||||||
|
});
|
||||||
|
|
||||||
export const optimizeSensorPlacement = async (
|
export const optimizeSensorPlacement = async (
|
||||||
input: OptimizeSchemeInput,
|
input: OptimizeSchemeInput,
|
||||||
): Promise<SensorPlacementScheme> => {
|
): Promise<SensorPlacementScheme> => {
|
||||||
const response = await api.post<SensorPlacementScheme>(
|
const response = await api.post<SensorPlacementRunResponse>(
|
||||||
`${config.BACKEND_URL}/api/v1/sensor-placement-optimization-runs`,
|
`${config.BACKEND_URL}/api/v1/sensor-placement-runs`,
|
||||||
input,
|
input,
|
||||||
);
|
);
|
||||||
return response.data;
|
return toSensorPlacementScheme(response.data);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const listSensorPlacementSchemes = async (): Promise<
|
||||||
|
SensorPlacementScheme[]
|
||||||
|
> => {
|
||||||
|
const response = await api.get<{
|
||||||
|
items: SensorPlacementRunResponse[];
|
||||||
|
}>(`${config.BACKEND_URL}/api/v1/sensor-placement-runs`, {
|
||||||
|
params: { limit: 1000, offset: 0 },
|
||||||
|
});
|
||||||
|
return response.data.items.map(toSensorPlacementScheme);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getSensorPlacementScheme = async (
|
export const getSensorPlacementScheme = async (
|
||||||
schemeId: number,
|
schemeId: string,
|
||||||
): Promise<SensorPlacementScheme> => {
|
): Promise<SensorPlacementScheme> => {
|
||||||
const response = await api.get<SensorPlacementScheme>(
|
const response = await api.get<SensorPlacementRunResponse>(
|
||||||
`${config.BACKEND_URL}/api/v1/sensor-placement-schemes/${schemeId}`,
|
`${config.BACKEND_URL}/api/v1/sensor-placement-runs/${encodeURIComponent(schemeId)}`,
|
||||||
);
|
);
|
||||||
return response.data;
|
return toSensorPlacementScheme(response.data);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getSensorPlacementCandidate = async (
|
export const getSensorPlacementCandidate = async (
|
||||||
@@ -43,29 +80,29 @@ export const getSensorPlacementCandidate = async (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const overwriteSensorPlacementScheme = async (
|
export const overwriteSensorPlacementScheme = async (
|
||||||
schemeId: number,
|
schemeId: string,
|
||||||
expectedSensorLocation: string[],
|
expectedSensorLocation: string[],
|
||||||
sensorLocation: string[],
|
sensorLocation: string[],
|
||||||
): Promise<SensorPlacementScheme> => {
|
): Promise<SensorPlacementScheme> => {
|
||||||
const response = await api.put<SensorPlacementScheme>(
|
const response = await api.put<SensorPlacementRunResponse>(
|
||||||
`${config.BACKEND_URL}/api/v1/sensor-placement-schemes/${schemeId}`,
|
`${config.BACKEND_URL}/api/v1/sensor-placement-runs/${encodeURIComponent(schemeId)}`,
|
||||||
{
|
{
|
||||||
expected_sensor_location: expectedSensorLocation,
|
expected_sensor_locations: expectedSensorLocation,
|
||||||
sensor_location: sensorLocation,
|
sensor_locations: sensorLocation,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
return response.data;
|
return toSensorPlacementScheme(response.data);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const exportSensorPlacementExcel = async (
|
export const exportSensorPlacementExcel = async (
|
||||||
schemeId: number,
|
schemeId: string,
|
||||||
sensorLocation: string[],
|
sensorLocation: string[],
|
||||||
adjustmentStatus: Record<string, AdjustmentStatus>,
|
adjustmentStatus: Record<string, AdjustmentStatus>,
|
||||||
) => {
|
) => {
|
||||||
const response = await api.post<Blob>(
|
const response = await api.post<Blob>(
|
||||||
`${config.BACKEND_URL}/api/v1/sensor-placement-schemes/${schemeId}/exports/excel`,
|
`${config.BACKEND_URL}/api/v1/sensor-placement-runs/${encodeURIComponent(schemeId)}/exports/excel`,
|
||||||
{
|
{
|
||||||
sensor_location: sensorLocation,
|
sensor_locations: sensorLocation,
|
||||||
adjustment_status: adjustmentStatus,
|
adjustment_status: adjustmentStatus,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ const point = (node_id: string): SensorPoint => ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const scheme: SensorPlacementScheme = {
|
const scheme: SensorPlacementScheme = {
|
||||||
id: 1,
|
id: "run-1",
|
||||||
scheme_name: "测试方案",
|
scheme_name: "测试方案",
|
||||||
sensor_number: 2,
|
sensor_number: 2,
|
||||||
min_diameter: 300,
|
min_diameter: 300,
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export interface SensorPoint {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface SensorPlacementScheme {
|
export interface SensorPlacementScheme {
|
||||||
id: number;
|
id: string;
|
||||||
scheme_name: string;
|
scheme_name: string;
|
||||||
sensor_number: number;
|
sensor_number: number;
|
||||||
min_diameter: number;
|
min_diameter: number;
|
||||||
@@ -25,7 +25,7 @@ export interface SensorPlacementScheme {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface SchemeRecord {
|
export interface SchemeRecord {
|
||||||
id: number;
|
id: string;
|
||||||
schemeName: string;
|
schemeName: string;
|
||||||
sensorNumber: number;
|
sensorNumber: number;
|
||||||
minDiameter: number;
|
minDiameter: number;
|
||||||
|
|||||||
@@ -192,7 +192,7 @@ const SCADADeviceList: React.FC<SCADADeviceListProps> = ({
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const activeWorkspace = workspace || config.MAP_WORKSPACE;
|
const activeWorkspace = workspace || config.MAP_WORKSPACE;
|
||||||
const url = `${config.MAP_URL}/${activeWorkspace}/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=${activeWorkspace}:geo_scada&outputFormat=application/json`;
|
const url = `${config.MAP_URL}/${activeWorkspace}/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=${activeWorkspace}:scada_devices&outputFormat=application/json`;
|
||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
if (!response.ok) throw new Error("Failed to fetch SCADA devices");
|
if (!response.ok) throw new Error("Failed to fetch SCADA devices");
|
||||||
const json = await response.json();
|
const json = await response.json();
|
||||||
@@ -202,7 +202,8 @@ const SCADADeviceList: React.FC<SCADADeviceListProps> = ({
|
|||||||
name: feature.get("id") || feature.getId(),
|
name: feature.get("id") || feature.getId(),
|
||||||
transmission_frequency: feature.get("transmission_frequency"),
|
transmission_frequency: feature.get("transmission_frequency"),
|
||||||
reliability: feature.get("reliability"),
|
reliability: feature.get("reliability"),
|
||||||
type: feature.get("type") === "pipe_flow" ? "流量" : "压力",
|
type:
|
||||||
|
feature.get("device_type") === "pipe_flow" ? "流量" : "压力",
|
||||||
status: STATUS_OPTIONS[Math.floor(Math.random() * 4)],
|
status: STATUS_OPTIONS[Math.floor(Math.random() * 4)],
|
||||||
coordinates: (feature.getGeometry() as Point)?.getCoordinates() as [
|
coordinates: (feature.getGeometry() as Point)?.getCoordinates() as [
|
||||||
number,
|
number,
|
||||||
@@ -488,7 +489,7 @@ const SCADADeviceList: React.FC<SCADADeviceListProps> = ({
|
|||||||
const layer = feature?.getId()?.toString().split(".")[0];
|
const layer = feature?.getId()?.toString().split(".")[0];
|
||||||
|
|
||||||
if (!feature) return;
|
if (!feature) return;
|
||||||
if (layer !== "geo_scada_mat" && layer !== "geo_scada") {
|
if (layer !== "scada_devices") {
|
||||||
open?.({
|
open?.({
|
||||||
type: "error",
|
type: "error",
|
||||||
message: "请选择 SCADA 设备。",
|
message: "请选择 SCADA 设备。",
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ interface TimelineProps {
|
|||||||
timeRange?: { start: Date; end: Date };
|
timeRange?: { start: Date; end: Date };
|
||||||
disableDateSelection?: boolean;
|
disableDateSelection?: boolean;
|
||||||
schemeName?: string;
|
schemeName?: string;
|
||||||
|
schemeRunId?: string;
|
||||||
schemeType?: string;
|
schemeType?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,6 +81,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
timeRange,
|
timeRange,
|
||||||
disableDateSelection = false,
|
disableDateSelection = false,
|
||||||
schemeName = "",
|
schemeName = "",
|
||||||
|
schemeRunId = "",
|
||||||
schemeType = "burst_analysis",
|
schemeType = "burst_analysis",
|
||||||
}) => {
|
}) => {
|
||||||
const data = useData();
|
const data = useData();
|
||||||
@@ -207,6 +209,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
sourceType,
|
sourceType,
|
||||||
target,
|
target,
|
||||||
schemeName,
|
schemeName,
|
||||||
|
schemeRunId,
|
||||||
schemeType,
|
schemeType,
|
||||||
signal,
|
signal,
|
||||||
}: {
|
}: {
|
||||||
@@ -216,6 +219,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
sourceType: "scheme" | "realtime";
|
sourceType: "scheme" | "realtime";
|
||||||
target: "primary" | "compare";
|
target: "primary" | "compare";
|
||||||
schemeName?: string;
|
schemeName?: string;
|
||||||
|
schemeRunId?: string;
|
||||||
schemeType?: string;
|
schemeType?: string;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
}) => {
|
}) => {
|
||||||
@@ -232,16 +236,16 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
junctionProperties,
|
junctionProperties,
|
||||||
sourceType,
|
sourceType,
|
||||||
"node",
|
"node",
|
||||||
schemeName || "",
|
schemeRunId || schemeName || "",
|
||||||
schemeType || ""
|
schemeType || ""
|
||||||
);
|
);
|
||||||
if (nodeCacheRef.current.has(nodeCacheKey)) {
|
if (nodeCacheRef.current.has(nodeCacheKey)) {
|
||||||
nodeRecords = nodeCacheRef.current.get(nodeCacheKey)!;
|
nodeRecords = nodeCacheRef.current.get(nodeCacheKey)!;
|
||||||
} else {
|
} else {
|
||||||
nodePromise =
|
nodePromise =
|
||||||
sourceType === "scheme" && schemeName
|
sourceType === "scheme" && schemeRunId
|
||||||
? apiFetch(
|
? apiFetch(
|
||||||
`${config.BACKEND_URL}/api/v1/timeseries/schemes/records?scheme_type=${schemeType}&scheme_name=${schemeName}&query_time=${query_time}&type=node&property=${junctionProperties}`,
|
`${config.BACKEND_URL}/api/v1/timeseries/analysis/runs/${encodeURIComponent(schemeRunId)}/values?result_time=${encodeURIComponent(query_time)}&element_type=node&field=${encodeURIComponent(junctionProperties)}`,
|
||||||
{ signal },
|
{ signal },
|
||||||
)
|
)
|
||||||
: apiFetch(
|
: apiFetch(
|
||||||
@@ -261,16 +265,16 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
normalizedPipeProperties,
|
normalizedPipeProperties,
|
||||||
sourceType,
|
sourceType,
|
||||||
"link",
|
"link",
|
||||||
schemeName || "",
|
schemeRunId || schemeName || "",
|
||||||
schemeType || ""
|
schemeType || ""
|
||||||
);
|
);
|
||||||
if (linkCacheRef.current.has(linkCacheKey)) {
|
if (linkCacheRef.current.has(linkCacheKey)) {
|
||||||
linkRecords = linkCacheRef.current.get(linkCacheKey)!;
|
linkRecords = linkCacheRef.current.get(linkCacheKey)!;
|
||||||
} else {
|
} else {
|
||||||
linkPromise =
|
linkPromise =
|
||||||
sourceType === "scheme" && schemeName
|
sourceType === "scheme" && schemeRunId
|
||||||
? apiFetch(
|
? apiFetch(
|
||||||
`${config.BACKEND_URL}/api/v1/timeseries/schemes/records?scheme_type=${schemeType}&scheme_name=${schemeName}&query_time=${query_time}&type=link&property=${normalizedPipeProperties}`,
|
`${config.BACKEND_URL}/api/v1/timeseries/analysis/runs/${encodeURIComponent(schemeRunId)}/values?result_time=${encodeURIComponent(query_time)}&element_type=link&field=${encodeURIComponent(normalizedPipeProperties)}`,
|
||||||
{ signal },
|
{ signal },
|
||||||
)
|
)
|
||||||
: apiFetch(
|
: apiFetch(
|
||||||
@@ -288,14 +292,22 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
if (!nodeResponse.ok) {
|
if (!nodeResponse.ok) {
|
||||||
throw new Error(`Node fetch failed: ${nodeResponse.status}`);
|
throw new Error(`Node fetch failed: ${nodeResponse.status}`);
|
||||||
}
|
}
|
||||||
nodeRecords = await nodeResponse.json();
|
const payload = await nodeResponse.json();
|
||||||
|
nodeRecords = sourceType === "scheme"
|
||||||
|
? {
|
||||||
|
results: Object.entries(payload ?? {}).map(([ID, value]) => ({
|
||||||
|
ID,
|
||||||
|
value,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
: payload;
|
||||||
nodeCacheRef.current.set(
|
nodeCacheRef.current.set(
|
||||||
buildCacheKey(
|
buildCacheKey(
|
||||||
query_time,
|
query_time,
|
||||||
junctionProperties,
|
junctionProperties,
|
||||||
sourceType,
|
sourceType,
|
||||||
"node",
|
"node",
|
||||||
schemeName || "",
|
schemeRunId || schemeName || "",
|
||||||
schemeType || ""
|
schemeType || ""
|
||||||
),
|
),
|
||||||
nodeRecords || []
|
nodeRecords || []
|
||||||
@@ -307,14 +319,22 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
if (!linkResponse.ok) {
|
if (!linkResponse.ok) {
|
||||||
throw new Error(`Link fetch failed: ${linkResponse.status}`);
|
throw new Error(`Link fetch failed: ${linkResponse.status}`);
|
||||||
}
|
}
|
||||||
linkRecords = await linkResponse.json();
|
const payload = await linkResponse.json();
|
||||||
|
linkRecords = sourceType === "scheme"
|
||||||
|
? {
|
||||||
|
results: Object.entries(payload ?? {}).map(([ID, value]) => ({
|
||||||
|
ID,
|
||||||
|
value,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
: payload;
|
||||||
linkCacheRef.current.set(
|
linkCacheRef.current.set(
|
||||||
buildCacheKey(
|
buildCacheKey(
|
||||||
query_time,
|
query_time,
|
||||||
normalizedPipeProperties,
|
normalizedPipeProperties,
|
||||||
sourceType,
|
sourceType,
|
||||||
"link",
|
"link",
|
||||||
schemeName || "",
|
schemeRunId || schemeName || "",
|
||||||
schemeType || ""
|
schemeType || ""
|
||||||
),
|
),
|
||||||
linkRecords || []
|
linkRecords || []
|
||||||
@@ -336,6 +356,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
junctionProperties: string,
|
junctionProperties: string,
|
||||||
pipeProperties: string,
|
pipeProperties: string,
|
||||||
schemeName: string,
|
schemeName: string,
|
||||||
|
schemeRunId: string,
|
||||||
schemeType: string
|
schemeType: string
|
||||||
) => {
|
) => {
|
||||||
const revision = frameRequestRevisionRef.current + 1;
|
const revision = frameRequestRevisionRef.current + 1;
|
||||||
@@ -344,7 +365,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
frameAbortControllerRef.current = abortController;
|
frameAbortControllerRef.current = abortController;
|
||||||
const primarySourceType =
|
const primarySourceType =
|
||||||
disableDateSelection && schemeName ? "scheme" : "realtime";
|
disableDateSelection && schemeRunId ? "scheme" : "realtime";
|
||||||
const tasks = [
|
const tasks = [
|
||||||
fetchDataBySource({
|
fetchDataBySource({
|
||||||
queryTime,
|
queryTime,
|
||||||
@@ -353,12 +374,13 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
sourceType: primarySourceType,
|
sourceType: primarySourceType,
|
||||||
target: "primary",
|
target: "primary",
|
||||||
schemeName,
|
schemeName,
|
||||||
|
schemeRunId,
|
||||||
schemeType,
|
schemeType,
|
||||||
signal: abortController.signal,
|
signal: abortController.signal,
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
if (isCompareMode && disableDateSelection && schemeName) {
|
if (isCompareMode && disableDateSelection && schemeRunId) {
|
||||||
tasks.push(
|
tasks.push(
|
||||||
fetchDataBySource({
|
fetchDataBySource({
|
||||||
queryTime,
|
queryTime,
|
||||||
@@ -600,6 +622,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
junctionText,
|
junctionText,
|
||||||
pipeText,
|
pipeText,
|
||||||
schemeName,
|
schemeName,
|
||||||
|
schemeRunId,
|
||||||
schemeType,
|
schemeType,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -612,6 +635,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
timelineCurrentTime,
|
timelineCurrentTime,
|
||||||
selectedDate,
|
selectedDate,
|
||||||
schemeName,
|
schemeName,
|
||||||
|
schemeRunId,
|
||||||
schemeType,
|
schemeType,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -687,6 +711,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
junctionText,
|
junctionText,
|
||||||
pipeText,
|
pipeText,
|
||||||
schemeName,
|
schemeName,
|
||||||
|
schemeRunId,
|
||||||
schemeType,
|
schemeType,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import {
|
|||||||
import { config, NETWORK_NAME } from "@/config/config";
|
import { config, NETWORK_NAME } from "@/config/config";
|
||||||
import { useProject } from "@/contexts/ProjectContext";
|
import { useProject } from "@/contexts/ProjectContext";
|
||||||
import { apiFetch } from "@/lib/apiFetch";
|
import { apiFetch } from "@/lib/apiFetch";
|
||||||
|
import { getAnalysisScheme } from "@/lib/analysisRuns";
|
||||||
import { permissionCodes } from "@/lib/permissions";
|
import { permissionCodes } from "@/lib/permissions";
|
||||||
import { useAccessStore } from "@/store/accessStore";
|
import { useAccessStore } from "@/store/accessStore";
|
||||||
|
|
||||||
@@ -65,8 +66,7 @@ type ActiveSchemeDetail = {
|
|||||||
valve_opening?: Record<string, number> | null;
|
valve_opening?: Record<string, number> | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const isValveLayer = (layerId: string | undefined) =>
|
const isValveLayer = (layerId: string | undefined) => layerId === "valves";
|
||||||
layerId === "geo_valves_mat" || layerId === "geo_valves";
|
|
||||||
|
|
||||||
const Toolbar: React.FC<ToolbarProps> = ({
|
const Toolbar: React.FC<ToolbarProps> = ({
|
||||||
hiddenButtons,
|
hiddenButtons,
|
||||||
@@ -93,11 +93,12 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
|||||||
const currentTime = data?.currentTime;
|
const currentTime = data?.currentTime;
|
||||||
const selectedDate = data?.selectedDate;
|
const selectedDate = data?.selectedDate;
|
||||||
const schemeName = data?.schemeName;
|
const schemeName = data?.schemeName;
|
||||||
|
const schemeRunId = data?.schemeRunId;
|
||||||
const networkName = project?.networkName || NETWORK_NAME;
|
const networkName = project?.networkName || NETWORK_NAME;
|
||||||
const isCompareMode = data?.isCompareMode ?? false;
|
const isCompareMode = data?.isCompareMode ?? false;
|
||||||
const toggleCompareMode = data?.toggleCompareMode;
|
const toggleCompareMode = data?.toggleCompareMode;
|
||||||
const canToggleCompare = Boolean(
|
const canToggleCompare = Boolean(
|
||||||
enableCompare && (isCompareMode || (queryType === "scheme" && schemeName)),
|
enableCompare && (isCompareMode || (queryType === "scheme" && schemeRunId)),
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -406,7 +407,7 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
|||||||
if (
|
if (
|
||||||
queryType !== "scheme" ||
|
queryType !== "scheme" ||
|
||||||
schemeType !== "flushing_analysis" ||
|
schemeType !== "flushing_analysis" ||
|
||||||
!schemeName ||
|
!schemeRunId ||
|
||||||
!selectedValveId
|
!selectedValveId
|
||||||
) {
|
) {
|
||||||
setActiveSchemeDetail(null);
|
setActiveSchemeDetail(null);
|
||||||
@@ -418,15 +419,7 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
|||||||
|
|
||||||
const querySchemeDetail = async () => {
|
const querySchemeDetail = async () => {
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams();
|
const payload = await getAnalysisScheme(schemeRunId);
|
||||||
if (schemeType) params.set("scheme_type", schemeType);
|
|
||||||
const response = await apiFetch(
|
|
||||||
`${config.BACKEND_URL}/api/v1/schemes/${encodeURIComponent(schemeName)}?${params.toString()}`,
|
|
||||||
);
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`getschemedetail failed: ${response.status}`);
|
|
||||||
}
|
|
||||||
const payload = await response.json();
|
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setActiveSchemeDetail(payload?.scheme_detail ?? null);
|
setActiveSchemeDetail(payload?.scheme_detail ?? null);
|
||||||
}
|
}
|
||||||
@@ -446,7 +439,7 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [open, queryType, schemeName, schemeType, selectedValveId]);
|
}, [open, queryType, schemeRunId, schemeType, selectedValveId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedValveId || isSimulationDataActive) {
|
if (!selectedValveId || isSimulationDataActive) {
|
||||||
@@ -710,39 +703,59 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
|||||||
dateObj.setHours(Math.floor(minutes / 60), minutes % 60, 0, 0);
|
dateObj.setHours(Math.floor(minutes / 60), minutes % 60, 0, 0);
|
||||||
// 转为 UTC ISO 字符串
|
// 转为 UTC ISO 字符串
|
||||||
const querytime = dateObj.toISOString(); // 例如 "2025-09-16T16:30:00.000Z"
|
const querytime = dateObj.toISOString(); // 例如 "2025-09-16T16:30:00.000Z"
|
||||||
let response: Response;
|
|
||||||
if (queryType === "scheme") {
|
if (queryType === "scheme") {
|
||||||
|
if (!schemeRunId) {
|
||||||
|
throw new Error("Analysis run ID is missing");
|
||||||
|
}
|
||||||
|
const fields = type === "node"
|
||||||
|
? ["actual_demand", "total_head", "pressure", "quality"]
|
||||||
|
: [
|
||||||
|
"flow",
|
||||||
|
"friction",
|
||||||
|
"headloss",
|
||||||
|
"quality",
|
||||||
|
"reaction",
|
||||||
|
"setting",
|
||||||
|
"status",
|
||||||
|
"velocity",
|
||||||
|
];
|
||||||
|
const values = await Promise.all(
|
||||||
|
fields.map(async (field) => {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
scheme_type: schemeType ?? "",
|
result_time: querytime,
|
||||||
scheme_name: schemeName ?? "",
|
element_type: type,
|
||||||
id: String(id),
|
field,
|
||||||
type,
|
|
||||||
query_time: querytime,
|
|
||||||
});
|
});
|
||||||
response = await apiFetch(
|
const response = await apiFetch(
|
||||||
`${config.BACKEND_URL}/api/v1/timeseries/schemes/simulation-results?${params.toString()}`,
|
`${config.BACKEND_URL}/api/v1/timeseries/analysis/runs/${encodeURIComponent(schemeRunId)}/values?${params.toString()}`,
|
||||||
);
|
);
|
||||||
} else {
|
if (!response.ok) {
|
||||||
|
throw new Error(`Analysis value fetch failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
const payload = await response.json();
|
||||||
|
return [field, payload?.[String(id)]] as const;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (!cancelled) {
|
||||||
|
setComputedProperties(
|
||||||
|
Object.fromEntries(values.filter(([, value]) => value !== undefined)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
id: String(id),
|
id: String(id),
|
||||||
type,
|
type,
|
||||||
query_time: querytime,
|
query_time: querytime,
|
||||||
});
|
});
|
||||||
response = await apiFetch(
|
const response = await apiFetch(
|
||||||
`${config.BACKEND_URL}/api/v1/timeseries/realtime/simulation-results?${params.toString()}`,
|
`${config.BACKEND_URL}/api/v1/timeseries/realtime/simulation-results?${params.toString()}`,
|
||||||
);
|
);
|
||||||
}
|
if (!response.ok) throw new Error("API request failed");
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error("API request failed");
|
|
||||||
}
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
if (!data.result || data.result.length === 0) {
|
setComputedProperties(data.result?.[0] || {});
|
||||||
setComputedProperties({});
|
|
||||||
} else {
|
|
||||||
setComputedProperties(data.result[0] || {});
|
|
||||||
// console.log("查询到的计算属性:", data.result[0]);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error querying computed properties:", error);
|
console.error("Error querying computed properties:", error);
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
@@ -759,7 +772,7 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [highlightFeatures, currentTime, open, selectedDate, queryType, schemeName, schemeType, showPropertyPanel]);
|
}, [highlightFeatures, currentTime, open, selectedDate, queryType, schemeName, schemeRunId, showPropertyPanel]);
|
||||||
|
|
||||||
const displayedComputedProperties = useMemo(() => {
|
const displayedComputedProperties = useMemo(() => {
|
||||||
if (!isSimulationDataActive || !selectedValveId) {
|
if (!isSimulationDataActive || !selectedValveId) {
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ const createValveFeature = () => {
|
|||||||
minor_loss: 0,
|
minor_loss: 0,
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
getId: () => "geo_valves.V1",
|
getId: () => "valves.V1",
|
||||||
getProperties: () => properties,
|
getProperties: () => properties,
|
||||||
} as unknown as Feature;
|
} as unknown as Feature;
|
||||||
};
|
};
|
||||||
@@ -161,7 +161,7 @@ describe("getSimulationElementType", () => {
|
|||||||
|
|
||||||
it("keeps point-rendered pumps on the same hydraulic-link path", () => {
|
it("keeps point-rendered pumps on the same hydraulic-link path", () => {
|
||||||
const pump = {
|
const pump = {
|
||||||
getId: () => "geo_pumps.P1",
|
getId: () => "pumps.P1",
|
||||||
getProperties: () => ({
|
getProperties: () => ({
|
||||||
id: "P1",
|
id: "P1",
|
||||||
geometry: { getType: () => "Point" },
|
geometry: { getType: () => "Point" },
|
||||||
@@ -174,7 +174,7 @@ describe("getSimulationElementType", () => {
|
|||||||
|
|
||||||
describe("buildFeatureProperties simulation values by hydraulic type", () => {
|
describe("buildFeatureProperties simulation values by hydraulic type", () => {
|
||||||
it("shows link simulation results for a point-rendered pump", () => {
|
it("shows link simulation results for a point-rendered pump", () => {
|
||||||
const pump = createFeature("geo_pumps", "P1", {
|
const pump = createFeature("pumps", "P1", {
|
||||||
node1: "J1",
|
node1: "J1",
|
||||||
node2: "J2",
|
node2: "J2",
|
||||||
geometry: { getType: () => "Point" },
|
geometry: { getType: () => "Point" },
|
||||||
@@ -196,8 +196,8 @@ describe("buildFeatureProperties simulation values by hydraulic type", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
["geo_tanks", "T1", "水池"],
|
["tanks", "T1", "水池"],
|
||||||
["geo_reservoirs", "R1", "水库"],
|
["reservoirs", "R1", "水库"],
|
||||||
])("shows node simulation results for %s", (layer, id, type) => {
|
])("shows node simulation results for %s", (layer, id, type) => {
|
||||||
const feature = createFeature(layer, id, {
|
const feature = createFeature(layer, id, {
|
||||||
geometry: { getType: () => "Point" },
|
geometry: { getType: () => "Point" },
|
||||||
|
|||||||
@@ -236,13 +236,13 @@ export const buildFeatureProperties = (
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
if (layer === "geo_pipes_mat" || layer === "geo_pipes") {
|
if (layer === "pipes") {
|
||||||
const result: ToolbarPropertyPanelData = {
|
const result: ToolbarPropertyPanelData = {
|
||||||
id: properties.id,
|
id: properties.id,
|
||||||
type: "管道",
|
type: "管道",
|
||||||
properties: [
|
properties: [
|
||||||
{ label: "起始节点ID", value: properties.node1 },
|
{ label: "起始节点ID", value: properties.start_node_id },
|
||||||
{ label: "终点节点ID", value: properties.node2 },
|
{ label: "终点节点ID", value: properties.end_node_id },
|
||||||
{ label: "长度", value: properties.length?.toFixed?.(1), unit: "m" },
|
{ label: "长度", value: properties.length?.toFixed?.(1), unit: "m" },
|
||||||
{
|
{
|
||||||
label: "管径",
|
label: "管径",
|
||||||
@@ -260,7 +260,7 @@ export const buildFeatureProperties = (
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (layer === "geo_junctions_mat" || layer === "geo_junctions") {
|
if (layer === "junctions") {
|
||||||
const result: ToolbarPropertyPanelData = {
|
const result: ToolbarPropertyPanelData = {
|
||||||
id: properties.id,
|
id: properties.id,
|
||||||
type: "节点",
|
type: "节点",
|
||||||
@@ -271,27 +271,15 @@ export const buildFeatureProperties = (
|
|||||||
unit: "m",
|
unit: "m",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "table",
|
|
||||||
label: "基本需水量",
|
label: "基本需水量",
|
||||||
columns: ["demand", "pattern"],
|
value: Number.isFinite(Number(properties.base_demand))
|
||||||
rows: Array.from({ length: 5 }, (_, i) => i + 1)
|
? toM3h(Number(properties.base_demand), "lps").toFixed(3)
|
||||||
.map((idx) => {
|
: properties.base_demand,
|
||||||
let demand = properties?.[`demand${idx}`];
|
unit: "m³/h",
|
||||||
const pattern = properties?.[`pattern${idx}`];
|
},
|
||||||
if (
|
{
|
||||||
demand !== undefined &&
|
label: "需水配置",
|
||||||
demand !== null &&
|
value: properties.demands,
|
||||||
demand !== ""
|
|
||||||
) {
|
|
||||||
demand = toM3h(Number(demand), "lps");
|
|
||||||
return [
|
|
||||||
typeof demand === "number" ? demand.toFixed(3) : demand,
|
|
||||||
pattern ?? "-",
|
|
||||||
];
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
})
|
|
||||||
.filter(Boolean) as (string | number)[][],
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
@@ -301,7 +289,7 @@ export const buildFeatureProperties = (
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (layer === "geo_tanks_mat" || layer === "geo_tanks") {
|
if (layer === "tanks") {
|
||||||
const result: ToolbarPropertyPanelData = {
|
const result: ToolbarPropertyPanelData = {
|
||||||
id: properties.id,
|
id: properties.id,
|
||||||
type: "水池",
|
type: "水池",
|
||||||
@@ -313,17 +301,17 @@ export const buildFeatureProperties = (
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "初始水位",
|
label: "初始水位",
|
||||||
value: properties.init_level?.toFixed?.(1),
|
value: properties.initial_level?.toFixed?.(1),
|
||||||
unit: "m",
|
unit: "m",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "最低水位",
|
label: "最低水位",
|
||||||
value: properties.min_level?.toFixed?.(1),
|
value: properties.minimum_level?.toFixed?.(1),
|
||||||
unit: "m",
|
unit: "m",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "最高水位",
|
label: "最高水位",
|
||||||
value: properties.max_level?.toFixed?.(1),
|
value: properties.maximum_level?.toFixed?.(1),
|
||||||
unit: "m",
|
unit: "m",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -333,7 +321,7 @@ export const buildFeatureProperties = (
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "最小容积",
|
label: "最小容积",
|
||||||
value: properties.min_vol?.toFixed?.(1),
|
value: properties.minimum_volume?.toFixed?.(1),
|
||||||
unit: "m³",
|
unit: "m³",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -346,7 +334,7 @@ export const buildFeatureProperties = (
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (layer === "geo_reservoirs_mat" || layer === "geo_reservoirs") {
|
if (layer === "reservoirs") {
|
||||||
const result: ToolbarPropertyPanelData = {
|
const result: ToolbarPropertyPanelData = {
|
||||||
id: properties.id,
|
id: properties.id,
|
||||||
type: "水库",
|
type: "水库",
|
||||||
@@ -356,37 +344,37 @@ export const buildFeatureProperties = (
|
|||||||
value: properties.head?.toFixed?.(1),
|
value: properties.head?.toFixed?.(1),
|
||||||
unit: "m",
|
unit: "m",
|
||||||
},
|
},
|
||||||
|
{ label: "模式", value: properties.pattern_id },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
appendNodeComputedProperties(result);
|
appendNodeComputedProperties(result);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (layer === "geo_pumps_mat" || layer === "geo_pumps") {
|
if (layer === "pumps") {
|
||||||
const result: ToolbarPropertyPanelData = {
|
const result: ToolbarPropertyPanelData = {
|
||||||
id: properties.id,
|
id: properties.id,
|
||||||
type: "水泵",
|
type: "水泵",
|
||||||
properties: [
|
properties: [
|
||||||
{ label: "起始节点 ID", value: properties.node1 },
|
{ label: "起始节点 ID", value: properties.start_node_id },
|
||||||
{ label: "终点节点 ID", value: properties.node2 },
|
{ label: "终点节点 ID", value: properties.end_node_id },
|
||||||
{
|
{
|
||||||
label: "功率",
|
label: "功率",
|
||||||
value: properties.power?.toFixed?.(1),
|
value: properties.power?.toFixed?.(1),
|
||||||
unit: "kW",
|
unit: "kW",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "扬程",
|
label: "扬程曲线",
|
||||||
value: properties.head?.toFixed?.(1),
|
value: properties.head_curve_id,
|
||||||
unit: "m",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "转速",
|
label: "转速",
|
||||||
value: properties.speed?.toFixed?.(1),
|
value: properties.speed?.toFixed?.(1),
|
||||||
unit: "rpm",
|
unit: "倍",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "模式",
|
label: "模式",
|
||||||
value: properties.pattern,
|
value: properties.pattern_id,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
@@ -394,8 +382,8 @@ export const buildFeatureProperties = (
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (layer === "geo_valves_mat" || layer === "geo_valves") {
|
if (layer === "valves") {
|
||||||
const valveType = valveSetting?.vType ?? properties.v_type;
|
const valveType = valveSetting?.vType ?? properties.valve_type;
|
||||||
const hasSimulationValues =
|
const hasSimulationValues =
|
||||||
Object.hasOwn(computedProperties, "status") ||
|
Object.hasOwn(computedProperties, "status") ||
|
||||||
Object.hasOwn(computedProperties, "setting");
|
Object.hasOwn(computedProperties, "setting");
|
||||||
@@ -409,8 +397,8 @@ export const buildFeatureProperties = (
|
|||||||
id: properties.id,
|
id: properties.id,
|
||||||
type: "阀门",
|
type: "阀门",
|
||||||
properties: [
|
properties: [
|
||||||
{ label: "起始节点 ID", value: properties.node1 },
|
{ label: "起始节点 ID", value: properties.start_node_id },
|
||||||
{ label: "终点节点 ID", value: properties.node2 },
|
{ label: "终点节点 ID", value: properties.end_node_id },
|
||||||
{
|
{
|
||||||
label: "直径",
|
label: "直径",
|
||||||
value: properties.diameter?.toFixed?.(1),
|
value: properties.diameter?.toFixed?.(1),
|
||||||
@@ -519,7 +507,7 @@ export const buildFeatureProperties = (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (layer === "geo_scada_mat" || layer === "geo_scada") {
|
if (layer === "scada_devices") {
|
||||||
return {
|
return {
|
||||||
id: properties.id,
|
id: properties.id,
|
||||||
type: "SCADA设备",
|
type: "SCADA设备",
|
||||||
@@ -527,11 +515,13 @@ export const buildFeatureProperties = (
|
|||||||
{
|
{
|
||||||
label: "类型",
|
label: "类型",
|
||||||
value:
|
value:
|
||||||
properties.type === "pipe_flow" ? "流量传感器" : "压力传感器",
|
properties.device_type === "pipe_flow"
|
||||||
|
? "流量传感器"
|
||||||
|
: "压力传感器",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "关联节点 ID",
|
label: "关联节点 ID",
|
||||||
value: properties.associated_element_id,
|
value: properties.node_id ?? properties.link_id,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "传输模式",
|
label: "传输模式",
|
||||||
|
|||||||
@@ -81,6 +81,8 @@ interface DataContextType {
|
|||||||
selectedDate?: Date; // 选择的日期
|
selectedDate?: Date; // 选择的日期
|
||||||
schemeName?: string; // 当前方案名称
|
schemeName?: string; // 当前方案名称
|
||||||
setSchemeName?: React.Dispatch<React.SetStateAction<string>>;
|
setSchemeName?: React.Dispatch<React.SetStateAction<string>>;
|
||||||
|
schemeRunId?: string; // 当前分析运行 ID
|
||||||
|
setSchemeRunId?: React.Dispatch<React.SetStateAction<string>>;
|
||||||
setSelectedDate?: React.Dispatch<React.SetStateAction<Date>>;
|
setSelectedDate?: React.Dispatch<React.SetStateAction<Date>>;
|
||||||
currentJunctionCalData?: any[]; // 当前计算结果
|
currentJunctionCalData?: any[]; // 当前计算结果
|
||||||
setCurrentJunctionCalData?: React.Dispatch<React.SetStateAction<any[]>>;
|
setCurrentJunctionCalData?: React.Dispatch<React.SetStateAction<any[]>>;
|
||||||
@@ -226,6 +228,7 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
|||||||
// const [selectedDate, setSelectedDate] = useState<Date>(new Date("2025-9-17"));
|
// const [selectedDate, setSelectedDate] = useState<Date>(new Date("2025-9-17"));
|
||||||
const [selectedDate, setSelectedDate] = useState<Date>(new Date()); // 默认今天
|
const [selectedDate, setSelectedDate] = useState<Date>(new Date()); // 默认今天
|
||||||
const [schemeName, setSchemeName] = useState<string>(""); // 当前方案名称
|
const [schemeName, setSchemeName] = useState<string>(""); // 当前方案名称
|
||||||
|
const [schemeRunId, setSchemeRunId] = useState<string>("");
|
||||||
// 记录 id、对应属性的计算值
|
// 记录 id、对应属性的计算值
|
||||||
const [currentJunctionCalData, setCurrentJunctionCalData] = useState<any[]>(
|
const [currentJunctionCalData, setCurrentJunctionCalData] = useState<any[]>(
|
||||||
[],
|
[],
|
||||||
@@ -791,6 +794,7 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
|||||||
);
|
);
|
||||||
setSelectedDate(new Date());
|
setSelectedDate(new Date());
|
||||||
setSchemeName("");
|
setSchemeName("");
|
||||||
|
setSchemeRunId("");
|
||||||
setCurrentJunctionCalData([]);
|
setCurrentJunctionCalData([]);
|
||||||
setCurrentPipeCalData([]);
|
setCurrentPipeCalData([]);
|
||||||
setCompareJunctionCalData([]);
|
setCompareJunctionCalData([]);
|
||||||
@@ -1118,6 +1122,8 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
|||||||
setSelectedDate,
|
setSelectedDate,
|
||||||
schemeName,
|
schemeName,
|
||||||
setSchemeName,
|
setSchemeName,
|
||||||
|
schemeRunId,
|
||||||
|
setSchemeRunId,
|
||||||
currentJunctionCalData,
|
currentJunctionCalData,
|
||||||
setCurrentJunctionCalData,
|
setCurrentJunctionCalData,
|
||||||
currentPipeCalData,
|
currentPipeCalData,
|
||||||
|
|||||||
@@ -63,6 +63,35 @@ import {
|
|||||||
} from "./operationalLayers";
|
} from "./operationalLayers";
|
||||||
|
|
||||||
describe("operational map resources", () => {
|
describe("operational map resources", () => {
|
||||||
|
it("requests the published tjwater_next layer names", () => {
|
||||||
|
const sources = createOperationalMapSources({
|
||||||
|
mapUrl: "https://maps.example.test/geoserver",
|
||||||
|
workspace: "tjwater_next",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect((sources.junctions as any).options.url).toContain(
|
||||||
|
"tjwater_next:junctions@WebMercatorQuad@pbf",
|
||||||
|
);
|
||||||
|
expect((sources.pipes as any).options.url).toContain(
|
||||||
|
"tjwater_next:pipes@WebMercatorQuad@pbf",
|
||||||
|
);
|
||||||
|
expect((sources.valves as any).options.url).toContain(
|
||||||
|
"tjwater_next:valves@WebMercatorQuad@pbf",
|
||||||
|
);
|
||||||
|
expect((sources.reservoirs as any).options.url).toContain(
|
||||||
|
"typeName=tjwater_next:reservoirs",
|
||||||
|
);
|
||||||
|
expect((sources.pumps as any).options.url).toContain(
|
||||||
|
"typeName=tjwater_next:pumps",
|
||||||
|
);
|
||||||
|
expect((sources.tanks as any).options.url).toContain(
|
||||||
|
"typeName=tjwater_next:tanks",
|
||||||
|
);
|
||||||
|
expect((sources.scada as any).options.url).toContain(
|
||||||
|
"typeName=tjwater_next:scada_devices",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("shares sources while keeping per-map layer instances independent", () => {
|
it("shares sources while keeping per-map layer instances independent", () => {
|
||||||
const options = {
|
const options = {
|
||||||
mapUrl: "https://maps.example.test/geoserver",
|
mapUrl: "https://maps.example.test/geoserver",
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
import { config } from "@/config/config";
|
import { config } from "@/config/config";
|
||||||
import { along, lineString, length, toMercator } from "@turf/turf";
|
|
||||||
import type { FeatureLike } from "ol/Feature";
|
import type { FeatureLike } from "ol/Feature";
|
||||||
import MVT from "ol/format/MVT";
|
import MVT from "ol/format/MVT";
|
||||||
import { Point } from "ol/geom";
|
|
||||||
import type BaseLayer from "ol/layer/Base";
|
import type BaseLayer from "ol/layer/Base";
|
||||||
import VectorLayer from "ol/layer/Vector";
|
import VectorLayer from "ol/layer/Vector";
|
||||||
import WebGLVectorTileLayer from "ol/layer/WebGLVectorTile";
|
import WebGLVectorTileLayer from "ol/layer/WebGLVectorTile";
|
||||||
import { toLonLat } from "ol/proj";
|
|
||||||
import GeoJSON from "ol/format/GeoJSON";
|
import GeoJSON from "ol/format/GeoJSON";
|
||||||
import VectorSource from "ol/source/Vector";
|
import VectorSource from "ol/source/Vector";
|
||||||
import VectorTileSource from "ol/source/VectorTile";
|
import VectorTileSource from "ol/source/VectorTile";
|
||||||
@@ -49,34 +46,12 @@ const createIconStyle = (src: string, scale = 0.1) =>
|
|||||||
|
|
||||||
const scadaStyle = (feature: FeatureLike) =>
|
const scadaStyle = (feature: FeatureLike) =>
|
||||||
createIconStyle(
|
createIconStyle(
|
||||||
feature.get("type") === "pipe_flow"
|
feature.get("device_type") === "pipe_flow"
|
||||||
? "/icons/scada_flow.svg"
|
? "/icons/scada_flow.svg"
|
||||||
: "/icons/scada_pressure.svg",
|
: "/icons/scada_pressure.svg",
|
||||||
);
|
);
|
||||||
|
|
||||||
const pumpStyle = (feature: FeatureLike) => {
|
const pumpStyle = () => createIconStyle("/icons/pump.svg", 0.12);
|
||||||
const geometry = feature.getGeometry();
|
|
||||||
if (!geometry || geometry.getType() !== "LineString") return [];
|
|
||||||
|
|
||||||
const coordinates = (geometry as any)
|
|
||||||
.getCoordinates()
|
|
||||||
.map((coordinate: number[]) => toLonLat(coordinate));
|
|
||||||
if (coordinates.length < 2) return [];
|
|
||||||
|
|
||||||
const featureLine = lineString(coordinates);
|
|
||||||
const midpoint = along(featureLine, length(featureLine) / 2).geometry
|
|
||||||
.coordinates;
|
|
||||||
return [
|
|
||||||
new Style({
|
|
||||||
geometry: new Point(toMercator(midpoint)),
|
|
||||||
image: new Icon({
|
|
||||||
src: "/icons/pump.svg",
|
|
||||||
scale: 0.12,
|
|
||||||
anchor: [0.5, 0.5],
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
};
|
|
||||||
|
|
||||||
const pointProperties = [
|
const pointProperties = [
|
||||||
{ name: "高程", value: "elevation" },
|
{ name: "高程", value: "elevation" },
|
||||||
@@ -110,34 +85,34 @@ export const createOperationalMapSources = ({
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
junctions: new VectorTileSource({
|
junctions: new VectorTileSource({
|
||||||
url: vectorTileUrl("geo_junctions"),
|
url: vectorTileUrl("junctions"),
|
||||||
format: new MVT(),
|
format: new MVT(),
|
||||||
projection: "EPSG:3857",
|
projection: "EPSG:3857",
|
||||||
}),
|
}),
|
||||||
pipes: new VectorTileSource({
|
pipes: new VectorTileSource({
|
||||||
url: vectorTileUrl("geo_pipes"),
|
url: vectorTileUrl("pipes"),
|
||||||
format: new MVT(),
|
format: new MVT(),
|
||||||
projection: "EPSG:3857",
|
projection: "EPSG:3857",
|
||||||
}),
|
}),
|
||||||
valves: new VectorTileSource({
|
valves: new VectorTileSource({
|
||||||
url: vectorTileUrl("geo_valves"),
|
url: vectorTileUrl("valves"),
|
||||||
format: new MVT(),
|
format: new MVT(),
|
||||||
projection: "EPSG:3857",
|
projection: "EPSG:3857",
|
||||||
}),
|
}),
|
||||||
reservoirs: new VectorSource({
|
reservoirs: new VectorSource({
|
||||||
url: vectorUrl("geo_reservoirs"),
|
url: vectorUrl("reservoirs"),
|
||||||
format: new GeoJSON(),
|
format: new GeoJSON(),
|
||||||
}),
|
}),
|
||||||
pumps: new VectorSource({
|
pumps: new VectorSource({
|
||||||
url: vectorUrl("geo_pumps"),
|
url: vectorUrl("pumps"),
|
||||||
format: new GeoJSON(),
|
format: new GeoJSON(),
|
||||||
}),
|
}),
|
||||||
tanks: new VectorSource({
|
tanks: new VectorSource({
|
||||||
url: vectorUrl("geo_tanks"),
|
url: vectorUrl("tanks"),
|
||||||
format: new GeoJSON(),
|
format: new GeoJSON(),
|
||||||
}),
|
}),
|
||||||
scada: new VectorSource({
|
scada: new VectorSource({
|
||||||
url: vectorUrl("geo_scada"),
|
url: vectorUrl("scada_devices"),
|
||||||
format: new GeoJSON(),
|
format: new GeoJSON(),
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
@@ -187,7 +162,7 @@ export const createOperationalMapResources = ({
|
|||||||
properties: {
|
properties: {
|
||||||
name: "阀门",
|
name: "阀门",
|
||||||
value: "valves",
|
value: "valves",
|
||||||
type: "linestring",
|
type: "point",
|
||||||
properties: [],
|
properties: [],
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -213,7 +188,7 @@ export const createOperationalMapResources = ({
|
|||||||
properties: {
|
properties: {
|
||||||
name: "水泵",
|
name: "水泵",
|
||||||
value: "pumps",
|
value: "pumps",
|
||||||
type: "linestring",
|
type: "point",
|
||||||
properties: [],
|
properties: [],
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -27,14 +27,14 @@ const parseMapExtent = (value: RuntimeConfig["MAP_EXTENT"]): number[] => {
|
|||||||
return value.split(",").map(Number);
|
return value.split(",").map(Number);
|
||||||
}
|
}
|
||||||
|
|
||||||
return [13508849, 3608036, 13555781, 3633813];
|
return [13508801.93, 3608163.35, 13555650.64, 3633685.14];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
BACKEND_URL: runtimeConfig.BACKEND_URL || "http://127.0.0.1:8000",
|
BACKEND_URL: runtimeConfig.BACKEND_URL || "http://127.0.0.1:8000",
|
||||||
AGENT_URL: runtimeConfig.AGENT_URL || "http://127.0.0.1:8788",
|
AGENT_URL: runtimeConfig.AGENT_URL || "http://127.0.0.1:8788",
|
||||||
MAP_URL: runtimeConfig.MAP_URL || "http://127.0.0.1:8080/geoserver",
|
MAP_URL: runtimeConfig.MAP_URL || "http://127.0.0.1:8080/geoserver",
|
||||||
MAP_WORKSPACE: runtimeConfig.MAP_WORKSPACE || "tjwater",
|
MAP_WORKSPACE: runtimeConfig.MAP_WORKSPACE || "tjwater_next",
|
||||||
MAP_EXTENT: parseMapExtent(runtimeConfig.MAP_EXTENT),
|
MAP_EXTENT: parseMapExtent(runtimeConfig.MAP_EXTENT),
|
||||||
MAP_DEFAULT_STYLE: {
|
MAP_DEFAULT_STYLE: {
|
||||||
"stroke-width": 3,
|
"stroke-width": 3,
|
||||||
@@ -61,7 +61,7 @@ export const config = {
|
|||||||
"scada",
|
"scada",
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
export let NETWORK_NAME = runtimeConfig.NETWORK_NAME || "tjwater";
|
export let NETWORK_NAME = runtimeConfig.NETWORK_NAME || "tjwater_next";
|
||||||
|
|
||||||
export const setNetworkName = (name: string) => {
|
export const setNetworkName = (name: string) => {
|
||||||
NETWORK_NAME = name;
|
NETWORK_NAME = name;
|
||||||
|
|||||||
+958
-10006
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,96 @@
|
|||||||
|
import { api } from "@/lib/api";
|
||||||
|
import {
|
||||||
|
getAnalysisResults,
|
||||||
|
getAnalysisScheme,
|
||||||
|
listAnalysisSchemes,
|
||||||
|
} from "./analysisRuns";
|
||||||
|
|
||||||
|
jest.mock("@/lib/api", () => ({
|
||||||
|
api: { get: jest.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const get = api.get as jest.Mock;
|
||||||
|
|
||||||
|
describe("analysis runs API adapter", () => {
|
||||||
|
beforeEach(() => get.mockReset());
|
||||||
|
|
||||||
|
it("filters runs and exposes the new run identity to scheme screens", async () => {
|
||||||
|
get.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
success: true,
|
||||||
|
count: 2,
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
run_id: "run-1",
|
||||||
|
name: "burst-a",
|
||||||
|
run_type: "burst_analysis",
|
||||||
|
created_by: "alice",
|
||||||
|
created_at: "2026-08-25T02:00:00Z",
|
||||||
|
started_at: "2026-08-25T01:00:00Z",
|
||||||
|
status: "completed",
|
||||||
|
parameters: { burst_ID: ["P-1"] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
run_id: "run-2",
|
||||||
|
name: "flush-a",
|
||||||
|
run_type: "flushing_analysis",
|
||||||
|
created_by: "bob",
|
||||||
|
created_at: "2026-08-24T02:00:00Z",
|
||||||
|
started_at: "2026-08-24T01:00:00Z",
|
||||||
|
status: "completed",
|
||||||
|
parameters: {},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await listAnalysisSchemes({
|
||||||
|
runType: "burst_analysis",
|
||||||
|
queryDate: "2026-08-25",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(get).toHaveBeenCalledWith("/api/v1/analysis/runs");
|
||||||
|
expect(result).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
id: "run-1",
|
||||||
|
scheme_id: "run-1",
|
||||||
|
scheme_name: "burst-a",
|
||||||
|
schemeDetail: { burst_ID: ["P-1"] },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loads a run and its structured results by run id", async () => {
|
||||||
|
get
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
data: {
|
||||||
|
run_id: "run-1",
|
||||||
|
name: "burst-a",
|
||||||
|
run_type: "burst_analysis",
|
||||||
|
created_by: "alice",
|
||||||
|
created_at: "2026-08-25T02:00:00Z",
|
||||||
|
started_at: "2026-08-25T01:00:00Z",
|
||||||
|
status: "completed",
|
||||||
|
parameters: {},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
result_id: "result-1",
|
||||||
|
run_id: "run-1",
|
||||||
|
result_type: "summary",
|
||||||
|
payload: { ok: true },
|
||||||
|
created_at: "2026-08-25T03:00:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect((await getAnalysisScheme("run-1")).scheme_id).toBe("run-1");
|
||||||
|
expect(await getAnalysisResults("run-1", "summary")).toHaveLength(1);
|
||||||
|
expect(get).toHaveBeenLastCalledWith(
|
||||||
|
"/api/v1/analysis/runs/run-1/results",
|
||||||
|
{ params: { result_type: "summary" } },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { api } from "@/lib/api";
|
||||||
|
|
||||||
|
export type AnalysisRun = {
|
||||||
|
run_id: string;
|
||||||
|
name: string;
|
||||||
|
run_type: string;
|
||||||
|
created_by: string;
|
||||||
|
created_at: string;
|
||||||
|
started_at: string;
|
||||||
|
status: string;
|
||||||
|
parameters: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AnalysisResult = {
|
||||||
|
result_id: string;
|
||||||
|
run_id: string;
|
||||||
|
result_type: string;
|
||||||
|
node_id?: string | null;
|
||||||
|
link_id?: string | null;
|
||||||
|
payload: Record<string, unknown>;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AnalysisRunListResponse = {
|
||||||
|
success: boolean;
|
||||||
|
data: AnalysisRun[];
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AnalysisSchemeRecord = AnalysisRun & {
|
||||||
|
id: string;
|
||||||
|
scheme_id: string;
|
||||||
|
scheme_name: string;
|
||||||
|
scheme_type: string;
|
||||||
|
username: string;
|
||||||
|
create_time: string;
|
||||||
|
scheme_start_time: string;
|
||||||
|
scheme_detail: Record<string, unknown>;
|
||||||
|
schemeName: string;
|
||||||
|
type: string;
|
||||||
|
startTime: string;
|
||||||
|
schemeDetail: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeParameters = (value: unknown): Record<string, unknown> =>
|
||||||
|
value && typeof value === "object" && !Array.isArray(value)
|
||||||
|
? (value as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
|
||||||
|
export const toAnalysisSchemeRecord = (run: AnalysisRun): AnalysisSchemeRecord => {
|
||||||
|
const parameters = normalizeParameters(run.parameters);
|
||||||
|
return {
|
||||||
|
...run,
|
||||||
|
parameters,
|
||||||
|
id: run.run_id,
|
||||||
|
scheme_id: run.run_id,
|
||||||
|
scheme_name: run.name,
|
||||||
|
scheme_type: run.run_type,
|
||||||
|
username: run.created_by,
|
||||||
|
create_time: run.created_at,
|
||||||
|
scheme_start_time: run.started_at,
|
||||||
|
scheme_detail: parameters,
|
||||||
|
schemeName: run.name,
|
||||||
|
type: run.run_type,
|
||||||
|
startTime: run.started_at,
|
||||||
|
schemeDetail: parameters,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const listAnalysisSchemes = async ({
|
||||||
|
runType,
|
||||||
|
queryDate,
|
||||||
|
}: {
|
||||||
|
runType?: string;
|
||||||
|
queryDate?: string;
|
||||||
|
} = {}): Promise<AnalysisSchemeRecord[]> => {
|
||||||
|
const response = await api.get<AnalysisRunListResponse>(
|
||||||
|
"/api/v1/analysis/runs",
|
||||||
|
);
|
||||||
|
const runs = Array.isArray(response.data.data) ? response.data.data : [];
|
||||||
|
return runs
|
||||||
|
.filter((run) => !runType || run.run_type === runType)
|
||||||
|
.filter((run) => !queryDate || run.created_at.slice(0, 10) === queryDate)
|
||||||
|
.map(toAnalysisSchemeRecord);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAnalysisScheme = async (
|
||||||
|
runId: string,
|
||||||
|
): Promise<AnalysisSchemeRecord> => {
|
||||||
|
const response = await api.get<AnalysisRun>(
|
||||||
|
`/api/v1/analysis/runs/${encodeURIComponent(runId)}`,
|
||||||
|
);
|
||||||
|
return toAnalysisSchemeRecord(response.data);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAnalysisResults = async (
|
||||||
|
runId: string,
|
||||||
|
resultType?: string,
|
||||||
|
): Promise<AnalysisResult[]> => {
|
||||||
|
const response = await api.get<AnalysisResult[]>(
|
||||||
|
`/api/v1/analysis/runs/${encodeURIComponent(runId)}/results`,
|
||||||
|
{ params: resultType ? { result_type: resultType } : undefined },
|
||||||
|
);
|
||||||
|
return Array.isArray(response.data) ? response.data : [];
|
||||||
|
};
|
||||||
+1
-2
@@ -29,8 +29,7 @@ const FIELD_LABELS: Record<string, string> = {
|
|||||||
scada_burst_start: "爆管开始时间",
|
scada_burst_start: "爆管开始时间",
|
||||||
scada_burst_end: "爆管结束时间",
|
scada_burst_end: "爆管结束时间",
|
||||||
use_scada_flow: "使用流量监测数据",
|
use_scada_flow: "使用流量监测数据",
|
||||||
simulation_scheme_name: "模拟方案名称",
|
simulation_run_id: "模拟运行 ID",
|
||||||
simulation_scheme_type: "模拟方案类型",
|
|
||||||
source: "污染源节点",
|
source: "污染源节点",
|
||||||
concentration: "污染物浓度",
|
concentration: "污染物浓度",
|
||||||
pattern: "污染物注入模式",
|
pattern: "污染物注入模式",
|
||||||
|
|||||||
@@ -100,6 +100,27 @@ describe("streamAgentChat", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("parses one complete final answer event", async () => {
|
||||||
|
mockNewSessionStream({
|
||||||
|
ok: true,
|
||||||
|
body: makeStream([
|
||||||
|
'event: final_answer\ndata: {"session_id":"s1","content":"完整分析结果"}\n\n',
|
||||||
|
'event: done\ndata: {"session_id":"s1"}\n\n',
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
const events: StreamEvent[] = [];
|
||||||
|
|
||||||
|
await streamAgentChat({
|
||||||
|
message: "分析",
|
||||||
|
onEvent: (event) => events.push(event),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(events).toEqual([
|
||||||
|
{ type: "final_answer", sessionId: "s1", content: "完整分析结果" },
|
||||||
|
{ type: "done", sessionId: "s1" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("parses state events from a resumed stream", async () => {
|
it("parses state events from a resumed stream", async () => {
|
||||||
apiFetch.mockResolvedValue({
|
apiFetch.mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -172,6 +193,41 @@ describe("streamAgentChat", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("parses grouped activities and inherited permission reasons", async () => {
|
||||||
|
mockNewSessionStream({
|
||||||
|
ok: true,
|
||||||
|
body: makeStream([
|
||||||
|
'event: activity_update\ndata: {"session_id":"s1","activity":{"id":"a1","title":"准备分析数据","reason":"需要先确认输入数据完整。","status":"running","started_at":100,"elapsed_ms":25,"actions":[{"id":"x1","tool":"tjwater_cli","title":"查询后端数据","status":"running","target":"data list","started_at":110,"elapsed_ms":15}]},"todos":[{"id":"t1","content":"准备分析数据","status":"completed","priority":"high"},{"id":"t2","content":"生成分析结果","status":"in_progress","priority":"medium"}],"todos_created_at":125}\n\n',
|
||||||
|
'event: permission_request\ndata: {"session_id":"s1","request_id":"p1","permission":"bash","patterns":["python3 analysis.py"],"target":"python3 analysis.py","always":[],"activity_id":"a1","reason":"需要先确认输入数据完整。","created_at":123}\n\n',
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
const events: StreamEvent[] = [];
|
||||||
|
|
||||||
|
await streamAgentChat({ message: "分析", onEvent: (event) => events.push(event) });
|
||||||
|
|
||||||
|
expect(events[0]).toMatchObject({
|
||||||
|
type: "activity_update",
|
||||||
|
sessionId: "s1",
|
||||||
|
activity: {
|
||||||
|
id: "a1",
|
||||||
|
title: "准备分析数据",
|
||||||
|
reason: "需要先确认输入数据完整。",
|
||||||
|
status: "running",
|
||||||
|
actions: [expect.objectContaining({ id: "x1", target: "data list" })],
|
||||||
|
},
|
||||||
|
todos: [
|
||||||
|
expect.objectContaining({ id: "t1", status: "completed" }),
|
||||||
|
expect.objectContaining({ id: "t2", status: "in_progress" }),
|
||||||
|
],
|
||||||
|
todosCreatedAt: 125,
|
||||||
|
});
|
||||||
|
expect(events[1]).toMatchObject({
|
||||||
|
type: "permission_request",
|
||||||
|
activityId: "a1",
|
||||||
|
reason: "需要先确认输入数据完整。",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("parses credential refresh lifecycle events", async () => {
|
it("parses credential refresh lifecycle events", async () => {
|
||||||
mockNewSessionStream({
|
mockNewSessionStream({
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -246,6 +302,8 @@ describe("streamAgentChat", () => {
|
|||||||
permission: "bash",
|
permission: "bash",
|
||||||
patterns: ["rm *"],
|
patterns: ["rm *"],
|
||||||
target: "rm tmp.txt",
|
target: "rm tmp.txt",
|
||||||
|
activityId: undefined,
|
||||||
|
reason: undefined,
|
||||||
always: ["rm *"],
|
always: ["rm *"],
|
||||||
tool: undefined,
|
tool: undefined,
|
||||||
createdAt: 123,
|
createdAt: 123,
|
||||||
|
|||||||
@@ -59,6 +59,35 @@ export type AgentTodoUpdate = {
|
|||||||
createdAt: number;
|
createdAt: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type AgentActivityStatus = "running" | "completed" | "error" | "cancelled";
|
||||||
|
|
||||||
|
export type AgentActivityAction = {
|
||||||
|
id: string;
|
||||||
|
tool: string;
|
||||||
|
title: string;
|
||||||
|
status: "running" | "completed" | "error";
|
||||||
|
target?: string;
|
||||||
|
error?: string;
|
||||||
|
startedAt: number;
|
||||||
|
endedAt?: number;
|
||||||
|
elapsedMs?: number;
|
||||||
|
elapsedSnapshotAt?: number;
|
||||||
|
durationMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AgentActivity = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
reason: string;
|
||||||
|
status: AgentActivityStatus;
|
||||||
|
actions: AgentActivityAction[];
|
||||||
|
startedAt: number;
|
||||||
|
endedAt?: number;
|
||||||
|
elapsedMs?: number;
|
||||||
|
elapsedSnapshotAt?: number;
|
||||||
|
durationMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type StreamEvent =
|
export type StreamEvent =
|
||||||
| {
|
| {
|
||||||
type: "state";
|
type: "state";
|
||||||
@@ -68,8 +97,16 @@ export type StreamEvent =
|
|||||||
runStatus?: string;
|
runStatus?: string;
|
||||||
}
|
}
|
||||||
| { type: "token"; sessionId: string; content: string }
|
| { type: "token"; sessionId: string; content: string }
|
||||||
|
| { type: "final_answer"; sessionId: string; content: string }
|
||||||
| { type: "done"; sessionId: string; totalDurationMs?: number }
|
| { type: "done"; sessionId: string; totalDurationMs?: number }
|
||||||
| { type: "session_title"; sessionId: string; title: string }
|
| { type: "session_title"; sessionId: string; title: string }
|
||||||
|
| {
|
||||||
|
type: "activity_update";
|
||||||
|
sessionId: string;
|
||||||
|
activity: AgentActivity;
|
||||||
|
todos?: AgentTodoItem[];
|
||||||
|
todosCreatedAt?: number;
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
type: "progress";
|
type: "progress";
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
@@ -127,6 +164,8 @@ export type StreamEvent =
|
|||||||
permission: string;
|
permission: string;
|
||||||
patterns: string[];
|
patterns: string[];
|
||||||
target?: string;
|
target?: string;
|
||||||
|
activityId?: string;
|
||||||
|
reason?: string;
|
||||||
always: string[];
|
always: string[];
|
||||||
tool?: {
|
tool?: {
|
||||||
messageID: string;
|
messageID: string;
|
||||||
@@ -295,6 +334,47 @@ const normalizeTodos = (value: unknown): AgentTodoItem[] => {
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const normalizeActivityStatus = (value: unknown): AgentActivityStatus => {
|
||||||
|
if (value === "completed" || value === "error" || value === "cancelled") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return "running";
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeActivity = (value: unknown): AgentActivity | undefined => {
|
||||||
|
if (!isObjectRecord(value) || typeof value.id !== "string") return undefined;
|
||||||
|
const now = Date.now();
|
||||||
|
const actions: AgentActivityAction[] = Array.isArray(value.actions)
|
||||||
|
? value.actions.filter(isObjectRecord).map((action, index) => ({
|
||||||
|
id: typeof action.id === "string" ? action.id : `${value.id}-action-${index}`,
|
||||||
|
tool: typeof action.tool === "string" ? action.tool : "tool",
|
||||||
|
title: typeof action.title === "string" ? action.title : "执行操作",
|
||||||
|
status: action.status === "completed" || action.status === "error"
|
||||||
|
? action.status
|
||||||
|
: "running",
|
||||||
|
target: typeof action.target === "string" ? action.target : undefined,
|
||||||
|
error: typeof action.error === "string" ? action.error : undefined,
|
||||||
|
startedAt: typeof action.started_at === "number" ? action.started_at : now,
|
||||||
|
endedAt: typeof action.ended_at === "number" ? action.ended_at : undefined,
|
||||||
|
elapsedMs: typeof action.elapsed_ms === "number" ? action.elapsed_ms : undefined,
|
||||||
|
elapsedSnapshotAt: typeof action.elapsed_ms === "number" ? now : undefined,
|
||||||
|
durationMs: typeof action.duration_ms === "number" ? action.duration_ms : undefined,
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
return {
|
||||||
|
id: value.id,
|
||||||
|
title: typeof value.title === "string" ? value.title : "正在处理",
|
||||||
|
reason: typeof value.reason === "string" ? value.reason : "",
|
||||||
|
status: normalizeActivityStatus(value.status),
|
||||||
|
actions,
|
||||||
|
startedAt: typeof value.started_at === "number" ? value.started_at : now,
|
||||||
|
endedAt: typeof value.ended_at === "number" ? value.ended_at : undefined,
|
||||||
|
elapsedMs: typeof value.elapsed_ms === "number" ? value.elapsed_ms : undefined,
|
||||||
|
elapsedSnapshotAt: typeof value.elapsed_ms === "number" ? now : undefined,
|
||||||
|
durationMs: typeof value.duration_ms === "number" ? value.duration_ms : undefined,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const emitParsedStreamEvent = (
|
const emitParsedStreamEvent = (
|
||||||
event: string,
|
event: string,
|
||||||
data: string,
|
data: string,
|
||||||
@@ -327,6 +407,7 @@ const emitParsedStreamEvent = (
|
|||||||
target?: string;
|
target?: string;
|
||||||
always?: unknown;
|
always?: unknown;
|
||||||
created_at?: number;
|
created_at?: number;
|
||||||
|
todos_created_at?: number;
|
||||||
reply?: PermissionReply;
|
reply?: PermissionReply;
|
||||||
questions?: unknown;
|
questions?: unknown;
|
||||||
answers?: unknown;
|
answers?: unknown;
|
||||||
@@ -335,6 +416,8 @@ const emitParsedStreamEvent = (
|
|||||||
todos?: unknown;
|
todos?: unknown;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
timeout_ms?: number;
|
timeout_ms?: number;
|
||||||
|
activity?: unknown;
|
||||||
|
activity_id?: string;
|
||||||
};
|
};
|
||||||
if (event === "state") {
|
if (event === "state") {
|
||||||
onEvent({
|
onEvent({
|
||||||
@@ -350,6 +433,12 @@ const emitParsedStreamEvent = (
|
|||||||
sessionId: parsed.session_id ?? "",
|
sessionId: parsed.session_id ?? "",
|
||||||
content: parsed.content ?? "",
|
content: parsed.content ?? "",
|
||||||
});
|
});
|
||||||
|
} else if (event === "final_answer") {
|
||||||
|
onEvent({
|
||||||
|
type: "final_answer",
|
||||||
|
sessionId: parsed.session_id ?? "",
|
||||||
|
content: parsed.content ?? "",
|
||||||
|
});
|
||||||
} else if (event === "progress") {
|
} else if (event === "progress") {
|
||||||
onEvent({
|
onEvent({
|
||||||
type: "progress",
|
type: "progress",
|
||||||
@@ -364,6 +453,19 @@ const emitParsedStreamEvent = (
|
|||||||
elapsedMs: parsed.elapsed_ms,
|
elapsedMs: parsed.elapsed_ms,
|
||||||
durationMs: parsed.duration_ms,
|
durationMs: parsed.duration_ms,
|
||||||
});
|
});
|
||||||
|
} else if (event === "activity_update") {
|
||||||
|
const activity = normalizeActivity(parsed.activity);
|
||||||
|
if (activity) {
|
||||||
|
onEvent({
|
||||||
|
type: "activity_update",
|
||||||
|
sessionId: parsed.session_id ?? "",
|
||||||
|
activity,
|
||||||
|
todos: Array.isArray(parsed.todos)
|
||||||
|
? normalizeTodos(parsed.todos)
|
||||||
|
: undefined,
|
||||||
|
todosCreatedAt: parsed.todos_created_at,
|
||||||
|
});
|
||||||
|
}
|
||||||
} else if (event === "done") {
|
} else if (event === "done") {
|
||||||
onEvent({
|
onEvent({
|
||||||
type: "done",
|
type: "done",
|
||||||
@@ -429,6 +531,8 @@ const emitParsedStreamEvent = (
|
|||||||
? parsed.patterns.filter((item): item is string => typeof item === "string")
|
? parsed.patterns.filter((item): item is string => typeof item === "string")
|
||||||
: [],
|
: [],
|
||||||
target: typeof parsed.target === "string" ? parsed.target : undefined,
|
target: typeof parsed.target === "string" ? parsed.target : undefined,
|
||||||
|
activityId: typeof parsed.activity_id === "string" ? parsed.activity_id : undefined,
|
||||||
|
reason: typeof parsed.reason === "string" ? parsed.reason : undefined,
|
||||||
always: Array.isArray(parsed.always)
|
always: Array.isArray(parsed.always)
|
||||||
? parsed.always.filter((item): item is string => typeof item === "string")
|
? parsed.always.filter((item): item is string => typeof item === "string")
|
||||||
: [],
|
: [],
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ interface MapClickEvent {
|
|||||||
const getGeoserverConfig = () => ({
|
const getGeoserverConfig = () => ({
|
||||||
url: config.MAP_URL,
|
url: config.MAP_URL,
|
||||||
workspace: config.MAP_WORKSPACE,
|
workspace: config.MAP_WORKSPACE,
|
||||||
layers: ["geo_pipes_mat", "geo_junctions_mat", "geo_valves"],
|
layers: ["pipes", "junctions", "valves"],
|
||||||
wfsVersion: "1.0.0",
|
wfsVersion: "1.0.0",
|
||||||
outputFormat: "application/json",
|
outputFormat: "application/json",
|
||||||
});
|
});
|
||||||
@@ -489,9 +489,6 @@ const handleMapClickSelectFeatures = async (
|
|||||||
// 如果要素来自 VectorTileSource,需要通过 WFS 查询完整信息
|
// 如果要素来自 VectorTileSource,需要通过 WFS 查询完整信息
|
||||||
const queryId = firstFeature.getProperties().id;
|
const queryId = firstFeature.getProperties().id;
|
||||||
const layerName = firstFeature.getProperties().layer;
|
const layerName = firstFeature.getProperties().layer;
|
||||||
if (layerName === "geo_pipes" || layerName === "geo_junctions") {
|
|
||||||
layerName.concat("_mat");
|
|
||||||
}
|
|
||||||
if (!queryId) {
|
if (!queryId) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user