The previous fix only prioritized authStore inside the route guard, so an unauthenticated NextAuth session or a suppressed access-context 401 still collapsed into an empty permission set. Propagate both authentication signals before authorization so expired sessions consistently reauthenticate.
1837 lines
65 KiB
TypeScript
1837 lines
65 KiB
TypeScript
"use client";
|
||
|
||
import React, {
|
||
FormEvent,
|
||
useCallback,
|
||
useEffect,
|
||
useMemo,
|
||
useRef,
|
||
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 { useNotification } from "@refinedev/core";
|
||
import { config } from "@config/config";
|
||
import { apiFetch } from "@/lib/apiFetch";
|
||
import { useSessionRecoveryDraft } from "@/lib/sessionRecoveryDraft";
|
||
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: "user", label: "普通用户" },
|
||
];
|
||
|
||
const projectRoleLabels: Record<string, string> = {
|
||
member: "项目成员",
|
||
viewer: "只读成员",
|
||
};
|
||
|
||
const projectRoleOptions = [
|
||
{ value: "member", label: projectRoleLabels.member },
|
||
{ value: "viewer", label: projectRoleLabels.viewer },
|
||
];
|
||
|
||
const projectRolePermissionSummary = [
|
||
{
|
||
role: "项目成员",
|
||
permissions: "WebGIS 编辑、SCADA 清洗、水力模拟、爆管、风险和监测点优化分析",
|
||
},
|
||
{
|
||
role: "只读成员",
|
||
permissions: "默认工作台、WebGIS、SCADA 和既有模拟结果只读",
|
||
},
|
||
];
|
||
|
||
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 与实时数据" },
|
||
] as const;
|
||
|
||
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,
|
||
},
|
||
};
|
||
|
||
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<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,
|
||
minWidth: 0,
|
||
boxShadow: "none",
|
||
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" lineHeight={1.2}>
|
||
{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>
|
||
);
|
||
|
||
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 (
|
||
<Stack spacing={2}>
|
||
<Stack direction={{ xs: "column", md: "row" }} spacing={1.5}>
|
||
<TextField
|
||
size="small"
|
||
label="项目名称"
|
||
value={value.name}
|
||
onChange={(event) => updateField("name", event.target.value)}
|
||
disabled={disabled}
|
||
required
|
||
fullWidth
|
||
/>
|
||
<TextField
|
||
size="small"
|
||
label="项目代码"
|
||
value={value.code}
|
||
onChange={(event) => updateField("code", event.target.value)}
|
||
disabled={disabled}
|
||
required
|
||
fullWidth
|
||
/>
|
||
</Stack>
|
||
<Stack direction={{ xs: "column", md: "row" }} spacing={1.5}>
|
||
<TextField
|
||
size="small"
|
||
label="GeoServer 工作区"
|
||
value={value.gs_workspace}
|
||
onChange={(event) => updateField("gs_workspace", event.target.value)}
|
||
disabled={disabled}
|
||
required
|
||
fullWidth
|
||
/>
|
||
<FormControl
|
||
size="small"
|
||
sx={{ minWidth: { xs: "100%", md: 160 } }}
|
||
disabled={disabled}
|
||
>
|
||
<InputLabel>状态</InputLabel>
|
||
<Select
|
||
label="状态"
|
||
value={value.status}
|
||
onChange={(event) => updateField("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={value.description}
|
||
onChange={(event) => updateField("description", event.target.value)}
|
||
disabled={disabled}
|
||
fullWidth
|
||
multiline
|
||
minRows={2}
|
||
/>
|
||
<TextField
|
||
size="small"
|
||
label="地图范围 JSON"
|
||
value={value.map_extent}
|
||
onChange={(event) => updateField("map_extent", event.target.value)}
|
||
disabled={disabled}
|
||
fullWidth
|
||
multiline
|
||
minRows={4}
|
||
placeholder='{"bbox":[120.1,30.1,120.2,30.2]}'
|
||
/>
|
||
</Stack>
|
||
);
|
||
};
|
||
|
||
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<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 [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(createEmptyDatabaseHealth);
|
||
|
||
const recoveryDraft = useMemo(
|
||
() => ({ tab, projectId, memberForm, projectForm, createProjectOpen, createProjectForm }),
|
||
[createProjectForm, createProjectOpen, memberForm, projectForm, projectId, tab],
|
||
);
|
||
const restoreRecoveryDraft = useCallback((draft: typeof recoveryDraft) => {
|
||
setTab(draft.tab);
|
||
setProjectId(draft.projectId);
|
||
setMemberForm(draft.memberForm);
|
||
setProjectForm(draft.projectForm);
|
||
setCreateProjectOpen(draft.createProjectOpen);
|
||
setCreateProjectForm(draft.createProjectForm);
|
||
}, []);
|
||
useSessionRecoveryDraft("system-admin", recoveryDraft, restoreRecoveryDraft);
|
||
|
||
useEffect(() => {
|
||
openNotificationRef.current = openNotification;
|
||
}, [openNotification]);
|
||
|
||
const notifySuccess = useCallback(
|
||
(message: string) => {
|
||
openNotificationRef.current?.({ type: "success", message });
|
||
},
|
||
[],
|
||
);
|
||
|
||
const notifyError = useCallback(
|
||
(message: string) => {
|
||
openNotificationRef.current?.({ type: "error", message });
|
||
},
|
||
[],
|
||
);
|
||
|
||
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(
|
||
() => 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([]);
|
||
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);
|
||
}
|
||
}, [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(createEmptyDatabaseHealth());
|
||
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/users/me`, {
|
||
projectHeaderMode: "omit",
|
||
});
|
||
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([]);
|
||
}
|
||
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,
|
||
);
|
||
}
|
||
} catch (err) {
|
||
if (!cancelled) {
|
||
setAdminChecked(true);
|
||
notifyError(String(err));
|
||
}
|
||
}
|
||
};
|
||
|
||
void loadInitialState();
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [currentProjectId, notifyError]);
|
||
|
||
useEffect(() => {
|
||
if (!currentProjectId || projectId.trim()) return;
|
||
setProjectId(currentProjectId);
|
||
}, [currentProjectId, projectId]);
|
||
|
||
useEffect(() => {
|
||
if (!isAuthorized || !hasProjectId) return;
|
||
loadMembers().catch((err) => notifyError(String(err)));
|
||
}, [hasProjectId, isAuthorized, loadMembers, notifyError]);
|
||
|
||
useEffect(() => {
|
||
applyProjectForm(selectedProject ?? null);
|
||
}, [applyProjectForm, selectedProject]);
|
||
|
||
useEffect(() => {
|
||
if (!isAuthorized || !hasProjectId || !metadataConfigAvailable) return;
|
||
loadDatabases().catch((err) => notifyError(String(err)));
|
||
}, [
|
||
hasProjectId,
|
||
isAuthorized,
|
||
loadDatabases,
|
||
metadataConfigAvailable,
|
||
notifyError,
|
||
]);
|
||
|
||
useEffect(() => {
|
||
if (!metadataConfigAvailable && tab !== 3) {
|
||
setTab(3);
|
||
}
|
||
}, [metadataConfigAvailable, tab]);
|
||
|
||
const updateUserActive = async (user: MetadataUser, isActive: boolean) => {
|
||
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) {
|
||
notifyError(await readErrorText(response));
|
||
return;
|
||
}
|
||
await loadUsers();
|
||
};
|
||
|
||
const updateUserRole = async (user: MetadataUser, role: string) => {
|
||
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) {
|
||
notifyError(await readErrorText(response));
|
||
return;
|
||
}
|
||
await loadUsers();
|
||
};
|
||
|
||
const addMember = async (event: FormEvent) => {
|
||
event.preventDefault();
|
||
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) {
|
||
notifyError(await readErrorText(response));
|
||
return;
|
||
}
|
||
notifySuccess("项目成员已添加");
|
||
setMemberForm((prev) => ({ ...prev, user_id: "" }));
|
||
await loadMembers();
|
||
};
|
||
|
||
const updateMemberRole = async (member: ProjectMember, projectRole: string) => {
|
||
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) {
|
||
notifyError(await readErrorText(response));
|
||
return;
|
||
}
|
||
await loadMembers();
|
||
};
|
||
|
||
const removeMember = async (member: ProjectMember) => {
|
||
const response = await apiFetch(
|
||
`${config.BACKEND_URL}/api/v1/admin/projects/${member.project_id}/members/${member.user_id}`,
|
||
{ method: "DELETE" },
|
||
);
|
||
if (!response.ok) {
|
||
notifyError(await readErrorText(response));
|
||
return;
|
||
}
|
||
notifySuccess("项目成员已移除");
|
||
await loadMembers();
|
||
};
|
||
|
||
const startNewProject = () => {
|
||
setCreateProjectForm(defaultProjectForm);
|
||
setCreateProjectOpen(true);
|
||
};
|
||
|
||
const selectProject = (value: string) => {
|
||
setProjectId(value);
|
||
setMembers([]);
|
||
setDatabases([]);
|
||
setDatabaseForms(createDefaultDatabaseForms());
|
||
setDatabaseHealth(createEmptyDatabaseHealth());
|
||
};
|
||
|
||
const updateDatabaseForm = <Field extends keyof DatabaseFormState>(
|
||
role: DatabaseRole,
|
||
field: Field,
|
||
value: DatabaseFormState[Field],
|
||
) => {
|
||
setDatabaseHealth((current) => ({ ...current, [role]: null }));
|
||
setDatabaseForms((current) => ({
|
||
...current,
|
||
[role]: { ...current[role], [field]: value },
|
||
}));
|
||
};
|
||
|
||
const buildProjectPayload = (form: ProjectFormState) => ({
|
||
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) {
|
||
notifyError("请先选择要编辑的项目。");
|
||
return;
|
||
}
|
||
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;
|
||
setProjectId(saved.project_id);
|
||
await loadProjects(saved.project_id);
|
||
notifySuccess("项目配置已更新");
|
||
} catch (err) {
|
||
notifyError(String(err));
|
||
}
|
||
};
|
||
|
||
const createProject = async (event: FormEvent) => {
|
||
event.preventDefault();
|
||
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);
|
||
setProjectId(saved.project_id);
|
||
await loadProjects(saved.project_id);
|
||
notifySuccess("项目已创建");
|
||
} catch (err) {
|
||
notifyError(String(err));
|
||
}
|
||
};
|
||
|
||
const saveDatabase = async (role: DatabaseRole) => {
|
||
if (!projectId.trim()) return;
|
||
const form = databaseForms[role];
|
||
if (!form.dsn.trim()) {
|
||
notifyError("请填写新的 DSN 并通过连通性测试后再保存。");
|
||
return;
|
||
}
|
||
if (!databaseHealth[role]?.ok) {
|
||
notifyError("请先通过连通性测试再保存数据库配置。");
|
||
return;
|
||
}
|
||
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;
|
||
}
|
||
notifyError(errorText);
|
||
return;
|
||
}
|
||
setDatabaseForms((current) => ({
|
||
...current,
|
||
[role]: { ...current[role], dsn: "" },
|
||
}));
|
||
notifySuccess(`${getDatabaseRoleLabel(role)}已保存`);
|
||
await loadDatabases();
|
||
};
|
||
|
||
const checkDatabaseHealth = async (role: DatabaseRole) => {
|
||
if (!projectId.trim()) return;
|
||
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-checks`,
|
||
{
|
||
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;
|
||
}
|
||
notifyError(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: DatabaseRole) => {
|
||
if (!projectId.trim()) return;
|
||
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;
|
||
}
|
||
notifyError(errorText);
|
||
return;
|
||
}
|
||
notifySuccess(`${getDatabaseRoleLabel(role)}配置已删除`);
|
||
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="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),
|
||
flexShrink: 0,
|
||
})}
|
||
>
|
||
<SecurityIcon />
|
||
</Box>
|
||
<Box>
|
||
<Typography variant="h5" fontWeight={800}>
|
||
系统管理
|
||
</Typography>
|
||
<Typography variant="body2" color="text.secondary">
|
||
管理系统用户、项目权限和数据库连接
|
||
</Typography>
|
||
</Box>
|
||
</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>
|
||
)}
|
||
{isAuthorized && !metadataConfigAvailable && (
|
||
<Alert severity="warning" sx={{ borderRadius: 2 }}>
|
||
后端尚未启用项目配置接口,请重启或部署包含 /api/v1/admin/projects 的后端后再使用项目配置和数据库配置。
|
||
</Alert>
|
||
)}
|
||
|
||
{isAuthorized && (
|
||
<>
|
||
<Box
|
||
sx={{
|
||
display: "grid",
|
||
gridTemplateColumns: {
|
||
xs: "repeat(2, minmax(0, 1fr))",
|
||
md: "repeat(3, minmax(0, 1fr))",
|
||
xl: "repeat(6, minmax(0, 1fr))",
|
||
},
|
||
gap: 2,
|
||
}}
|
||
>
|
||
<StatCard
|
||
icon={<PeopleIcon />}
|
||
label="系统用户"
|
||
value={users.length}
|
||
/>
|
||
<StatCard
|
||
icon={<CheckCircleIcon />}
|
||
label="启用用户"
|
||
value={userStats.active}
|
||
tone="success"
|
||
/>
|
||
<StatCard
|
||
icon={<AdminPanelSettingsIcon />}
|
||
label="系统管理员"
|
||
value={userStats.systemAdmins}
|
||
tone="warning"
|
||
/>
|
||
<StatCard
|
||
icon={<SecurityIcon />}
|
||
label="超级管理员"
|
||
value={userStats.superusers}
|
||
tone="info"
|
||
/>
|
||
<StatCard
|
||
icon={<GroupsIcon />}
|
||
label="当前项目成员"
|
||
value={hasProjectId ? members.length : "-"}
|
||
tone="info"
|
||
/>
|
||
<StatCard
|
||
icon={<DnsIcon />}
|
||
label="系统项目"
|
||
value={projects.length}
|
||
tone="primary"
|
||
/>
|
||
</Box>
|
||
|
||
{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="项目访问角色保存在 Metadata DB 的 user_project_membership.project_role。模型上传由桌面端发起,仅系统管理员可执行。"
|
||
action={
|
||
<Button
|
||
variant="outlined"
|
||
startIcon={<RefreshIcon />}
|
||
onClick={loadMembers}
|
||
disabled={!hasProjectId}
|
||
>
|
||
刷新成员
|
||
</Button>
|
||
}
|
||
/>
|
||
<Paper variant="outlined" sx={{ p: 2, borderRadius: 2 }}>
|
||
<Typography variant="subtitle2" fontWeight={800} sx={{ mb: 1 }}>
|
||
固定项目权限
|
||
</Typography>
|
||
<Stack
|
||
direction={{ xs: "column", md: "row" }}
|
||
spacing={1}
|
||
useFlexGap
|
||
flexWrap="wrap"
|
||
>
|
||
{projectRolePermissionSummary.map((item) => (
|
||
<Chip
|
||
key={item.role}
|
||
label={`${item.role}:${item.permissions}`}
|
||
variant="outlined"
|
||
/>
|
||
))}
|
||
</Stack>
|
||
</Paper>
|
||
{!hasProjectId && (
|
||
<Alert severity="warning" sx={{ borderRadius: 2 }}>
|
||
当前未选择管理项目。
|
||
</Alert>
|
||
)}
|
||
<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;
|
||
|
||
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>
|
||
<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 ? "不能操作当前登录用户" : ""}
|
||
>
|
||
<span>
|
||
<Button
|
||
size="small"
|
||
color="error"
|
||
variant="outlined"
|
||
startIcon={<RemoveCircleOutlineIcon />}
|
||
onClick={() => removeMember(member)}
|
||
disabled={isSelf}
|
||
>
|
||
移除
|
||
</Button>
|
||
</span>
|
||
</Tooltip>
|
||
</TableCell>
|
||
</TableRow>
|
||
);
|
||
})}
|
||
</TableBody>
|
||
</Table>
|
||
</TableContainer>
|
||
</Stack>
|
||
)}
|
||
|
||
{tab === 1 && (
|
||
<Stack spacing={2}>
|
||
<SectionHeader
|
||
title="项目配置"
|
||
description="维护项目基础信息、GeoServer 工作区、地图范围和项目状态。"
|
||
action={
|
||
<Button
|
||
startIcon={<RefreshIcon />}
|
||
variant="outlined"
|
||
onClick={() => loadProjects()}
|
||
>
|
||
刷新
|
||
</Button>
|
||
}
|
||
/>
|
||
{!selectedProject && (
|
||
<Alert severity="warning" sx={{ borderRadius: 2 }}>
|
||
当前未选择管理项目。请在页面顶部选择项目,或使用“新建项目”创建项目。
|
||
</Alert>
|
||
)}
|
||
<Paper
|
||
component="form"
|
||
variant="outlined"
|
||
sx={{ p: 2, borderRadius: 2 }}
|
||
onSubmit={saveProject}
|
||
>
|
||
<Stack spacing={2}>
|
||
<ProjectFormFields
|
||
value={projectForm}
|
||
onChange={setProjectForm}
|
||
disabled={!selectedProject}
|
||
/>
|
||
<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;
|
||
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) =>
|
||
updateDatabaseForm(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) =>
|
||
updateDatabaseForm(
|
||
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) =>
|
||
updateDatabaseForm(
|
||
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 }}>
|
||
<ProjectFormFields
|
||
value={createProjectForm}
|
||
onChange={setCreateProjectForm}
|
||
/>
|
||
</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>
|
||
);
|
||
};
|