update:添加分页、成员添加优化

This commit is contained in:
xiaozhu
2026-09-02 10:10:41 +08:00
committed by tao.chen
parent 5a90f7db36
commit b7b8e28b52
20 changed files with 1729 additions and 920 deletions
@@ -1,7 +1,7 @@
import { useEffect, useState } from "react";
import { useCallback, useMemo, useState } from "react";
import { Plus, Search } from "lucide-react";
import { ApiRequestError, type Employee, type Workspace, type WorkspaceMember } from "../../services/api";
import { ApiRequestError, type Workspace, type WorkspaceMember } from "../../services/api";
import { useApi, useAuth } from "../../context/AuthContext";
import { Button } from "~/components/ui/button";
import { ConfirmDialog } from "~/components/common/ConfirmDialog";
@@ -14,9 +14,11 @@ import {
TableRow,
} from "~/components/ui/table";
import { AdminColgroup, USER_PROJECT_COL_WIDTHS } from "./AdminTable";
import { AdminPagination } from "./AdminPagination";
import { ProjectEditDialog } from "./ProjectEditDialog";
import { ImportMemberDialog } from "./ImportMemberDialog";
import { ProjectMembersDrawer } from "./ProjectMembersDrawer";
import { useCursorPage } from "./useCursorPage";
import { useDebouncedValue } from "./useDebouncedValue";
import {
adminEmptyClass,
adminPageClass,
@@ -50,44 +52,53 @@ export function ProjectManagementPage({
}) {
const api = useApi();
const { user, refreshWorkspaces } = useAuth();
const [projects, setProjects] = useState<Workspace[]>([]);
const [projectLoading, setProjectLoading] = useState(true);
const [projectDialogOpen, setProjectDialogOpen] = useState(false);
const [projectForm, setProjectForm] = useState(EMPTY_PROJECT_FORM);
const [editingProject, setEditingProject] = useState<Workspace | null>(null);
const [importMemberDialogOpen, setImportMemberDialogOpen] = useState(false);
const [selectedProject, setSelectedProject] = useState<Workspace | null>(null);
const [availableUsers, setAvailableUsers] = useState<Employee[]>([]);
const [selectedUserIds, setSelectedUserIds] = useState<string[]>([]);
const [projectSearchTerm, setProjectSearchTerm] = useState("");
const [saving, setSaving] = useState(false);
const [membersDrawerOpen, setMembersDrawerOpen] = useState(false);
const [currentProjectMembers, setCurrentProjectMembers] = useState<WorkspaceMember[]>([]);
const [membersLoading, setMembersLoading] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<Workspace | null>(null);
const [removeMemberTarget, setRemoveMemberTarget] = useState<WorkspaceMember | null>(null);
const debouncedSearch = useDebouncedValue(projectSearchTerm, 300);
const canManage = user?.role_code === "admin";
const loadProjects = async (): Promise<void> => {
setProjectLoading(true);
try {
const workspaceList = await api.listWorkspaces();
setProjects(workspaceList);
onConnectionChange(true);
} catch (error) {
onConnectionChange(false);
onNotify({
tone: "error",
message: error instanceof Error ? error.message : "项目列表加载失败",
});
} finally {
setProjectLoading(false);
}
};
const fetchProjects = useCallback(
async (input: { limit: number; cursor: string | null; q: string }) => {
try {
const page = await api.listWorkspaces(input);
onConnectionChange(true);
return page;
} catch (error) {
onConnectionChange(false);
onNotify({
tone: "error",
message: error instanceof Error ? error.message : "项目列表加载失败",
});
throw error;
}
},
[api, onConnectionChange, onNotify],
);
useEffect(() => {
void loadProjects();
}, []);
const {
items: projects,
setItems: setProjects,
page,
loading: projectLoading,
meta,
totalPages,
goNext,
goPrev,
goToPage,
canGoToPage,
reload,
refreshFromStart,
} = useCursorPage(fetchProjects, debouncedSearch);
const openCreateProject = (): void => {
setEditingProject(null);
@@ -125,26 +136,36 @@ export function ProjectManagementPage({
description: projectForm.description.trim() || undefined,
});
setProjects((current) =>
current.map((p) => (p.workspace_id === updated.workspace_id ? updated : p))
current.map((p) => (p.workspace_id === updated.workspace_id ? updated : p)),
);
onNotify({ tone: "success", message: "项目信息已更新" });
} else {
const generatedCode = projectForm.workspace_code.trim() || projectForm.workspace_name.trim().toLowerCase().replace(/[^a-z0-9-]/g, "-").slice(0, 32);
const created = await api.createWorkspace({
const generatedCode =
projectForm.workspace_code.trim() ||
projectForm.workspace_name
.trim()
.toLowerCase()
.replace(/[^a-z0-9-]/g, "-")
.slice(0, 32);
await api.createWorkspace({
workspace_code: generatedCode,
workspace_name: projectForm.workspace_name.trim(),
quota_bytes: projectForm.quota_bytes,
description: projectForm.description.trim() || undefined,
});
setProjects((current) => [...current, created]);
void refreshWorkspaces();
onNotify({ tone: "success", message: "项目已创建" });
await refreshFromStart();
}
setProjectDialogOpen(false);
} catch (error) {
onNotify({
tone: "error",
message: error instanceof ApiRequestError ? error.message : (editingProject ? "更新项目失败" : "创建项目失败"),
message: error instanceof ApiRequestError
? error.message
: editingProject
? "更新项目失败"
: "创建项目失败",
});
} finally {
setSaving(false);
@@ -154,8 +175,12 @@ export function ProjectManagementPage({
const executeDeleteProject = async (project: Workspace): Promise<void> => {
try {
await api.deleteWorkspace(project.workspace_id);
setProjects((current) => current.filter((p) => p.workspace_id !== project.workspace_id));
onNotify({ tone: "success", message: "项目已删除" });
if (projects.length <= 1 && page > 1) {
goPrev();
} else {
reload();
}
} catch (error) {
onNotify({
tone: "error",
@@ -164,26 +189,21 @@ export function ProjectManagementPage({
}
};
const openImportMemberDialog = async (project: Workspace): Promise<void> => {
setSelectedProject(project);
setSelectedUserIds([]);
setImportMemberDialogOpen(true);
// 加载可用用户和项目成员
try {
const [allUsers, currentMembers] = await Promise.all([
api.listPlatformEmployees(),
api.listWorkspaceMembers(project.workspace_id),
]);
setAvailableUsers(allUsers);
setCurrentProjectMembers(currentMembers);
} catch (error) {
// 如果加载失败,仍显示所有用户
const allUsers = await api.listPlatformEmployees();
setAvailableUsers(allUsers);
setCurrentProjectMembers([]);
}
};
const loadImportUsers = useCallback(
async (input: { q: string; cursor: string | null; limit: number }) => {
const page = await api.listPlatformEmployees({
limit: input.limit,
cursor: input.cursor,
q: input.q,
});
return {
items: page.items,
hasMore: page.meta.has_more,
nextCursor: page.meta.next_cursor,
};
},
[api],
);
const openMembersDrawer = (project: Workspace): void => {
setSelectedProject(project);
@@ -206,17 +226,23 @@ export function ProjectManagementPage({
}
};
const removeMember = async (userId: string): Promise<void> => {
if (!selectedProject) return;
// 管理员不能被移除
const requestRemoveMember = (userId: string): void => {
const targetMember = currentProjectMembers.find((m) => m.user_id === userId);
if (targetMember?.role_code === "admin") {
if (!targetMember) return;
if (targetMember.role_code === "admin") {
onNotify({ tone: "error", message: "管理员不能被移除" });
return;
}
setRemoveMemberTarget(targetMember);
};
const executeRemoveMember = async (member: WorkspaceMember): Promise<void> => {
if (!selectedProject) return;
try {
await api.deleteWorkspaceMember(selectedProject.workspace_id, userId);
setCurrentProjectMembers((current) => current.filter((m) => m.user_id !== userId));
await api.deleteWorkspaceMember(selectedProject.workspace_id, member.user_id);
setCurrentProjectMembers((current) =>
current.filter((m) => m.user_id !== member.user_id),
);
onNotify({ tone: "success", message: "成员已移除" });
} catch (error) {
onNotify({
@@ -226,32 +252,39 @@ export function ProjectManagementPage({
}
};
const importMember = async (): Promise<void> => {
if (!selectedProject || selectedUserIds.length === 0) {
const importMembers = async (userIds: string[]): Promise<void> => {
if (!selectedProject || userIds.length === 0) {
onNotify({ tone: "error", message: "请选择要添加的用户" });
return;
}
setSaving(true);
try {
await Promise.all(
selectedUserIds.map((userId) =>
userIds.map((userId) =>
api.addWorkspaceMember(selectedProject.workspace_id, {
user_id: userId,
})
)
}),
),
);
setImportMemberDialogOpen(false);
onNotify({ tone: "success", message: `已添加 ${selectedUserIds.length} 名成员` });
onNotify({ tone: "success", message: `已添加 ${userIds.length} 名成员` });
await loadProjectMembers(selectedProject.workspace_id);
} catch (error) {
onNotify({
tone: "error",
message: error instanceof ApiRequestError ? error.message : "添加成员失败",
});
throw error;
} finally {
setSaving(false);
}
};
const emptyMessage = useMemo(() => {
if (projectLoading) return "正在加载项目…";
if (debouncedSearch.trim()) return "未找到匹配的项目";
return "暂无项目";
}, [projectLoading, debouncedSearch]);
return (
<section className={adminPageClass}>
<div className={adminToolbarClass}>
@@ -291,90 +324,90 @@ export function ProjectManagementPage({
</TableRow>
</TableHeader>
<TableBody>
{projectLoading ? (
<TableRow className="min-h-[64px] border-t border-[#edf1f5] hover:bg-[#f7fafc] data-[state=selected]:bg-transparent">
{projectLoading || projects.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>
</TableCell>
</TableRow>
) : projects.length === 0 ? (
<TableRow className="min-h-[64px] border-t border-[#edf1f5] hover:bg-[#f7fafc] data-[state=selected]:bg-transparent">
<TableCell colSpan={5} className="p-0">
<p className={adminEmptyClass}></p>
<p className={adminEmptyClass}>{emptyMessage}</p>
</TableCell>
</TableRow>
) : (
projects
.filter((project) => {
const term = projectSearchTerm.toLowerCase().trim();
if (!term) return true;
return (
project.workspace_name.toLowerCase().includes(term) ||
project.workspace_code.toLowerCase().includes(term) ||
(project.description && project.description.toLowerCase().includes(term))
);
})
.map((project) => (
<TableRow key={project.workspace_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}>{project.workspace_name.slice(0, 1)}</b>
<span>
<strong>{project.workspace_name}</strong>
<small>{project.description ?? "无描述"}</small>
</span>
</span>
</TableCell>
<TableCell className="p-3 px-4 align-middle"><code>{project.workspace_code}</code></TableCell>
<TableCell className="p-3 px-4 align-middle">
projects.map((project) => (
<TableRow key={project.workspace_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}>{project.workspace_name.slice(0, 1)}</b>
<span>
<span className={statusPillClass(project.status)}>
{project.status === "active" ? "正常" : project.status === "archived" ? "已归档" : "已删除"}
</span>
<strong>{project.workspace_name}</strong>
<small>{project.description ?? "无描述"}</small>
</span>
</TableCell>
<TableCell className="p-3 px-4 align-middle"><span>{project.quota_bytes > 0 ? `${(project.quota_bytes / 1024 / 1024 / 1024).toFixed(1)} GB` : "无限制"}</span></TableCell>
<TableCell className="p-3 px-4 align-middle">
<span className={employeeActionsClass}>
<Button
variant="outline"
size="xs"
type="button"
disabled={!canManage}
onClick={() => openMembersDrawer(project)}
className={rowButtonClass}
>
</Button>
<Button
variant="outline"
size="xs"
type="button"
disabled={!canManage}
onClick={() => openEditProject(project)}
className={rowButtonClass}
>
</Button>
<Button
variant="destructive"
size="xs"
type="button"
disabled={!canManage || project.status === "disabled"}
title={project.status === "disabled" ? "已删除的项目不能操作" : "删除项目"}
onClick={() => setDeleteTarget(project)}
className={rowDangerButtonClass}
>
</Button>
</span>
</TableCell>
</TableRow>
))
</span>
</TableCell>
<TableCell className="p-3 px-4 align-middle"><code>{project.workspace_code}</code></TableCell>
<TableCell className="p-3 px-4 align-middle">
<span className={statusPillClass(project.status)}>
{project.status === "active" ? "正常" : project.status === "archived" ? "已归档" : "已删除"}
</span>
</TableCell>
<TableCell className="p-3 px-4 align-middle">
<span>
{project.quota_bytes > 0
? `${(project.quota_bytes / 1024 / 1024 / 1024).toFixed(1)} GB`
: "无限制"}
</span>
</TableCell>
<TableCell className="p-3 px-4 align-middle">
<span className={employeeActionsClass}>
<Button
variant="outline"
size="xs"
type="button"
disabled={!canManage}
onClick={() => openMembersDrawer(project)}
className={rowButtonClass}
>
</Button>
<Button
variant="outline"
size="xs"
type="button"
disabled={!canManage}
onClick={() => openEditProject(project)}
className={rowButtonClass}
>
</Button>
<Button
variant="destructive"
size="xs"
type="button"
disabled={!canManage || project.status === "disabled"}
title={project.status === "disabled" ? "已删除的项目不能操作" : "删除项目"}
onClick={() => setDeleteTarget(project)}
className={rowDangerButtonClass}
>
</Button>
</span>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
<AdminPagination
page={page}
totalPages={totalPages}
totalCount={meta.total_count}
loading={projectLoading}
hasMore={meta.has_more}
canGoToPage={canGoToPage}
onPrev={goPrev}
onNext={goNext}
onGoToPage={goToPage}
/>
<ProjectEditDialog
open={projectDialogOpen}
editingProject={editingProject}
@@ -386,32 +419,20 @@ export function ProjectManagementPage({
onClose={() => setProjectDialogOpen(false)}
/>
<ImportMemberDialog
open={importMemberDialogOpen}
selectedProject={selectedProject}
availableUsers={availableUsers}
selectedUserIds={selectedUserIds}
existingMemberIds={currentProjectMembers.map((m) => m.user_id)}
saving={saving}
onNotify={onNotify}
onChangeSelectedUserIds={setSelectedUserIds}
onSubmit={() => void importMember()}
onClose={() => setImportMemberDialogOpen(false)}
/>
<ProjectMembersDrawer
open={membersDrawerOpen}
selectedProject={selectedProject}
members={currentProjectMembers}
membersLoading={membersLoading}
canManage={canManage}
onClose={() => setMembersDrawerOpen(false)}
onAddMembers={() => {
saving={saving}
onClose={() => {
setMembersDrawerOpen(false);
if (selectedProject) void openImportMemberDialog(selectedProject);
setRemoveMemberTarget(null);
}}
onRemoveMember={(userId) => void removeMember(userId)}
onNotify={onNotify}
onRemoveMember={requestRemoveMember}
onAddMembers={importMembers}
loadUsers={loadImportUsers}
/>
<ConfirmDialog
@@ -433,6 +454,26 @@ export function ProjectManagementPage({
setDeleteTarget(null);
}}
/>
<ConfirmDialog
open={removeMemberTarget !== null}
onOpenChange={(nextOpen) => {
if (!nextOpen) setRemoveMemberTarget(null);
}}
title="确定移除成员?"
description={
removeMemberTarget
? `确定将"${removeMemberTarget.display_name}"从项目中移除吗?`
: ""
}
confirmLabel="移除"
destructive
onConfirm={async () => {
if (!removeMemberTarget) return;
await executeRemoveMember(removeMemberTarget);
setRemoveMemberTarget(null);
}}
/>
</section>
);
}