480 lines
16 KiB
TypeScript
480 lines
16 KiB
TypeScript
import { useCallback, useMemo, useState } from "react";
|
|
|
|
import { Plus, Search } from "lucide-react";
|
|
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";
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "~/components/ui/table";
|
|
import { AdminColgroup, USER_PROJECT_COL_WIDTHS } from "./AdminTable";
|
|
import { AdminPagination } from "./AdminPagination";
|
|
import { ProjectEditDialog } from "./ProjectEditDialog";
|
|
import { ProjectMembersDrawer } from "./ProjectMembersDrawer";
|
|
import { useCursorPage } from "./useCursorPage";
|
|
import { useDebouncedValue } from "./useDebouncedValue";
|
|
import {
|
|
adminEmptyClass,
|
|
adminPageClass,
|
|
adminReadonlyClass,
|
|
adminToolbarClass,
|
|
adminToolbarInputClass,
|
|
adminToolbarSearchClass,
|
|
employeeActionsClass,
|
|
employeeAvatarClass,
|
|
employeeNameClass,
|
|
rowButtonClass,
|
|
rowDangerButtonClass,
|
|
statusPillClass,
|
|
} from "./adminUi";
|
|
|
|
import { primaryGradientButtonClass } from "~/components/common/buttonClasses";
|
|
|
|
const EMPTY_PROJECT_FORM = {
|
|
workspace_code: "",
|
|
workspace_name: "",
|
|
quota_bytes: 0,
|
|
description: "",
|
|
};
|
|
|
|
export function ProjectManagementPage({
|
|
onNotify,
|
|
onConnectionChange,
|
|
}: {
|
|
onNotify: (notice: { tone: "success" | "error" | "info"; message: string }) => void;
|
|
onConnectionChange: (online: boolean) => void;
|
|
}) {
|
|
const api = useApi();
|
|
const { user, refreshWorkspaces } = useAuth();
|
|
const [projectDialogOpen, setProjectDialogOpen] = useState(false);
|
|
const [projectForm, setProjectForm] = useState(EMPTY_PROJECT_FORM);
|
|
const [editingProject, setEditingProject] = useState<Workspace | null>(null);
|
|
const [selectedProject, setSelectedProject] = useState<Workspace | null>(null);
|
|
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 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],
|
|
);
|
|
|
|
const {
|
|
items: projects,
|
|
setItems: setProjects,
|
|
page,
|
|
loading: projectLoading,
|
|
meta,
|
|
totalPages,
|
|
goNext,
|
|
goPrev,
|
|
goToPage,
|
|
canGoToPage,
|
|
reload,
|
|
refreshFromStart,
|
|
} = useCursorPage(fetchProjects, debouncedSearch);
|
|
|
|
const openCreateProject = (): void => {
|
|
setEditingProject(null);
|
|
setProjectForm(EMPTY_PROJECT_FORM);
|
|
setProjectDialogOpen(true);
|
|
};
|
|
|
|
const openEditProject = (project: Workspace): void => {
|
|
setEditingProject(project);
|
|
setProjectForm({
|
|
workspace_code: project.workspace_code,
|
|
workspace_name: project.workspace_name,
|
|
quota_bytes: project.quota_bytes,
|
|
description: project.description ?? "",
|
|
});
|
|
setProjectDialogOpen(true);
|
|
};
|
|
|
|
const submitProject = async (event: React.FormEvent): Promise<void> => {
|
|
event.preventDefault();
|
|
if (!projectForm.workspace_name.trim()) {
|
|
onNotify({ tone: "error", message: "请输入项目名称" });
|
|
return;
|
|
}
|
|
if (!projectForm.workspace_code.trim() && !editingProject) {
|
|
onNotify({ tone: "error", message: "请输入项目编码" });
|
|
return;
|
|
}
|
|
setSaving(true);
|
|
try {
|
|
if (editingProject) {
|
|
const updated = await api.updateWorkspace(editingProject.workspace_id, {
|
|
workspace_name: projectForm.workspace_name.trim(),
|
|
quota_bytes: projectForm.quota_bytes,
|
|
description: projectForm.description.trim() || undefined,
|
|
});
|
|
setProjects((current) =>
|
|
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);
|
|
await api.createWorkspace({
|
|
workspace_code: generatedCode,
|
|
workspace_name: projectForm.workspace_name.trim(),
|
|
quota_bytes: projectForm.quota_bytes,
|
|
description: projectForm.description.trim() || undefined,
|
|
});
|
|
void refreshWorkspaces();
|
|
onNotify({ tone: "success", message: "项目已创建" });
|
|
await refreshFromStart();
|
|
}
|
|
setProjectDialogOpen(false);
|
|
} catch (error) {
|
|
onNotify({
|
|
tone: "error",
|
|
message: error instanceof ApiRequestError
|
|
? error.message
|
|
: editingProject
|
|
? "更新项目失败"
|
|
: "创建项目失败",
|
|
});
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const executeDeleteProject = async (project: Workspace): Promise<void> => {
|
|
try {
|
|
await api.deleteWorkspace(project.workspace_id);
|
|
onNotify({ tone: "success", message: "项目已删除" });
|
|
if (projects.length <= 1 && page > 1) {
|
|
goPrev();
|
|
} else {
|
|
reload();
|
|
}
|
|
} catch (error) {
|
|
onNotify({
|
|
tone: "error",
|
|
message: error instanceof Error ? error.message : "删除项目失败",
|
|
});
|
|
}
|
|
};
|
|
|
|
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);
|
|
setMembersDrawerOpen(true);
|
|
void loadProjectMembers(project.workspace_id);
|
|
};
|
|
|
|
const loadProjectMembers = async (workspaceId: string): Promise<void> => {
|
|
setMembersLoading(true);
|
|
try {
|
|
const members = await api.listWorkspaceMembers(workspaceId);
|
|
setCurrentProjectMembers(members);
|
|
} catch (error) {
|
|
onNotify({
|
|
tone: "error",
|
|
message: error instanceof Error ? error.message : "成员列表加载失败",
|
|
});
|
|
} finally {
|
|
setMembersLoading(false);
|
|
}
|
|
};
|
|
|
|
const requestRemoveMember = (userId: string): void => {
|
|
const targetMember = currentProjectMembers.find((m) => m.user_id === userId);
|
|
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, member.user_id);
|
|
setCurrentProjectMembers((current) =>
|
|
current.filter((m) => m.user_id !== member.user_id),
|
|
);
|
|
onNotify({ tone: "success", message: "成员已移除" });
|
|
} catch (error) {
|
|
onNotify({
|
|
tone: "error",
|
|
message: error instanceof ApiRequestError ? error.message : "移除成员失败",
|
|
});
|
|
}
|
|
};
|
|
|
|
const importMembers = async (userIds: string[]): Promise<void> => {
|
|
if (!selectedProject || userIds.length === 0) {
|
|
onNotify({ tone: "error", message: "请选择要添加的用户" });
|
|
return;
|
|
}
|
|
setSaving(true);
|
|
try {
|
|
await Promise.all(
|
|
userIds.map((userId) =>
|
|
api.addWorkspaceMember(selectedProject.workspace_id, {
|
|
user_id: userId,
|
|
}),
|
|
),
|
|
);
|
|
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}>
|
|
<div className={adminToolbarSearchClass}>
|
|
<Search size={14} />
|
|
<input
|
|
type="text"
|
|
placeholder="请输入项目名称"
|
|
className={adminToolbarInputClass}
|
|
value={projectSearchTerm}
|
|
onChange={(event) => setProjectSearchTerm(event.target.value)}
|
|
/>
|
|
</div>
|
|
<Button
|
|
variant="default"
|
|
size="sm"
|
|
type="button"
|
|
disabled={!canManage}
|
|
onClick={openCreateProject}
|
|
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>
|
|
{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}>{emptyMessage}</p>
|
|
</TableCell>
|
|
</TableRow>
|
|
) : (
|
|
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>
|
|
<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">
|
|
<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}
|
|
projectForm={projectForm}
|
|
saving={saving}
|
|
onNotify={onNotify}
|
|
onChangeForm={setProjectForm}
|
|
onSubmit={(event) => void submitProject(event)}
|
|
onClose={() => setProjectDialogOpen(false)}
|
|
/>
|
|
|
|
<ProjectMembersDrawer
|
|
open={membersDrawerOpen}
|
|
selectedProject={selectedProject}
|
|
members={currentProjectMembers}
|
|
membersLoading={membersLoading}
|
|
canManage={canManage}
|
|
saving={saving}
|
|
onClose={() => {
|
|
setMembersDrawerOpen(false);
|
|
setRemoveMemberTarget(null);
|
|
}}
|
|
onRemoveMember={requestRemoveMember}
|
|
onAddMembers={importMembers}
|
|
loadUsers={loadImportUsers}
|
|
/>
|
|
|
|
<ConfirmDialog
|
|
open={deleteTarget !== null}
|
|
onOpenChange={(nextOpen) => {
|
|
if (!nextOpen) setDeleteTarget(null);
|
|
}}
|
|
title="确定删除项目?"
|
|
description={
|
|
deleteTarget
|
|
? `确定要删除项目"${deleteTarget.workspace_name}"吗?此操作将级联软删所有成员。`
|
|
: ""
|
|
}
|
|
confirmLabel="删除"
|
|
destructive
|
|
onConfirm={async () => {
|
|
if (!deleteTarget) return;
|
|
await executeDeleteProject(deleteTarget);
|
|
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>
|
|
);
|
|
}
|