diff --git a/src/components/admin/SystemAdminPanel.tsx b/src/components/admin/SystemAdminPanel.tsx index 246528c..b1f1ad2 100644 --- a/src/components/admin/SystemAdminPanel.tsx +++ b/src/components/admin/SystemAdminPanel.tsx @@ -1,6 +1,13 @@ "use client"; -import React, { FormEvent, useCallback, useEffect, useMemo, useState } from "react"; +import React, { + FormEvent, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { Alert, alpha, @@ -47,6 +54,7 @@ import { Security as SecurityIcon, Storage as StorageIcon, } from "@mui/icons-material"; +import { useNotification } from "@refinedev/core"; import { config } from "@config/config"; import { apiFetch } from "@/lib/apiFetch"; import { useProjectStore } from "@/store/projectStore"; @@ -125,7 +133,7 @@ const projectStatusOptions = [ const databaseRoleOptions = [ { value: "biz_data", label: "业务数据库", helper: "PostgreSQL / 管网业务数据" }, { value: "iot_data", label: "时序数据库", helper: "TimescaleDB / SCADA 与实时数据" }, -]; +] as const; const defaultProjectForm = { name: "", @@ -149,17 +157,32 @@ const defaultDatabaseForms = { }, }; +type ProjectFormState = typeof defaultProjectForm; +type DatabaseRole = keyof typeof defaultDatabaseForms; +type DatabaseFormState = (typeof defaultDatabaseForms)[DatabaseRole]; + const createDefaultDatabaseForms = () => ({ biz_data: { ...defaultDatabaseForms.biz_data }, iot_data: { ...defaultDatabaseForms.iot_data }, }); +const createEmptyDatabaseHealth = (): Record< + DatabaseRole, + DatabaseHealth | null +> => ({ + biz_data: null, + iot_data: null, +}); + const getBusinessRoleLabel = (role: string) => businessRoleOptions.find((option) => option.value === role)?.label ?? role; const getProjectRoleLabel = (role: string) => projectRoleLabels[role] ?? role; +const getDatabaseRoleLabel = (role: DatabaseRole) => + databaseRoleOptions.find((option) => option.value === role)?.label ?? role; + const formatJsonField = (value?: Record | null) => value ? JSON.stringify(value, null, 2) : ""; @@ -254,8 +277,8 @@ const StatCard = ({ icon, label, value, tone = "primary" }: StatCardProps) => ( sx={(theme) => ({ ...cardSx, p: 2, - flex: "1 1 180px", minWidth: 0, + boxShadow: "none", bgcolor: alpha(theme.palette[tone].main, 0.04), })} > @@ -274,7 +297,7 @@ const StatCard = ({ icon, label, value, tone = "primary" }: StatCardProps) => ( {icon} - + {label} @@ -335,7 +358,100 @@ const EmptyRow = ({ colSpan, label }: { colSpan: number; label: string }) => ( ); +type ProjectFormFieldsProps = { + value: ProjectFormState; + onChange: (value: ProjectFormState) => void; + disabled?: boolean; +}; + +const ProjectFormFields = ({ + value, + onChange, + disabled = false, +}: ProjectFormFieldsProps) => { + const updateField = (field: keyof ProjectFormState, nextValue: string) => { + onChange({ ...value, [field]: nextValue }); + }; + + return ( + + + updateField("name", event.target.value)} + disabled={disabled} + required + fullWidth + /> + updateField("code", event.target.value)} + disabled={disabled} + required + fullWidth + /> + + + updateField("gs_workspace", event.target.value)} + disabled={disabled} + required + fullWidth + /> + + 状态 + + + + updateField("description", event.target.value)} + disabled={disabled} + fullWidth + multiline + minRows={2} + /> + updateField("map_extent", event.target.value)} + disabled={disabled} + fullWidth + multiline + minRows={4} + placeholder='{"bbox":[120.1,30.1,120.2,30.2]}' + /> + + ); +}; + export const SystemAdminPanel = () => { + const { open: openNotification } = useNotification(); + const openNotificationRef = useRef(openNotification); const currentProjectId = useProjectStore((state) => state.currentProjectId); const [tab, setTab] = useState(0); const [users, setUsers] = useState([]); @@ -347,10 +463,6 @@ export const SystemAdminPanel = () => { const [isAuthorized, setIsAuthorized] = useState(false); const [metadataConfigAvailable, setMetadataConfigAvailable] = useState(true); 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", @@ -359,23 +471,36 @@ export const SystemAdminPanel = () => { const [createProjectOpen, setCreateProjectOpen] = useState(false); const [createProjectForm, setCreateProjectForm] = useState(defaultProjectForm); const [databaseForms, setDatabaseForms] = useState(createDefaultDatabaseForms); - const [databaseHealth, setDatabaseHealth] = useState< - Record - >({ - biz_data: null, - iot_data: null, - }); + const [databaseHealth, setDatabaseHealth] = useState(createEmptyDatabaseHealth); - const activeUsers = useMemo( - () => users.filter((user) => user.is_active).length, - [users], + useEffect(() => { + openNotificationRef.current = openNotification; + }, [openNotification]); + + const notifySuccess = useCallback( + (message: string) => { + openNotificationRef.current?.({ type: "success", message }); + }, + [], ); - const systemAdminUsers = useMemo( - () => users.filter((user) => user.role === "admin").length, - [users], + + const notifyError = useCallback( + (message: string) => { + openNotificationRef.current?.({ type: "error", message }); + }, + [], ); - const superUsers = useMemo( - () => users.filter((user) => user.is_superuser).length, + + const userStats = useMemo( + () => + users.reduce( + (stats, user) => ({ + active: stats.active + Number(user.is_active), + systemAdmins: stats.systemAdmins + Number(user.role === "admin"), + superusers: stats.superusers + Number(user.is_superuser), + }), + { active: 0, systemAdmins: 0, superusers: 0 }, + ), [users], ); const memberUserIds = useMemo( @@ -440,7 +565,6 @@ export const SystemAdminPanel = () => { if (isFastApiRouteNotFound(response, errorText)) { setMetadataConfigAvailable(false); setProjects([]); - applyProjectForm(null); return; } throw new Error(errorText); @@ -457,10 +581,7 @@ export const SystemAdminPanel = () => { if (activeProjectId && !projectId.trim()) { setProjectId(activeProjectId); } - applyProjectForm( - payload.find((project) => project.project_id === activeProjectId) ?? null, - ); - }, [applyProjectForm, currentProjectId, projectId]); + }, [currentProjectId, projectId]); const loadMembers = useCallback(async () => { if (!projectId.trim()) return; @@ -475,7 +596,7 @@ export const SystemAdminPanel = () => { const loadDatabases = useCallback(async () => { if (!projectId.trim()) return; - setDatabaseHealth({ biz_data: null, iot_data: null }); + setDatabaseHealth(createEmptyDatabaseHealth()); const response = await apiFetch( `${config.BACKEND_URL}/api/v1/admin/projects/${projectId.trim()}/databases`, ); @@ -546,7 +667,6 @@ export const SystemAdminPanel = () => { if (!cancelled) { setMetadataConfigAvailable(false); setProjects([]); - applyProjectForm(null); } return; } @@ -561,16 +681,11 @@ export const SystemAdminPanel = () => { setProjectId((current) => current.trim() || !initialProjectId ? current : initialProjectId, ); - applyProjectForm( - projectsPayload.find( - (project) => project.project_id === initialProjectId, - ) ?? null, - ); } } catch (err) { if (!cancelled) { setAdminChecked(true); - setError(String(err)); + notifyError(String(err)); } } }; @@ -580,7 +695,7 @@ export const SystemAdminPanel = () => { return () => { cancelled = true; }; - }, [applyProjectForm, currentProjectId]); + }, [currentProjectId, notifyError]); useEffect(() => { if (!currentProjectId || projectId.trim()) return; @@ -588,26 +703,23 @@ export const SystemAdminPanel = () => { }, [currentProjectId, projectId]); useEffect(() => { - if (!isAuthorized || !hasProjectId || hasLoadedCurrentProjectMembers) return; - - setHasLoadedCurrentProjectMembers(true); - loadMembers().catch((err) => setError(String(err))); - }, [hasLoadedCurrentProjectMembers, hasProjectId, isAuthorized, loadMembers]); + if (!isAuthorized || !hasProjectId) return; + loadMembers().catch((err) => notifyError(String(err))); + }, [hasProjectId, isAuthorized, loadMembers, notifyError]); useEffect(() => { - if (!selectedProject) return; - applyProjectForm(selectedProject); + applyProjectForm(selectedProject ?? null); }, [applyProjectForm, selectedProject]); useEffect(() => { if (!isAuthorized || !hasProjectId || !metadataConfigAvailable) return; - setDatabaseHealth({ biz_data: null, iot_data: null }); - loadDatabases().catch((err) => setError(String(err))); + loadDatabases().catch((err) => notifyError(String(err))); }, [ hasProjectId, isAuthorized, loadDatabases, metadataConfigAvailable, + notifyError, ]); useEffect(() => { @@ -617,7 +729,6 @@ export const SystemAdminPanel = () => { }, [metadataConfigAvailable, tab]); const updateUserActive = async (user: MetadataUser, isActive: boolean) => { - setError(null); const response = await apiFetch( `${config.BACKEND_URL}/api/v1/admin/users/${user.id}`, { @@ -627,14 +738,13 @@ export const SystemAdminPanel = () => { }, ); if (!response.ok) { - setError(await readErrorText(response)); + notifyError(await readErrorText(response)); 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}`, { @@ -644,7 +754,7 @@ export const SystemAdminPanel = () => { }, ); if (!response.ok) { - setError(await readErrorText(response)); + notifyError(await readErrorText(response)); return; } await loadUsers(); @@ -652,8 +762,6 @@ export const SystemAdminPanel = () => { 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`, { @@ -663,16 +771,15 @@ export const SystemAdminPanel = () => { }, ); if (!response.ok) { - setError(await readErrorText(response)); + notifyError(await readErrorText(response)); return; } - setMessage("项目成员已添加"); + notifySuccess("项目成员已添加"); 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}`, { @@ -682,23 +789,22 @@ export const SystemAdminPanel = () => { }, ); if (!response.ok) { - setError(await readErrorText(response)); + notifyError(await readErrorText(response)); 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 readErrorText(response)); + notifyError(await readErrorText(response)); return; } - setMessage("项目成员已移除"); + notifySuccess("项目成员已移除"); await loadMembers(); }; @@ -709,18 +815,25 @@ export const SystemAdminPanel = () => { const selectProject = (value: string) => { setProjectId(value); - setHasLoadedCurrentProjectMembers(false); setMembers([]); setDatabases([]); setDatabaseForms(createDefaultDatabaseForms()); - setDatabaseHealth({ biz_data: null, iot_data: null }); + setDatabaseHealth(createEmptyDatabaseHealth()); }; - const resetDatabaseHealth = (role: keyof typeof defaultDatabaseForms) => { + const updateDatabaseForm = ( + role: DatabaseRole, + field: Field, + value: DatabaseFormState[Field], + ) => { setDatabaseHealth((current) => ({ ...current, [role]: null })); + setDatabaseForms((current) => ({ + ...current, + [role]: { ...current[role], [field]: value }, + })); }; - const buildProjectPayload = (form: typeof defaultProjectForm) => ({ + const buildProjectPayload = (form: ProjectFormState) => ({ name: form.name.trim(), code: form.code.trim(), description: form.description.trim() || null, @@ -732,11 +845,9 @@ export const SystemAdminPanel = () => { const saveProject = async (event: FormEvent) => { event.preventDefault(); if (!selectedProject?.project_id) { - setError("请先选择要编辑的项目。"); + notifyError("请先选择要编辑的项目。"); return; } - setError(null); - setMessage(null); try { const response = await apiFetch( `${config.BACKEND_URL}/api/v1/admin/projects/${selectedProject.project_id}`, @@ -750,20 +861,16 @@ export const SystemAdminPanel = () => { throw new Error(await readErrorText(response)); } const saved = (await response.json()) as AdminProject; - setHasLoadedCurrentProjectMembers(false); setProjectId(saved.project_id); - applyProjectForm(saved); await loadProjects(saved.project_id); - setMessage("项目配置已更新"); + notifySuccess("项目配置已更新"); } catch (err) { - setError(String(err)); + notifyError(String(err)); } }; const createProject = async (event: FormEvent) => { event.preventDefault(); - setError(null); - setMessage(null); try { const response = await apiFetch(`${config.BACKEND_URL}/api/v1/admin/projects`, { method: "POST", @@ -776,29 +883,25 @@ export const SystemAdminPanel = () => { const saved = (await response.json()) as AdminProject; setCreateProjectOpen(false); setCreateProjectForm(defaultProjectForm); - setHasLoadedCurrentProjectMembers(false); setProjectId(saved.project_id); - applyProjectForm(saved); await loadProjects(saved.project_id); - setMessage("项目已创建"); + notifySuccess("项目已创建"); } catch (err) { - setError(String(err)); + notifyError(String(err)); } }; - const saveDatabase = async (role: keyof typeof defaultDatabaseForms) => { + const saveDatabase = async (role: DatabaseRole) => { if (!projectId.trim()) return; const form = databaseForms[role]; if (!form.dsn.trim()) { - setError("请填写新的 DSN 并通过连通性测试后再保存。"); + notifyError("请填写新的 DSN 并通过连通性测试后再保存。"); return; } if (!databaseHealth[role]?.ok) { - setError("请先通过连通性测试再保存数据库配置。"); + notifyError("请先通过连通性测试再保存数据库配置。"); return; } - setError(null); - setMessage(null); const payload: Record = { db_role: role, pool_min_size: Number(form.pool_min_size), @@ -821,20 +924,19 @@ export const SystemAdminPanel = () => { setMetadataConfigAvailable(false); return; } - setError(errorText); + notifyError(errorText); return; } setDatabaseForms((current) => ({ ...current, [role]: { ...current[role], dsn: "" }, })); - setMessage(`${databaseRoleOptions.find((item) => item.value === role)?.label}已保存`); + notifySuccess(`${getDatabaseRoleLabel(role)}已保存`); await loadDatabases(); }; - const checkDatabaseHealth = async (role: keyof typeof defaultDatabaseForms) => { + const checkDatabaseHealth = async (role: DatabaseRole) => { if (!projectId.trim()) return; - setError(null); setDatabaseHealth((current) => ({ ...current, [role]: null })); const form = databaseForms[role]; const body = form.dsn.trim() ? { dsn: form.dsn.trim() } : {}; @@ -866,7 +968,7 @@ export const SystemAdminPanel = () => { })); return; } - setError(normalizeDatabaseHealthDetail(errorText, false)); + notifyError(normalizeDatabaseHealthDetail(errorText, false)); return; } const payload = (await response.json()) as DatabaseHealth; @@ -879,9 +981,8 @@ export const SystemAdminPanel = () => { })); }; - const deleteDatabase = async (role: keyof typeof defaultDatabaseForms) => { + const deleteDatabase = async (role: DatabaseRole) => { if (!projectId.trim()) return; - setError(null); const response = await apiFetch( `${config.BACKEND_URL}/api/v1/admin/projects/${projectId.trim()}/databases/${role}`, { method: "DELETE" }, @@ -892,10 +993,10 @@ export const SystemAdminPanel = () => { setMetadataConfigAvailable(false); return; } - setError(errorText); + notifyError(errorText); return; } - setMessage(`${databaseRoleOptions.find((item) => item.value === role)?.label}配置已删除`); + notifySuccess(`${getDatabaseRoleLabel(role)}配置已删除`); await loadDatabases(); }; @@ -920,43 +1021,29 @@ export const SystemAdminPanel = () => { : 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 负责登录身份,系统配置库负责系统角色、账号状态和项目权限 - - - - {adminChecked && isAuthorized && ( - } - label="管理员权限已验证" - sx={{ alignSelf: { xs: "flex-start", md: "center" } }} - /> - )} + + ({ + width: 48, + height: 48, + borderRadius: 2, + display: "grid", + placeItems: "center", + color: "primary.main", + bgcolor: alpha(theme.palette.primary.main, 0.12), + flexShrink: 0, + })} + > + + + + + 系统管理 + + + 管理系统用户、项目权限和数据库连接 + + @@ -974,16 +1061,6 @@ export const SystemAdminPanel = () => { 无系统管理权限 )} - {message && ( - setMessage(null)} sx={{ borderRadius: 2 }}> - {message} - - )} - {error && ( - setError(null)} sx={{ borderRadius: 2 }}> - {error} - - )} {isAuthorized && !metadataConfigAvailable && ( 后端尚未启用项目配置接口,请重启或部署包含 /api/v1/admin/projects 的后端后再使用项目配置和数据库配置。 @@ -992,7 +1069,17 @@ export const SystemAdminPanel = () => { {isAuthorized && ( <> - + } label="系统用户" @@ -1001,24 +1088,24 @@ export const SystemAdminPanel = () => { } label="启用用户" - value={activeUsers} + value={userStats.active} tone="success" /> } - label="系统管理员角色" - value={systemAdminUsers} + label="系统管理员" + value={userStats.systemAdmins} tone="warning" /> } label="超级管理员" - value={superUsers} + value={userStats.superusers} tone="info" /> } - label={hasProjectId ? "当前项目成员" : "项目成员未加载"} + label="当前项目成员" value={hasProjectId ? members.length : "-"} tone="info" /> @@ -1028,7 +1115,7 @@ export const SystemAdminPanel = () => { value={projects.length} tone="primary" /> - + {metadataConfigAvailable && ( @@ -1270,63 +1357,6 @@ export const SystemAdminPanel = () => { 当前未选择管理项目。 )} - {hasProjectId && ( - - - - ({ - width: 40, - height: 40, - borderRadius: 1.5, - display: "grid", - placeItems: "center", - color: "primary.main", - bgcolor: alpha(theme.palette.primary.main, 0.12), - flexShrink: 0, - })} - > - - - - - 当前管理项目 - - - {selectedProject?.name ?? "未命名项目"} - - - {selectedProject - ? `${selectedProject.code} · ${projectId}` - : projectId} - - - - - {selectedProject && ( - option.value === selectedProject.status, - )?.label ?? selectedProject.status - } - /> - )} - } - label={`${members.length} 名成员`} - /> - - - - )} { title="项目配置" description="维护项目基础信息、GeoServer 工作区、地图范围和项目状态。" action={ - - - + } /> {!selectedProject && ( @@ -1553,93 +1585,10 @@ export const SystemAdminPanel = () => { onSubmit={saveProject} > - - - setProjectForm({ ...projectForm, name: event.target.value }) - } - disabled={!selectedProject} - required - fullWidth - /> - - setProjectForm({ ...projectForm, code: event.target.value }) - } - disabled={!selectedProject} - required - fullWidth - /> - - setProjectForm({ - ...projectForm, - gs_workspace: event.target.value, - }) - } - disabled={!selectedProject} - required - fullWidth - /> - - 状态 - - - - - setProjectForm({ - ...projectForm, - description: event.target.value, - }) - } + - - setProjectForm({ - ...projectForm, - map_extent: event.target.value, - }) - } - disabled={!selectedProject} - fullWidth - multiline - minRows={4} - placeholder='{"bbox":[120.1,30.1,120.2,30.2]}' />