440 lines
16 KiB
TypeScript
440 lines
16 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
|
|
import { Plus, Search } from "lucide-react";
|
|
import { ApiRequestError, type Employee, 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 { ProjectEditDialog } from "./ProjectEditDialog";
|
|
import { ImportMemberDialog } from "./ImportMemberDialog";
|
|
import { ProjectMembersDrawer } from "./ProjectMembersDrawer";
|
|
import {
|
|
adminEmptyClass,
|
|
adminPageClass,
|
|
adminReadonlyClass,
|
|
adminToolbarClass,
|
|
adminToolbarInputClass,
|
|
adminToolbarSearchClass,
|
|
employeeActionsClass,
|
|
employeeAvatarClass,
|
|
employeeNameClass,
|
|
rowButtonClass,
|
|
rowDangerButtonClass,
|
|
statusPillClass,
|
|
} from "./adminUi";
|
|
|
|
const EMPTY_PROJECT_FORM = {
|
|
workspace_code: "",
|
|
workspace_name: "",
|
|
quota_bytes: 0,
|
|
description: "",
|
|
};
|
|
|
|
const PRIMARY_BUTTON_CLASS =
|
|
"h-[37px] gap-[7px] rounded-md border border-[#126ac3] bg-gradient-to-br from-[#1881e7] to-[#1268c9] px-[15px] text-xs font-[650] text-white shadow-[0_6px_15px_rgb(20_111_202/18%)] enabled:hover:from-[#1175d8] enabled:hover:to-[#0e5bad]";
|
|
|
|
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 [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 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);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
void loadProjects();
|
|
}, []);
|
|
|
|
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);
|
|
const created = 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: "项目已创建" });
|
|
}
|
|
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);
|
|
setProjects((current) => current.filter((p) => p.workspace_id !== project.workspace_id));
|
|
onNotify({ tone: "success", message: "项目已删除" });
|
|
} catch (error) {
|
|
onNotify({
|
|
tone: "error",
|
|
message: error instanceof Error ? error.message : "删除项目失败",
|
|
});
|
|
}
|
|
};
|
|
|
|
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 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 removeMember = async (userId: string): Promise<void> => {
|
|
if (!selectedProject) return;
|
|
// 管理员不能被移除
|
|
const targetMember = currentProjectMembers.find((m) => m.user_id === userId);
|
|
if (targetMember?.role_code === "admin") {
|
|
onNotify({ tone: "error", message: "管理员不能被移除" });
|
|
return;
|
|
}
|
|
try {
|
|
await api.deleteWorkspaceMember(selectedProject.workspace_id, userId);
|
|
setCurrentProjectMembers((current) => current.filter((m) => m.user_id !== userId));
|
|
onNotify({ tone: "success", message: "成员已移除" });
|
|
} catch (error) {
|
|
onNotify({
|
|
tone: "error",
|
|
message: error instanceof ApiRequestError ? error.message : "移除成员失败",
|
|
});
|
|
}
|
|
};
|
|
|
|
const importMember = async (): Promise<void> => {
|
|
if (!selectedProject || selectedUserIds.length === 0) {
|
|
onNotify({ tone: "error", message: "请选择要添加的用户" });
|
|
return;
|
|
}
|
|
setSaving(true);
|
|
try {
|
|
await Promise.all(
|
|
selectedUserIds.map((userId) =>
|
|
api.addWorkspaceMember(selectedProject.workspace_id, {
|
|
user_id: userId,
|
|
})
|
|
)
|
|
);
|
|
setImportMemberDialogOpen(false);
|
|
onNotify({ tone: "success", message: `已添加 ${selectedUserIds.length} 名成员` });
|
|
} catch (error) {
|
|
onNotify({
|
|
tone: "error",
|
|
message: error instanceof ApiRequestError ? error.message : "添加成员失败",
|
|
});
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
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={PRIMARY_BUTTON_CLASS}
|
|
>
|
|
<Plus size={15} />
|
|
新建项目
|
|
</Button>
|
|
</div>
|
|
|
|
{!canManage && <div className={adminReadonlyClass}>当前为开发人员,只能查看项目列表。</div>}
|
|
<Table className="rounded-[9px] border border-[#dfe7ef] overflow-hidden text-[11px] text-[#44576a] [--border-color:#dfe7ef] table-fixed bg-white">
|
|
<AdminColgroup widths={USER_PROJECT_COL_WIDTHS} />
|
|
<TableHeader className="bg-[#f5f8fb] text-[#748598]">
|
|
<TableRow className="hover:bg-transparent border-b border-[#edf1f5]">
|
|
<TableHead className="h-10 px-4 text-[10px] font-bold text-[#748598]">项目名称</TableHead>
|
|
<TableHead className="h-10 px-4 text-[10px] font-bold text-[#748598]">项目编码</TableHead>
|
|
<TableHead className="h-10 px-4 text-[10px] font-bold text-[#748598]">状态</TableHead>
|
|
<TableHead className="h-10 px-4 text-[10px] font-bold text-[#748598]">配额</TableHead>
|
|
<TableHead className="h-10 px-4 text-[10px] font-bold text-[#748598]">操作</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{projectLoading ? (
|
|
<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>
|
|
</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>
|
|
</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">
|
|
<span>
|
|
<span className={statusPillClass(project.status)}>
|
|
{project.status === "active" ? "正常" : project.status === "archived" ? "已归档" : "已删除"}
|
|
</span>
|
|
</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>
|
|
|
|
<ProjectEditDialog
|
|
open={projectDialogOpen}
|
|
editingProject={editingProject}
|
|
projectForm={projectForm}
|
|
saving={saving}
|
|
onNotify={onNotify}
|
|
onChangeForm={setProjectForm}
|
|
onSubmit={(event) => void submitProject(event)}
|
|
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={() => {
|
|
setMembersDrawerOpen(false);
|
|
if (selectedProject) void openImportMemberDialog(selectedProject);
|
|
}}
|
|
onRemoveMember={(userId) => void removeMember(userId)}
|
|
onNotify={onNotify}
|
|
/>
|
|
|
|
<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);
|
|
}}
|
|
/>
|
|
</section>
|
|
);
|
|
}
|