f7a720204a
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用 - 添加所有子项目的完整源代码 - 保留原始 .git 为 .git.bak 备份
303 lines
11 KiB
TypeScript
303 lines
11 KiB
TypeScript
"use client";
|
||
|
||
import { useState } from "react";
|
||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||
import api from "@/lib/api";
|
||
import type { PlatformUser, PlatformOrg } from "@/lib/types";
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Input } from "@/components/ui/input";
|
||
import {
|
||
Select,
|
||
SelectContent,
|
||
SelectItem,
|
||
SelectTrigger,
|
||
SelectValue,
|
||
} from "@/components/ui/select";
|
||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||
import { toast } from "sonner";
|
||
import { Building2 } from "lucide-react";
|
||
import { Pagination } from "@/components/ui/pagination";
|
||
|
||
const roleLabels: Record<string, string> = {
|
||
super_admin: "平台管理员",
|
||
admin: "机构管理员",
|
||
creator: "创作者",
|
||
user: "普通用户",
|
||
};
|
||
|
||
const roleColors: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
|
||
super_admin: "destructive",
|
||
admin: "default",
|
||
creator: "secondary",
|
||
user: "outline",
|
||
};
|
||
|
||
export default function PlatformUsersPage() {
|
||
const qc = useQueryClient();
|
||
const [search, setSearch] = useState("");
|
||
const [orgFilter, setOrgFilter] = useState("all");
|
||
const [roleFilter, setRoleFilter] = useState("all");
|
||
const [page, setPage] = useState(1);
|
||
const [migrateTarget, setMigrateTarget] = useState<PlatformUser | null>(null);
|
||
const [newOrgID, setNewOrgID] = useState("");
|
||
|
||
const { data: orgs } = useQuery({
|
||
queryKey: ["platformOrgs"],
|
||
queryFn: () => api.get<PlatformOrg[]>("/api/v1/platform/orgs"),
|
||
});
|
||
|
||
const { data } = useQuery({
|
||
queryKey: ["platformUsers", search, orgFilter, roleFilter, page],
|
||
queryFn: () => {
|
||
const p = new URLSearchParams();
|
||
p.set("page", String(page));
|
||
if (search) p.set("q", search);
|
||
if (orgFilter !== "all") p.set("org_id", orgFilter);
|
||
if (roleFilter !== "all") p.set("role", roleFilter);
|
||
return api.get<{
|
||
items: PlatformUser[];
|
||
total: number;
|
||
page: number;
|
||
page_size: number;
|
||
}>(`/api/v1/platform/users?${p}`);
|
||
},
|
||
});
|
||
|
||
// 切换过滤条件时重置到第一页
|
||
const resetSearch = (v: string) => {
|
||
setSearch(v);
|
||
setPage(1);
|
||
};
|
||
const resetOrg = (v: string | null) => {
|
||
setOrgFilter(v ?? "all");
|
||
setPage(1);
|
||
};
|
||
const resetRole = (v: string | null) => {
|
||
setRoleFilter(v ?? "all");
|
||
setPage(1);
|
||
};
|
||
|
||
const updateRole = useMutation({
|
||
mutationFn: ({ id, role }: { id: string; role: string }) =>
|
||
api.put(`/api/v1/platform/users/${id}/role`, { role }),
|
||
onSuccess: () => {
|
||
qc.invalidateQueries({ queryKey: ["platformUsers"] });
|
||
toast.success("角色已更新");
|
||
},
|
||
onError: (e: Error) => toast.error(e.message),
|
||
});
|
||
|
||
const updateStatus = useMutation({
|
||
mutationFn: ({ id, status }: { id: string; status: string }) =>
|
||
api.put(`/api/v1/platform/users/${id}/status`, { status }),
|
||
onSuccess: () => {
|
||
qc.invalidateQueries({ queryKey: ["platformUsers"] });
|
||
toast.success("状态已更新");
|
||
},
|
||
onError: (e: Error) => toast.error(e.message),
|
||
});
|
||
|
||
const assignOrg = useMutation({
|
||
mutationFn: ({ id, org_id }: { id: string; org_id: string }) =>
|
||
api.put(`/api/v1/platform/users/${id}/org`, { org_id }),
|
||
onSuccess: () => {
|
||
qc.invalidateQueries({ queryKey: ["platformUsers"] });
|
||
toast.success("已迁移到目标机构");
|
||
setMigrateTarget(null);
|
||
setNewOrgID("");
|
||
},
|
||
onError: (e: Error) => toast.error(e.message),
|
||
});
|
||
|
||
return (
|
||
<div>
|
||
<div className="flex items-center justify-between mb-4">
|
||
<h1 className="text-2xl font-bold">全局用户</h1>
|
||
<span className="text-xs text-muted-foreground">共 {data?.total ?? 0} 条</span>
|
||
</div>
|
||
|
||
<div className="flex flex-wrap items-center gap-3 mb-4">
|
||
<Input
|
||
placeholder="搜索姓名或邮箱..."
|
||
value={search}
|
||
onChange={(e) => resetSearch(e.target.value)}
|
||
className="w-64"
|
||
/>
|
||
<Select value={orgFilter} onValueChange={resetOrg}>
|
||
<SelectTrigger className="w-40">
|
||
<span>
|
||
{orgFilter === "all"
|
||
? "全部机构"
|
||
: orgs?.find((o) => o.id === orgFilter)?.short_name ||
|
||
orgs?.find((o) => o.id === orgFilter)?.name ||
|
||
orgFilter}
|
||
</span>
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="all">全部机构</SelectItem>
|
||
{orgs?.map((o) => (
|
||
<SelectItem key={o.id} value={o.id}>
|
||
{o.short_name || o.name}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
<Select value={roleFilter} onValueChange={resetRole}>
|
||
<SelectTrigger className="w-36">
|
||
<span>{roleFilter === "all" ? "全部角色" : roleLabels[roleFilter] || roleFilter}</span>
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="all">全部角色</SelectItem>
|
||
<SelectItem value="super_admin">平台管理员</SelectItem>
|
||
<SelectItem value="admin">机构管理员</SelectItem>
|
||
<SelectItem value="creator">创作者</SelectItem>
|
||
<SelectItem value="user">普通用户</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
<Pagination
|
||
page={data?.page ?? 1}
|
||
pageSize={data?.page_size ?? 20}
|
||
total={data?.total ?? 0}
|
||
onChange={setPage}
|
||
/>
|
||
|
||
<div className="border rounded-lg overflow-hidden">
|
||
<table className="w-full text-sm">
|
||
<thead className="bg-muted/50">
|
||
<tr>
|
||
<th className="text-left p-3">用户</th>
|
||
<th className="text-left p-3">所属机构</th>
|
||
<th className="text-left p-3">角色</th>
|
||
<th className="text-left p-3">状态</th>
|
||
<th className="text-left p-3">登录</th>
|
||
<th className="text-left p-3">操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{data?.items?.map((u) => (
|
||
<tr key={u.id} className="border-t">
|
||
<td className="p-3">
|
||
<div className="flex items-center gap-2">
|
||
<Avatar className="h-8 w-8">
|
||
<AvatarImage src={u.avatar_url} />
|
||
<AvatarFallback>{u.name.charAt(0)}</AvatarFallback>
|
||
</Avatar>
|
||
<div>
|
||
<div className="font-medium">{u.name}</div>
|
||
<div className="text-xs text-muted-foreground">{u.email}</div>
|
||
</div>
|
||
</div>
|
||
</td>
|
||
<td className="p-3">
|
||
{u.org_name ? (
|
||
<Badge variant="outline" className="gap-1">
|
||
<Building2 className="h-3 w-3" />
|
||
{u.org_short || u.org_name}
|
||
</Badge>
|
||
) : (
|
||
<span className="text-xs text-muted-foreground italic">—(平台级)</span>
|
||
)}
|
||
</td>
|
||
<td className="p-3">
|
||
<Badge variant={roleColors[u.role]}>{roleLabels[u.role]}</Badge>
|
||
</td>
|
||
<td className="p-3">
|
||
<Badge variant={u.status === "active" ? "default" : "destructive"}>
|
||
{u.status === "active" ? "正常" : "禁用"}
|
||
</Badge>
|
||
</td>
|
||
<td className="p-3 text-muted-foreground text-xs">{u.login_count} 次</td>
|
||
<td className="p-3">
|
||
<div className="flex items-center gap-1.5">
|
||
<Select
|
||
defaultValue={u.role}
|
||
onValueChange={(role) => role && updateRole.mutate({ id: u.id, role })}
|
||
>
|
||
<SelectTrigger className="w-28 h-7 text-xs">
|
||
<span>{roleLabels[u.role] || u.role}</span>
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="user">普通用户</SelectItem>
|
||
<SelectItem value="creator">创作者</SelectItem>
|
||
<SelectItem value="admin">机构管理员</SelectItem>
|
||
<SelectItem value="super_admin">平台管理员</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
className="h-7 text-xs"
|
||
onClick={() => {
|
||
setMigrateTarget(u);
|
||
setNewOrgID(u.org_id || "");
|
||
}}
|
||
>
|
||
迁移
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
className="h-7 text-xs"
|
||
onClick={() =>
|
||
updateStatus.mutate({
|
||
id: u.id,
|
||
status: u.status === "active" ? "disabled" : "active",
|
||
})
|
||
}
|
||
>
|
||
{u.status === "active" ? "禁用" : "启用"}
|
||
</Button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
{/* 迁移机构对话框 */}
|
||
<Dialog open={!!migrateTarget} onOpenChange={(o) => !o && setMigrateTarget(null)}>
|
||
<DialogContent className="max-w-md">
|
||
<DialogHeader>
|
||
<DialogTitle>迁移用户机构</DialogTitle>
|
||
</DialogHeader>
|
||
<div className="space-y-4">
|
||
<p className="text-sm text-muted-foreground">
|
||
将用户「{migrateTarget?.name}」迁移到目标机构
|
||
</p>
|
||
<Select value={newOrgID} onValueChange={(v) => setNewOrgID(v ?? "")}>
|
||
<SelectTrigger>
|
||
<SelectValue placeholder="选择目标机构" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{orgs?.map((o) => (
|
||
<SelectItem key={o.id} value={o.id}>
|
||
{o.name}({o.short_name})
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
<div className="flex justify-end gap-2">
|
||
<Button variant="outline" onClick={() => setMigrateTarget(null)}>
|
||
取消
|
||
</Button>
|
||
<Button
|
||
disabled={!newOrgID || newOrgID === migrateTarget?.org_id}
|
||
onClick={() =>
|
||
migrateTarget && assignOrg.mutate({ id: migrateTarget.id, org_id: newOrgID })
|
||
}
|
||
>
|
||
确认迁移
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</div>
|
||
);
|
||
}
|