feat: add user management features including user creation, editing, and password reset dialogs
- Implemented CreateUserDrawer for adding new users with validation. - Added EditDrawer for editing existing user details. - Created InitialPasswordDialog to display generated passwords after user creation. - Developed ResetPasswordDialogs for resetting user passwords with copy functionality. - Enhanced user listing page with filtering, pagination, and role assignment capabilities. - Updated company picker import path to reflect new directory structure.
This commit is contained in:
@@ -0,0 +1,287 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
MoreHorizontalIcon,
|
||||
Pencil,
|
||||
PlusIcon,
|
||||
RefreshCwIcon,
|
||||
SearchIcon,
|
||||
Trash2Icon,
|
||||
UsersRoundIcon,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { EditDialog } from "./team-edit";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
createAgentTeam,
|
||||
deleteAgentTeam,
|
||||
fetchAgentTeams,
|
||||
updateAgentTeam,
|
||||
type AdminAgentTeam,
|
||||
type CreateAdminAgentTeamPayload,
|
||||
} from "@/lib/api/admin";
|
||||
import { Status, StatusLabels } from "@/lib/generated/enums";
|
||||
import { getEnumLabel } from "@/lib/enums";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type AgentTeamSidebarProps = {
|
||||
selectedTeamId: number | null;
|
||||
onSelectTeam: (team: AdminAgentTeam | null) => void;
|
||||
onTeamsChange?: (teams: AdminAgentTeam[]) => void;
|
||||
};
|
||||
|
||||
const statusTabs = [
|
||||
{ value: "all", label: "全部" },
|
||||
{ value: String(Status.Ok), label: StatusLabels[Status.Ok] },
|
||||
{ value: String(Status.Disabled), label: StatusLabels[Status.Disabled] },
|
||||
] as const;
|
||||
|
||||
export function AgentTeamSidebar({
|
||||
selectedTeamId,
|
||||
onSelectTeam,
|
||||
onTeamsChange,
|
||||
}: AgentTeamSidebarProps) {
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [statusFilter, setStatusFilter] =
|
||||
useState<(typeof statusTabs)[number]["value"]>("all");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [actionLoadingId, setActionLoadingId] = useState<number | null>(null);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingItem, setEditingItem] = useState<AdminAgentTeam | null>(null);
|
||||
const [teams, setTeams] = useState<AdminAgentTeam[]>([]);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await fetchAgentTeams({ page: 1, limit: 200 });
|
||||
setTeams(data);
|
||||
onTeamsChange?.(data);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "加载客服组失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [onTeamsChange]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadData();
|
||||
}, [loadData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedTeamId == null) {
|
||||
return;
|
||||
}
|
||||
const matchedTeam =
|
||||
teams.find((item) => item.id === selectedTeamId) ?? null;
|
||||
if (matchedTeam) {
|
||||
onSelectTeam(matchedTeam);
|
||||
return;
|
||||
}
|
||||
if (!loading && teams.length > 0) {
|
||||
onSelectTeam(teams[0]);
|
||||
}
|
||||
}, [loading, onSelectTeam, selectedTeamId, teams]);
|
||||
|
||||
const filteredTeams = useMemo(() => {
|
||||
const output = keyword.trim().toLowerCase();
|
||||
return teams.filter((item) => {
|
||||
const matchedKeyword =
|
||||
output.length === 0 ||
|
||||
item.name.toLowerCase().includes(output) ||
|
||||
item.description.toLowerCase().includes(output);
|
||||
const matchedStatus =
|
||||
statusFilter === "all" || String(item.status) === statusFilter;
|
||||
return matchedKeyword && matchedStatus;
|
||||
});
|
||||
}, [keyword, statusFilter, teams]);
|
||||
|
||||
function openCreateDialog() {
|
||||
setEditingItem(null);
|
||||
setDialogOpen(true);
|
||||
}
|
||||
|
||||
function openEditDialog(item: AdminAgentTeam) {
|
||||
setEditingItem(item);
|
||||
setDialogOpen(true);
|
||||
}
|
||||
|
||||
function handleDialogOpenChange(open: boolean) {
|
||||
if (saving) {
|
||||
return;
|
||||
}
|
||||
if (!open) {
|
||||
setEditingItem(null);
|
||||
}
|
||||
setDialogOpen(open);
|
||||
}
|
||||
|
||||
async function handleSubmit(payload: CreateAdminAgentTeamPayload) {
|
||||
if (saving) {
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editingItem) {
|
||||
await updateAgentTeam({ id: editingItem.id, ...payload });
|
||||
toast.success(`已更新客服组:${editingItem.name}`);
|
||||
} else {
|
||||
await createAgentTeam(payload);
|
||||
toast.success(`已创建客服组:${payload.name}`);
|
||||
}
|
||||
setDialogOpen(false);
|
||||
setEditingItem(null);
|
||||
await loadData();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "保存客服组失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(item: AdminAgentTeam) {
|
||||
setActionLoadingId(item.id);
|
||||
try {
|
||||
await deleteAgentTeam(item.id);
|
||||
toast.success(`已删除客服组:${item.name}`);
|
||||
await loadData();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "删除客服组失败");
|
||||
} finally {
|
||||
setActionLoadingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-full flex-col border-r bg-muted/10">
|
||||
<div className="border-b px-3 py-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<div className="text-sm font-medium">客服组</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative mt-3">
|
||||
<SearchIcon className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={keyword}
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
placeholder="搜索客服组"
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{statusTabs.map((item) => (
|
||||
<Button
|
||||
key={item.value}
|
||||
variant={statusFilter === item.value ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setStatusFilter(item.value)}
|
||||
>
|
||||
{item.label}
|
||||
</Button>
|
||||
))}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void loadData()}
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCwIcon
|
||||
className={cn("size-4", loading && "animate-spin")}
|
||||
/>
|
||||
</Button>
|
||||
|
||||
<Button size="icon-sm" onClick={openCreateDialog}>
|
||||
<PlusIcon />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="px-2 py-2">
|
||||
{filteredTeams.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={cn(
|
||||
"group mt-1 flex items-center gap-2 rounded-lg px-2 py-2 text-sm transition-colors hover:bg-accent",
|
||||
selectedTeamId === item.id &&
|
||||
"bg-accent text-accent-foreground",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 flex-1 items-center gap-2 text-left"
|
||||
onClick={() => onSelectTeam(item)}
|
||||
>
|
||||
<UsersRoundIcon className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-medium">
|
||||
{item.name}
|
||||
</span>
|
||||
</span>
|
||||
<Badge
|
||||
variant={
|
||||
item.status === Status.Ok ? "secondary" : "outline"
|
||||
}
|
||||
>
|
||||
{getEnumLabel(StatusLabels, item.status as Status)}
|
||||
</Badge>
|
||||
</button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="opacity-0 group-hover:opacity-100"
|
||||
/>
|
||||
}
|
||||
aria-label={`更多操作 ${item.name}`}
|
||||
>
|
||||
<MoreHorizontalIcon />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40 min-w-40">
|
||||
<DropdownMenuItem onClick={() => openEditDialog(item)}>
|
||||
<Pencil />
|
||||
编辑
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => void handleDelete(item)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2Icon />
|
||||
{actionLoadingId === item.id ? "删除中..." : "删除"}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
))}
|
||||
{!loading && filteredTeams.length === 0 ? (
|
||||
<div className="px-2 py-10 text-center text-sm text-muted-foreground">
|
||||
没有匹配的客服组
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
<EditDialog
|
||||
open={dialogOpen}
|
||||
saving={saving}
|
||||
itemId={editingItem?.id ?? null}
|
||||
onOpenChange={handleDialogOpenChange}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user