update:添加分页、成员添加优化
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { Plus, Search } from "lucide-react";
|
||||
import { ApiRequestError, type Employee, type Role } from "../../services/api";
|
||||
@@ -20,6 +20,8 @@ import {
|
||||
TableRow,
|
||||
} from "~/components/ui/table";
|
||||
import { AdminColgroup, USER_PROJECT_COL_WIDTHS } from "./AdminTable";
|
||||
import { AdminPagination } from "./AdminPagination";
|
||||
import { useCursorPage } from "./useCursorPage";
|
||||
import {
|
||||
formFieldClass,
|
||||
formInputClass,
|
||||
@@ -42,6 +44,7 @@ import {
|
||||
} from "./adminUi";
|
||||
|
||||
import { primaryGradientButtonClass } from "~/components/common/buttonClasses";
|
||||
import { useDebouncedValue } from "./useDebouncedValue";
|
||||
|
||||
const EMPTY_FORM = {
|
||||
username: "",
|
||||
@@ -54,6 +57,7 @@ const EMPTY_FORM = {
|
||||
|
||||
const FORM_INPUT_CLASS =
|
||||
"h-[39px] w-full rounded-md border border-[#d6e0e9] bg-white px-[11px] text-xs text-[#283a4e] outline-none focus:border-[#5b9ddb] focus:ring-3 focus:ring-brand/10 disabled:cursor-not-allowed disabled:opacity-50";
|
||||
|
||||
export function UserManagementPage({
|
||||
onNotify,
|
||||
onConnectionChange,
|
||||
@@ -63,42 +67,55 @@ export function UserManagementPage({
|
||||
}) {
|
||||
const api = useApi();
|
||||
const { user } = useAuth();
|
||||
const [employees, setEmployees] = useState<Employee[]>([]);
|
||||
const [roles, setRoles] = useState<Role[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [editing, setEditing] = useState<Employee | null>(null);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [form, setForm] = useState(EMPTY_FORM);
|
||||
const [userSearchTerm, setUserSearchTerm] = useState("");
|
||||
const [deleteTarget, setDeleteTarget] = useState<Employee | null>(null);
|
||||
const debouncedSearch = useDebouncedValue(userSearchTerm, 300);
|
||||
|
||||
const canManage = user?.role_code === "admin";
|
||||
|
||||
const load = async (): Promise<void> => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [employeeList, roleList] = await Promise.all([
|
||||
api.listPlatformEmployees(),
|
||||
api.listPlatformRoles(),
|
||||
]);
|
||||
setEmployees(employeeList);
|
||||
setRoles(roleList);
|
||||
onConnectionChange(true);
|
||||
} catch (error) {
|
||||
onConnectionChange(false);
|
||||
onNotify({
|
||||
tone: "error",
|
||||
message: error instanceof Error ? error.message : "用户列表加载失败",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
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 load();
|
||||
}, []);
|
||||
void api.listPlatformRoles().then(setRoles).catch(() => {
|
||||
/* roles only needed for edit dialog */
|
||||
});
|
||||
}, [api]);
|
||||
|
||||
const openCreate = (): void => {
|
||||
setEditing(null);
|
||||
@@ -122,12 +139,10 @@ export function UserManagementPage({
|
||||
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;
|
||||
}
|
||||
// 编辑自身时,前端二次拦截禁用 status / role_code 的修改;管理员账号禁止停用/锁定
|
||||
if (editing) {
|
||||
const editingSelf = editing.user_id === user?.user_id;
|
||||
const targetIsAdmin = editing.role_code === "admin";
|
||||
@@ -144,7 +159,6 @@ export function UserManagementPage({
|
||||
return;
|
||||
}
|
||||
}
|
||||
// 密码长度校验 8~72 字符
|
||||
if (form.password && (form.password.length < 8 || form.password.length > 72)) {
|
||||
onNotify({ tone: "error", message: "密码长度必须在 8~72 字符之间" });
|
||||
return;
|
||||
@@ -158,20 +172,20 @@ export function UserManagementPage({
|
||||
role_code: form.role_code,
|
||||
status: form.status,
|
||||
});
|
||||
setEmployees((current) => current.map(
|
||||
(item) => item.user_id === updated.user_id ? updated : item,
|
||||
));
|
||||
setEmployees((current) =>
|
||||
current.map((item) => (item.user_id === updated.user_id ? updated : item)),
|
||||
);
|
||||
onNotify({ tone: "success", message: "用户信息已更新" });
|
||||
} else {
|
||||
const created = await api.createPlatformEmployee({
|
||||
await api.createPlatformEmployee({
|
||||
username: form.username.trim(),
|
||||
display_name: form.display_name.trim(),
|
||||
email: form.email.trim() || undefined,
|
||||
password: form.password,
|
||||
role_code: "developer",
|
||||
});
|
||||
setEmployees((current) => [...current, created]);
|
||||
onNotify({ tone: "success", message: "用户已添加" });
|
||||
await refreshFromStart();
|
||||
}
|
||||
setDialogOpen(false);
|
||||
} catch (error) {
|
||||
@@ -187,8 +201,12 @@ export function UserManagementPage({
|
||||
const executeRemove = async (employee: Employee): Promise<void> => {
|
||||
try {
|
||||
await api.deletePlatformEmployee(employee.user_id);
|
||||
setEmployees((current) => current.filter((item) => item.user_id !== employee.user_id));
|
||||
onNotify({ tone: "success", message: "用户已删除" });
|
||||
if (employees.length <= 1 && page > 1) {
|
||||
goPrev();
|
||||
} else {
|
||||
reload();
|
||||
}
|
||||
} catch (error) {
|
||||
onNotify({
|
||||
tone: "error",
|
||||
@@ -197,6 +215,12 @@ export function UserManagementPage({
|
||||
}
|
||||
};
|
||||
|
||||
const emptyMessage = useMemo(() => {
|
||||
if (loading) return "正在加载用户…";
|
||||
if (debouncedSearch.trim()) return "未找到匹配的用户";
|
||||
return "暂无用户";
|
||||
}, [loading, debouncedSearch]);
|
||||
|
||||
return (
|
||||
<section className={adminPageClass}>
|
||||
<div className={adminToolbarClass}>
|
||||
@@ -236,61 +260,63 @@ export function UserManagementPage({
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow className="min-h-[64px] border-t border-[#edf1f5] hover:bg-[#f7fafc] data-[state=selected]:bg-transparent">
|
||||
{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}>正在加载用户…</p>
|
||||
<p className={adminEmptyClass}>{emptyMessage}</p>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
employees
|
||||
.filter((employee) => {
|
||||
const term = userSearchTerm.toLowerCase().trim();
|
||||
if (!term) return true;
|
||||
return (
|
||||
employee.display_name.toLowerCase().includes(term) ||
|
||||
employee.username.toLowerCase().includes(term) ||
|
||||
(employee.email && employee.email.toLowerCase().includes(term))
|
||||
);
|
||||
})
|
||||
.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>
|
||||
);
|
||||
})
|
||||
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}
|
||||
/>
|
||||
|
||||
<AppFormDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={(nextOpen) => {
|
||||
|
||||
Reference in New Issue
Block a user