update:添加分页、成员添加优化
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user