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
+56 -48
View File
@@ -106,24 +106,30 @@ type DatabaseHealth = {
const businessRoleOptions = [
{ value: "admin", label: "系统管理员" },
{ value: "operator", label: "运行人员" },
{ value: "user", label: "普通用户" },
{ value: "viewer", label: "只读用户" },
];
const projectRoleLabels: Record<string, string> = {
owner: "项目负责人",
admin: "项目管理员",
member: "项目成员",
viewer: "只读成员",
};
const projectRoleOptions = [
{ value: "admin", label: projectRoleLabels.admin },
{ value: "member", label: projectRoleLabels.member },
{ value: "viewer", label: projectRoleLabels.viewer },
];
const projectRolePermissionSummary = [
{
role: "项目成员",
permissions: "WebGIS 编辑、SCADA 清洗、水力模拟、爆管、风险和监测点优化分析",
},
{
role: "只读成员",
permissions: "默认工作台、WebGIS、SCADA 和既有模拟结果只读",
},
];
const projectStatusOptions = [
{ value: "active", label: "启用" },
{ value: "inactive", label: "停用" },
@@ -1340,7 +1346,7 @@ export const SystemAdminPanel = () => {
<Stack spacing={2}>
<SectionHeader
title="项目成员"
description="维护当前项目内权限;此处只管理项目管理员、项目成员和只读成员,项目负责人另行维护。"
description="项目访问角色保存在 Metadata DB 的 user_project_membership.project_role。模型上传由桌面端发起,仅系统管理员可执行。"
action={
<Button
variant="outlined"
@@ -1352,6 +1358,25 @@ export const SystemAdminPanel = () => {
</Button>
}
/>
<Paper variant="outlined" sx={{ p: 2, borderRadius: 2 }}>
<Typography variant="subtitle2" fontWeight={800} sx={{ mb: 1 }}>
</Typography>
<Stack
direction={{ xs: "column", md: "row" }}
spacing={1}
useFlexGap
flexWrap="wrap"
>
{projectRolePermissionSummary.map((item) => (
<Chip
key={item.role}
label={`${item.role}${item.permissions}`}
variant="outlined"
/>
))}
</Stack>
</Paper>
{!hasProjectId && (
<Alert severity="warning" sx={{ borderRadius: 2 }}>
@@ -1467,7 +1492,6 @@ export const SystemAdminPanel = () => {
)}
{members.map((member) => {
const isSelf = member.user_id === currentAdmin?.id;
const isOwner = member.project_role === "owner";
return (
<TableRow key={member.id} hover>
@@ -1486,54 +1510,38 @@ export const SystemAdminPanel = () => {
</TableCell>
<TableCell>{member.email}</TableCell>
<TableCell>
{isOwner ? (
<Tooltip title="项目负责人不在系统管理页维护">
<Chip
<Tooltip title={isSelf ? "不能操作当前登录用户" : ""}>
<span>
<FormControl
size="small"
color="warning"
label={getProjectRoleLabel(member.project_role)}
/>
</Tooltip>
) : (
<Tooltip title={isSelf ? "不能操作当前登录用户" : ""}>
<span>
<FormControl
size="small"
sx={{ minWidth: 140 }}
disabled={isSelf}
sx={{ minWidth: 140 }}
disabled={isSelf}
>
<Select
value={member.project_role}
onChange={(e) =>
updateMemberRole(member, e.target.value)
}
renderValue={(value) =>
getProjectRoleLabel(String(value))
}
>
<Select
value={member.project_role}
onChange={(e) =>
updateMemberRole(member, e.target.value)
}
renderValue={(value) =>
getProjectRoleLabel(String(value))
}
>
{projectRoleOptions.map((role) => (
<MenuItem key={role.value} value={role.value}>
{role.label}
</MenuItem>
))}
</Select>
</FormControl>
</span>
</Tooltip>
)}
{projectRoleOptions.map((role) => (
<MenuItem key={role.value} value={role.value}>
{role.label}
</MenuItem>
))}
</Select>
</FormControl>
</span>
</Tooltip>
</TableCell>
<TableCell>
<StatusChip active={member.is_active} />
</TableCell>
<TableCell align="right">
<Tooltip
title={
isSelf
? "不能操作当前登录用户"
: isOwner
? "项目负责人不在系统管理页维护"
: ""
}
title={isSelf ? "不能操作当前登录用户" : ""}
>
<span>
<Button
@@ -1542,7 +1550,7 @@ export const SystemAdminPanel = () => {
variant="outlined"
startIcon={<RemoveCircleOutlineIcon />}
onClick={() => removeMember(member)}
disabled={isSelf || isOwner}
disabled={isSelf}
>
</Button>
+20 -43
View File
@@ -49,6 +49,8 @@ import {
} from "@mui/icons-material";
import { config } from "@config/config";
import { apiFetch } from "@/lib/apiFetch";
import { permissionCodes } from "@/lib/permissions";
import { useAccessStore } from "@/store/accessStore";
type AuditLog = {
id: string;
@@ -105,6 +107,8 @@ const defaultFilters: AuditFilters = {
end_time: "",
};
const AUDIT_LOGS_PATH = "/api/v1/audit/logs";
const statusFilterOptions: Array<{ value: AuditStatusFilter; label: string }> = [
{ value: "all", label: "全部状态" },
{ value: "success", label: "成功 2xx" },
@@ -338,8 +342,14 @@ const DetailSection = ({
);
export const AuditLogPanel = () => {
const [adminChecked, setAdminChecked] = useState(false);
const [isAuthorized, setIsAuthorized] = useState(false);
const accessLoading = useAccessStore((state) => state.loading);
const permissions = useAccessStore((state) => state.permissions);
const isSystemAdmin = useAccessStore(
(state) => state.context?.is_system_admin === true,
);
const canViewAudit = permissions.includes(permissionCodes.auditView);
const adminChecked = !accessLoading;
const isAuthorized = adminChecked && canViewAudit && isSystemAdmin;
const [logs, setLogs] = useState<AuditLog[]>([]);
const [users, setUsers] = useState<MetadataUser[]>([]);
const [projects, setProjects] = useState<AdminProject[]>([]);
@@ -392,8 +402,8 @@ export const AuditLogPanel = () => {
const params = buildServerParams(appliedFilters, page * rowsPerPage, rowsPerPage);
const countParams = buildCountParams(appliedFilters);
const [logsResponse, countResponse] = await Promise.all([
apiFetch(`${config.BACKEND_URL}/api/v1/audit/logs?${params.toString()}`),
apiFetch(`${config.BACKEND_URL}/api/v1/audit/logs/count?${countParams.toString()}`),
apiFetch(`${config.BACKEND_URL}${AUDIT_LOGS_PATH}?${params.toString()}`),
apiFetch(`${config.BACKEND_URL}${AUDIT_LOGS_PATH}/count?${countParams.toString()}`),
]);
if (!logsResponse.ok) throw new Error(await readErrorText(logsResponse));
if (!countResponse.ok) throw new Error(await readErrorText(countResponse));
@@ -402,7 +412,7 @@ export const AuditLogPanel = () => {
setTotalCount(Number(countPayload.count ?? 0));
} else {
const params = buildServerParams(appliedFilters, 0, 1000);
const response = await apiFetch(`${config.BACKEND_URL}/api/v1/audit/logs?${params.toString()}`);
const response = await apiFetch(`${config.BACKEND_URL}${AUDIT_LOGS_PATH}?${params.toString()}`);
if (!response.ok) throw new Error(await readErrorText(response));
const payload = (await response.json()) as AuditLog[];
setLogs(payload);
@@ -417,42 +427,9 @@ export const AuditLogPanel = () => {
}, [appliedFilters, page, rowsPerPage]);
useEffect(() => {
let cancelled = false;
const checkAdmin = async () => {
try {
const response = await apiFetch(`${config.BACKEND_URL}/api/v1/admin/me`, {
projectHeaderMode: "omit",
skipAuthRedirect: true,
});
if (cancelled) return;
if (!response.ok) {
setIsAuthorized(false);
setAdminChecked(true);
return;
}
const payload = await response.json();
setIsAuthorized(Boolean(payload?.is_superuser || payload?.role === "admin"));
setAdminChecked(true);
} catch (err) {
if (!cancelled) {
setIsAuthorized(false);
setAdminChecked(true);
setError(String(err));
}
}
};
void checkAdmin();
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
if (!adminChecked || !isAuthorized) return;
if (!isAuthorized) return;
void loadOptions();
}, [adminChecked, isAuthorized, loadOptions]);
}, [isAuthorized, loadOptions]);
useEffect(() => {
if (!adminChecked || !isAuthorized) return;
@@ -476,7 +453,7 @@ export const AuditLogPanel = () => {
setError(null);
try {
const params = buildServerParams(appliedFilters, 0, 1000);
const response = await apiFetch(`${config.BACKEND_URL}/api/v1/audit/logs?${params.toString()}`);
const response = await apiFetch(`${config.BACKEND_URL}${AUDIT_LOGS_PATH}?${params.toString()}`);
if (!response.ok) throw new Error(await readErrorText(response));
const payload = ((await response.json()) as AuditLog[]).filter((log) =>
matchesStatusFilter(log, appliedFilters.status),
@@ -544,7 +521,7 @@ export const AuditLogPanel = () => {
</Typography>
<Typography variant="body2" color="text.secondary">
</Typography>
</Box>
</Stack>
@@ -552,7 +529,7 @@ export const AuditLogPanel = () => {
<Chip
color="success"
icon={<AdminPanelSettingsIcon />}
label="管理员权限已验证"
label="系统管理员权限已验证"
sx={{ alignSelf: { xs: "flex-start", md: "center" } }}
/>
)}
@@ -0,0 +1,45 @@
"use client";
import LockOutlinedIcon from "@mui/icons-material/LockOutlined";
import { Alert, Box, CircularProgress, Stack, Typography } from "@mui/material";
import { usePathname } from "next/navigation";
import type { ReactNode } from "react";
import { permissionForPath } from "@/lib/permissions";
import { useAccessStore } from "@/store/accessStore";
export const RoutePermissionGuard = ({
children,
}: {
children: ReactNode;
}) => {
const pathname = usePathname();
const permissions = useAccessStore((state) => state.permissions);
const loading = useAccessStore((state) => state.loading);
const requiredPermission = permissionForPath(pathname);
if (requiredPermission && loading) {
return (
<Box sx={{ minHeight: 320, display: "grid", placeItems: "center" }}>
<CircularProgress size={28} />
</Box>
);
}
if (requiredPermission && !permissions.includes(requiredPermission)) {
return (
<Box sx={{ p: { xs: 2, md: 4 } }}>
<Alert severity="error" icon={<LockOutlinedIcon />}>
<Stack spacing={0.5}>
<Typography fontWeight={700}>访</Typography>
<Typography variant="body2">
{requiredPermission}
</Typography>
</Stack>
</Alert>
</Box>
);
}
return children;
};
@@ -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="标记绘制"