feat(frontend): enforce RBAC and refine burst analysis

This commit is contained in:
2026-07-30 16:45:10 +08:00
parent f355ddd002
commit 723931b6ae
15 changed files with 772 additions and 390 deletions
@@ -11,7 +11,10 @@ import AnalysisReport, {
matchesValveAnalysis,
} from "./AnalysisReport";
import { SchemeRecord, ValveIsolationResult } from "./types";
import { isAllowedAccidentPipe } from "./valveIsolationScope";
import {
isAllowedAccidentPipe,
normalizeValveIsolationResult,
} from "./valveIsolationScope";
jest.mock("@/utils/mapQueryService", () => ({
queryFeaturesByIds: jest.fn(),
@@ -139,12 +142,69 @@ describe("AnalysisReport", () => {
);
});
it("shows only the affected count for a non-isolatable result", async () => {
const legacyAffectedNodes = Array.from(
{ length: 100 },
(_, index) => `legacy-node-${index}`,
);
const nonIsolatableResult = {
...valveResult,
affected_nodes: legacyAffectedNodes,
affected_node_count: 85747,
must_close_valves: [],
isolatable: false,
};
render(
<AnalysisReport
scheme={scheme}
valveResult={nonIsolatableResult}
disabledValves={[]}
generatedAt={new Date("2026-07-30T10:00:00+08:00")}
/>,
);
const preview = within(
screen.getByTestId("burst-analysis-report-preview"),
);
expect(preview.getByText("85747 个")).toBeInTheDocument();
expect(
preview.getByText("不可隔离,未生成受影响节点清单。"),
).toBeInTheDocument();
expect(preview.queryByText("legacy-node-0")).not.toBeInTheDocument();
expect(preview.queryByText("legacy-node-99")).not.toBeInTheDocument();
await waitFor(() =>
expect(preview.getByText("315 mm")).toBeInTheDocument(),
);
});
it("limits scheme-bound valve analysis to the scheme accident pipes", () => {
expect(isAllowedAccidentPipe("P-1", ["P-1", "P-2"])).toBe(true);
expect(isAllowedAccidentPipe("P-99", ["P-1", "P-2"])).toBe(false);
expect(isAllowedAccidentPipe("P-99", undefined)).toBe(true);
});
it("normalizes legacy valve results without changing isolatable lists", () => {
expect(
normalizeValveIsolationResult({
...valveResult,
affected_nodes: ["J-1", "J-2", "J-3"],
must_close_valves: [],
isolatable: false,
}),
).toMatchObject({
affected_nodes: [],
affected_node_count: 3,
isolatable: false,
});
expect(normalizeValveIsolationResult(valveResult)).toMatchObject({
affected_nodes: ["J-1", "J-2"],
affected_node_count: 2,
isolatable: true,
});
});
it("prints only after assigning the report print state", async () => {
const originalTitle = document.title;
const print = jest
@@ -35,6 +35,7 @@ import {
type PipeDiameterMap,
} from "./schemePipeDiameters";
import { SchemeRecord, ValveIsolationResult } from "./types";
import { getAffectedNodeCount } from "./valveIsolationScope";
import PanelEmptyState from "@components/olmap/common/PanelEmptyState";
interface AnalysisReportProps {
@@ -212,6 +213,18 @@ const ReportDocument: React.FC<ReportDocumentProps> = ({
? valveResult
: null;
const duration = scheme.schemeDetail?.modify_total_duration;
const valveDetailRows: Array<[string, string[] | undefined]> =
matchedValveResult
? [
["已分析事故管段", matchedValveResult.accident_elements],
["必关阀门", matchedValveResult.must_close_valves],
["可选阀门", matchedValveResult.optional_valves],
["不可用阀门", disabledValves],
]
: [];
if (matchedValveResult?.isolatable) {
valveDetailRows.push(["受影响节点", matchedValveResult.affected_nodes]);
}
return (
<Box
@@ -348,7 +361,7 @@ const ReportDocument: React.FC<ReportDocumentProps> = ({
{[
["隔离结论", matchedValveResult.isolatable ? "可隔离" : "不可隔离"],
["必关阀门", `${matchedValveResult.must_close_valves?.length ?? 0}`],
["受影响节点", `${matchedValveResult.affected_nodes?.length ?? 0}`],
["受影响节点", `${getAffectedNodeCount(matchedValveResult)}`],
].map(([label, value]) => (
<Paper
key={label}
@@ -364,20 +377,19 @@ const ReportDocument: React.FC<ReportDocumentProps> = ({
</Paper>
))}
</Box>
{[
["已分析事故管段", matchedValveResult.accident_elements],
["必关阀门", matchedValveResult.must_close_valves],
["可选阀门", matchedValveResult.optional_valves],
["不可用阀门", disabledValves],
["受影响节点", matchedValveResult.affected_nodes],
].map(([label, values]) => (
<Box key={label as string} sx={{ breakInside: "avoid" }}>
{valveDetailRows.map(([label, values]) => (
<Box key={label} sx={{ breakInside: "avoid" }}>
<Typography sx={{ mb: 0.75, fontSize: 13, fontWeight: 700 }}>
{label as string}
{label}
</Typography>
<IdList values={values as string[]} />
<IdList values={values} />
</Box>
))}
{!matchedValveResult.isolatable && (
<Alert severity="info" variant="outlined">
</Alert>
)}
</Stack>
) : (
<Alert severity="info" variant="outlined">
@@ -52,7 +52,11 @@ import {
import { Point } from "ol/geom";
import { toLonLat } from "ol/proj";
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
import { isAllowedAccidentPipe } from "./valveIsolationScope";
import {
getAffectedNodeCount,
isAllowedAccidentPipe,
normalizeValveIsolationResult,
} from "./valveIsolationScope";
interface ValveIsolationProps {
initialPipeIds?: string[];
@@ -363,7 +367,7 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
if (disabled.length > 0) {
params.disabled_valves = disabled;
}
const response = await api.get(
const response = await api.get<ValveIsolationResult>(
`${config.BACKEND_URL}/api/v1/valve-isolation-analysis`,
{
params,
@@ -372,7 +376,7 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
},
},
);
setResult(response.data);
setResult(normalizeValveIsolationResult(response.data));
if (!isExpandSearch) {
setActiveStep(1);
} else {
@@ -711,7 +715,7 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
{[
{ label: "必关阀门", value: result.must_close_valves?.length || 0, color: "red", bgInfo: "from-red-50 to-red-100", textInfo: "text-red-700" },
{ label: "可选阀门", value: result.optional_valves?.length || 0, color: "orange", bgInfo: "from-orange-50 to-orange-100", textInfo: "text-orange-700" },
{ label: "影响节点", value: result.affected_nodes?.length || 0, color: "blue", bgInfo: "from-blue-50 to-blue-100", textInfo: "text-blue-700" },
{ label: "影响节点", value: getAffectedNodeCount(result), color: "blue", bgInfo: "from-blue-50 to-blue-100", textInfo: "text-blue-700" },
].map((item, index) => (
<Box
key={index}
@@ -877,8 +881,14 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
</Box>
)}
{!result.isolatable && (
<Alert severity="info" variant="outlined">
</Alert>
)}
{/* 受影响节点 */}
{result.affected_nodes && result.affected_nodes.length > 0 && (
{result.isolatable && result.affected_nodes && result.affected_nodes.length > 0 && (
<Box>
<Box className="flex items-center justify-between mb-2">
<Typography variant="caption" className="text-gray-800 font-bold border-l-4 border-blue-500 pl-2">
@@ -31,6 +31,7 @@ export interface SchemaItem {
export interface ValveIsolationResult {
accident_elements: string[];
affected_nodes: string[];
affected_node_count?: number;
must_close_valves: string[];
optional_valves: string[];
isolatable: boolean;
@@ -1,4 +1,17 @@
import type { ValveIsolationResult } from "./types";
export const isAllowedAccidentPipe = (
pipeId: string,
allowedPipeIds: string[] | undefined,
) => allowedPipeIds === undefined || allowedPipeIds.includes(pipeId);
export const getAffectedNodeCount = (result: ValveIsolationResult) =>
result.affected_node_count ?? result.affected_nodes?.length ?? 0;
export const normalizeValveIsolationResult = (
result: ValveIsolationResult,
): ValveIsolationResult => ({
...result,
affected_nodes: result.isolatable ? result.affected_nodes ?? [] : [],
affected_node_count: getAffectedNodeCount(result),
});