507 lines
18 KiB
TypeScript
507 lines
18 KiB
TypeScript
"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>
|
||
);
|
||
}
|