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" } }} + /> + + + )} + + + + 添加成员 + + + + 选择用户 + + + + 项目角色 + + + + + + + + + + + 用户 + 邮箱 + 项目角色 + 状态 + 操作 + + + + {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 ? ( + + + + ) : ( + + + + + + + + )} + + + + + + + + + + + + + ); + })} + +
+
+
+ )} + +
+
+ + )} +
+
+ ); +};