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
+150 -87
View File
@@ -1,32 +1,38 @@
"use client"; "use client";
import { Refine, type AuthProvider } from "@refinedev/core";
import { RefineKbar, RefineKbarProvider } from "@refinedev/kbar";
import { import {
RefineSnackbarProvider, Refine,
} from "@refinedev/mui"; type AccessControlProvider,
type AuthProvider,
} from "@refinedev/core";
import { RefineKbar, RefineKbarProvider } from "@refinedev/kbar";
import { RefineSnackbarProvider } from "@refinedev/mui";
import { SessionProvider, signIn, signOut, useSession } from "next-auth/react"; import { SessionProvider, signIn, signOut, useSession } from "next-auth/react";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import React, { useEffect, useState } from "react"; import React, { useEffect } from "react";
import routerProvider from "@refinedev/nextjs-router"; import routerProvider from "@refinedev/nextjs-router";
import { ColorModeContextProvider } from "@contexts/color-mode"; import { ColorModeContextProvider } from "@contexts/color-mode";
import { dataProvider } from "@providers/data-provider"; import { dataProvider } from "@providers/data-provider";
import { ProjectProvider } from "@/contexts/ProjectContext"; import { ProjectProvider } from "@/contexts/ProjectContext";
import { RoutePermissionGuard } from "@/components/auth/RoutePermissionGuard";
import { useAuthStore } from "@/store/authStore"; import { useAuthStore } from "@/store/authStore";
import { useAccessStore } from "@/store/accessStore";
import { useProjectStore } from "@/store/projectStore";
import { apiFetch } from "@/lib/apiFetch"; import { apiFetch } from "@/lib/apiFetch";
import { permissionCodes, resourcePermissions } from "@/lib/permissions";
import { config } from "@config/config"; import { config } from "@config/config";
import { useAppNotificationProvider } from "@/providers/notification-provider/useAppNotificationProvider"; import { useAppNotificationProvider } from "@/providers/notification-provider/useAppNotificationProvider";
import { LiaNetworkWiredSolid } from "react-icons/lia"; import { LiaNetworkWiredSolid } from "react-icons/lia";
import { TbDatabaseEdit, TbLocationPin, TbActivity } from "react-icons/tb"; import { TbActivity, TbDatabaseEdit, TbLocationPin } from "react-icons/tb";
import { LuReplace } from "react-icons/lu"; import { LuReplace } from "react-icons/lu";
import { AiOutlineSecurityScan } from "react-icons/ai"; import { AiOutlineSecurityScan } from "react-icons/ai";
import { MdWater, MdOutlineWaterDrop, MdCleaningServices } from "react-icons/md"; import { MdCleaningServices, MdOutlineWaterDrop } from "react-icons/md";
import { import {
ManageAccounts as ManageAccountsIcon,
FactCheck as FactCheckIcon, FactCheck as FactCheckIcon,
ManageAccounts as ManageAccountsIcon,
MyLocation as MyLocationIcon, MyLocation as MyLocationIcon,
Search as SearchIcon, Search as SearchIcon,
} from "@mui/icons-material"; } from "@mui/icons-material";
@@ -36,16 +42,12 @@ type RefineContextProps = {
}; };
export const RefineContext = ( export const RefineContext = (
props: React.PropsWithChildren<RefineContextProps> props: React.PropsWithChildren<RefineContextProps>,
) => { ) => (
return (
<SessionProvider> <SessionProvider>
<ProjectProvider>
<App {...props} /> <App {...props} />
</ProjectProvider>
</SessionProvider> </SessionProvider>
); );
};
type AppProps = { type AppProps = {
defaultMode?: string; defaultMode?: string;
@@ -55,40 +57,71 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
const { data, status } = useSession(); const { data, status } = useSession();
const to = usePathname(); const to = usePathname();
const setAccessToken = useAuthStore((state) => state.setAccessToken); const setAccessToken = useAuthStore((state) => state.setAccessToken);
const [isMetadataAdmin, setIsMetadataAdmin] = useState(false); const currentProjectId = useProjectStore((state) => state.currentProjectId);
const permissions = useAccessStore((state) => state.permissions);
const setAccessContext = useAccessStore((state) => state.setContext);
const setAccessLoading = useAccessStore((state) => state.setLoading);
const resetAccess = useAccessStore((state) => state.reset);
const can = (permission: string) => permissions.includes(permission);
useEffect(() => { useEffect(() => {
setAccessToken(typeof data?.accessToken === "string" ? data.accessToken : null); setAccessToken(
typeof data?.accessToken === "string" ? data.accessToken : null,
);
}, [data?.accessToken, setAccessToken]); }, [data?.accessToken, setAccessToken]);
useEffect(() => { useEffect(() => {
if (status !== "authenticated") { if (status !== "authenticated") {
setIsMetadataAdmin(false); resetAccess();
return; return;
} }
let cancelled = false; let cancelled = false;
apiFetch(`${config.BACKEND_URL}/api/v1/admin/me`, { setAccessLoading(true);
projectHeaderMode: "omit", apiFetch(`${config.BACKEND_URL}/api/v1/access/context`, {
projectHeaderMode: currentProjectId ? "include" : "omit",
skipAuthRedirect: true, skipAuthRedirect: true,
}) })
.then(async (response) => { .then(async (response) => {
if (cancelled) return; if (cancelled) return;
if (!response.ok) { if (!response.ok) {
setIsMetadataAdmin(false); resetAccess();
return; return;
} }
const payload = await response.json(); setAccessContext(await response.json());
setIsMetadataAdmin(Boolean(payload?.is_superuser || payload?.role === "admin"));
}) })
.catch(() => { .catch(() => {
if (!cancelled) setIsMetadataAdmin(false); if (!cancelled) resetAccess();
}); });
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [status]); }, [
currentProjectId,
resetAccess,
setAccessContext,
setAccessLoading,
status,
]);
useEffect(() => {
if (status !== "authenticated" || !data?.user?.id) return;
const auditKey = `tjwater-login-audit:${data.user.id}`;
if (sessionStorage.getItem(auditKey)) return;
apiFetch(`${config.BACKEND_URL}/api/v1/audit/session-events`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ event: "login" }),
projectHeaderMode: "omit",
skipAuthRedirect: true,
})
.then((response) => {
if (response.ok) sessionStorage.setItem(auditKey, "1");
})
.catch(() => undefined);
}, [data?.user?.id, status]);
if (status === "loading") { if (status === "loading") {
return <span>loading...</span>; return <span>loading...</span>;
@@ -100,75 +133,65 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
callbackUrl: to ? to.toString() : "/", callbackUrl: to ? to.toString() : "/",
redirect: true, redirect: true,
}); });
return { success: true };
return {
success: true,
};
}, },
logout: async () => { logout: async () => {
signOut({ try {
redirect: true, await apiFetch(`${config.BACKEND_URL}/api/v1/audit/session-events`, {
callbackUrl: "/login", method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ event: "logout" }),
projectHeaderMode: "omit",
skipAuthRedirect: true,
}); });
} catch {
return { // Logout must still complete when audit storage is unavailable.
success: true, }
}; if (data?.user?.id) {
sessionStorage.removeItem(`tjwater-login-audit:${data.user.id}`);
}
signOut({ redirect: true, callbackUrl: "/login" });
return { success: true };
}, },
onError: async (error) => { onError: async (error) => {
if (error.response?.status === 401) { if (error.response?.status === 401) {
return { return { logout: true };
logout: true,
};
} }
return { error };
return {
error,
};
},
check: async () => {
if (status === "unauthenticated") {
return {
authenticated: false,
redirectTo: "/login",
};
}
return {
authenticated: true,
};
},
getPermissions: async () => {
return null;
}, },
check: async () =>
status === "unauthenticated"
? { authenticated: false, redirectTo: "/login" }
: { authenticated: true },
getPermissions: async () => permissions,
getIdentity: async () => { getIdentity: async () => {
if (data?.user) { if (!data?.user) return null;
const { user } = data;
return { return {
id: user.id, id: data.user.id,
username: user.username, username: data.user.username,
name: user.name, name: data.user.name,
avatar: user.image, avatar: data.user.image,
}; };
}
return null;
}, },
}; };
const defaultMode = props?.defaultMode; const accessControlProvider: AccessControlProvider = {
can: async ({ resource }) => {
const requiredPermission = resource
? resourcePermissions[resource]
: undefined;
return {
can: !requiredPermission || permissions.includes(requiredPermission),
reason: requiredPermission
? `需要权限:${requiredPermission}`
: undefined,
};
},
};
return ( const resources = [
<> ...(can(permissionCodes.simulationView)
<RefineKbarProvider> ? [
<ColorModeContextProvider defaultMode={defaultMode}>
<RefineSnackbarProvider>
<Refine
routerProvider={routerProvider}
dataProvider={dataProvider}
notificationProvider={useAppNotificationProvider}
authProvider={authProvider}
resources={[
{ {
name: "管网在线模拟", name: "管网在线模拟",
list: "/network-simulation", list: "/network-simulation",
@@ -177,6 +200,10 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
label: "管网在线模拟", label: "管网在线模拟",
}, },
}, },
]
: []),
...(can(permissionCodes.scadaClean)
? [
{ {
name: "SCADA 数据清洗", name: "SCADA 数据清洗",
list: "/scada-data-cleaning", list: "/scada-data-cleaning",
@@ -185,6 +212,10 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
label: "SCADA 数据清洗", label: "SCADA 数据清洗",
}, },
}, },
]
: []),
...(can(permissionCodes.optimizationRun)
? [
{ {
name: "监测点优化布置", name: "监测点优化布置",
list: "/monitoring-place-optimization", list: "/monitoring-place-optimization",
@@ -193,6 +224,10 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
label: "监测点优化布置", label: "监测点优化布置",
}, },
}, },
]
: []),
...(can(permissionCodes.riskRun)
? [
{ {
name: "健康风险分析", name: "健康风险分析",
list: "/health-risk-analysis", list: "/health-risk-analysis",
@@ -201,13 +236,18 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
label: "健康风险分析", label: "健康风险分析",
}, },
}, },
]
: []),
...(can(permissionCodes.simulationRun) || can(permissionCodes.burstRun)
? [
{ {
name: "Hydraulic Simulation", name: "Hydraulic Simulation",
meta: { meta: { label: "事件模拟" },
// icon: <MdWater className="w-6 h-6" />,
label: "事件模拟",
},
}, },
]
: []),
...(can(permissionCodes.burstRun)
? [
{ {
name: "爆管模拟", name: "爆管模拟",
list: "/hydraulic-simulation/burst-simulation", list: "/hydraulic-simulation/burst-simulation",
@@ -244,6 +284,10 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
label: "DMA 漏损识别", label: "DMA 漏损识别",
}, },
}, },
]
: []),
...(can(permissionCodes.simulationRun)
? [
{ {
name: "水质模拟", name: "水质模拟",
list: "/hydraulic-simulation/contaminant-simulation", list: "/hydraulic-simulation/contaminant-simulation",
@@ -262,7 +306,9 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
label: "管道冲洗", label: "管道冲洗",
}, },
}, },
...(isMetadataAdmin ]
: []),
...(can(permissionCodes.environmentManage)
? [ ? [
{ {
name: "系统管理", name: "系统管理",
@@ -272,6 +318,10 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
label: "系统管理", label: "系统管理",
}, },
}, },
]
: []),
...(can(permissionCodes.auditView)
? [
{ {
name: "审计日志", name: "审计日志",
list: "/audit-logs", list: "/audit-logs",
@@ -282,18 +332,31 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
}, },
] ]
: []), : []),
]} ];
return (
<ProjectProvider>
<RefineKbarProvider>
<ColorModeContextProvider defaultMode={props.defaultMode}>
<RefineSnackbarProvider>
<Refine
routerProvider={routerProvider}
dataProvider={dataProvider}
notificationProvider={useAppNotificationProvider}
authProvider={authProvider}
accessControlProvider={accessControlProvider}
resources={resources}
options={{ options={{
syncWithLocation: true, syncWithLocation: true,
warnWhenUnsavedChanges: true, warnWhenUnsavedChanges: true,
}} }}
> >
{props.children} <RoutePermissionGuard>{props.children}</RoutePermissionGuard>
<RefineKbar /> <RefineKbar />
</Refine> </Refine>
</RefineSnackbarProvider> </RefineSnackbarProvider>
</ColorModeContextProvider> </ColorModeContextProvider>
</RefineKbarProvider> </RefineKbarProvider>
</> </ProjectProvider>
); );
}; };
+33 -25
View File
@@ -106,24 +106,30 @@ type DatabaseHealth = {
const businessRoleOptions = [ const businessRoleOptions = [
{ value: "admin", label: "系统管理员" }, { value: "admin", label: "系统管理员" },
{ value: "operator", label: "运行人员" },
{ value: "user", label: "普通用户" }, { value: "user", label: "普通用户" },
{ value: "viewer", label: "只读用户" },
]; ];
const projectRoleLabels: Record<string, string> = { const projectRoleLabels: Record<string, string> = {
owner: "项目负责人",
admin: "项目管理员",
member: "项目成员", member: "项目成员",
viewer: "只读成员", viewer: "只读成员",
}; };
const projectRoleOptions = [ const projectRoleOptions = [
{ value: "admin", label: projectRoleLabels.admin },
{ value: "member", label: projectRoleLabels.member }, { value: "member", label: projectRoleLabels.member },
{ value: "viewer", label: projectRoleLabels.viewer }, { value: "viewer", label: projectRoleLabels.viewer },
]; ];
const projectRolePermissionSummary = [
{
role: "项目成员",
permissions: "WebGIS 编辑、SCADA 清洗、水力模拟、爆管、风险和监测点优化分析",
},
{
role: "只读成员",
permissions: "默认工作台、WebGIS、SCADA 和既有模拟结果只读",
},
];
const projectStatusOptions = [ const projectStatusOptions = [
{ value: "active", label: "启用" }, { value: "active", label: "启用" },
{ value: "inactive", label: "停用" }, { value: "inactive", label: "停用" },
@@ -1340,7 +1346,7 @@ export const SystemAdminPanel = () => {
<Stack spacing={2}> <Stack spacing={2}>
<SectionHeader <SectionHeader
title="项目成员" title="项目成员"
description="维护当前项目内权限;此处只管理项目管理员、项目成员和只读成员,项目负责人另行维护。" description="项目访问角色保存在 Metadata DB 的 user_project_membership.project_role。模型上传由桌面端发起,仅系统管理员可执行。"
action={ action={
<Button <Button
variant="outlined" variant="outlined"
@@ -1352,6 +1358,25 @@ export const SystemAdminPanel = () => {
</Button> </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 && ( {!hasProjectId && (
<Alert severity="warning" sx={{ borderRadius: 2 }}> <Alert severity="warning" sx={{ borderRadius: 2 }}>
@@ -1467,7 +1492,6 @@ export const SystemAdminPanel = () => {
)} )}
{members.map((member) => { {members.map((member) => {
const isSelf = member.user_id === currentAdmin?.id; const isSelf = member.user_id === currentAdmin?.id;
const isOwner = member.project_role === "owner";
return ( return (
<TableRow key={member.id} hover> <TableRow key={member.id} hover>
@@ -1486,15 +1510,6 @@ export const SystemAdminPanel = () => {
</TableCell> </TableCell>
<TableCell>{member.email}</TableCell> <TableCell>{member.email}</TableCell>
<TableCell> <TableCell>
{isOwner ? (
<Tooltip title="项目负责人不在系统管理页维护">
<Chip
size="small"
color="warning"
label={getProjectRoleLabel(member.project_role)}
/>
</Tooltip>
) : (
<Tooltip title={isSelf ? "不能操作当前登录用户" : ""}> <Tooltip title={isSelf ? "不能操作当前登录用户" : ""}>
<span> <span>
<FormControl <FormControl
@@ -1520,20 +1535,13 @@ export const SystemAdminPanel = () => {
</FormControl> </FormControl>
</span> </span>
</Tooltip> </Tooltip>
)}
</TableCell> </TableCell>
<TableCell> <TableCell>
<StatusChip active={member.is_active} /> <StatusChip active={member.is_active} />
</TableCell> </TableCell>
<TableCell align="right"> <TableCell align="right">
<Tooltip <Tooltip
title={ title={isSelf ? "不能操作当前登录用户" : ""}
isSelf
? "不能操作当前登录用户"
: isOwner
? "项目负责人不在系统管理页维护"
: ""
}
> >
<span> <span>
<Button <Button
@@ -1542,7 +1550,7 @@ export const SystemAdminPanel = () => {
variant="outlined" variant="outlined"
startIcon={<RemoveCircleOutlineIcon />} startIcon={<RemoveCircleOutlineIcon />}
onClick={() => removeMember(member)} onClick={() => removeMember(member)}
disabled={isSelf || isOwner} disabled={isSelf}
> >
</Button> </Button>
+20 -43
View File
@@ -49,6 +49,8 @@ import {
} from "@mui/icons-material"; } from "@mui/icons-material";
import { config } from "@config/config"; import { config } from "@config/config";
import { apiFetch } from "@/lib/apiFetch"; import { apiFetch } from "@/lib/apiFetch";
import { permissionCodes } from "@/lib/permissions";
import { useAccessStore } from "@/store/accessStore";
type AuditLog = { type AuditLog = {
id: string; id: string;
@@ -105,6 +107,8 @@ const defaultFilters: AuditFilters = {
end_time: "", end_time: "",
}; };
const AUDIT_LOGS_PATH = "/api/v1/audit/logs";
const statusFilterOptions: Array<{ value: AuditStatusFilter; label: string }> = [ const statusFilterOptions: Array<{ value: AuditStatusFilter; label: string }> = [
{ value: "all", label: "全部状态" }, { value: "all", label: "全部状态" },
{ value: "success", label: "成功 2xx" }, { value: "success", label: "成功 2xx" },
@@ -338,8 +342,14 @@ const DetailSection = ({
); );
export const AuditLogPanel = () => { export const AuditLogPanel = () => {
const [adminChecked, setAdminChecked] = useState(false); const accessLoading = useAccessStore((state) => state.loading);
const [isAuthorized, setIsAuthorized] = useState(false); 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 [logs, setLogs] = useState<AuditLog[]>([]);
const [users, setUsers] = useState<MetadataUser[]>([]); const [users, setUsers] = useState<MetadataUser[]>([]);
const [projects, setProjects] = useState<AdminProject[]>([]); const [projects, setProjects] = useState<AdminProject[]>([]);
@@ -392,8 +402,8 @@ export const AuditLogPanel = () => {
const params = buildServerParams(appliedFilters, page * rowsPerPage, rowsPerPage); const params = buildServerParams(appliedFilters, page * rowsPerPage, rowsPerPage);
const countParams = buildCountParams(appliedFilters); const countParams = buildCountParams(appliedFilters);
const [logsResponse, countResponse] = await Promise.all([ const [logsResponse, countResponse] = await Promise.all([
apiFetch(`${config.BACKEND_URL}/api/v1/audit/logs?${params.toString()}`), apiFetch(`${config.BACKEND_URL}${AUDIT_LOGS_PATH}?${params.toString()}`),
apiFetch(`${config.BACKEND_URL}/api/v1/audit/logs/count?${countParams.toString()}`), apiFetch(`${config.BACKEND_URL}${AUDIT_LOGS_PATH}/count?${countParams.toString()}`),
]); ]);
if (!logsResponse.ok) throw new Error(await readErrorText(logsResponse)); if (!logsResponse.ok) throw new Error(await readErrorText(logsResponse));
if (!countResponse.ok) throw new Error(await readErrorText(countResponse)); if (!countResponse.ok) throw new Error(await readErrorText(countResponse));
@@ -402,7 +412,7 @@ export const AuditLogPanel = () => {
setTotalCount(Number(countPayload.count ?? 0)); setTotalCount(Number(countPayload.count ?? 0));
} else { } else {
const params = buildServerParams(appliedFilters, 0, 1000); 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)); if (!response.ok) throw new Error(await readErrorText(response));
const payload = (await response.json()) as AuditLog[]; const payload = (await response.json()) as AuditLog[];
setLogs(payload); setLogs(payload);
@@ -417,42 +427,9 @@ export const AuditLogPanel = () => {
}, [appliedFilters, page, rowsPerPage]); }, [appliedFilters, page, rowsPerPage]);
useEffect(() => { useEffect(() => {
let cancelled = false; if (!isAuthorized) return;
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;
void loadOptions(); void loadOptions();
}, [adminChecked, isAuthorized, loadOptions]); }, [isAuthorized, loadOptions]);
useEffect(() => { useEffect(() => {
if (!adminChecked || !isAuthorized) return; if (!adminChecked || !isAuthorized) return;
@@ -476,7 +453,7 @@ export const AuditLogPanel = () => {
setError(null); setError(null);
try { try {
const params = buildServerParams(appliedFilters, 0, 1000); 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)); if (!response.ok) throw new Error(await readErrorText(response));
const payload = ((await response.json()) as AuditLog[]).filter((log) => const payload = ((await response.json()) as AuditLog[]).filter((log) =>
matchesStatusFilter(log, appliedFilters.status), matchesStatusFilter(log, appliedFilters.status),
@@ -544,7 +521,7 @@ export const AuditLogPanel = () => {
</Typography> </Typography>
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
</Typography> </Typography>
</Box> </Box>
</Stack> </Stack>
@@ -552,7 +529,7 @@ export const AuditLogPanel = () => {
<Chip <Chip
color="success" color="success"
icon={<AdminPanelSettingsIcon />} icon={<AdminPanelSettingsIcon />}
label="管理员权限已验证" label="系统管理员权限已验证"
sx={{ alignSelf: { xs: "flex-start", md: "center" } }} 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, matchesValveAnalysis,
} from "./AnalysisReport"; } from "./AnalysisReport";
import { SchemeRecord, ValveIsolationResult } from "./types"; import { SchemeRecord, ValveIsolationResult } from "./types";
import { isAllowedAccidentPipe } from "./valveIsolationScope"; import {
isAllowedAccidentPipe,
normalizeValveIsolationResult,
} from "./valveIsolationScope";
jest.mock("@/utils/mapQueryService", () => ({ jest.mock("@/utils/mapQueryService", () => ({
queryFeaturesByIds: jest.fn(), 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", () => { it("limits scheme-bound valve analysis to the scheme accident pipes", () => {
expect(isAllowedAccidentPipe("P-1", ["P-1", "P-2"])).toBe(true); expect(isAllowedAccidentPipe("P-1", ["P-1", "P-2"])).toBe(true);
expect(isAllowedAccidentPipe("P-99", ["P-1", "P-2"])).toBe(false); expect(isAllowedAccidentPipe("P-99", ["P-1", "P-2"])).toBe(false);
expect(isAllowedAccidentPipe("P-99", undefined)).toBe(true); 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 () => { it("prints only after assigning the report print state", async () => {
const originalTitle = document.title; const originalTitle = document.title;
const print = jest const print = jest
@@ -35,6 +35,7 @@ import {
type PipeDiameterMap, type PipeDiameterMap,
} from "./schemePipeDiameters"; } from "./schemePipeDiameters";
import { SchemeRecord, ValveIsolationResult } from "./types"; import { SchemeRecord, ValveIsolationResult } from "./types";
import { getAffectedNodeCount } from "./valveIsolationScope";
import PanelEmptyState from "@components/olmap/common/PanelEmptyState"; import PanelEmptyState from "@components/olmap/common/PanelEmptyState";
interface AnalysisReportProps { interface AnalysisReportProps {
@@ -212,6 +213,18 @@ const ReportDocument: React.FC<ReportDocumentProps> = ({
? valveResult ? valveResult
: null; : null;
const duration = scheme.schemeDetail?.modify_total_duration; 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 ( return (
<Box <Box
@@ -348,7 +361,7 @@ const ReportDocument: React.FC<ReportDocumentProps> = ({
{[ {[
["隔离结论", matchedValveResult.isolatable ? "可隔离" : "不可隔离"], ["隔离结论", matchedValveResult.isolatable ? "可隔离" : "不可隔离"],
["必关阀门", `${matchedValveResult.must_close_valves?.length ?? 0}`], ["必关阀门", `${matchedValveResult.must_close_valves?.length ?? 0}`],
["受影响节点", `${matchedValveResult.affected_nodes?.length ?? 0}`], ["受影响节点", `${getAffectedNodeCount(matchedValveResult)}`],
].map(([label, value]) => ( ].map(([label, value]) => (
<Paper <Paper
key={label} key={label}
@@ -364,20 +377,19 @@ const ReportDocument: React.FC<ReportDocumentProps> = ({
</Paper> </Paper>
))} ))}
</Box> </Box>
{[ {valveDetailRows.map(([label, values]) => (
["已分析事故管段", matchedValveResult.accident_elements], <Box key={label} sx={{ breakInside: "avoid" }}>
["必关阀门", matchedValveResult.must_close_valves],
["可选阀门", matchedValveResult.optional_valves],
["不可用阀门", disabledValves],
["受影响节点", matchedValveResult.affected_nodes],
].map(([label, values]) => (
<Box key={label as string} sx={{ breakInside: "avoid" }}>
<Typography sx={{ mb: 0.75, fontSize: 13, fontWeight: 700 }}> <Typography sx={{ mb: 0.75, fontSize: 13, fontWeight: 700 }}>
{label as string} {label}
</Typography> </Typography>
<IdList values={values as string[]} /> <IdList values={values} />
</Box> </Box>
))} ))}
{!matchedValveResult.isolatable && (
<Alert severity="info" variant="outlined">
</Alert>
)}
</Stack> </Stack>
) : ( ) : (
<Alert severity="info" variant="outlined"> <Alert severity="info" variant="outlined">
@@ -52,7 +52,11 @@ import {
import { Point } from "ol/geom"; import { Point } from "ol/geom";
import { toLonLat } from "ol/proj"; import { toLonLat } from "ol/proj";
import { useControllableObjectState } from "@components/olmap/core/useControllableState"; import { useControllableObjectState } from "@components/olmap/core/useControllableState";
import { isAllowedAccidentPipe } from "./valveIsolationScope"; import {
getAffectedNodeCount,
isAllowedAccidentPipe,
normalizeValveIsolationResult,
} from "./valveIsolationScope";
interface ValveIsolationProps { interface ValveIsolationProps {
initialPipeIds?: string[]; initialPipeIds?: string[];
@@ -363,7 +367,7 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
if (disabled.length > 0) { if (disabled.length > 0) {
params.disabled_valves = disabled; params.disabled_valves = disabled;
} }
const response = await api.get( const response = await api.get<ValveIsolationResult>(
`${config.BACKEND_URL}/api/v1/valve-isolation-analysis`, `${config.BACKEND_URL}/api/v1/valve-isolation-analysis`,
{ {
params, params,
@@ -372,7 +376,7 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
}, },
}, },
); );
setResult(response.data); setResult(normalizeValveIsolationResult(response.data));
if (!isExpandSearch) { if (!isExpandSearch) {
setActiveStep(1); setActiveStep(1);
} else { } 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.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.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) => ( ].map((item, index) => (
<Box <Box
key={index} key={index}
@@ -877,8 +881,14 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
</Box> </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>
<Box className="flex items-center justify-between mb-2"> <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"> <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 { export interface ValveIsolationResult {
accident_elements: string[]; accident_elements: string[];
affected_nodes: string[]; affected_nodes: string[];
affected_node_count?: number;
must_close_valves: string[]; must_close_valves: string[];
optional_valves: string[]; optional_valves: string[];
isolatable: boolean; isolatable: boolean;
@@ -1,4 +1,17 @@
import type { ValveIsolationResult } from "./types";
export const isAllowedAccidentPipe = ( export const isAllowedAccidentPipe = (
pipeId: string, pipeId: string,
allowedPipeIds: string[] | undefined, allowedPipeIds: string[] | undefined,
) => allowedPipeIds === undefined || allowedPipeIds.includes(pipeId); ) => 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),
});
@@ -29,6 +29,8 @@ import { FiSkipBack, FiSkipForward } from "react-icons/fi";
import { useData } from "../MapComponent"; import { useData } from "../MapComponent";
import { config, NETWORK_NAME } from "@/config/config"; import { config, NETWORK_NAME } from "@/config/config";
import { apiFetch } from "@/lib/apiFetch"; import { apiFetch } from "@/lib/apiFetch";
import { permissionCodes } from "@/lib/permissions";
import { useAccessStore } from "@/store/accessStore";
import { useMap } from "../MapComponent"; import { useMap } from "../MapComponent";
import { import {
formatTimelineTime, formatTimelineTime,
@@ -81,6 +83,9 @@ const Timeline: React.FC<TimelineProps> = ({
schemeType = "burst_analysis", schemeType = "burst_analysis",
}) => { }) => {
const data = useData(); const data = useData();
const canRunSimulation = useAccessStore((state) =>
state.permissions.includes(permissionCodes.simulationRun),
);
const fallbackSelectedDateRef = useRef(new Date()); const fallbackSelectedDateRef = useRef(new Date());
const hasTimelineState = const hasTimelineState =
data && data &&
@@ -926,8 +931,8 @@ const Timeline: React.FC<TimelineProps> = ({
</IconButton> </IconButton>
</Tooltip> </Tooltip>
</Box> </Box>
{canRunSimulation && (
<Box sx={{ display: "flex", gap: 1 }} className="ml-4"> <Box sx={{ display: "flex", gap: 1 }} className="ml-4">
{/* 强制计算时间段 */}
<FormControl size="small" sx={{ minWidth: 100 }}> <FormControl size="small" sx={{ minWidth: 100 }}>
<InputLabel></InputLabel> <InputLabel></InputLabel>
<Select <Select
@@ -943,7 +948,6 @@ const Timeline: React.FC<TimelineProps> = ({
</Select> </Select>
</FormControl> </FormControl>
{/* 功能按钮 */}
<Tooltip title="强制计算"> <Tooltip title="强制计算">
<Button <Button
variant="outlined" variant="outlined"
@@ -955,6 +959,7 @@ const Timeline: React.FC<TimelineProps> = ({
</Button> </Button>
</Tooltip> </Tooltip>
</Box> </Box>
)}
{/* 当前时间显示 */} {/* 当前时间显示 */}
<Typography <Typography
+18 -3
View File
@@ -29,6 +29,8 @@ import { useStyleEditor } from "./useStyleEditor";
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 { permissionCodes } from "@/lib/permissions";
import { useAccessStore } from "@/store/accessStore";
// 添加接口定义隐藏按钮的props // 添加接口定义隐藏按钮的props
interface ToolbarProps { interface ToolbarProps {
@@ -94,6 +96,9 @@ const Toolbar: React.FC<ToolbarProps> = ({
const map = useMap(); const map = useMap();
const data = useData(); const data = useData();
const project = useProject(); const project = useProject();
const canEditNetwork = useAccessStore((state) =>
state.permissions.includes(permissionCodes.webgisEdit),
);
const { open } = useNotification(); const { open } = useNotification();
const [activeTools, setActiveTools] = useState<string[]>([]); const [activeTools, setActiveTools] = useState<string[]>([]);
const [highlightFeatures, setHighlightFeatures] = useState<Feature[]>([]); const [highlightFeatures, setHighlightFeatures] = useState<Feature[]>([]);
@@ -119,6 +124,15 @@ const Toolbar: React.FC<ToolbarProps> = ({
} }
}, [enableCompare, isCompareMode, toggleCompareMode]); }, [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) // Chat tool action → direct featureInfos override (bypasses OL Feature lookup)
const [chatPanelFeatureInfos, setChatPanelFeatureInfos] = useState< const [chatPanelFeatureInfos, setChatPanelFeatureInfos] = useState<
[string, string][] | null [string, string][] | null
@@ -697,7 +711,7 @@ const Toolbar: React.FC<ToolbarProps> = ({
buildFeatureProperties( buildFeatureProperties(
selectedFeature, selectedFeature,
computedProperties, computedProperties,
selectedValveId canEditNetwork && selectedValveId
? { ? {
value: valveStatus, value: valveStatus,
loading: isValveStatusLoading, loading: isValveStatusLoading,
@@ -705,7 +719,7 @@ const Toolbar: React.FC<ToolbarProps> = ({
onSave: handleValveStatusSave, onSave: handleValveStatusSave,
} }
: undefined, : undefined,
selectedValveId canEditNetwork && selectedValveId
? { ? {
value: valveProperties.setting, value: valveProperties.setting,
vType: selectedValveType, vType: selectedValveType,
@@ -719,6 +733,7 @@ const Toolbar: React.FC<ToolbarProps> = ({
[ [
selectedFeature, selectedFeature,
computedProperties, computedProperties,
canEditNetwork,
selectedValveId, selectedValveId,
valveStatus, valveStatus,
valveProperties, valveProperties,
@@ -755,7 +770,7 @@ const Toolbar: React.FC<ToolbarProps> = ({
onClick={() => handleToolClick("history")} onClick={() => handleToolClick("history")}
/> />
)} )}
{!hiddenButtons?.includes("draw") && ( {canEditNetwork && !hiddenButtons?.includes("draw") && (
<ToolbarButton <ToolbarButton
icon={<EditOutlinedIcon />} icon={<EditOutlinedIcon />}
name="标记绘制" name="标记绘制"
+57 -37
View File
@@ -1,9 +1,26 @@
"use client"; "use client";
import React, { createContext, useCallback, useContext, useEffect, useState } from "react";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import { config, NETWORK_NAME, setMapWorkspace, setNetworkName, setMapExtent } from "@/config/config"; import { usePathname, useRouter } from "next/navigation";
import React, {
createContext,
useCallback,
useContext,
useEffect,
useState,
} from "react";
import { ProjectSelector } from "@/components/project/ProjectSelector"; import { ProjectSelector } from "@/components/project/ProjectSelector";
import {
config,
NETWORK_NAME,
setMapExtent,
setMapWorkspace,
setNetworkName,
} from "@/config/config";
import { apiFetch } from "@/lib/apiFetch"; import { apiFetch } from "@/lib/apiFetch";
import { permissionCodes } from "@/lib/permissions";
import { useAccessStore } from "@/store/accessStore";
import { useProjectStore } from "@/store/projectStore"; import { useProjectStore } from "@/store/projectStore";
interface ProjectContextType { interface ProjectContextType {
@@ -21,6 +38,11 @@ export const ProjectProvider: React.FC<{ children: React.ReactNode }> = ({
children, children,
}) => { }) => {
const { status } = useSession(); const { status } = useSession();
const pathname = usePathname();
const router = useRouter();
const canManageEnvironment = useAccessStore((state) =>
state.permissions.includes(permissionCodes.environmentManage),
);
const [isConfigured, setIsConfigured] = useState(false); const [isConfigured, setIsConfigured] = useState(false);
const setCurrentProjectId = useProjectStore( const setCurrentProjectId = useProjectStore(
(state) => state.setCurrentProjectId, (state) => state.setCurrentProjectId,
@@ -31,76 +53,69 @@ export const ProjectProvider: React.FC<{ children: React.ReactNode }> = ({
extent: config.MAP_EXTENT, extent: config.MAP_EXTENT,
}); });
const applyConfig = useCallback(async ( const applyConfig = useCallback(
async (
projectId: string, projectId: string,
ws: string, workspace: string,
net: string, networkName: string,
extent: number[], extent: number[],
) => { ) => {
const resolvedProjectId = projectId || net || ws; const resolvedProjectId = projectId || networkName || workspace;
setMapWorkspace(ws); setMapWorkspace(workspace);
setNetworkName(net); setNetworkName(networkName);
setMapExtent(extent); setMapExtent(extent);
localStorage.setItem(MAP_WORKSPACE_STORAGE_KEY, workspace);
localStorage.setItem(NETWORK_NAME_STORAGE_KEY, networkName);
localStorage.setItem(MAP_EXTENT_STORAGE_KEY, extent.join(",")); localStorage.setItem(MAP_EXTENT_STORAGE_KEY, extent.join(","));
// Reset extent cache localStorage.removeItem(`${workspace}_map_view`);
localStorage.removeItem(`${ws}_map_view`); setCurrentProject({ workspace, networkName, extent });
setCurrentProject({ workspace: ws, networkName: net, extent: extent });
setCurrentProjectId(resolvedProjectId); setCurrentProjectId(resolvedProjectId);
// Save to localStorage
localStorage.setItem(MAP_WORKSPACE_STORAGE_KEY, ws);
localStorage.setItem(NETWORK_NAME_STORAGE_KEY, net);
setIsConfigured(true); setIsConfigured(true);
try { try {
// Open project backend (simulation model)
const openResponse = await apiFetch( const openResponse = await apiFetch(
`${config.BACKEND_URL}/api/v1/projects/open?network=${net}`, `${config.BACKEND_URL}/api/v1/projects/open?network=${networkName}`,
{ { method: "POST" },
method: "POST",
},
); );
if (!openResponse.ok) { if (!openResponse.ok) {
throw new Error(`Failed to open project: HTTP ${openResponse.status}`); throw new Error(`Failed to open project: HTTP ${openResponse.status}`);
} }
// Fetch project metadata
const infoResponse = await apiFetch( const infoResponse = await apiFetch(
`${config.BACKEND_URL}/api/v1/project-info?network=${net}`, `${config.BACKEND_URL}/api/v1/project-info?network=${networkName}`,
); );
if (!infoResponse.ok) { if (!infoResponse.ok) {
console.warn( console.warn(
`Failed to fetch project info: HTTP ${infoResponse.status}`, `Failed to fetch project info: HTTP ${infoResponse.status}`,
); );
} else { return;
const data = await infoResponse.json(); }
// Update workspace if different const data = await infoResponse.json();
if (data?.gs_workspace && data.gs_workspace !== ws) { if (data?.gs_workspace && data.gs_workspace !== workspace) {
setMapWorkspace(data.gs_workspace); setMapWorkspace(data.gs_workspace);
localStorage.setItem(MAP_WORKSPACE_STORAGE_KEY, data.gs_workspace); localStorage.setItem(MAP_WORKSPACE_STORAGE_KEY, data.gs_workspace);
setCurrentProject((prev) => ({ setCurrentProject((previous) => ({
...prev, ...previous,
workspace: data.gs_workspace, workspace: data.gs_workspace,
})); }));
} }
// Update extent if available
const bbox = Array.isArray(data?.map_extent?.bbox) const bbox = Array.isArray(data?.map_extent?.bbox)
? data.map_extent.bbox.map((value: number) => Number(value)) ? data.map_extent.bbox.map((value: number) => Number(value))
: null; : null;
if (bbox && bbox.length === 4) { if (bbox?.length === 4) {
setMapExtent(bbox); setMapExtent(bbox);
localStorage.setItem(MAP_EXTENT_STORAGE_KEY, bbox.join(",")); localStorage.setItem(MAP_EXTENT_STORAGE_KEY, bbox.join(","));
localStorage.removeItem(`${ws}_map_view`); localStorage.removeItem(`${workspace}_map_view`);
setCurrentProject((prev) => ({ ...prev, extent: bbox })); setCurrentProject((previous) => ({ ...previous, extent: bbox }));
}
} }
} catch (error) { } catch (error) {
console.error("Failed to setup project:", error); console.error("Failed to setup project:", error);
} }
}, [setCurrentProjectId]); },
[setCurrentProjectId],
);
useEffect(() => { useEffect(() => {
// Check localStorage // Check localStorage
@@ -120,11 +135,16 @@ export const ProjectProvider: React.FC<{ children: React.ReactNode }> = ({
} }
}, [applyConfig]); }, [applyConfig]);
// Only show selector if authenticated and not configured const isGlobalManagementRoute =
if (status === "authenticated" && !isConfigured) { pathname.startsWith("/system-admin") || pathname.startsWith("/audit-logs");
if (status === "authenticated" && !isConfigured && !isGlobalManagementRoute) {
return ( return (
<ProjectSelector <ProjectSelector
open={true} open={true}
onClose={
canManageEnvironment ? () => router.push("/system-admin") : undefined
}
onSelect={(projectId, ws, net, extent) => onSelect={(projectId, ws, net, extent) =>
applyConfig(projectId, ws, net, extent) applyConfig(projectId, ws, net, extent)
} }
+34
View File
@@ -0,0 +1,34 @@
import {
permissionCodes,
permissionForPath,
resourcePermissions,
} from "./permissions";
describe("permission mappings", () => {
it("maps protected routes to backend permission codes", () => {
expect(permissionForPath("/system-admin")).toBe(
permissionCodes.environmentManage,
);
expect(permissionForPath("/scada-data-cleaning/devices")).toBe(
permissionCodes.scadaClean,
);
expect(permissionForPath("/monitoring-place-optimization")).toBe(
permissionCodes.optimizationRun,
);
expect(
permissionForPath("/hydraulic-simulation/burst-location"),
).toBe(permissionCodes.burstRun);
});
it("leaves public or project-selection routes without a feature permission", () => {
expect(permissionForPath("/login")).toBeUndefined();
expect(permissionForPath("/")).toBeUndefined();
});
it("uses the same permission codes for resources and routes", () => {
expect(resourcePermissions["系统管理"]).toBe(
permissionCodes.environmentManage,
);
expect(resourcePermissions["审计日志"]).toBe(permissionCodes.auditView);
});
});
+91
View File
@@ -0,0 +1,91 @@
export const permissionCodes = {
webgisView: "webgis.view",
webgisEdit: "webgis.edit",
scadaView: "scada.view",
scadaClean: "scada.clean",
simulationView: "simulation.view",
simulationRun: "simulation.run",
burstView: "burst.view",
burstRun: "burst.run",
riskView: "risk.view",
riskRun: "risk.run",
optimizationView: "optimization.view",
optimizationRun: "optimization.run",
modelImport: "model.import",
auditView: "audit.view",
environmentManage: "environment.manage",
membershipManage: "membership.manage",
} as const;
export type PermissionCode =
(typeof permissionCodes)[keyof typeof permissionCodes];
export type AccessContext = {
user_id: string;
username: string;
system_role: string;
is_system_admin: boolean;
project_id?: string | null;
project_role?: string | null;
permissions: string[];
};
export const resourcePermissions: Record<string, PermissionCode> = {
"管网在线模拟": permissionCodes.simulationView,
"SCADA 数据清洗": permissionCodes.scadaClean,
"监测点优化布置": permissionCodes.optimizationRun,
"健康风险分析": permissionCodes.riskRun,
"爆管模拟": permissionCodes.burstRun,
"爆管侦测": permissionCodes.burstRun,
"爆管定位": permissionCodes.burstRun,
"DMA 漏损识别": permissionCodes.burstRun,
"水质模拟": permissionCodes.simulationRun,
"管道冲洗": permissionCodes.simulationRun,
"系统管理": permissionCodes.environmentManage,
"审计日志": permissionCodes.auditView,
};
export const pathPermissions: Array<{
prefix: string;
permission: PermissionCode;
}> = [
{ prefix: "/system-admin", permission: permissionCodes.environmentManage },
{ prefix: "/audit-logs", permission: permissionCodes.auditView },
{ prefix: "/scada-data-cleaning", permission: permissionCodes.scadaClean },
{
prefix: "/monitoring-place-optimization",
permission: permissionCodes.optimizationRun,
},
{ prefix: "/health-risk-analysis", permission: permissionCodes.riskRun },
{
prefix: "/hydraulic-simulation/burst-simulation",
permission: permissionCodes.burstRun,
},
{
prefix: "/hydraulic-simulation/burst-detection",
permission: permissionCodes.burstRun,
},
{
prefix: "/hydraulic-simulation/burst-location",
permission: permissionCodes.burstRun,
},
{
prefix: "/hydraulic-simulation/dma-leak-detection",
permission: permissionCodes.burstRun,
},
{
prefix: "/hydraulic-simulation/contaminant-simulation",
permission: permissionCodes.simulationRun,
},
{
prefix: "/hydraulic-simulation/flushing-analysis",
permission: permissionCodes.simulationRun,
},
{
prefix: "/network-simulation",
permission: permissionCodes.simulationView,
},
];
export const permissionForPath = (pathname: string) =>
pathPermissions.find(({ prefix }) => pathname.startsWith(prefix))?.permission;
+28
View File
@@ -0,0 +1,28 @@
import { create } from "zustand";
import type { AccessContext } from "@/lib/permissions";
type AccessState = {
context: AccessContext | null;
permissions: string[];
loading: boolean;
setLoading: (loading: boolean) => void;
setContext: (context: AccessContext) => void;
reset: () => void;
};
export const useAccessStore = create<AccessState>((set) => ({
context: null,
permissions: [],
loading: true,
setLoading: (loading) => set({ loading }),
setContext: (context) =>
set({
context,
permissions: Array.isArray(context.permissions)
? context.permissions
: [],
loading: false,
}),
reset: () => set({ context: null, permissions: [], loading: false }),
}));