Files
model-platform/frontend/app/features/admin/UserManagementPage.tsx
T

406 lines
14 KiB
TypeScript

import { useCallback, useEffect, useMemo, useState } from "react";
import { Plus, Search } from "lucide-react";
import { ApiRequestError, type Employee, type Role, type Workspace } from "../../services/api";
import { useApi, useAuth } from "../../context/AuthContext";
import { ConfirmDialog } from "~/components/common/ConfirmDialog";
import { Button } from "~/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "~/components/ui/table";
import { AdminColgroup, USER_PROJECT_COL_WIDTHS } from "./AdminTable";
import { AdminPagination } from "./AdminPagination";
import { UserFormDialog, type UserFormState } from "./UserFormDialog";
import { useCursorPage } from "./useCursorPage";
import {
adminEmptyClass,
adminPageClass,
adminReadonlyClass,
adminToolbarClass,
adminToolbarInputClass,
adminToolbarSearchClass,
employeeActionsClass,
employeeAvatarClass,
employeeNameClass,
rolePillClass,
rowButtonClass,
rowDangerButtonClass,
statusPillClass,
} from "./adminUi";
import { primaryGradientButtonClass } from "~/components/common/buttonClasses";
import { useDebouncedValue } from "./useDebouncedValue";
const EMPTY_FORM: UserFormState = {
username: "",
display_name: "",
email: "",
role_code: "developer",
password: "",
status: "active",
};
export function UserManagementPage({
onNotify,
onConnectionChange,
}: {
onNotify: (notice: { tone: "success" | "error" | "info"; message: string }) => void;
onConnectionChange: (online: boolean) => void;
}) {
const api = useApi();
const { user } = useAuth();
const [roles, setRoles] = useState<Role[]>([]);
const [saving, setSaving] = useState(false);
const [editing, setEditing] = useState<Employee | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [form, setForm] = useState<UserFormState>(EMPTY_FORM);
const [userSearchTerm, setUserSearchTerm] = useState("");
const [deleteTarget, setDeleteTarget] = useState<Employee | null>(null);
const [createProjects, setCreateProjects] = useState<Workspace[]>([]);
const [createProjectsLoading, setCreateProjectsLoading] = useState(false);
const [selectedWorkspaceIds, setSelectedWorkspaceIds] = useState<string[]>([]);
const debouncedSearch = useDebouncedValue(userSearchTerm, 300);
const canManage = user?.role_code === "admin";
const fetchEmployees = useCallback(
async (input: { limit: number; cursor: string | null; q: string }) => {
try {
const page = await api.listPlatformEmployees(input);
onConnectionChange(true);
return page;
} catch (error) {
onConnectionChange(false);
onNotify({
tone: "error",
message: error instanceof Error ? error.message : "用户列表加载失败",
});
throw error;
}
},
[api, onConnectionChange, onNotify],
);
const {
items: employees,
setItems: setEmployees,
page,
loading,
meta,
totalPages,
goNext,
goPrev,
goToPage,
canGoToPage,
reload,
refreshFromStart,
} = useCursorPage(fetchEmployees, debouncedSearch);
useEffect(() => {
void api.listPlatformRoles().then(setRoles).catch(() => {
/* roles only needed for dialog */
});
}, [api]);
const createRoleOptions = useMemo(
() => roles.filter((role) => role.role_code !== "admin"),
[roles],
);
const loadCreateProjects = useCallback(async (): Promise<void> => {
setCreateProjectsLoading(true);
try {
const page = await api.listWorkspaces({ limit: 200, cursor: null, q: "" });
setCreateProjects(page.items.filter((item) => item.status === "active"));
} catch {
setCreateProjects([]);
onNotify({ tone: "error", message: "项目列表加载失败" });
} finally {
setCreateProjectsLoading(false);
}
}, [api, onNotify]);
const openCreate = (): void => {
setEditing(null);
setForm(EMPTY_FORM);
setSelectedWorkspaceIds([]);
setDialogOpen(true);
void loadCreateProjects();
};
const openEdit = (employee: Employee): void => {
setEditing(employee);
setForm({
username: employee.username,
display_name: employee.display_name,
email: employee.email ?? "",
role_code: employee.role_code,
password: "",
status: employee.status,
});
setSelectedWorkspaceIds([]);
setDialogOpen(true);
};
const toggleCreateWorkspace = (workspaceId: string): void => {
setSelectedWorkspaceIds((current) =>
current.includes(workspaceId)
? current.filter((id) => id !== workspaceId)
: [...current, workspaceId],
);
};
const submit = async (event: React.FormEvent): Promise<void> => {
event.preventDefault();
if (!form.display_name.trim() || (!editing && !form.username.trim())) return;
if (!editing && !form.password.trim()) {
onNotify({ tone: "error", message: "请输入密码" });
return;
}
if (editing) {
const editingSelf = editing.user_id === user?.user_id;
const targetIsAdmin = editing.role_code === "admin";
if (editingSelf && form.status !== editing.status) {
onNotify({ tone: "error", message: "不能停用当前登录账号" });
return;
}
if (targetIsAdmin && form.status !== editing.status) {
onNotify({ tone: "error", message: "不能停用或锁定管理员账号" });
return;
}
if (editingSelf && form.role_code !== editing.role_code) {
onNotify({ tone: "error", message: "不能降级自身管理员角色" });
return;
}
}
if (form.password && (form.password.length < 8 || form.password.length > 72)) {
onNotify({ tone: "error", message: "密码长度必须在 8~72 字符之间" });
return;
}
if (!editing && form.role_code === "admin") {
onNotify({ tone: "error", message: "新建用户不能指定管理员角色" });
return;
}
setSaving(true);
try {
if (editing) {
const updated = await api.updatePlatformEmployee(editing.user_id, {
display_name: form.display_name.trim(),
email: form.email.trim() || null,
role_code: form.role_code,
status: form.status,
});
setEmployees((current) =>
current.map((item) => (item.user_id === updated.user_id ? updated : item)),
);
onNotify({ tone: "success", message: "用户信息已更新" });
} else {
const created = await api.createPlatformEmployee({
username: form.username.trim(),
display_name: form.display_name.trim(),
email: form.email.trim() || undefined,
password: form.password,
role_code: form.role_code,
});
if (selectedWorkspaceIds.length > 0) {
const results = await Promise.allSettled(
selectedWorkspaceIds.map((workspaceId) =>
api.addWorkspaceMember(workspaceId, { user_id: created.user_id }),
),
);
const failed = results.filter((item) => item.status === "rejected").length;
const joined = results.length - failed;
if (failed === 0) {
onNotify({
tone: "success",
message: `用户已添加,并加入 ${joined} 个项目`,
});
} else {
onNotify({
tone: "error",
message: `用户已创建,但有 ${failed} 个项目加入失败`,
});
}
} else {
onNotify({
tone: "info",
message: "用户已添加",
});
}
await refreshFromStart();
}
setDialogOpen(false);
} catch (error) {
onNotify({
tone: "error",
message: error instanceof ApiRequestError ? error.message : "保存用户失败",
});
} finally {
setSaving(false);
}
};
const executeRemove = async (employee: Employee): Promise<void> => {
try {
await api.deletePlatformEmployee(employee.user_id);
onNotify({ tone: "success", message: "用户已删除" });
if (employees.length <= 1 && page > 1) {
goPrev();
} else {
reload();
}
} catch (error) {
onNotify({
tone: "error",
message: error instanceof Error ? error.message : "删除用户失败",
});
}
};
const emptyMessage = useMemo(() => {
if (loading) return "正在加载用户…";
if (debouncedSearch.trim()) return "未找到匹配的用户";
return "暂无用户";
}, [loading, debouncedSearch]);
return (
<section className={adminPageClass}>
<div className={adminToolbarClass}>
<div className={adminToolbarSearchClass}>
<Search size={14} />
<input
type="text"
placeholder="请输入用户名"
className={adminToolbarInputClass}
value={userSearchTerm}
onChange={(event) => setUserSearchTerm(event.target.value)}
/>
</div>
<Button
variant="default"
size="sm"
type="button"
disabled={!canManage}
onClick={openCreate}
className={primaryGradientButtonClass}
>
<Plus size={15} />
新建用户
</Button>
</div>
{!canManage && <div className={adminReadonlyClass}>当前为开发人员,只能查看用户列表。</div>}
<Table className="rounded-[9px] border border-line overflow-hidden text-[11px] text-[#44576a] [--border-color:var(--color-line)] table-fixed bg-white">
<AdminColgroup widths={USER_PROJECT_COL_WIDTHS} />
<TableHeader className="bg-[#f5f8fb] text-ink-caption">
<TableRow className="hover:bg-transparent border-b border-[#edf1f5]">
<TableHead className="h-10 px-4 text-[10px] font-bold text-ink-caption">用户</TableHead>
<TableHead className="h-10 px-4 text-[10px] font-bold text-ink-caption">账号</TableHead>
<TableHead className="h-10 px-4 text-[10px] font-bold text-ink-caption">角色</TableHead>
<TableHead className="h-10 px-4 text-[10px] font-bold text-ink-caption">状态</TableHead>
<TableHead className="h-10 px-4 text-[10px] font-bold text-ink-caption">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading || employees.length === 0 ? (
<TableRow className="min-h-[64px] border-t border-[#edf1f5] hover:bg-transparent data-[state=selected]:bg-transparent">
<TableCell colSpan={5} className="p-0">
<p className={adminEmptyClass}>{emptyMessage}</p>
</TableCell>
</TableRow>
) : (
employees.map((employee) => {
const isProtectedAdmin = employee.role_code === "admin";
return (
<TableRow key={employee.user_id} className="min-h-[64px] border-t border-[#edf1f5] hover:bg-[#f7fafc] data-[state=selected]:bg-transparent">
<TableCell className="p-3 px-4 align-middle"><span className={employeeNameClass}><b className={employeeAvatarClass}>{employee.display_name.slice(0, 1)}</b><span><strong>{employee.display_name}</strong><small>{employee.email ?? "未设置邮箱"}</small></span></span></TableCell>
<TableCell className="p-3 px-4 align-middle"><code>{employee.username}</code></TableCell>
<TableCell className="p-3 px-4 align-middle">{employee.role_code ? (<span className={rolePillClass(employee.role_code)}>{employee.role_name}</span>) : (<span>-</span>)}</TableCell>
<TableCell className="p-3 px-4 align-middle"><span className={statusPillClass(employee.status)}>{employee.status === "active" ? "正常" : employee.status === "disabled" ? "已停用" : "已锁定"}</span></TableCell>
<TableCell className="p-3 px-4 align-middle"><span className={employeeActionsClass}>
<Button
variant="outline"
size="xs"
type="button"
disabled={!canManage}
onClick={() => openEdit(employee)}
className={rowButtonClass}
>
编辑
</Button>
<Button
variant="destructive"
size="xs"
type="button"
disabled={!canManage || isProtectedAdmin}
title={isProtectedAdmin ? "管理员账号不能删除" : "删除"}
onClick={() => setDeleteTarget(employee)}
className={rowDangerButtonClass}
>
删除
</Button>
</span></TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
<AdminPagination
page={page}
totalPages={totalPages}
totalCount={meta.total_count}
loading={loading}
hasMore={meta.has_more}
canGoToPage={canGoToPage}
onPrev={goPrev}
onNext={goNext}
onGoToPage={goToPage}
/>
<UserFormDialog
open={dialogOpen}
editing={editing}
form={form}
saving={saving}
currentUserId={user?.user_id}
roles={roles}
createRoleOptions={createRoleOptions}
createProjects={createProjects}
createProjectsLoading={createProjectsLoading}
selectedWorkspaceIds={selectedWorkspaceIds}
onChangeForm={setForm}
onToggleWorkspace={toggleCreateWorkspace}
onSubmit={(event) => void submit(event)}
onClose={() => setDialogOpen(false)}
/>
<ConfirmDialog
open={deleteTarget !== null}
onOpenChange={(nextOpen) => {
if (!nextOpen) setDeleteTarget(null);
}}
title="确定删除用户?"
description={
deleteTarget
? `确定从平台删除用户"${deleteTarget.display_name}"吗?`
: ""
}
confirmLabel="删除"
destructive
onConfirm={async () => {
if (!deleteTarget) return;
await executeRemove(deleteTarget);
setDeleteTarget(null);
}}
/>
</section>
);
}