geo/frontend/app/(dashboard)/dashboard/clients/page.tsx

507 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useState, useMemo } from "react";
import Image from "next/image";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import {
Table,
TableHeader,
TableBody,
TableRow,
TableHead,
TableCell,
} from "@/components/ui/table";
import {
Dialog,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { LoadingState, ErrorState, EmptyState } from "@/components/ui/api-states";
import { useApi } from "@/lib/hooks/use-api";
import { fetchWithAuth } from "@/lib/api/client";
import type {
OrganizationInfo,
OrganizationMember,
MemberRole,
MemberStatus,
InviteMemberPayload,
} from "@/lib/api/organization";
import { Users, Mail, Search, Plus, MoreVertical, Shield, UserCheck, Eye, Trash2 } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
const roleConfig: Record<MemberRole, { label: string; icon: React.ReactNode; color: string }> = {
admin: {
label: "管理员",
icon: <Shield className="h-3.5 w-3.5" />,
color: "bg-red-50 text-red-700 border-red-200",
},
member: {
label: "成员",
icon: <UserCheck className="h-3.5 w-3.5" />,
color: "bg-blue-50 text-blue-700 border-blue-200",
},
viewer: {
label: "查看者",
icon: <Eye className="h-3.5 w-3.5" />,
color: "bg-gray-50 text-gray-700 border-gray-200",
},
};
const statusConfig: Record<MemberStatus, { label: string; color: string }> = {
active: {
label: "活跃",
color: "bg-green-100 text-green-700",
},
pending: {
label: "待加入",
color: "bg-yellow-100 text-yellow-700",
},
inactive: {
label: "已停用",
color: "bg-gray-100 text-gray-600",
},
};
function getInitials(name: string): string {
return name.slice(0, 2).toUpperCase();
}
function formatDate(dateStr: string): string {
const date = new Date(dateStr);
return date.toLocaleDateString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
});
}
export default function ClientsPage() {
const [searchQuery, setSearchQuery] = useState("");
const [roleFilter, setRoleFilter] = useState<string>("all");
const [inviteDialogOpen, setInviteDialogOpen] = useState(false);
const [inviteEmail, setInviteEmail] = useState("");
const [inviteRole, setInviteRole] = useState<MemberRole>("member");
const [selectedMember, setSelectedMember] = useState<OrganizationMember | null>(null);
const [roleChangeDialogOpen, setRoleChangeDialogOpen] = useState(false);
const [newRole, setNewRole] = useState<MemberRole>("member");
const [removeDialogOpen, setRemoveDialogOpen] = useState(false);
const [isMutating, setIsMutating] = useState(false);
const {
data: orgInfo,
isLoading: orgLoading,
error: orgError,
} = useApi<OrganizationInfo>("/api/v1/organization/info");
const {
data: members,
isLoading: membersLoading,
error: membersError,
refresh: refreshMembers,
} = useApi<OrganizationMember[]>("/api/v1/organization/members");
const filteredMembers = useMemo(() => {
const memberList = members || [];
return memberList.filter((member) => {
const matchesSearch =
!searchQuery ||
member.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
member.email.toLowerCase().includes(searchQuery.toLowerCase());
const matchesRole = roleFilter === "all" || member.role === roleFilter;
return matchesSearch && matchesRole;
});
}, [members, searchQuery, roleFilter]);
const safeOrgInfo = orgInfo ?? null;
const loading = orgLoading || membersLoading;
const handleInvite = async () => {
if (!inviteEmail) return;
try {
setIsMutating(true);
await fetchWithAuth("/api/v1/organization/members/invite", {
method: "POST",
body: JSON.stringify({ email: inviteEmail, role: inviteRole }),
});
setInviteDialogOpen(false);
setInviteEmail("");
setInviteRole("member");
refreshMembers();
} finally {
setIsMutating(false);
}
};
const handleRoleChange = async () => {
if (!selectedMember) return;
try {
setIsMutating(true);
await fetchWithAuth(`/api/v1/organization/members/${selectedMember.id}/role`, {
method: "PUT",
body: JSON.stringify({ role: newRole }),
});
setRoleChangeDialogOpen(false);
setSelectedMember(null);
refreshMembers();
} finally {
setIsMutating(false);
}
};
const handleRemoveMember = async () => {
if (!selectedMember) return;
try {
setIsMutating(true);
await fetchWithAuth(`/api/v1/organization/members/${selectedMember.id}`, {
method: "DELETE",
});
setRemoveDialogOpen(false);
setSelectedMember(null);
refreshMembers();
} finally {
setIsMutating(false);
}
};
if (loading) {
return (
<div className="space-y-6">
<div className="mb-8">
<LoadingState rows={1} rowHeight="h-8" />
<div className="mt-2">
<LoadingState rows={1} rowHeight="h-4" />
</div>
</div>
<LoadingState rows={1} rowHeight="h-32" />
<LoadingState rows={5} rowHeight="h-16" />
</div>
);
}
if (orgError || membersError) {
return (
<div className="space-y-6">
<div className="mb-8">
<h1 className="text-2xl font-bold text-gray-900"></h1>
</div>
<ErrorState
error={orgError || membersError!}
onRetry={() => window.location.reload()}
/>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900"></h1>
<p className="mt-1 text-sm text-gray-500"></p>
</div>
<Button onClick={() => setInviteDialogOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
</Button>
</div>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Users className="h-5 w-5 text-gray-500" />
</CardTitle>
</CardHeader>
<CardContent>
{safeOrgInfo ? (
<div className="grid gap-4 md:grid-cols-3">
<div>
<p className="text-sm text-gray-500"></p>
<p className="mt-1 text-lg font-semibold text-gray-900">
{safeOrgInfo.name}
</p>
</div>
<div>
<p className="text-sm text-gray-500"></p>
<p className="mt-1 text-lg font-semibold text-gray-900">
{safeOrgInfo.member_count}
</p>
</div>
<div>
<p className="text-sm text-gray-500"></p>
<p className="mt-1 text-lg font-semibold text-gray-900">
{formatDate(safeOrgInfo.created_at)}
</p>
</div>
</div>
) : (
<p className="text-sm text-muted-foreground">...</p>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Users className="h-5 w-5 text-gray-500" />
</CardTitle>
</CardHeader>
<CardContent>
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
<Input
placeholder="搜索姓名或邮箱..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
/>
</div>
<Select value={roleFilter} onValueChange={setRoleFilter}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="按角色筛选" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="admin"></SelectItem>
<SelectItem value="member"></SelectItem>
<SelectItem value="viewer"></SelectItem>
</SelectContent>
</Select>
</div>
{filteredMembers.length === 0 ? (
<EmptyState
icon={<Users className="h-6 w-6 text-gray-400" />}
message="没有找到成员"
description="尝试调整搜索条件或筛选器"
/>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-[80px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredMembers.map((member) => (
<TableRow key={member.id}>
<TableCell>
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary text-sm font-bold">
{member.avatar_url ? (
<Image
src={member.avatar_url}
alt={member.name}
width={40}
height={40}
className="h-full w-full rounded-full object-cover"
/>
) : (
getInitials(member.name)
)}
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">
{member.name}
</p>
<p className="text-xs text-gray-500 truncate">
{member.email}
</p>
</div>
</div>
</TableCell>
<TableCell>
<Badge
variant="outline"
className={`flex items-center gap-1.5 ${roleConfig[member.role].color}`}
>
{roleConfig[member.role].icon}
{roleConfig[member.role].label}
</Badge>
</TableCell>
<TableCell>
<Badge
variant="secondary"
className={statusConfig[member.status].color}
>
{statusConfig[member.status].label}
</Badge>
</TableCell>
<TableCell className="text-sm text-gray-500">
{formatDate(member.joined_at)}
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
setSelectedMember(member);
setNewRole(member.role);
setRoleChangeDialogOpen(true);
}}
>
<Shield className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem
className="text-red-600 focus:text-red-600"
onClick={() => {
setSelectedMember(member);
setRemoveDialogOpen(true);
}}
>
<Trash2 className="mr-2 h-4 w-4" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<Dialog open={inviteDialogOpen} onOpenChange={setInviteDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Mail className="h-5 w-5" />
</DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700"></label>
<Input
type="email"
placeholder="example@company.com"
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700"></label>
<Select
value={inviteRole}
onValueChange={(value) => setInviteRole(value as MemberRole)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="admin"> - </SelectItem>
<SelectItem value="member"> - </SelectItem>
<SelectItem value="viewer"> - </SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setInviteDialogOpen(false)}>
</Button>
<Button onClick={handleInvite} disabled={!inviteEmail || isMutating}>
{isMutating ? "发送中..." : "发送邀请"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={roleChangeDialogOpen} onOpenChange={setRoleChangeDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
{selectedMember?.name}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700"></label>
<Select
value={newRole}
onValueChange={(value) => setNewRole(value as MemberRole)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="admin"> - </SelectItem>
<SelectItem value="member"> - </SelectItem>
<SelectItem value="viewer"> - </SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setRoleChangeDialogOpen(false)}>
</Button>
<Button onClick={handleRoleChange} disabled={isMutating}>
{isMutating ? "保存中..." : "保存"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={removeDialogOpen} onOpenChange={setRemoveDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
{selectedMember?.name}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setRemoveDialogOpen(false)}>
</Button>
<Button
variant="destructive"
onClick={handleRemoveMember}
disabled={isMutating}
>
{isMutating ? "移除中..." : "确认移除"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}