Files
TJWaterFrontend_Refine/src/components/admin/SystemAdminPanel.tsx
T

1982 lines
75 KiB
TypeScript

"use client";
import React, { FormEvent, useCallback, useEffect, useMemo, useState } from "react";
import {
Alert,
alpha,
Box,
Button,
Chip,
CircularProgress,
Divider,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
FormControl,
IconButton,
InputLabel,
MenuItem,
Paper,
Select,
Stack,
Tab,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Tabs,
TextField,
Tooltip,
Typography,
} from "@mui/material";
import {
Add as AddIcon,
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";
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;
};
type AdminProject = {
project_id: string;
name: string;
code: string;
description?: string | null;
gs_workspace: string;
map_extent?: Record<string, unknown> | 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 DatabaseHealth = {
ok: boolean;
detail: string;
};
const businessRoleOptions = [
{ value: "admin", label: "系统管理员" },
{ value: "operator", label: "运行人员" },
{ value: "user", label: "普通用户" },
{ value: "viewer", label: "只读用户" },
];
const projectRoleLabels: Record<string, string> = {
owner: "项目负责人",
admin: "项目管理员",
member: "项目成员",
viewer: "只读成员",
};
const projectRoleOptions = [
{ value: "admin", label: projectRoleLabels.admin },
{ value: "member", label: projectRoleLabels.member },
{ 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 getBusinessRoleLabel = (role: string) =>
businessRoleOptions.find((option) => option.value === role)?.label ?? role;
const getProjectRoleLabel = (role: string) =>
projectRoleLabels[role] ?? role;
const formatJsonField = (value?: Record<string, unknown> | 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<string, unknown>;
};
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",
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) => (
<Paper
variant="outlined"
sx={(theme) => ({
...cardSx,
p: 2,
flex: "1 1 180px",
minWidth: 0,
bgcolor: alpha(theme.palette[tone].main, 0.04),
})}
>
<Stack direction="row" spacing={1.5} alignItems="center">
<Box
sx={(theme) => ({
width: 40,
height: 40,
borderRadius: 1.5,
display: "grid",
placeItems: "center",
color: `${tone}.main`,
bgcolor: alpha(theme.palette[tone].main, 0.12),
})}
>
{icon}
</Box>
<Box sx={{ minWidth: 0 }}>
<Typography variant="body2" color="text.secondary" noWrap>
{label}
</Typography>
<Typography variant="h6" fontWeight={800} lineHeight={1.2}>
{value}
</Typography>
</Box>
</Stack>
</Paper>
);
const SectionHeader = ({
title,
description,
action,
}: {
title: string;
description?: string;
action?: React.ReactNode;
}) => (
<Stack
direction={{ xs: "column", sm: "row" }}
spacing={1.5}
alignItems={{ xs: "stretch", sm: "center" }}
justifyContent="space-between"
>
<Box>
<Typography variant="subtitle1" fontWeight={800}>
{title}
</Typography>
{description && (
<Typography variant="body2" color="text.secondary">
{description}
</Typography>
)}
</Box>
{action}
</Stack>
);
const StatusChip = ({ active }: { active: boolean }) => (
<Chip
size="small"
color={active ? "success" : "default"}
icon={active ? <CheckCircleIcon /> : <BlockIcon />}
label={active ? "启用" : "禁用"}
variant={active ? "filled" : "outlined"}
sx={{ minWidth: 76, justifyContent: "flex-start" }}
/>
);
const EmptyRow = ({ colSpan, label }: { colSpan: number; label: string }) => (
<TableRow>
<TableCell colSpan={colSpan} sx={{ py: 6 }}>
<Typography align="center" color="text.secondary">
{label}
</Typography>
</TableCell>
</TableRow>
);
export const SystemAdminPanel = () => {
const currentProjectId = useProjectStore((state) => state.currentProjectId);
const [tab, setTab] = useState(0);
const [users, setUsers] = useState<MetadataUser[]>([]);
const [members, setMembers] = useState<ProjectMember[]>([]);
const [projects, setProjects] = useState<AdminProject[]>([]);
const [databases, setDatabases] = useState<ProjectDatabaseConfig[]>([]);
const [currentAdmin, setCurrentAdmin] = useState<MetadataUser | null>(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);
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [memberForm, setMemberForm] = useState({
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<string, DatabaseHealth | null>
>({
biz_data: null,
iot_data: null,
});
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 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 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 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]);
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 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);
setError(String(err));
}
}
};
void loadInitialState();
return () => {
cancelled = true;
};
}, [applyProjectForm, currentProjectId]);
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]);
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)));
}, [
hasProjectId,
isAuthorized,
loadDatabases,
metadataConfigAvailable,
]);
useEffect(() => {
if (!metadataConfigAvailable && tab !== 3) {
setTab(3);
}
}, [metadataConfigAvailable, tab]);
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 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}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ role }),
},
);
if (!response.ok) {
setError(await readErrorText(response));
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 readErrorText(response));
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 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));
return;
}
setMessage("项目成员已移除");
await loadMembers();
};
const startNewProject = () => {
setCreateProjectForm(defaultProjectForm);
setCreateProjectOpen(true);
};
const selectProject = (value: string) => {
setProjectId(value);
setHasLoadedCurrentProjectMembers(false);
setMembers([]);
setDatabases([]);
setDatabaseForms(createDefaultDatabaseForms());
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<string, unknown> = {
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();
};
return (
<Box
sx={{
minHeight: "100%",
overflow: "auto",
bgcolor: "background.default",
p: { xs: 2, md: 3 },
}}
>
<Stack spacing={2.5} sx={{ maxWidth: 1440, mx: "auto" }}>
<Paper
variant="outlined"
sx={(theme) => ({
...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),
})}
>
<Stack
direction={{ xs: "column", md: "row" }}
spacing={2}
alignItems={{ xs: "stretch", md: "center" }}
justifyContent="space-between"
>
<Stack direction="row" spacing={1.5} alignItems="center">
<Box
sx={(theme) => ({
width: 48,
height: 48,
borderRadius: 2,
display: "grid",
placeItems: "center",
color: "primary.main",
bgcolor: alpha(theme.palette.primary.main, 0.12),
})}
>
<SecurityIcon />
</Box>
<Box>
<Typography variant="h5" fontWeight={800}>
系统管理
</Typography>
<Typography variant="body2" color="text.secondary">
Keycloak 负责登录身份,系统配置库负责系统角色、账号状态和项目权限
</Typography>
</Box>
</Stack>
{adminChecked && isAuthorized && (
<Chip
color="success"
icon={<AdminPanelSettingsIcon />}
label="管理员权限已验证"
sx={{ alignSelf: { xs: "flex-start", md: "center" } }}
/>
)}
</Stack>
</Paper>
{!adminChecked && (
<Paper variant="outlined" sx={{ ...cardSx, p: 5 }}>
<Stack alignItems="center" spacing={2}>
<CircularProgress size={28} />
<Typography color="text.secondary">正在校验系统管理权限</Typography>
</Stack>
</Paper>
)}
{adminChecked && !isAuthorized && (
<Alert severity="error" sx={{ borderRadius: 2 }}>
无系统管理权限
</Alert>
)}
{message && (
<Alert severity="success" onClose={() => setMessage(null)} sx={{ borderRadius: 2 }}>
{message}
</Alert>
)}
{error && (
<Alert severity="error" onClose={() => setError(null)} sx={{ borderRadius: 2 }}>
{error}
</Alert>
)}
{isAuthorized && !metadataConfigAvailable && (
<Alert severity="warning" sx={{ borderRadius: 2 }}>
后端尚未启用项目配置接口,请重启或部署包含 /api/v1/admin/projects 的后端后再使用项目配置和数据库配置。
</Alert>
)}
{isAuthorized && (
<>
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
<StatCard
icon={<PeopleIcon />}
label="系统用户"
value={users.length}
/>
<StatCard
icon={<CheckCircleIcon />}
label="启用用户"
value={activeUsers}
tone="success"
/>
<StatCard
icon={<AdminPanelSettingsIcon />}
label="系统管理员角色"
value={systemAdminUsers}
tone="warning"
/>
<StatCard
icon={<SecurityIcon />}
label="超级管理员"
value={superUsers}
tone="info"
/>
<StatCard
icon={<GroupsIcon />}
label={hasProjectId ? "当前项目成员" : "项目成员未加载"}
value={hasProjectId ? members.length : "-"}
tone="info"
/>
<StatCard
icon={<DnsIcon />}
label="系统项目"
value={projects.length}
tone="primary"
/>
</Stack>
{metadataConfigAvailable && (
<Paper variant="outlined" sx={{ ...cardSx, p: 2 }}>
<Stack
direction={{ xs: "column", md: "row" }}
spacing={2}
alignItems={{ xs: "stretch", md: "center" }}
justifyContent="space-between"
>
<Box sx={{ minWidth: 0 }}>
<Typography variant="subtitle2" fontWeight={800}>
当前管理项目
</Typography>
<Typography variant="body2" color="text.secondary" noWrap>
{selectedProject
? `${selectedProject.name} / ${selectedProject.code}`
: "未选择项目"}
</Typography>
</Box>
<Stack direction={{ xs: "column", sm: "row" }} spacing={1}>
<FormControl size="small" sx={{ minWidth: { xs: "100%", sm: 280 } }}>
<InputLabel>选择项目</InputLabel>
<Select
label="选择项目"
value={selectedProject?.project_id ?? ""}
onChange={(event) => selectProject(event.target.value)}
>
{projects.length === 0 && (
<MenuItem value="" disabled>
暂无项目
</MenuItem>
)}
{projects.map((project) => (
<MenuItem key={project.project_id} value={project.project_id}>
{project.name} / {project.code}
</MenuItem>
))}
</Select>
</FormControl>
<Button
startIcon={<RefreshIcon />}
variant="outlined"
onClick={() => loadProjects()}
>
刷新项目
</Button>
<Button
startIcon={<AddIcon />}
variant="contained"
onClick={startNewProject}
>
新建项目
</Button>
</Stack>
</Stack>
</Paper>
)}
<Paper variant="outlined" sx={{ ...cardSx, overflow: "hidden" }}>
<Tabs
value={tab}
onChange={(_, value) => setTab(value)}
variant="scrollable"
scrollButtons="auto"
sx={{
px: 2,
minHeight: 56,
borderBottom: 1,
borderColor: "divider",
"& .MuiTab-root": {
minHeight: 56,
textTransform: "none",
fontWeight: 700,
},
}}
>
<Tab
icon={<GroupsIcon />}
iconPosition="start"
label="项目成员"
disabled={!metadataConfigAvailable}
/>
<Tab
icon={<DnsIcon />}
iconPosition="start"
label="项目配置"
disabled={!metadataConfigAvailable}
/>
<Tab
icon={<StorageIcon />}
iconPosition="start"
label="数据库配置"
disabled={!metadataConfigAvailable}
/>
<Tab icon={<PeopleIcon />} iconPosition="start" label="系统用户" />
</Tabs>
<Box sx={{ p: { xs: 2, md: 2.5 } }}>
{tab === 3 && (
<Stack spacing={2}>
<SectionHeader
title="系统用户"
description="Keycloak 信息只读展示;这里维护系统角色和启用状态。超级管理员由后台初始化或受控脚本设置,不在页面修改。"
action={
<Button startIcon={<RefreshIcon />} variant="outlined" onClick={loadUsers}>
刷新
</Button>
}
/>
<TableContainer component={Paper} variant="outlined" sx={{ borderRadius: 2 }}>
<Table size="small" sx={tableSx}>
<TableHead>
<TableRow>
<TableCell>用户身份</TableCell>
<TableCell>系统角色</TableCell>
<TableCell>超级权限</TableCell>
<TableCell>状态</TableCell>
<TableCell align="right">操作</TableCell>
</TableRow>
</TableHead>
<TableBody>
{users.length === 0 && <EmptyRow colSpan={5} label="暂无用户数据" />}
{users.map((user) => {
const isSelf = user.id === currentAdmin?.id;
const protectedUserReason = isSelf
? "不能操作当前登录用户"
: user.is_superuser
? "超级管理员权限独立于系统角色,需后台设置"
: "";
return (
<TableRow key={user.id} hover>
<TableCell>
<Stack spacing={0.25}>
<Stack direction="row" spacing={1} alignItems="center">
<Typography fontWeight={700}>{user.username}</Typography>
{isSelf && (
<Chip size="small" color="info" label="当前用户" />
)}
</Stack>
<Typography variant="body2" color="text.secondary">
{user.email}
</Typography>
<Typography variant="caption" color="text.secondary">
{user.keycloak_id}
</Typography>
</Stack>
</TableCell>
<TableCell>
<Tooltip title={protectedUserReason}>
<span>
<FormControl
size="small"
sx={{ minWidth: 150 }}
disabled={user.is_superuser || isSelf}
>
<Select
value={user.role}
onChange={(e) => updateUserRole(user, e.target.value)}
renderValue={(value) =>
getBusinessRoleLabel(String(value))
}
>
{businessRoleOptions.map((role) => (
<MenuItem key={role.value} value={role.value}>
{role.label}
</MenuItem>
))}
</Select>
</FormControl>
</span>
</Tooltip>
</TableCell>
<TableCell>
{user.is_superuser ? (
<Chip
size="small"
color="warning"
icon={<SecurityIcon />}
label="超级管理员"
/>
) : (
<Chip size="small" label="无" variant="outlined" />
)}
</TableCell>
<TableCell>
<StatusChip active={user.is_active} />
</TableCell>
<TableCell align="right">
<Tooltip
title={
isSelf
? "不能操作当前登录用户"
: user.is_superuser
? "超级管理员不可禁用"
: ""
}
>
<span>
<Button
size="small"
variant="outlined"
color={user.is_active ? "warning" : "success"}
onClick={() => updateUserActive(user, !user.is_active)}
disabled={user.is_superuser || isSelf}
>
{user.is_active ? "禁用" : "启用"}
</Button>
</span>
</Tooltip>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
</Stack>
)}
{tab === 0 && (
<Stack spacing={2}>
<SectionHeader
title="项目成员"
description="维护当前项目内权限;此处只管理项目管理员、项目成员和只读成员,项目负责人另行维护。"
action={
<Button
variant="outlined"
startIcon={<RefreshIcon />}
onClick={loadMembers}
disabled={!hasProjectId}
>
刷新成员
</Button>
}
/>
{!hasProjectId && (
<Alert severity="warning" sx={{ borderRadius: 2 }}>
当前未选择管理项目。
</Alert>
)}
{hasProjectId && (
<Paper variant="outlined" sx={{ p: 2, borderRadius: 2 }}>
<Stack
direction={{ xs: "column", md: "row" }}
spacing={2}
alignItems={{ xs: "stretch", md: "center" }}
justifyContent="space-between"
>
<Stack direction="row" spacing={1.5} alignItems="center" sx={{ minWidth: 0 }}>
<Box
sx={(theme) => ({
width: 40,
height: 40,
borderRadius: 1.5,
display: "grid",
placeItems: "center",
color: "primary.main",
bgcolor: alpha(theme.palette.primary.main, 0.12),
flexShrink: 0,
})}
>
<DnsIcon />
</Box>
<Box sx={{ minWidth: 0 }}>
<Typography variant="caption" color="text.secondary">
当前管理项目
</Typography>
<Typography variant="subtitle1" fontWeight={800} noWrap>
{selectedProject?.name ?? "未命名项目"}
</Typography>
<Typography variant="body2" color="text.secondary" noWrap>
{selectedProject
? `${selectedProject.code} · ${projectId}`
: projectId}
</Typography>
</Box>
</Stack>
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
{selectedProject && (
<Chip
color={selectedProject.status === "active" ? "success" : "default"}
label={
projectStatusOptions.find(
(option) => option.value === selectedProject.status,
)?.label ?? selectedProject.status
}
/>
)}
<Chip
color="info"
icon={<GroupsIcon />}
label={`${members.length} 名成员`}
/>
</Stack>
</Stack>
</Paper>
)}
<Paper
component="form"
variant="outlined"
sx={{ p: 2, borderRadius: 2, bgcolor: "action.hover" }}
onSubmit={addMember}
>
<Stack spacing={1.5}>
<Typography variant="subtitle2" fontWeight={800}>
添加成员
</Typography>
<Stack
direction="row"
spacing={1.5}
useFlexGap
flexWrap="wrap"
alignItems="center"
>
<FormControl
size="small"
disabled={!hasProjectId}
sx={{
width: { xs: "100%", sm: 320, md: 360 },
flexShrink: 0,
}}
>
<InputLabel>选择用户</InputLabel>
<Select
label="选择用户"
value={memberForm.user_id}
onChange={(e) =>
setMemberForm({ ...memberForm, user_id: e.target.value })
}
renderValue={() => selectedMemberUser?.username ?? ""}
required
>
{availableMemberUsers.length === 0 && (
<MenuItem value="" disabled>
暂无可添加用户
</MenuItem>
)}
{availableMemberUsers.map((user) => (
<MenuItem key={user.id} value={user.id}>
<Stack spacing={0.25} sx={{ minWidth: 0 }}>
<Typography variant="body2" fontWeight={700} noWrap>
{user.username}
</Typography>
<Typography
variant="caption"
color="text.secondary"
noWrap
>
{user.email}
</Typography>
</Stack>
</MenuItem>
))}
</Select>
</FormControl>
<FormControl
size="small"
sx={{ width: { xs: "calc(50% - 6px)", sm: 160 } }}
disabled={!hasProjectId}
>
<InputLabel>项目角色</InputLabel>
<Select
label="项目角色"
value={memberForm.project_role}
onChange={(e) =>
setMemberForm({ ...memberForm, project_role: e.target.value })
}
>
{projectRoleOptions.map((role) => (
<MenuItem key={role.value} value={role.value}>
{role.label}
</MenuItem>
))}
</Select>
</FormControl>
<Button
type="submit"
variant="contained"
startIcon={<AddIcon />}
disabled={!hasProjectId || !memberForm.user_id}
sx={{
width: { xs: "calc(50% - 6px)", sm: "auto" },
minWidth: 120,
}}
>
添加成员
</Button>
</Stack>
</Stack>
</Paper>
<TableContainer component={Paper} variant="outlined" sx={{ borderRadius: 2 }}>
<Table size="small" sx={tableSx}>
<TableHead>
<TableRow>
<TableCell>用户</TableCell>
<TableCell>邮箱</TableCell>
<TableCell>项目角色</TableCell>
<TableCell>状态</TableCell>
<TableCell align="right">操作</TableCell>
</TableRow>
</TableHead>
<TableBody>
{members.length === 0 && (
<EmptyRow colSpan={5} label="暂无项目成员数据" />
)}
{members.map((member) => {
const isSelf = member.user_id === currentAdmin?.id;
const isOwner = member.project_role === "owner";
return (
<TableRow key={member.id} hover>
<TableCell>
<Stack spacing={0.25}>
<Stack direction="row" spacing={1} alignItems="center">
<Typography fontWeight={700}>{member.username}</Typography>
{isSelf && (
<Chip size="small" color="info" label="当前用户" />
)}
</Stack>
<Typography variant="caption" color="text.secondary">
{member.user_id}
</Typography>
</Stack>
</TableCell>
<TableCell>{member.email}</TableCell>
<TableCell>
{isOwner ? (
<Tooltip title="项目负责人不在系统管理页维护">
<Chip
size="small"
color="warning"
label={getProjectRoleLabel(member.project_role)}
/>
</Tooltip>
) : (
<Tooltip title={isSelf ? "不能操作当前登录用户" : ""}>
<span>
<FormControl
size="small"
sx={{ minWidth: 140 }}
disabled={isSelf}
>
<Select
value={member.project_role}
onChange={(e) =>
updateMemberRole(member, e.target.value)
}
renderValue={(value) =>
getProjectRoleLabel(String(value))
}
>
{projectRoleOptions.map((role) => (
<MenuItem key={role.value} value={role.value}>
{role.label}
</MenuItem>
))}
</Select>
</FormControl>
</span>
</Tooltip>
)}
</TableCell>
<TableCell>
<StatusChip active={member.is_active} />
</TableCell>
<TableCell align="right">
<Tooltip
title={
isSelf
? "不能操作当前登录用户"
: isOwner
? "项目负责人不在系统管理页维护"
: ""
}
>
<span>
<Button
size="small"
color="error"
variant="outlined"
startIcon={<RemoveCircleOutlineIcon />}
onClick={() => removeMember(member)}
disabled={isSelf || isOwner}
>
移除
</Button>
</span>
</Tooltip>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
</Stack>
)}
{tab === 1 && (
<Stack spacing={2}>
<SectionHeader
title="项目配置"
description="维护项目基础信息、GeoServer 工作区、地图范围和项目状态。"
action={
<Stack direction="row" spacing={1}>
<Button startIcon={<RefreshIcon />} variant="outlined" onClick={() => loadProjects()}>
刷新
</Button>
</Stack>
}
/>
{!selectedProject && (
<Alert severity="warning" sx={{ borderRadius: 2 }}>
当前未选择管理项目。请在页面顶部选择项目,或使用“新建项目”创建项目。
</Alert>
)}
<Paper
component="form"
variant="outlined"
sx={{ p: 2, borderRadius: 2 }}
onSubmit={saveProject}
>
<Stack spacing={2}>
<Stack direction={{ xs: "column", md: "row" }} spacing={1.5}>
<TextField
size="small"
label="项目名称"
value={projectForm.name}
onChange={(event) =>
setProjectForm({ ...projectForm, name: event.target.value })
}
disabled={!selectedProject}
required
fullWidth
/>
<TextField
size="small"
label="项目代码"
value={projectForm.code}
onChange={(event) =>
setProjectForm({ ...projectForm, code: event.target.value })
}
disabled={!selectedProject}
required
fullWidth
/>
<TextField
size="small"
label="GeoServer 工作区"
value={projectForm.gs_workspace}
onChange={(event) =>
setProjectForm({
...projectForm,
gs_workspace: event.target.value,
})
}
disabled={!selectedProject}
required
fullWidth
/>
<FormControl size="small" sx={{ minWidth: 140 }} disabled={!selectedProject}>
<InputLabel>状态</InputLabel>
<Select
label="状态"
value={projectForm.status}
onChange={(event) =>
setProjectForm({
...projectForm,
status: event.target.value,
})
}
>
{projectStatusOptions.map((option) => (
<MenuItem key={option.value} value={option.value}>
{option.label}
</MenuItem>
))}
</Select>
</FormControl>
</Stack>
<TextField
size="small"
label="描述"
value={projectForm.description}
onChange={(event) =>
setProjectForm({
...projectForm,
description: event.target.value,
})
}
disabled={!selectedProject}
fullWidth
multiline
minRows={2}
/>
<TextField
size="small"
label="地图范围 JSON"
value={projectForm.map_extent}
onChange={(event) =>
setProjectForm({
...projectForm,
map_extent: event.target.value,
})
}
disabled={!selectedProject}
fullWidth
multiline
minRows={4}
placeholder='{"bbox":[120.1,30.1,120.2,30.2]}'
/>
<Stack direction="row" justifyContent="flex-end">
<Button
type="submit"
variant="contained"
startIcon={<SaveIcon />}
disabled={!selectedProject}
>
保存项目
</Button>
</Stack>
</Stack>
</Paper>
</Stack>
)}
{tab === 2 && (
<Stack spacing={2}>
<SectionHeader
title="项目数据库配置"
description="配置当前管理项目的 biz_data 和 iot_data 路由;填写或修改后先测试连通性,通过后才能保存。"
action={
<Button
startIcon={<RefreshIcon />}
variant="outlined"
onClick={loadDatabases}
disabled={!hasProjectId}
>
刷新配置
</Button>
}
/>
{!hasProjectId && (
<Alert severity="warning" sx={{ borderRadius: 2 }}>
当前未选择管理项目。
</Alert>
)}
{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 (
<Paper key={role} variant="outlined" sx={{ p: 2, borderRadius: 2 }}>
<Stack spacing={2}>
<Stack
direction={{ xs: "column", md: "row" }}
spacing={1}
alignItems={{ xs: "stretch", md: "center" }}
justifyContent="space-between"
>
<Box>
<Typography variant="subtitle2" fontWeight={800}>
{roleOption.label}
</Typography>
<Typography variant="body2" color="text.secondary">
{roleOption.helper}
</Typography>
</Box>
<Chip
color={configRecord?.has_dsn ? "success" : "default"}
label={configRecord?.has_dsn ? "DSN 已配置" : "未配置 DSN"}
sx={{ alignSelf: { xs: "flex-start", md: "center" } }}
/>
</Stack>
<Stack direction={{ xs: "column", md: "row" }} spacing={1.5}>
<TextField
size="small"
type="password"
label={configRecord?.has_dsn ? "替换 DSN" : "DSN"}
value={form.dsn}
onChange={(event) => {
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
/>
<TextField
size="small"
type="number"
label="最小连接"
value={form.pool_min_size}
onChange={(event) => {
resetDatabaseHealth(role);
setDatabaseForms((current) => ({
...current,
[role]: {
...current[role],
pool_min_size: Number(event.target.value),
},
}));
}}
sx={{ width: { xs: "100%", md: 120 } }}
/>
<TextField
size="small"
type="number"
label="最大连接"
value={form.pool_max_size}
onChange={(event) => {
resetDatabaseHealth(role);
setDatabaseForms((current) => ({
...current,
[role]: {
...current[role],
pool_max_size: Number(event.target.value),
},
}));
}}
sx={{ width: { xs: "100%", md: 120 } }}
/>
</Stack>
{health && (
<Alert
severity={health.ok ? "success" : "error"}
sx={{ borderRadius: 2 }}
>
{health.detail}
</Alert>
)}
<Stack direction="row" spacing={1} justifyContent="flex-end">
<Button
variant="outlined"
onClick={() => checkDatabaseHealth(role)}
disabled={!configRecord?.has_dsn && !form.dsn.trim()}
>
测试连通性
</Button>
<Button
variant="outlined"
color="error"
onClick={() => deleteDatabase(role)}
disabled={!configRecord?.has_dsn}
>
删除
</Button>
<Button
variant="contained"
startIcon={<SaveIcon />}
onClick={() => saveDatabase(role)}
disabled={!form.dsn.trim() || !health?.ok}
>
保存
</Button>
</Stack>
</Stack>
</Paper>
);
})}
</Stack>
)}
</Box>
</Paper>
<Dialog
open={createProjectOpen}
onClose={() => setCreateProjectOpen(false)}
fullWidth
maxWidth="md"
PaperProps={{
sx: {
borderRadius: 2,
overflow: "hidden",
},
}}
>
<Box component="form" onSubmit={createProject}>
<DialogTitle sx={{ px: 3, py: 2 }}>
<Stack direction="row" alignItems="center" justifyContent="space-between">
<Stack direction="row" spacing={1.5} alignItems="center" sx={{ minWidth: 0 }}>
<Box
sx={(theme) => ({
width: 40,
height: 40,
borderRadius: 1.5,
display: "grid",
placeItems: "center",
color: "primary.main",
bgcolor: alpha(theme.palette.primary.main, 0.12),
flexShrink: 0,
})}
>
<DnsIcon />
</Box>
<Box sx={{ minWidth: 0 }}>
<Typography variant="h6" fontWeight={800} lineHeight={1.2}>
新建项目
</Typography>
<Typography variant="body2" color="text.secondary" noWrap>
系统项目基础配置
</Typography>
</Box>
</Stack>
<IconButton
aria-label="关闭"
onClick={() => setCreateProjectOpen(false)}
edge="end"
>
<CloseIcon />
</IconButton>
</Stack>
</DialogTitle>
<DialogContent dividers sx={{ px: 3, py: 2.5 }}>
<Stack spacing={2.5}>
<Box>
<Typography variant="subtitle2" fontWeight={800} sx={{ mb: 1.5 }}>
基础信息
</Typography>
<Stack direction={{ xs: "column", md: "row" }} spacing={1.5}>
<TextField
size="small"
label="项目名称"
value={createProjectForm.name}
onChange={(event) =>
setCreateProjectForm({
...createProjectForm,
name: event.target.value,
})
}
required
fullWidth
/>
<TextField
size="small"
label="项目代码"
value={createProjectForm.code}
onChange={(event) =>
setCreateProjectForm({
...createProjectForm,
code: event.target.value,
})
}
required
fullWidth
/>
</Stack>
</Box>
<Box>
<Typography variant="subtitle2" fontWeight={800} sx={{ mb: 1.5 }}>
服务配置
</Typography>
<Stack direction={{ xs: "column", md: "row" }} spacing={1.5}>
<TextField
size="small"
label="GeoServer 工作区"
value={createProjectForm.gs_workspace}
onChange={(event) =>
setCreateProjectForm({
...createProjectForm,
gs_workspace: event.target.value,
})
}
required
fullWidth
/>
<FormControl size="small" sx={{ minWidth: { xs: "100%", md: 160 } }}>
<InputLabel>状态</InputLabel>
<Select
label="状态"
value={createProjectForm.status}
onChange={(event) =>
setCreateProjectForm({
...createProjectForm,
status: event.target.value,
})
}
>
{projectStatusOptions.map((option) => (
<MenuItem key={option.value} value={option.value}>
{option.label}
</MenuItem>
))}
</Select>
</FormControl>
</Stack>
</Box>
<Box>
<Typography variant="subtitle2" fontWeight={800} sx={{ mb: 1.5 }}>
描述与范围
</Typography>
<Stack spacing={1.5}>
<TextField
size="small"
label="描述"
value={createProjectForm.description}
onChange={(event) =>
setCreateProjectForm({
...createProjectForm,
description: event.target.value,
})
}
fullWidth
multiline
minRows={2}
/>
<TextField
size="small"
label="地图范围 JSON"
value={createProjectForm.map_extent}
onChange={(event) =>
setCreateProjectForm({
...createProjectForm,
map_extent: event.target.value,
})
}
fullWidth
multiline
minRows={4}
placeholder='{"bbox":[120.1,30.1,120.2,30.2]}'
/>
</Stack>
</Box>
</Stack>
</DialogContent>
<DialogActions sx={{ px: 3, py: 2, bgcolor: "action.hover" }}>
<Button onClick={() => setCreateProjectOpen(false)}>取消</Button>
<Button type="submit" variant="contained" startIcon={<SaveIcon />}>
创建项目
</Button>
</DialogActions>
</Box>
</Dialog>
</>
)}
</Stack>
</Box>
);
};