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),
});
+33 -28
View File
@@ -29,6 +29,8 @@ import { FiSkipBack, FiSkipForward } from "react-icons/fi";
import { useData } from "../MapComponent";
import { config, NETWORK_NAME } from "@/config/config";
import { apiFetch } from "@/lib/apiFetch";
import { permissionCodes } from "@/lib/permissions";
import { useAccessStore } from "@/store/accessStore";
import { useMap } from "../MapComponent";
import {
formatTimelineTime,
@@ -81,6 +83,9 @@ const Timeline: React.FC<TimelineProps> = ({
schemeType = "burst_analysis",
}) => {
const data = useData();
const canRunSimulation = useAccessStore((state) =>
state.permissions.includes(permissionCodes.simulationRun),
);
const fallbackSelectedDateRef = useRef(new Date());
const hasTimelineState =
data &&
@@ -926,35 +931,35 @@ const Timeline: React.FC<TimelineProps> = ({
</IconButton>
</Tooltip>
</Box>
<Box sx={{ display: "flex", gap: 1 }} className="ml-4">
{/* 强制计算时间段 */}
<FormControl size="small" sx={{ minWidth: 100 }}>
<InputLabel></InputLabel>
<Select
value={calculatedInterval}
label="强制计算时间段"
onChange={handleCalculatedIntervalChange}
>
{resolvedCalculatedIntervalOptions.map((option) => (
<MenuItem key={option.value} value={option.value}>
{option.label}
</MenuItem>
))}
</Select>
</FormControl>
{canRunSimulation && (
<Box sx={{ display: "flex", gap: 1 }} className="ml-4">
<FormControl size="small" sx={{ minWidth: 100 }}>
<InputLabel></InputLabel>
<Select
value={calculatedInterval}
label="强制计算时间段"
onChange={handleCalculatedIntervalChange}
>
{resolvedCalculatedIntervalOptions.map((option) => (
<MenuItem key={option.value} value={option.value}>
{option.label}
</MenuItem>
))}
</Select>
</FormControl>
{/* 功能按钮 */}
<Tooltip title="强制计算">
<Button
variant="outlined"
startIcon={<Refresh />}
onClick={handleForceCalculate}
disabled={isCalculating}
>
</Button>
</Tooltip>
</Box>
<Tooltip title="强制计算">
<Button
variant="outlined"
startIcon={<Refresh />}
onClick={handleForceCalculate}
disabled={isCalculating}
>
</Button>
</Tooltip>
</Box>
)}
{/* 当前时间显示 */}
<Typography
+18 -3
View File
@@ -29,6 +29,8 @@ import { useStyleEditor } from "./useStyleEditor";
import { config, NETWORK_NAME } from "@/config/config";
import { useProject } from "@/contexts/ProjectContext";
import { apiFetch } from "@/lib/apiFetch";
import { permissionCodes } from "@/lib/permissions";
import { useAccessStore } from "@/store/accessStore";
// 添加接口定义隐藏按钮的props
interface ToolbarProps {
@@ -94,6 +96,9 @@ const Toolbar: React.FC<ToolbarProps> = ({
const map = useMap();
const data = useData();
const project = useProject();
const canEditNetwork = useAccessStore((state) =>
state.permissions.includes(permissionCodes.webgisEdit),
);
const { open } = useNotification();
const [activeTools, setActiveTools] = useState<string[]>([]);
const [highlightFeatures, setHighlightFeatures] = useState<Feature[]>([]);
@@ -119,6 +124,15 @@ const Toolbar: React.FC<ToolbarProps> = ({
}
}, [enableCompare, isCompareMode, toggleCompareMode]);
useEffect(() => {
if (!canEditNetwork) {
setShowDrawPanel(false);
setActiveTools((previous) =>
previous.filter((tool) => tool !== "draw"),
);
}
}, [canEditNetwork]);
// Chat tool action → direct featureInfos override (bypasses OL Feature lookup)
const [chatPanelFeatureInfos, setChatPanelFeatureInfos] = useState<
[string, string][] | null
@@ -697,7 +711,7 @@ const Toolbar: React.FC<ToolbarProps> = ({
buildFeatureProperties(
selectedFeature,
computedProperties,
selectedValveId
canEditNetwork && selectedValveId
? {
value: valveStatus,
loading: isValveStatusLoading,
@@ -705,7 +719,7 @@ const Toolbar: React.FC<ToolbarProps> = ({
onSave: handleValveStatusSave,
}
: undefined,
selectedValveId
canEditNetwork && selectedValveId
? {
value: valveProperties.setting,
vType: selectedValveType,
@@ -719,6 +733,7 @@ const Toolbar: React.FC<ToolbarProps> = ({
[
selectedFeature,
computedProperties,
canEditNetwork,
selectedValveId,
valveStatus,
valveProperties,
@@ -755,7 +770,7 @@ const Toolbar: React.FC<ToolbarProps> = ({
onClick={() => handleToolClick("history")}
/>
)}
{!hiddenButtons?.includes("draw") && (
{canEditNetwork && !hiddenButtons?.includes("draw") && (
<ToolbarButton
icon={<EditOutlinedIcon />}
name="标记绘制"