406 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|