diff --git a/src/app/(main)/system-admin/page.tsx b/src/app/(main)/system-admin/page.tsx
new file mode 100644
index 0000000..29f663e
--- /dev/null
+++ b/src/app/(main)/system-admin/page.tsx
@@ -0,0 +1,5 @@
+import { SystemAdminPanel } from "@/components/admin/SystemAdminPanel";
+
+export default function SystemAdminPage() {
+ return ;
+}
diff --git a/src/app/_refine_context.tsx b/src/app/_refine_context.tsx
index e78741d..29511e2 100644
--- a/src/app/_refine_context.tsx
+++ b/src/app/_refine_context.tsx
@@ -8,7 +8,7 @@ import {
} from "@refinedev/mui";
import { SessionProvider, signIn, signOut, useSession } from "next-auth/react";
import { usePathname } from "next/navigation";
-import React, { useEffect } from "react";
+import React, { useEffect, useState } from "react";
import routerProvider from "@refinedev/nextjs-router";
@@ -16,6 +16,8 @@ import { ColorModeContextProvider } from "@contexts/color-mode";
import { dataProvider } from "@providers/data-provider";
import { ProjectProvider } from "@/contexts/ProjectContext";
import { useAuthStore } from "@/store/authStore";
+import { apiFetch } from "@/lib/apiFetch";
+import { config } from "@config/config";
import { LiaNetworkWiredSolid } from "react-icons/lia";
import { TbDatabaseEdit, TbLocationPin, TbActivity } from "react-icons/tb";
@@ -23,6 +25,7 @@ import { LuReplace } from "react-icons/lu";
import { AiOutlineSecurityScan } from "react-icons/ai";
import { MdWater, MdOutlineWaterDrop, MdCleaningServices } from "react-icons/md";
import {
+ ManageAccounts as ManageAccountsIcon,
MyLocation as MyLocationIcon,
Search as SearchIcon,
} from "@mui/icons-material";
@@ -51,11 +54,41 @@ const App = (props: React.PropsWithChildren) => {
const { data, status } = useSession();
const to = usePathname();
const setAccessToken = useAuthStore((state) => state.setAccessToken);
+ const [isMetadataAdmin, setIsMetadataAdmin] = useState(false);
useEffect(() => {
setAccessToken(typeof data?.accessToken === "string" ? data.accessToken : null);
}, [data?.accessToken, setAccessToken]);
+ useEffect(() => {
+ if (status !== "authenticated") {
+ setIsMetadataAdmin(false);
+ return;
+ }
+
+ let cancelled = false;
+ apiFetch(`${config.BACKEND_URL}/api/v1/admin/me`, {
+ projectHeaderMode: "omit",
+ skipAuthRedirect: true,
+ })
+ .then(async (response) => {
+ if (cancelled) return;
+ if (!response.ok) {
+ setIsMetadataAdmin(false);
+ return;
+ }
+ const payload = await response.json();
+ setIsMetadataAdmin(Boolean(payload?.is_superuser || payload?.role === "admin"));
+ })
+ .catch(() => {
+ if (!cancelled) setIsMetadataAdmin(false);
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, [status]);
+
if (status === "loading") {
return loading...;
}
@@ -227,6 +260,18 @@ const App = (props: React.PropsWithChildren) => {
label: "管道冲洗",
},
},
+ ...(isMetadataAdmin
+ ? [
+ {
+ name: "系统管理",
+ list: "/system-admin",
+ meta: {
+ icon: ,
+ label: "系统管理",
+ },
+ },
+ ]
+ : []),
]}
options={{
syncWithLocation: true,
diff --git a/src/components/admin/SystemAdminPanel.tsx b/src/components/admin/SystemAdminPanel.tsx
new file mode 100644
index 0000000..306b22c
--- /dev/null
+++ b/src/components/admin/SystemAdminPanel.tsx
@@ -0,0 +1,936 @@
+"use client";
+
+import React, { FormEvent, useCallback, useEffect, useMemo, useState } from "react";
+import {
+ Alert,
+ alpha,
+ Box,
+ Button,
+ Chip,
+ CircularProgress,
+ Divider,
+ FormControl,
+ InputLabel,
+ MenuItem,
+ Paper,
+ Select,
+ Stack,
+ Tab,
+ Table,
+ TableBody,
+ TableCell,
+ TableContainer,
+ TableHead,
+ TableRow,
+ Tabs,
+ Tooltip,
+ Typography,
+} from "@mui/material";
+import {
+ Add as AddIcon,
+ AdminPanelSettings as AdminPanelSettingsIcon,
+ Block as BlockIcon,
+ CheckCircle as CheckCircleIcon,
+ Groups as GroupsIcon,
+ People as PeopleIcon,
+ Refresh as RefreshIcon,
+ RemoveCircleOutline as RemoveCircleOutlineIcon,
+ Security as SecurityIcon,
+} from "@mui/icons-material";
+import { config } from "@config/config";
+import { apiFetch } from "@/lib/apiFetch";
+import { useProjectStore } from "@/store/projectStore";
+
+type MetadataUser = {
+ id: string;
+ keycloak_id: string;
+ username: string;
+ email: string;
+ role: string;
+ is_active: boolean;
+ is_superuser: boolean;
+};
+
+type ProjectMember = {
+ id: string;
+ user_id: string;
+ project_id: string;
+ project_role: string;
+ username: string;
+ email: string;
+ is_active: boolean;
+};
+
+const businessRoleOptions = [
+ { value: "admin", label: "系统管理员" },
+ { value: "operator", label: "运行人员" },
+ { value: "user", label: "普通用户" },
+ { value: "viewer", label: "只读用户" },
+];
+
+const projectRoleLabels: Record = {
+ owner: "项目负责人",
+ admin: "项目管理员",
+ member: "项目成员",
+ viewer: "只读成员",
+};
+
+const projectRoleOptions = [
+ { value: "admin", label: projectRoleLabels.admin },
+ { value: "member", label: projectRoleLabels.member },
+ { value: "viewer", label: projectRoleLabels.viewer },
+];
+
+const getBusinessRoleLabel = (role: string) =>
+ businessRoleOptions.find((option) => option.value === role)?.label ?? role;
+
+const getProjectRoleLabel = (role: string) =>
+ projectRoleLabels[role] ?? role;
+
+const cardSx = {
+ borderRadius: 2,
+ borderColor: "divider",
+ boxShadow: "0 10px 30px rgba(15, 23, 42, 0.06)",
+};
+
+const tableSx = {
+ minWidth: 720,
+ "& .MuiTableCell-head": {
+ bgcolor: "action.hover",
+ color: "text.secondary",
+ fontSize: 12,
+ fontWeight: 700,
+ letterSpacing: 0,
+ },
+ "& .MuiTableRow-root:last-child .MuiTableCell-body": {
+ borderBottom: 0,
+ },
+};
+
+type StatCardProps = {
+ icon: React.ReactNode;
+ label: string;
+ value: string | number;
+ tone?: "primary" | "success" | "warning" | "info";
+};
+
+const StatCard = ({ icon, label, value, tone = "primary" }: StatCardProps) => (
+ ({
+ ...cardSx,
+ p: 2,
+ flex: "1 1 180px",
+ minWidth: 0,
+ bgcolor: alpha(theme.palette[tone].main, 0.04),
+ })}
+ >
+
+ ({
+ width: 40,
+ height: 40,
+ borderRadius: 1.5,
+ display: "grid",
+ placeItems: "center",
+ color: `${tone}.main`,
+ bgcolor: alpha(theme.palette[tone].main, 0.12),
+ })}
+ >
+ {icon}
+
+
+
+ {label}
+
+
+ {value}
+
+
+
+
+);
+
+const SectionHeader = ({
+ title,
+ description,
+ action,
+}: {
+ title: string;
+ description?: string;
+ action?: React.ReactNode;
+}) => (
+
+
+
+ {title}
+
+ {description && (
+
+ {description}
+
+ )}
+
+ {action}
+
+);
+
+const StatusChip = ({ active }: { active: boolean }) => (
+ : }
+ label={active ? "启用" : "禁用"}
+ variant={active ? "filled" : "outlined"}
+ sx={{ minWidth: 76, justifyContent: "flex-start" }}
+ />
+);
+
+const EmptyRow = ({ colSpan, label }: { colSpan: number; label: string }) => (
+
+
+
+ {label}
+
+
+
+);
+
+export const SystemAdminPanel = () => {
+ const currentProjectId = useProjectStore((state) => state.currentProjectId);
+ const [tab, setTab] = useState(0);
+ const [users, setUsers] = useState([]);
+ const [members, setMembers] = useState([]);
+ const [currentAdmin, setCurrentAdmin] = useState(null);
+ const [adminChecked, setAdminChecked] = useState(false);
+ const [isAuthorized, setIsAuthorized] = useState(false);
+ const [projectId, setProjectId] = useState(currentProjectId ?? "");
+ const [hasLoadedCurrentProjectMembers, setHasLoadedCurrentProjectMembers] =
+ useState(false);
+ const [message, setMessage] = useState(null);
+ const [error, setError] = useState(null);
+ const [memberForm, setMemberForm] = useState({
+ user_id: "",
+ project_role: "viewer",
+ });
+
+ const activeUsers = useMemo(
+ () => users.filter((user) => user.is_active).length,
+ [users],
+ );
+ const systemAdminUsers = useMemo(
+ () => users.filter((user) => user.role === "admin").length,
+ [users],
+ );
+ const superUsers = useMemo(
+ () => users.filter((user) => user.is_superuser).length,
+ [users],
+ );
+ const memberUserIds = useMemo(
+ () => new Set(members.map((member) => member.user_id)),
+ [members],
+ );
+ const availableMemberUsers = useMemo(
+ () =>
+ users.filter(
+ (user) =>
+ user.is_active &&
+ !user.is_superuser &&
+ user.id !== currentAdmin?.id &&
+ !memberUserIds.has(user.id),
+ ),
+ [currentAdmin?.id, memberUserIds, users],
+ );
+ const selectedMemberUser = useMemo(
+ () => users.find((user) => user.id === memberForm.user_id),
+ [memberForm.user_id, users],
+ );
+ const hasProjectId = Boolean(projectId.trim());
+
+ const loadUsers = useCallback(async () => {
+ const response = await apiFetch(`${config.BACKEND_URL}/api/v1/admin/users`);
+ if (!response.ok) {
+ throw new Error(await response.text());
+ }
+ setUsers(await response.json());
+ }, []);
+
+ const loadMembers = useCallback(async () => {
+ if (!projectId.trim()) return;
+ const response = await apiFetch(
+ `${config.BACKEND_URL}/api/v1/admin/projects/${projectId.trim()}/members`,
+ );
+ if (!response.ok) {
+ throw new Error(await response.text());
+ }
+ setMembers(await response.json());
+ }, [projectId]);
+
+ useEffect(() => {
+ let cancelled = false;
+
+ const loadInitialState = async () => {
+ try {
+ const adminResponse = await apiFetch(`${config.BACKEND_URL}/api/v1/admin/me`, {
+ projectHeaderMode: "omit",
+ skipAuthRedirect: true,
+ });
+ if (!adminResponse.ok) {
+ if (!cancelled) {
+ setIsAuthorized(false);
+ setCurrentAdmin(null);
+ setAdminChecked(true);
+ }
+ return;
+ }
+ const adminPayload = await adminResponse.json();
+ if (!cancelled) {
+ setCurrentAdmin(adminPayload);
+ setIsAuthorized(true);
+ setAdminChecked(true);
+ }
+
+ const usersResponse = await apiFetch(`${config.BACKEND_URL}/api/v1/admin/users`);
+ if (!usersResponse.ok) {
+ throw new Error(await usersResponse.text());
+ }
+ const payload = await usersResponse.json();
+ if (!cancelled) setUsers(payload);
+ } catch (err) {
+ if (!cancelled) {
+ setAdminChecked(true);
+ setError(String(err));
+ }
+ }
+ };
+
+ void loadInitialState();
+
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ useEffect(() => {
+ if (!currentProjectId || projectId.trim()) return;
+ setProjectId(currentProjectId);
+ }, [currentProjectId, projectId]);
+
+ useEffect(() => {
+ if (!isAuthorized || !hasProjectId || hasLoadedCurrentProjectMembers) return;
+
+ setHasLoadedCurrentProjectMembers(true);
+ loadMembers().catch((err) => setError(String(err)));
+ }, [hasLoadedCurrentProjectMembers, hasProjectId, isAuthorized, loadMembers]);
+
+ const updateUserActive = async (user: MetadataUser, isActive: boolean) => {
+ setError(null);
+ const response = await apiFetch(
+ `${config.BACKEND_URL}/api/v1/admin/users/${user.id}`,
+ {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ is_active: isActive }),
+ },
+ );
+ if (!response.ok) {
+ setError(await response.text());
+ return;
+ }
+ await loadUsers();
+ };
+
+ const updateUserRole = async (user: MetadataUser, role: string) => {
+ setError(null);
+ const response = await apiFetch(
+ `${config.BACKEND_URL}/api/v1/admin/users/${user.id}`,
+ {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ role }),
+ },
+ );
+ if (!response.ok) {
+ setError(await response.text());
+ return;
+ }
+ await loadUsers();
+ };
+
+ const addMember = async (event: FormEvent) => {
+ event.preventDefault();
+ setError(null);
+ setMessage(null);
+ const response = await apiFetch(
+ `${config.BACKEND_URL}/api/v1/admin/projects/${projectId.trim()}/members`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(memberForm),
+ },
+ );
+ if (!response.ok) {
+ setError(await response.text());
+ return;
+ }
+ setMessage("项目成员已添加");
+ setMemberForm((prev) => ({ ...prev, user_id: "" }));
+ await loadMembers();
+ };
+
+ const updateMemberRole = async (member: ProjectMember, projectRole: string) => {
+ setError(null);
+ const response = await apiFetch(
+ `${config.BACKEND_URL}/api/v1/admin/projects/${member.project_id}/members/${member.user_id}`,
+ {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ project_role: projectRole }),
+ },
+ );
+ if (!response.ok) {
+ setError(await response.text());
+ return;
+ }
+ await loadMembers();
+ };
+
+ const removeMember = async (member: ProjectMember) => {
+ setError(null);
+ const response = await apiFetch(
+ `${config.BACKEND_URL}/api/v1/admin/projects/${member.project_id}/members/${member.user_id}`,
+ { method: "DELETE" },
+ );
+ if (!response.ok) {
+ setError(await response.text());
+ return;
+ }
+ setMessage("项目成员已移除");
+ await loadMembers();
+ };
+
+ return (
+
+
+ ({
+ ...cardSx,
+ p: { xs: 2, md: 3 },
+ bgcolor:
+ theme.palette.mode === "dark"
+ ? alpha(theme.palette.primary.main, 0.1)
+ : alpha(theme.palette.primary.main, 0.04),
+ })}
+ >
+
+
+ ({
+ width: 48,
+ height: 48,
+ borderRadius: 2,
+ display: "grid",
+ placeItems: "center",
+ color: "primary.main",
+ bgcolor: alpha(theme.palette.primary.main, 0.12),
+ })}
+ >
+
+
+
+
+ 系统管理
+
+
+ Keycloak 负责登录身份,metadata 负责系统角色、账号状态和项目权限
+
+
+
+ {adminChecked && isAuthorized && (
+ }
+ label="管理员权限已验证"
+ sx={{ alignSelf: { xs: "flex-start", md: "center" } }}
+ />
+ )}
+
+
+
+ {!adminChecked && (
+
+
+
+ 正在校验系统管理权限
+
+
+ )}
+
+ {adminChecked && !isAuthorized && (
+
+ 无系统管理权限
+
+ )}
+ {message && (
+ setMessage(null)} sx={{ borderRadius: 2 }}>
+ {message}
+
+ )}
+ {error && (
+ setError(null)} sx={{ borderRadius: 2 }}>
+ {error}
+
+ )}
+
+ {isAuthorized && (
+ <>
+
+ }
+ label="系统用户"
+ value={users.length}
+ />
+ }
+ label="启用用户"
+ value={activeUsers}
+ tone="success"
+ />
+ }
+ label="系统管理员角色"
+ value={systemAdminUsers}
+ tone="warning"
+ />
+ }
+ label="超级管理员"
+ value={superUsers}
+ tone="info"
+ />
+ }
+ label={hasProjectId ? "当前项目成员" : "项目成员未加载"}
+ value={hasProjectId ? members.length : "-"}
+ tone="info"
+ />
+
+
+
+ setTab(value)}
+ variant="scrollable"
+ scrollButtons="auto"
+ sx={{
+ px: 2,
+ minHeight: 56,
+ borderBottom: 1,
+ borderColor: "divider",
+ "& .MuiTab-root": {
+ minHeight: 56,
+ textTransform: "none",
+ fontWeight: 700,
+ },
+ }}
+ >
+ } iconPosition="start" label="用户" />
+ } iconPosition="start" label="项目成员" />
+
+
+
+ {tab === 0 && (
+
+ } variant="outlined" onClick={loadUsers}>
+ 刷新
+
+ }
+ />
+
+
+
+
+ 用户身份
+ 系统角色
+ 超级权限
+ 状态
+ 操作
+
+
+
+ {users.length === 0 && }
+ {users.map((user) => {
+ const isSelf = user.id === currentAdmin?.id;
+ const protectedUserReason = isSelf
+ ? "不能操作当前登录用户"
+ : user.is_superuser
+ ? "超级管理员权限独立于系统角色,需后台设置"
+ : "";
+
+ return (
+
+
+
+
+ {user.username}
+ {isSelf && (
+
+ )}
+
+
+ {user.email}
+
+
+ {user.keycloak_id}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {user.is_superuser ? (
+ }
+ label="超级管理员"
+ />
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ })}
+
+
+
+
+ )}
+
+ {tab === 1 && (
+
+ }
+ onClick={loadMembers}
+ disabled={!hasProjectId}
+ >
+ 刷新成员
+
+ }
+ />
+ {!hasProjectId && (
+
+ 当前未选择项目,请先通过顶部用户菜单切换项目。
+
+ )}
+ {hasProjectId && (
+
+
+
+
+ 当前项目
+
+
+ {projectId}
+
+
+ }
+ label={`${members.length} 名成员`}
+ sx={{ alignSelf: { xs: "flex-start", md: "center" } }}
+ />
+
+
+ )}
+
+
+
+ 添加成员
+
+
+
+ 选择用户
+
+
+
+ 项目角色
+
+
+ }
+ disabled={!hasProjectId || !memberForm.user_id}
+ sx={{
+ width: { xs: "calc(50% - 6px)", sm: "auto" },
+ minWidth: 120,
+ }}
+ >
+ 添加成员
+
+
+
+
+
+
+
+
+ 用户
+ 邮箱
+ 项目角色
+ 状态
+ 操作
+
+
+
+ {members.length === 0 && (
+
+ )}
+ {members.map((member) => {
+ const isSelf = member.user_id === currentAdmin?.id;
+ const isOwner = member.project_role === "owner";
+
+ return (
+
+
+
+
+ {member.username}
+ {isSelf && (
+
+ )}
+
+
+ {member.user_id}
+
+
+
+ {member.email}
+
+ {isOwner ? (
+
+
+
+ ) : (
+
+
+
+
+
+
+
+ )}
+
+
+
+
+
+
+
+ }
+ onClick={() => removeMember(member)}
+ disabled={isSelf || isOwner}
+ >
+ 移除
+
+
+
+
+
+ );
+ })}
+
+
+
+
+ )}
+
+
+
+ >
+ )}
+
+
+ );
+};