Compare commits

..
Author SHA1 Message Date
jiang cd9d04a370 feat(history): merge event simulation history with interpolation
Generic Container CI/CD / test-build-publish (push) Successful in 1m8s
Frontend CI/CD v2 / build-test-publish-and-deploy (push) Successful in 1m8s
2026-11-07 12:54:19 +08:00
jiang eff59c5a7e feat(profile): publish stable default avatar asset
Generic Container CI/CD / test-build-publish (push) Successful in 1m4s
Frontend CI/CD v2 / build-test-publish-and-deploy (push) Successful in 1m4s
2026-11-04 15:20:50 +08:00
jiang d98fa359ae fix(auth): redirect expired routes into login flow
The global route guard previously rendered redirect copy without navigating and also blocked the login route itself. Allow the login page through, redirect protected unauthenticated routes, and avoid a /login callback loop.
2026-11-04 15:20:46 +08:00
10 changed files with 347 additions and 22 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

+36 -1
View File
@@ -1,4 +1,5 @@
import { render, screen, waitFor } from "@testing-library/react";
import { signIn } from "next-auth/react";
import type { ReactNode } from "react";
import { useAccessStore } from "@/store/accessStore";
@@ -25,6 +26,8 @@ let mockSessionState: {
};
let mockPathname = "/network-simulation";
let mockProjectProviderBlocks = false;
let capturedLogin: (() => Promise<unknown>) | null = null;
const replaceMock = jest.fn();
jest.mock("next-auth/react", () => ({
SessionProvider: ({ children }: { children: ReactNode }) => children,
@@ -34,10 +37,20 @@ jest.mock("next-auth/react", () => ({
jest.mock("next/navigation", () => ({
usePathname: () => mockPathname,
useRouter: () => ({ replace: replaceMock }),
}));
jest.mock("@refinedev/core", () => ({
Refine: ({ children }: { children: ReactNode }) => children,
Refine: ({
children,
authProvider,
}: {
children: ReactNode;
authProvider: { login: (params: Record<string, never>) => Promise<unknown> };
}) => {
capturedLogin = () => authProvider.login({});
return children;
},
}));
jest.mock("@refinedev/kbar", () => ({
@@ -78,6 +91,9 @@ describe("RefineContext access authentication", () => {
clearIntentionalLogout();
mockPathname = "/network-simulation";
mockProjectProviderBlocks = false;
capturedLogin = null;
replaceMock.mockReset();
jest.mocked(signIn).mockClear();
mockSessionState = {
data: {
accessToken: "expired-access-token",
@@ -217,6 +233,25 @@ describe("RefineContext access authentication", () => {
});
});
it("starts login with a safe post-authentication callback from the login route", async () => {
mockPathname = "/login";
mockSessionState = { data: null, status: "unauthenticated" };
render(
<App>
<div>登录页面</div>
</App>,
);
expect(capturedLogin).not.toBeNull();
await capturedLogin!();
expect(signIn).toHaveBeenCalledWith("keycloak", {
callbackUrl: "/",
redirect: true,
});
});
it("does not treat returning to a protected route after logout as session expiry", async () => {
markIntentionalLogout();
mockSessionState = { data: null, status: "unauthenticated" };
+1 -1
View File
@@ -214,7 +214,7 @@ export const App = (props: React.PropsWithChildren<AppProps>) => {
const authProvider: AuthProvider = {
login: async () => {
signIn("keycloak", {
callbackUrl: to ? to.toString() : "/",
callbackUrl: to && to !== "/login" ? to.toString() : "/",
redirect: true,
});
return { success: true };
@@ -4,8 +4,12 @@ import { useAccessStore } from "@/store/accessStore";
import { useAuthStore } from "@/store/authStore";
import { RoutePermissionGuard } from "./RoutePermissionGuard";
const replaceMock = jest.fn();
let mockPathname = "/network-simulation";
jest.mock("next/navigation", () => ({
usePathname: () => "/network-simulation",
usePathname: () => mockPathname,
useRouter: () => ({ replace: replaceMock }),
}));
jest.mock("@/components/header", () => ({
@@ -16,6 +20,8 @@ jest.mock("@/components/header", () => ({
describe("RoutePermissionGuard", () => {
beforeEach(() => {
replaceMock.mockReset();
mockPathname = "/network-simulation";
useAccessStore.setState({
context: null,
permissions: [],
@@ -86,7 +92,7 @@ describe("RoutePermissionGuard", () => {
expect(screen.getByTestId("access-header")).toHaveTextContent("project");
});
it("checks authentication before the expired state effect runs", () => {
it("redirects an unauthenticated protected route to login", () => {
render(
<RoutePermissionGuard authenticated={false}>
<div>受保护内容</div>
@@ -95,5 +101,20 @@ describe("RoutePermissionGuard", () => {
expect(screen.getByText("登录状态已失效")).toBeInTheDocument();
expect(screen.queryByText("无权访问此功能")).not.toBeInTheDocument();
expect(replaceMock).toHaveBeenCalledWith("/login");
});
it("allows the login route to render while unauthenticated", () => {
mockPathname = "/login";
render(
<RoutePermissionGuard authenticated={false}>
<div>登录页面</div>
</RoutePermissionGuard>,
);
expect(screen.getByText("登录页面")).toBeInTheDocument();
expect(screen.queryByText("登录状态已失效")).not.toBeInTheDocument();
expect(replaceMock).not.toHaveBeenCalled();
});
});
+14 -2
View File
@@ -8,8 +8,8 @@ import {
Stack,
Typography,
} from "@mui/material";
import { usePathname } from "next/navigation";
import type { ReactNode } from "react";
import { usePathname, useRouter } from "next/navigation";
import { type ReactNode, useEffect } from "react";
import { Header } from "@/components/header";
import { permissionForPath } from "@/lib/permissions";
@@ -26,10 +26,22 @@ export const RoutePermissionGuard = ({
accessDeniedReason?: "account" | "project" | null;
}) => {
const pathname = usePathname();
const router = useRouter();
const permissions = useAccessStore((state) => state.permissions);
const loading = useAccessStore((state) => state.loading);
const sessionExpired = useAuthStore((state) => state.sessionExpired);
const requiredPermission = permissionForPath(pathname);
const isLoginRoute = pathname === "/login";
useEffect(() => {
if (!authenticated && !isLoginRoute) {
router.replace("/login");
}
}, [authenticated, isLoginRoute, router]);
if (isLoginRoute) {
return children;
}
if (!authenticated || sessionExpired) {
return (
@@ -126,10 +126,10 @@ describe("fetchHistoryData", () => {
] });
});
const result = await fetchHistoryData(targets, range, "scheme", "run-1");
expect(result.series).toHaveLength(4);
expect(result.points[0].values).toEqual({ '["pipe","P-1","realtime_simulation"]': 36, '["junction","J-1","realtime_simulation"]': 30 });
expect(result.points[1].values['["pipe","P-1","analysis_simulation"]']).toBe(72);
expect(result.points[2].values['["pipe","P-1","realtime_simulation"]']).toBeCloseTo(43.2);
expect(result.series).toHaveLength(3);
expect(result.points[0].values).toEqual({ '["pipe","P-1","event_simulation"]': 36, '["junction","J-1","event_simulation"]': 30 });
expect(result.points[1].values['["pipe","P-1","event_simulation"]']).toBe(72);
expect(result.points[2].values['["pipe","P-1","event_simulation"]']).toBeCloseTo(43.2);
expect(apiFetch).toHaveBeenCalledTimes(2);
for (const [, init] of jest.mocked(apiFetch).mock.calls) {
expect(JSON.parse(String(init?.body)).elements).toHaveLength(2);
@@ -317,7 +317,7 @@ describe("HistoryDataPanel requests", () => {
expect(getAnalysisScheme).toHaveBeenCalledTimes(2);
});
it("plots native timestamps and explicit gaps with the event interval marked", async () => {
it("plots a single interpolated event curve with the event interval marked", async () => {
const series = {
element_id: "P-1", element_type: "pipe", device_id: null, metric: "flow",
source_unit: "m3/h", display_unit: "m³/h", unit_inferred: false,
@@ -340,16 +340,21 @@ describe("HistoryDataPanel requests", () => {
const option = jest.mocked(ReactECharts).mock.calls.at(-1)![0].option as any;
expect(option.xAxis).toMatchObject({ type: "time", min: Date.parse("2026-08-17T08:00:00Z"), max: Date.parse("2026-08-17T11:00:00Z") });
expect(option.series[0].connectNulls).toBe(false);
expect(option.series[0].data).toEqual([
[Date.parse("2026-08-17T08:00:00Z"), 10],
[Date.parse("2026-08-17T09:00:00Z"), null],
[Date.parse("2026-08-17T11:00:00Z"), 12],
]);
expect(option.series).toHaveLength(1);
expect(option.series[0].name).toBe("管道 P-1 (事件模拟) [m³/h]");
expect(option.series[0].data).toContainEqual([Date.parse("2026-08-17T08:00:00Z"), 10]);
expect(option.series[0].data).toContainEqual([Date.parse("2026-08-17T09:00:00Z"), 8.4]);
expect(option.series[0].data).toContainEqual([Date.parse("2026-08-17T09:15:00Z"), 8]);
expect(option.series[0].data).toContainEqual([Date.parse("2026-08-17T11:00:00Z"), 12]);
expect(option.series[0].markArea.data[0]).toEqual([
{ name: "事件方案时段", xAxis: Date.parse("2026-08-17T09:00:00Z") },
{ xAxis: Date.parse("2026-08-17T10:00:00Z") },
]);
expect(option.series[1].data).toEqual([[Date.parse("2026-08-17T09:15:00Z"), 8]]);
expect(screen.queryByText(/当前时段暂无在线模拟数据/)).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("tab", { name: "表格" }));
expect(await screen.findByRole("columnheader", { name: "管道 P-1 (事件模拟) [m³/h]" })).toBeInTheDocument();
expect(screen.getAllByRole("columnheader")).toHaveLength(2);
expect(screen.getByRole("gridcell", { name: "8.40" })).toBeInTheDocument();
});
it("snaps off-step event context without changing the actual event marker", async () => {
@@ -40,9 +40,9 @@ import {
fetchElementHistory,
historySeriesKey,
historySeriesLabel,
toTimeSeriesPoints,
TimeSeriesPoint,
} from "@/lib/elementHistory";
import { mergeEventHistory, type EventHistoryOptions } from "@/lib/eventHistory";
import {
type AnalysisHistoryContext,
getAnalysisHistoryContext,
@@ -106,6 +106,7 @@ export const fetchHistoryData = async (
type: "realtime" | "scheme" | "none",
schemeRunId?: string,
signal?: AbortSignal,
eventOptions?: EventHistoryOptions,
): Promise<ElementHistoryResult> => {
if (featureInfos.length === 0) {
return { points: [], series: [] };
@@ -157,7 +158,9 @@ export const fetchHistoryData = async (
...online.series,
...analysis.series.filter((item) => item.source === "analysis_simulation"),
];
return { series, points: toTimeSeriesPoints(series) };
return mergeEventHistory(series, range, eventOptions ?? {
eventRange: null, stepSeconds: 0,
});
}
return fetchElementHistory(
elements,
@@ -308,6 +311,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
const [activeTab, setActiveTab] = useState<PanelTab>(defaultTab);
const [timeSeries, setTimeSeries] = useState<TimeSeriesPoint[]>([]);
const [historySeries, setHistorySeries] = useState<ElementHistorySeries[]>([]);
const [hasOnlineSimulationData, setHasOnlineSimulationData] = useState(false);
const [loadingState, setLoadingState] = useState<LoadingState>("idle");
const [error, setError] = useState<string | null>(null);
const [isSimulating, setIsSimulating] = useState(false);
@@ -402,6 +406,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
requestControllerRef.current = null;
setTimeSeries([]);
setHistorySeries([]);
setHasOnlineSimulationData(false);
setLoadingState("idle");
setError(null);
return;
@@ -425,6 +430,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
: type,
schemeContext?.simulationRunId,
requestController.signal,
{ eventRange: eventRange ?? null, stepSeconds },
);
if (
requestController.signal.aborted ||
@@ -432,6 +438,10 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
) return;
setTimeSeries(result.points);
setHistorySeries(result.series);
setHasOnlineSimulationData(result.hasOnlineSimulationData ?? result.series.some(
(series) => series.source === "realtime_simulation"
&& series.points.some((point) => point.value !== null && Number.isFinite(point.value)),
));
setLoadingState("success");
} catch (err) {
if (
@@ -443,13 +453,14 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
}
setTimeSeries([]);
setHistorySeries([]);
setHasOnlineSimulationData(false);
setError(err instanceof Error ? err.message : "未知错误");
setLoadingState("error");
}
},
[
requestedFeatureInfos, hasDevices, normalizedRange,
schemeReady, schemeError, schemeContext, type,
schemeReady, schemeError, schemeContext, type, eventRange, stepSeconds,
],
);
@@ -1017,7 +1028,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
</Typography>
)}
{!isSimulating && type !== "none" && hasDevices && loadingState === "success" &&
!historySeries.some((series) => series.source === "realtime_simulation" && series.points.some((point) => point.value !== null)) && (
!hasOnlineSimulationData && (
<Typography variant="caption" sx={{ mt: 1, display: "block" }}>
当前时段暂无在线模拟数据。{canRunSimulation ? "可点击“在线模拟”计算。" : ""}
</Typography>
+8 -1
View File
@@ -12,7 +12,9 @@ export type HistorySource =
| "scada_raw"
| "scada_cleaned"
| "realtime_simulation"
| "analysis_simulation";
| "analysis_simulation"
// 前端按事件时段拼接历史在线结果与方案结果。
| "event_simulation";
export interface ElementHistoryTarget {
element_id: string;
@@ -40,6 +42,8 @@ export interface TimeSeriesPoint {
export interface ElementHistoryResult {
points: TimeSeriesPoint[];
series: ElementHistorySeries[];
/** 事件序列合并后仍保留在线历史是否存在的信息。 */
hasOnlineSimulationData?: boolean;
}
const SOURCE_SUFFIX: Record<HistorySource, string> = {
@@ -47,6 +51,7 @@ const SOURCE_SUFFIX: Record<HistorySource, string> = {
scada_cleaned: "clean",
realtime_simulation: "sim",
analysis_simulation: "scheme_sim",
event_simulation: "event_sim",
};
export const historySeriesKey = (series: ElementHistorySeries) => {
@@ -64,6 +69,8 @@ export const historySourceLabel = (source: HistorySource) => {
return "实时模拟";
case "analysis_simulation":
return "方案模拟";
case "event_simulation":
return "事件模拟";
}
};
+106
View File
@@ -0,0 +1,106 @@
import { type ElementHistorySeries, historySeriesKey } from "./elementHistory";
import { mergeEventHistory } from "./eventHistory";
const time = (minutes: number) => new Date(Date.UTC(2026, 7, 17, 8, minutes)).toISOString();
const series = (
source: ElementHistorySeries["source"],
points: Array<[number, number | null]>,
overrides: Partial<ElementHistorySeries> = {},
): ElementHistorySeries => ({
element_id: "P-1", element_type: "pipe", device_id: null, metric: "flow",
source, source_unit: "m3/h", display_unit: "m³/h", unit_inferred: false,
points: points.map(([minutes, value]) => ({ time: time(minutes), value })),
...overrides,
});
const range = { from: new Date(time(0)), to: new Date(time(120)) };
const options = { eventRange: { start: time(38), end: time(83) }, stepSeconds: 900 };
const values = (item: ElementHistorySeries) => Object.fromEntries(
item.points.map((point) => [point.time, point.value]),
);
describe("mergeEventHistory", () => {
it("uses event values only in the event window and interpolates mismatched times and transitions", () => {
const result = mergeEventHistory([
series("realtime_simulation", [[0, 100], [30, 100], [45, 999], [60, 999], [90, 100], [120, 100]]),
series("analysis_simulation", [[23, 999], [38, 80], [53, 60], [68, 40], [83, 80], [98, 999]]),
], range, options);
expect(result.series).toHaveLength(1);
expect(result.series[0].source).toBe("event_simulation");
const merged = values(result.series[0]);
expect(merged[time(15)]).toBe(100);
expect(merged[time(38)]).toBe(80);
expect(merged[time(45)]).toBeCloseTo(70.6666667);
expect(merged[time(60)]).toBeCloseTo(50.6666667);
expect(merged[time(83)]).toBe(80);
expect(merged[time(90)]).toBe(100);
expect(merged[time(105)]).toBe(100);
expect(merged[time(23)]).toBeUndefined();
expect(merged[time(98)]).toBeUndefined();
expect(result.points.find((point) => point.timestamp === time(45))?.values[
historySeriesKey(result.series[0])
]).toBeCloseTo(70.6666667);
});
it("interpolates across the seam when the first event sample starts after the event boundary", () => {
const result = mergeEventHistory([
series("realtime_simulation", [[30, 100], [45, 999], [90, 100]]),
series("analysis_simulation", [[53, 54], [68, 24]]),
], range, options);
const merged = values(result.series[0]);
expect(merged[time(38)]).toBe(84);
expect(merged[time(45)]).toBe(70);
expect(merged[time(75)]).toBeCloseTo(48.181818);
expect(merged[time(0)]).toBeNull();
expect(merged[time(120)]).toBeNull();
});
it("converts different source units before merging and preserves SCADA samples", () => {
const observed = series("scada_raw", [[40, 12], [50, null]], { device_id: "F-1", source_unit: "L/s" });
const result = mergeEventHistory([
observed,
series("realtime_simulation", [[0, 10], [90, 10]], { source_unit: "L/s" }),
series("analysis_simulation", [[38, 20], [83, 20]]),
], range, options);
expect(result.series[0]).toBe(observed);
expect(result.points.find((point) => point.timestamp === time(40))?.values["F-1_raw"]).toBe(43.2);
const merged = values(result.series[1]);
expect(merged[time(0)]).toBe(36);
expect(merged[time(38)]).toBe(20);
expect(merged[time(90)]).toBe(36);
});
it("preserves explicit missing samples and never interpolates through them", () => {
const result = mergeEventHistory([
series("realtime_simulation", [[0, 100], [30, null], [90, 100], [120, 100]]),
series("analysis_simulation", [[38, 80], [53, null], [68, 40], [83, 80]]),
], range, options);
const merged = values(result.series[0]);
expect(merged[time(15)]).toBeNull();
expect(merged[time(30)]).toBeNull();
expect(merged[time(45)]).toBeNull();
expect(merged[time(53)]).toBeNull();
expect(merged[time(60)]).toBeNull();
expect(merged[time(75)]).toBeCloseTo(58.6666667);
});
it("keeps missing event results as gaps instead of inserting normal baseline values", () => {
const result = mergeEventHistory([
series("realtime_simulation", [[0, 100], [30, 100], [45, 999], [90, 100], [120, 100]]),
], range, options);
expect(values(result.series[0])[time(45)]).toBeNull();
expect(values(result.series[0])[time(90)]).toBe(100);
});
it("keeps same-name pipes and junctions separate and supports analysis-only results", () => {
const result = mergeEventHistory([
series("analysis_simulation", [[38, 80], [83, 80]]),
series("analysis_simulation", [[38, 30], [83, 30]], {
element_type: "junction", metric: "pressure", source_unit: "m", display_unit: "m",
}),
], range, options);
expect(result.series).toHaveLength(2);
const point = result.points.find((item) => item.timestamp === time(45))!;
expect(Object.values(point.values)).toEqual([80, 30]);
expect(Object.values(result.points[0].values)).toEqual([null, null]);
});
});
+128
View File
@@ -0,0 +1,128 @@
import {
type ElementHistoryResult,
type ElementHistorySeries,
toTimeSeriesPoints,
} from "./elementHistory";
import { FLOW_DISPLAY_UNIT, PRESSURE_DISPLAY_UNIT, toDisplayValue } from "@/utils/units";
export interface EventHistoryOptions {
eventRange: { start: string; end: string } | null;
stepSeconds: number;
}
type Sample = { time: number; value: number | null };
const samples = (series: ElementHistorySeries | undefined): Sample[] => {
const values = new Map<number, number | null>();
series?.points.forEach((point) => {
const time = Date.parse(point.time);
if (!Number.isFinite(time)) return;
const value = toDisplayValue(point.value, series.metric, series.source_unit);
values.set(time, value !== null && Number.isFinite(value) ? value : null);
});
return Array.from(values, ([time, value]) => ({ time, value }))
.sort((left, right) => left.time - right.time);
};
/** 拼接已保存的模拟结果;仅在相邻有效样本之间插值,保留显式缺测,不外推。 */
export const mergeEventHistory = (
series: ElementHistorySeries[],
range: { from: Date; to: Date },
{ eventRange, stepSeconds }: EventHistoryOptions,
): ElementHistoryResult => {
const groups = new Map<string, {
online?: ElementHistorySeries;
analysis?: ElementHistorySeries;
}>();
const observed = series.filter((item) => item.source.startsWith("scada_"));
series.forEach((item) => {
if (item.source !== "realtime_simulation" && item.source !== "analysis_simulation") return;
const key = JSON.stringify([item.element_type, item.element_id, item.metric]);
const group = groups.get(key) ?? {};
if (item.source === "realtime_simulation") group.online = item;
else group.analysis = item;
groups.set(key, group);
});
const merged = Array.from(groups.values(), ({ online, analysis }) => {
const baseline = samples(online);
const event = samples(analysis);
const start = eventRange ? Date.parse(eventRange.start) : event[0]?.time ?? NaN;
const end = eventRange ? Date.parse(eventRange.end) : event.at(-1)?.time ?? NaN;
const hasEventRange = Number.isFinite(start) && Number.isFinite(end) && end >= start;
const anchors = new Map<number, number | null>();
baseline.forEach((point) => {
if (!hasEventRange || point.time < start || point.time > end) {
anchors.set(point.time, point.value);
}
});
event.forEach((point) => {
if (hasEventRange && point.time >= start && point.time <= end) {
anchors.set(point.time, point.value);
}
});
if (hasEventRange && !event.some((point) => point.time >= start && point.time <= end)) {
anchors.set(start, null);
anchors.set(end, null);
}
const ordered = Array.from(anchors, ([time, value]) => ({ time, value }))
.sort((left, right) => left.time - right.time);
const from = range.from.getTime();
const to = range.to.getTime();
const times = new Set([
...baseline.map((point) => point.time),
...ordered.map((point) => point.time),
from, to,
]);
if (hasEventRange) {
times.add(start);
times.add(end);
}
const step = stepSeconds * 1000;
if (Number.isFinite(step) && step > 0 && ordered.length) {
const first = Math.max(from, ordered[0].time);
const last = Math.min(to, ordered[ordered.length - 1].time);
for (let time = Math.ceil(first / step) * step; time <= last; time += step) {
times.add(time);
}
}
let index = 0;
const points = Array.from(times).filter((time) => time >= from && time <= to)
.sort((left, right) => left - right).map((time) => {
while (index < ordered.length && ordered[index].time < time) index += 1;
const right = ordered[index];
const left = ordered[index - 1];
let value: number | null = null;
if (right?.time === time) value = right.value;
else if (left?.value != null && right?.value != null) {
value = left.value + (right.value - left.value)
* (time - left.time) / (right.time - left.time);
}
return { time: new Date(time).toISOString(), value };
});
const metadata = analysis ?? online!;
const unit = metadata.metric === "flow" ? FLOW_DISPLAY_UNIT : PRESSURE_DISPLAY_UNIT;
return {
...metadata,
device_id: null,
source: "event_simulation" as const,
source_unit: unit,
display_unit: unit,
unit_inferred: Boolean(online?.unit_inferred || analysis?.unit_inferred),
points,
};
});
const result = [...observed, ...merged];
return {
series: result,
points: toTimeSeriesPoints(result),
hasOnlineSimulationData: series.some((item) =>
item.source === "realtime_simulation" && item.points.some((point) =>
point.value !== null && Number.isFinite(point.value)
&& Date.parse(point.time) >= range.from.getTime()
&& Date.parse(point.time) <= range.to.getTime(),
),
),
};
};