diff --git a/src/components/admin/SystemAdminPanel.tsx b/src/components/admin/SystemAdminPanel.tsx index 306b22c..69d7303 100644 --- a/src/components/admin/SystemAdminPanel.tsx +++ b/src/components/admin/SystemAdminPanel.tsx @@ -9,7 +9,12 @@ import { Chip, CircularProgress, Divider, + Dialog, + DialogActions, + DialogContent, + DialogTitle, FormControl, + IconButton, InputLabel, MenuItem, Paper, @@ -23,6 +28,7 @@ import { TableHead, TableRow, Tabs, + TextField, Tooltip, Typography, } from "@mui/material"; @@ -31,11 +37,15 @@ import { AdminPanelSettings as AdminPanelSettingsIcon, Block as BlockIcon, CheckCircle as CheckCircleIcon, + Close as CloseIcon, + Dns as DnsIcon, Groups as GroupsIcon, People as PeopleIcon, Refresh as RefreshIcon, RemoveCircleOutline as RemoveCircleOutlineIcon, + Save as SaveIcon, Security as SecurityIcon, + Storage as StorageIcon, } from "@mui/icons-material"; import { config } from "@config/config"; import { apiFetch } from "@/lib/apiFetch"; @@ -61,6 +71,43 @@ type ProjectMember = { is_active: boolean; }; +type AdminProject = { + project_id: string; + name: string; + code: string; + description?: string | null; + gs_workspace: string; + map_extent?: Record | null; + status: string; +}; + +type ProjectDatabaseConfig = { + id: string; + project_id: string; + db_role: string; + db_type: string; + pool_min_size: number; + pool_max_size: number; + has_dsn: boolean; +}; + +type ProjectGeoServerConfig = { + id?: string | null; + project_id: string; + gs_base_url?: string | null; + gs_admin_user?: string | null; + gs_datastore_name: string; + default_extent?: Record | null; + srid: number; + configured?: boolean; + has_password: boolean; +}; + +type DatabaseHealth = { + ok: boolean; + detail: string; +}; + const businessRoleOptions = [ { value: "admin", label: "系统管理员" }, { value: "operator", label: "运行人员" }, @@ -81,12 +128,120 @@ const projectRoleOptions = [ { value: "viewer", label: projectRoleLabels.viewer }, ]; +const projectStatusOptions = [ + { value: "active", label: "启用" }, + { value: "inactive", label: "停用" }, + { value: "archived", label: "归档" }, +]; + +const databaseRoleOptions = [ + { value: "biz_data", label: "业务数据库", helper: "PostgreSQL / 管网业务数据" }, + { value: "iot_data", label: "时序数据库", helper: "TimescaleDB / SCADA 与实时数据" }, +]; + +const defaultProjectForm = { + name: "", + code: "", + description: "", + gs_workspace: "", + map_extent: "", + status: "active", +}; + +const defaultDatabaseForms = { + biz_data: { + dsn: "", + pool_min_size: 2, + pool_max_size: 10, + }, + iot_data: { + dsn: "", + pool_min_size: 1, + pool_max_size: 10, + }, +}; + +const createDefaultDatabaseForms = () => ({ + biz_data: { ...defaultDatabaseForms.biz_data }, + iot_data: { ...defaultDatabaseForms.iot_data }, +}); + +const defaultGeoserverForm = { + gs_base_url: "", + gs_admin_user: "", + gs_admin_password: "", + gs_datastore_name: "ds_postgis", + default_extent: "", + srid: 4326, +}; + const getBusinessRoleLabel = (role: string) => businessRoleOptions.find((option) => option.value === role)?.label ?? role; const getProjectRoleLabel = (role: string) => projectRoleLabels[role] ?? role; +const formatJsonField = (value?: Record | null) => + value ? JSON.stringify(value, null, 2) : ""; + +const parseOptionalJsonObject = (value: string, label: string) => { + const trimmed = value.trim(); + if (!trimmed) return null; + const parsed = JSON.parse(trimmed); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error(`${label} 必须是 JSON 对象`); + } + return parsed as Record; +}; + +const readErrorText = async (response: Response) => { + const text = await response.text(); + return text || `HTTP ${response.status}`; +}; + +const isFastApiRouteNotFound = (response: Response, text: string) => + response.status === 404 && text.includes('"detail":"Not Found"'); + +const normalizeDatabaseHealthDetail = (detail: string, ok: boolean) => { + if (ok) return detail || "连通性测试通过"; + + const lowerDetail = detail.toLowerCase(); + if (lowerDetail.includes("password authentication failed")) { + return "连通性测试失败:用户名或密码错误,请检查 DSN 中的账号密码。"; + } + if (lowerDetail.includes("connection refused")) { + return "连通性测试失败:目标主机或端口拒绝连接,请检查地址、端口和服务状态。"; + } + if (lowerDetail.includes("timeout") || lowerDetail.includes("timed out")) { + return "连通性测试失败:连接超时,请检查网络、防火墙和数据库服务状态。"; + } + if ( + lowerDetail.includes("could not translate host name") || + lowerDetail.includes("name or service not known") + ) { + return "连通性测试失败:数据库主机名无法解析,请检查 DSN 中的主机地址。"; + } + if (detail.startsWith("数据库连接失败:")) { + return detail.replace("数据库连接失败:", "连通性测试失败:"); + } + return detail || "连通性测试失败"; +}; + +const parseDatabaseHealthText = (text: string): DatabaseHealth | null => { + try { + const payload = JSON.parse(text); + if (payload && typeof payload === "object" && "ok" in payload) { + return { + ok: Boolean(payload.ok), + detail: String(payload.detail ?? ""), + }; + } + } catch { + return null; + } + return null; +}; + const cardSx = { borderRadius: 2, borderColor: "divider", @@ -206,9 +361,14 @@ export const SystemAdminPanel = () => { const [tab, setTab] = useState(0); const [users, setUsers] = useState([]); const [members, setMembers] = useState([]); + const [projects, setProjects] = useState([]); + const [databases, setDatabases] = useState([]); + const [geoserverConfig, setGeoserverConfig] = + useState(null); const [currentAdmin, setCurrentAdmin] = useState(null); const [adminChecked, setAdminChecked] = useState(false); const [isAuthorized, setIsAuthorized] = useState(false); + const [metadataConfigAvailable, setMetadataConfigAvailable] = useState(true); const [projectId, setProjectId] = useState(currentProjectId ?? ""); const [hasLoadedCurrentProjectMembers, setHasLoadedCurrentProjectMembers] = useState(false); @@ -218,6 +378,17 @@ export const SystemAdminPanel = () => { user_id: "", project_role: "viewer", }); + const [projectForm, setProjectForm] = useState(defaultProjectForm); + 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 [geoserverForm, setGeoserverForm] = useState(defaultGeoserverForm); const activeUsers = useMemo( () => users.filter((user) => user.is_active).length, @@ -251,26 +422,146 @@ export const SystemAdminPanel = () => { [memberForm.user_id, users], ); const hasProjectId = Boolean(projectId.trim()); + const selectedProject = useMemo( + () => projects.find((project) => project.project_id === projectId.trim()), + [projectId, projects], + ); + const databasesByRole = useMemo( + () => + new Map( + databases.map((database) => [database.db_role, database] as const), + ), + [databases], + ); const loadUsers = useCallback(async () => { const response = await apiFetch(`${config.BACKEND_URL}/api/v1/admin/users`); if (!response.ok) { - throw new Error(await response.text()); + throw new Error(await readErrorText(response)); } setUsers(await response.json()); }, []); + const applyProjectForm = useCallback((project: AdminProject | null) => { + if (!project) { + setProjectForm(defaultProjectForm); + return; + } + setProjectForm({ + name: project.name ?? "", + code: project.code ?? "", + description: project.description ?? "", + gs_workspace: project.gs_workspace ?? "", + map_extent: formatJsonField(project.map_extent), + status: project.status || "active", + }); + }, []); + + const loadProjects = useCallback(async (preferredProjectId?: string) => { + const response = await apiFetch(`${config.BACKEND_URL}/api/v1/admin/projects`); + if (!response.ok) { + const errorText = await readErrorText(response); + if (isFastApiRouteNotFound(response, errorText)) { + setMetadataConfigAvailable(false); + setProjects([]); + applyProjectForm(null); + return; + } + throw new Error(errorText); + } + setMetadataConfigAvailable(true); + const payload = (await response.json()) as AdminProject[]; + setProjects(payload); + const activeProjectId = + preferredProjectId || + projectId.trim() || + currentProjectId || + payload[0]?.project_id || + ""; + if (activeProjectId && !projectId.trim()) { + setProjectId(activeProjectId); + } + applyProjectForm( + payload.find((project) => project.project_id === activeProjectId) ?? null, + ); + }, [applyProjectForm, currentProjectId, projectId]); + 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()); + throw new Error(await readErrorText(response)); } setMembers(await response.json()); }, [projectId]); + const loadDatabases = useCallback(async () => { + if (!projectId.trim()) return; + setDatabaseHealth({ biz_data: null, iot_data: null }); + const response = await apiFetch( + `${config.BACKEND_URL}/api/v1/admin/projects/${projectId.trim()}/databases`, + ); + if (!response.ok) { + const errorText = await readErrorText(response); + if (isFastApiRouteNotFound(response, errorText)) { + setMetadataConfigAvailable(false); + setDatabases([]); + return; + } + throw new Error(errorText); + } + setMetadataConfigAvailable(true); + const payload = (await response.json()) as ProjectDatabaseConfig[]; + setDatabases(payload); + setDatabaseForms((current) => { + const next = createDefaultDatabaseForms(); + for (const database of payload) { + const role = database.db_role as keyof typeof defaultDatabaseForms; + if (role in next) { + next[role] = { + ...current[role], + dsn: "", + pool_min_size: database.pool_min_size, + pool_max_size: database.pool_max_size, + }; + } + } + return next; + }); + }, [projectId]); + + const loadGeoserverConfig = useCallback(async () => { + if (!projectId.trim()) return; + const response = await apiFetch( + `${config.BACKEND_URL}/api/v1/admin/projects/${projectId.trim()}/geoserver`, + ); + if (response.status === 404) { + const errorText = await readErrorText(response); + if (isFastApiRouteNotFound(response, errorText)) { + setMetadataConfigAvailable(false); + } + setGeoserverConfig(null); + setGeoserverForm(defaultGeoserverForm); + return; + } + if (!response.ok) { + throw new Error(await readErrorText(response)); + } + setMetadataConfigAvailable(true); + const payload = (await response.json()) as ProjectGeoServerConfig; + setGeoserverConfig(payload); + setGeoserverForm({ + gs_base_url: payload.gs_base_url ?? "", + gs_admin_user: payload.gs_admin_user ?? "", + gs_admin_password: "", + gs_datastore_name: payload.gs_datastore_name || "ds_postgis", + default_extent: formatJsonField(payload.default_extent), + srid: payload.srid || 4326, + }); + }, [projectId]); + useEffect(() => { let cancelled = false; @@ -297,10 +588,39 @@ export const SystemAdminPanel = () => { const usersResponse = await apiFetch(`${config.BACKEND_URL}/api/v1/admin/users`); if (!usersResponse.ok) { - throw new Error(await usersResponse.text()); + throw new Error(await readErrorText(usersResponse)); } const payload = await usersResponse.json(); if (!cancelled) setUsers(payload); + + const projectsResponse = await apiFetch(`${config.BACKEND_URL}/api/v1/admin/projects`); + if (!projectsResponse.ok) { + const errorText = await readErrorText(projectsResponse); + if (isFastApiRouteNotFound(projectsResponse, errorText)) { + if (!cancelled) { + setMetadataConfigAvailable(false); + setProjects([]); + applyProjectForm(null); + } + return; + } + throw new Error(errorText); + } + if (!cancelled) setMetadataConfigAvailable(true); + const projectsPayload = (await projectsResponse.json()) as AdminProject[]; + if (!cancelled) { + setProjects(projectsPayload); + const initialProjectId = + currentProjectId || projectsPayload[0]?.project_id || ""; + setProjectId((current) => + current.trim() || !initialProjectId ? current : initialProjectId, + ); + applyProjectForm( + projectsPayload.find( + (project) => project.project_id === initialProjectId, + ) ?? null, + ); + } } catch (err) { if (!cancelled) { setAdminChecked(true); @@ -314,7 +634,7 @@ export const SystemAdminPanel = () => { return () => { cancelled = true; }; - }, []); + }, [applyProjectForm, currentProjectId]); useEffect(() => { if (!currentProjectId || projectId.trim()) return; @@ -328,6 +648,30 @@ export const SystemAdminPanel = () => { loadMembers().catch((err) => setError(String(err))); }, [hasLoadedCurrentProjectMembers, hasProjectId, isAuthorized, loadMembers]); + useEffect(() => { + if (!selectedProject) return; + applyProjectForm(selectedProject); + }, [applyProjectForm, selectedProject]); + + useEffect(() => { + if (!isAuthorized || !hasProjectId || !metadataConfigAvailable) return; + setDatabaseHealth({ biz_data: null, iot_data: null }); + loadDatabases().catch((err) => setError(String(err))); + loadGeoserverConfig().catch((err) => setError(String(err))); + }, [ + hasProjectId, + isAuthorized, + loadDatabases, + loadGeoserverConfig, + metadataConfigAvailable, + ]); + + useEffect(() => { + if (!metadataConfigAvailable && tab !== 4) { + setTab(4); + } + }, [metadataConfigAvailable, tab]); + const updateUserActive = async (user: MetadataUser, isActive: boolean) => { setError(null); const response = await apiFetch( @@ -339,7 +683,7 @@ export const SystemAdminPanel = () => { }, ); if (!response.ok) { - setError(await response.text()); + setError(await readErrorText(response)); return; } await loadUsers(); @@ -356,7 +700,7 @@ export const SystemAdminPanel = () => { }, ); if (!response.ok) { - setError(await response.text()); + setError(await readErrorText(response)); return; } await loadUsers(); @@ -375,7 +719,7 @@ export const SystemAdminPanel = () => { }, ); if (!response.ok) { - setError(await response.text()); + setError(await readErrorText(response)); return; } setMessage("项目成员已添加"); @@ -394,7 +738,7 @@ export const SystemAdminPanel = () => { }, ); if (!response.ok) { - setError(await response.text()); + setError(await readErrorText(response)); return; } await loadMembers(); @@ -407,13 +751,257 @@ export const SystemAdminPanel = () => { { method: "DELETE" }, ); if (!response.ok) { - setError(await response.text()); + setError(await readErrorText(response)); return; } setMessage("项目成员已移除"); await loadMembers(); }; + const startNewProject = () => { + setCreateProjectForm(defaultProjectForm); + setCreateProjectOpen(true); + }; + + const selectProject = (value: string) => { + setProjectId(value); + setHasLoadedCurrentProjectMembers(false); + setMembers([]); + setDatabases([]); + setDatabaseForms(createDefaultDatabaseForms()); + setGeoserverConfig(null); + setGeoserverForm(defaultGeoserverForm); + setDatabaseHealth({ biz_data: null, iot_data: null }); + }; + + const resetDatabaseHealth = (role: keyof typeof defaultDatabaseForms) => { + setDatabaseHealth((current) => ({ ...current, [role]: null })); + }; + + const buildProjectPayload = (form: typeof defaultProjectForm) => ({ + name: form.name.trim(), + code: form.code.trim(), + description: form.description.trim() || null, + gs_workspace: form.gs_workspace.trim(), + map_extent: parseOptionalJsonObject(form.map_extent, "地图范围"), + status: form.status, + }); + + const saveProject = async (event: FormEvent) => { + event.preventDefault(); + if (!selectedProject?.project_id) { + setError("请先选择要编辑的项目。"); + return; + } + setError(null); + setMessage(null); + try { + const response = await apiFetch( + `${config.BACKEND_URL}/api/v1/admin/projects/${selectedProject.project_id}`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(buildProjectPayload(projectForm)), + }, + ); + if (!response.ok) { + 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("项目配置已更新"); + } catch (err) { + setError(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", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(buildProjectPayload(createProjectForm)), + }); + if (!response.ok) { + throw new Error(await readErrorText(response)); + } + 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("项目已创建"); + } catch (err) { + setError(String(err)); + } + }; + + const saveDatabase = async (role: keyof typeof defaultDatabaseForms) => { + if (!projectId.trim()) return; + const form = databaseForms[role]; + if (!form.dsn.trim()) { + setError("请填写新的 DSN 并通过连通性测试后再保存。"); + return; + } + if (!databaseHealth[role]?.ok) { + setError("请先通过连通性测试再保存数据库配置。"); + return; + } + setError(null); + setMessage(null); + const payload: Record = { + db_role: role, + pool_min_size: Number(form.pool_min_size), + pool_max_size: Number(form.pool_max_size), + }; + if (form.dsn.trim()) { + payload.dsn = form.dsn.trim(); + } + const response = await apiFetch( + `${config.BACKEND_URL}/api/v1/admin/projects/${projectId.trim()}/databases`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }, + ); + if (!response.ok) { + const errorText = await readErrorText(response); + if (isFastApiRouteNotFound(response, errorText)) { + setMetadataConfigAvailable(false); + return; + } + setError(errorText); + return; + } + setDatabaseForms((current) => ({ + ...current, + [role]: { ...current[role], dsn: "" }, + })); + setMessage(`${databaseRoleOptions.find((item) => item.value === role)?.label}已保存`); + await loadDatabases(); + }; + + const checkDatabaseHealth = async (role: keyof typeof defaultDatabaseForms) => { + if (!projectId.trim()) return; + setError(null); + setDatabaseHealth((current) => ({ ...current, [role]: null })); + const form = databaseForms[role]; + const body = form.dsn.trim() ? { dsn: form.dsn.trim() } : {}; + const response = await apiFetch( + `${config.BACKEND_URL}/api/v1/admin/projects/${projectId.trim()}/databases/${role}/health`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }, + ); + if (!response.ok) { + const errorText = await readErrorText(response); + if (isFastApiRouteNotFound(response, errorText)) { + setMetadataConfigAvailable(false); + return; + } + const healthPayload = parseDatabaseHealthText(errorText); + if (healthPayload) { + setDatabaseHealth((current) => ({ + ...current, + [role]: { + ...healthPayload, + detail: normalizeDatabaseHealthDetail( + healthPayload.detail, + healthPayload.ok, + ), + }, + })); + return; + } + setError(normalizeDatabaseHealthDetail(errorText, false)); + return; + } + const payload = (await response.json()) as DatabaseHealth; + setDatabaseHealth((current) => ({ + ...current, + [role]: { + ...payload, + detail: normalizeDatabaseHealthDetail(payload.detail, payload.ok), + }, + })); + }; + + const deleteDatabase = async (role: keyof typeof defaultDatabaseForms) => { + if (!projectId.trim()) return; + setError(null); + const response = await apiFetch( + `${config.BACKEND_URL}/api/v1/admin/projects/${projectId.trim()}/databases/${role}`, + { method: "DELETE" }, + ); + if (!response.ok) { + const errorText = await readErrorText(response); + if (isFastApiRouteNotFound(response, errorText)) { + setMetadataConfigAvailable(false); + return; + } + setError(errorText); + return; + } + setMessage(`${databaseRoleOptions.find((item) => item.value === role)?.label}配置已删除`); + await loadDatabases(); + }; + + const saveGeoserverConfig = async (event: FormEvent) => { + event.preventDefault(); + if (!projectId.trim()) return; + setError(null); + setMessage(null); + try { + const payload: Record = { + gs_base_url: geoserverForm.gs_base_url.trim() || null, + gs_admin_user: geoserverForm.gs_admin_user.trim() || null, + gs_datastore_name: geoserverForm.gs_datastore_name.trim() || "ds_postgis", + default_extent: parseOptionalJsonObject( + geoserverForm.default_extent, + "默认范围", + ), + srid: Number(geoserverForm.srid), + }; + if (geoserverForm.gs_admin_password.trim()) { + payload.gs_admin_password = geoserverForm.gs_admin_password.trim(); + } + const response = await apiFetch( + `${config.BACKEND_URL}/api/v1/admin/projects/${projectId.trim()}/geoserver`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }, + ); + if (!response.ok) { + const errorText = await readErrorText(response); + if (isFastApiRouteNotFound(response, errorText)) { + setMetadataConfigAvailable(false); + return; + } + throw new Error(errorText); + } + const saved = (await response.json()) as ProjectGeoServerConfig; + setGeoserverConfig(saved); + setGeoserverForm((current) => ({ ...current, gs_admin_password: "" })); + setMessage("GeoServer 配置已保存"); + await loadGeoserverConfig(); + } catch (err) { + setError(String(err)); + } + }; + return ( { 系统管理 - Keycloak 负责登录身份,metadata 负责系统角色、账号状态和项目权限 + Keycloak 负责登录身份,系统配置库负责系统角色、账号状态和项目权限 @@ -499,6 +1087,11 @@ export const SystemAdminPanel = () => { {error} )} + {isAuthorized && !metadataConfigAvailable && ( + + 后端尚未启用项目配置接口,请重启或部署包含 /api/v1/admin/projects 的后端后再使用项目配置、数据库配置和 GeoServer 配置。 + + )} {isAuthorized && ( <> @@ -532,8 +1125,71 @@ export const SystemAdminPanel = () => { value={hasProjectId ? members.length : "-"} tone="info" /> + } + label="系统项目" + value={projects.length} + tone="primary" + /> + {metadataConfigAvailable && ( + + + + + 当前管理项目 + + + {selectedProject + ? `${selectedProject.name} / ${selectedProject.code}` + : "未选择项目"} + + + + + 选择项目 + + + + + + + + )} + { }, }} > - } iconPosition="start" label="用户" /> - } iconPosition="start" label="项目成员" /> + } + iconPosition="start" + label="项目成员" + disabled={!metadataConfigAvailable} + /> + } + iconPosition="start" + label="项目配置" + disabled={!metadataConfigAvailable} + /> + } + iconPosition="start" + label="数据库配置" + disabled={!metadataConfigAvailable} + /> + } + iconPosition="start" + label="GeoServer" + disabled={!metadataConfigAvailable} + /> + } iconPosition="start" label="系统用户" /> - {tab === 0 && ( + {tab === 4 && ( } variant="outlined" onClick={loadUsers}> 刷新 @@ -679,7 +1358,7 @@ export const SystemAdminPanel = () => { )} - {tab === 1 && ( + {tab === 0 && ( { /> {!hasProjectId && ( - 当前未选择项目,请先通过顶部用户菜单切换项目。 + 当前未选择管理项目。 )} {hasProjectId && ( - - - 当前项目 - - - {projectId} - - - } - label={`${members.length} 名成员`} - sx={{ alignSelf: { xs: "flex-start", md: "center" } }} - /> + + ({ + 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} 名成员`} + /> + )} @@ -926,8 +1637,596 @@ export const SystemAdminPanel = () => { )} + {tab === 1 && ( + + + + + } + /> + {!selectedProject && ( + + 当前未选择管理项目。请在页面顶部选择项目,或使用“新建项目”创建项目。 + + )} + + + + + 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, + }) + } + disabled={!selectedProject} + fullWidth + multiline + minRows={2} + /> + + setProjectForm({ + ...projectForm, + map_extent: event.target.value, + }) + } + disabled={!selectedProject} + fullWidth + multiline + minRows={4} + placeholder='{"bbox":[120.1,30.1,120.2,30.2]}' + /> + + + + + + + )} + + {tab === 2 && ( + + } + variant="outlined" + onClick={loadDatabases} + disabled={!hasProjectId} + > + 刷新配置 + + } + /> + {!hasProjectId && ( + + 当前未选择管理项目。 + + )} + {hasProjectId && + databaseRoleOptions.map((roleOption) => { + const role = roleOption.value as keyof typeof defaultDatabaseForms; + const form = databaseForms[role]; + const configRecord = databasesByRole.get(role); + const health = databaseHealth[role]; + + return ( + + + + + + {roleOption.label} + + + {roleOption.helper} + + + + + + { + resetDatabaseHealth(role); + setDatabaseForms((current) => ({ + ...current, + [role]: { ...current[role], dsn: event.target.value }, + })); + }} + placeholder="postgresql://user:password@host:5432/db" + helperText={ + configRecord?.has_dsn + ? "填写新 DSN 后测试新连接,通过后才能保存;留空只测试已保存 DSN。" + : "首次保存前必须填写 DSN 并通过连通性测试。" + } + fullWidth + /> + { + resetDatabaseHealth(role); + setDatabaseForms((current) => ({ + ...current, + [role]: { + ...current[role], + pool_min_size: Number(event.target.value), + }, + })); + }} + sx={{ width: { xs: "100%", md: 120 } }} + /> + { + resetDatabaseHealth(role); + setDatabaseForms((current) => ({ + ...current, + [role]: { + ...current[role], + pool_max_size: Number(event.target.value), + }, + })); + }} + sx={{ width: { xs: "100%", md: 120 } }} + /> + + {health && ( + + {health.detail} + + )} + + + + + + + + ); + })} + + )} + + {tab === 3 && ( + + } + variant="outlined" + onClick={loadGeoserverConfig} + disabled={!hasProjectId} + > + 刷新配置 + + } + /> + {!hasProjectId && ( + + 当前未选择管理项目。 + + )} + {hasProjectId && ( + + + + + + {selectedProject?.name ?? projectId} + + + {selectedProject?.gs_workspace ?? "未设置工作区"} + + + + + + + setGeoserverForm({ + ...geoserverForm, + gs_base_url: event.target.value, + }) + } + fullWidth + /> + + setGeoserverForm({ + ...geoserverForm, + gs_admin_user: event.target.value, + }) + } + fullWidth + /> + + setGeoserverForm({ + ...geoserverForm, + gs_admin_password: event.target.value, + }) + } + fullWidth + /> + + + + setGeoserverForm({ + ...geoserverForm, + gs_datastore_name: event.target.value, + }) + } + fullWidth + required + /> + + setGeoserverForm({ + ...geoserverForm, + srid: Number(event.target.value), + }) + } + sx={{ width: { xs: "100%", md: 180 } }} + required + /> + + + setGeoserverForm({ + ...geoserverForm, + default_extent: event.target.value, + }) + } + fullWidth + multiline + minRows={4} + placeholder='{"bbox":[120.1,30.1,120.2,30.2]}' + /> + + + + + + )} + + )} + + setCreateProjectOpen(false)} + fullWidth + maxWidth="md" + PaperProps={{ + sx: { + borderRadius: 2, + overflow: "hidden", + }, + }} + > + + + + + ({ + width: 40, + height: 40, + borderRadius: 1.5, + display: "grid", + placeItems: "center", + color: "primary.main", + bgcolor: alpha(theme.palette.primary.main, 0.12), + flexShrink: 0, + })} + > + + + + + 新建项目 + + + 系统项目基础配置 + + + + setCreateProjectOpen(false)} + edge="end" + > + + + + + + + + + 基础信息 + + + + setCreateProjectForm({ + ...createProjectForm, + name: event.target.value, + }) + } + required + fullWidth + /> + + setCreateProjectForm({ + ...createProjectForm, + code: event.target.value, + }) + } + required + fullWidth + /> + + + + + 服务配置 + + + + setCreateProjectForm({ + ...createProjectForm, + gs_workspace: event.target.value, + }) + } + required + fullWidth + /> + + 状态 + + + + + + + 描述与范围 + + + + setCreateProjectForm({ + ...createProjectForm, + description: event.target.value, + }) + } + fullWidth + multiline + minRows={2} + /> + + setCreateProjectForm({ + ...createProjectForm, + map_extent: event.target.value, + }) + } + fullWidth + multiline + minRows={4} + placeholder='{"bbox":[120.1,30.1,120.2,30.2]}' + /> + + + + + + + + + + )}