feat(api, i18n): add agent_node_id to various admin queries and enhance multi-language support
Introduced the agent_node_id field in AdminDrawListQuery, AdminPlayerListQuery, AdminSettlementBatchListQuery, TicketItemsListQuery, and TransferOrderListQuery to improve filtering capabilities. Updated the admin-breadcrumb and admin-sidebar components to include new translations for agent-related terms in English, Nepali, and Chinese, enhancing the overall user experience and multi-language support across the admin interface.
This commit is contained in:
1084
src/modules/agents/agents-console.tsx
Normal file
1084
src/modules/agents/agents-console.tsx
Normal file
@@ -0,0 +1,1084 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronRight, KeyRound, Pencil, Plus, Search, Shield, Trash2, Users } from "lucide-react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
deleteAgentNode,
|
||||
deleteAgentRole,
|
||||
getAgentNodeAdminUsers,
|
||||
getAgentNodeRoles,
|
||||
getAgentTree,
|
||||
postAgentAdminUser,
|
||||
postAgentNode,
|
||||
postAgentRole,
|
||||
putAgentAdminUserRoles,
|
||||
putAgentNode,
|
||||
putAgentRole,
|
||||
putAgentRolePermissions,
|
||||
getAgentDelegationGrants,
|
||||
putAgentDelegationGrants,
|
||||
} from "@/api/admin-agents";
|
||||
import { getAdminIntegrationSites } from "@/api/admin-integration-sites";
|
||||
import { getAdminUserPermissionCatalog } from "@/api/admin-users";
|
||||
import { AdminPageCard } from "@/components/admin/admin-page-card";
|
||||
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||
import {
|
||||
PRD_AGENT_MANAGE,
|
||||
PRD_AGENT_ROLE_MANAGE,
|
||||
PRD_AGENT_ROLE_VIEW,
|
||||
PRD_AGENT_USER_MANAGE,
|
||||
PRD_AGENT_USER_VIEW,
|
||||
} from "@/lib/admin-prd";
|
||||
import { resolveRoleStatusTone } from "@/lib/admin-status-tone";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAdminProfile } from "@/stores/admin-session";
|
||||
import type { AgentDelegationGrantRow, AgentNodeRow } from "@/types/api/admin-agent";
|
||||
import type { AdminPermissionCatalogData, AdminRoleRow, AdminUserPermissionRow } from "@/types/api/admin-user";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
|
||||
function flattenTree(nodes: AgentNodeRow[]): AgentNodeRow[] {
|
||||
const out: AgentNodeRow[] = [];
|
||||
const walk = (list: AgentNodeRow[]) => {
|
||||
for (const node of list) {
|
||||
out.push(node);
|
||||
if (node.children?.length) {
|
||||
walk(node.children);
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(nodes);
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function filterTree(nodes: AgentNodeRow[], keyword: string): AgentNodeRow[] {
|
||||
const normalized = keyword.trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return nodes;
|
||||
}
|
||||
|
||||
const filterNode = (node: AgentNodeRow): AgentNodeRow | null => {
|
||||
const children = node.children
|
||||
?.map((child) => filterNode(child))
|
||||
.filter((child): child is AgentNodeRow => child !== null) ?? [];
|
||||
const selfMatch =
|
||||
node.name.toLowerCase().includes(normalized) || node.code.toLowerCase().includes(normalized);
|
||||
if (!selfMatch && children.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...node,
|
||||
children,
|
||||
};
|
||||
};
|
||||
|
||||
return nodes.map((node) => filterNode(node)).filter((node): node is AgentNodeRow => node !== null);
|
||||
}
|
||||
|
||||
function AgentTreeNodes({
|
||||
nodes,
|
||||
depth,
|
||||
selectedId,
|
||||
expandedIds,
|
||||
onToggleExpand,
|
||||
onSelect,
|
||||
}: {
|
||||
nodes: AgentNodeRow[];
|
||||
depth: number;
|
||||
selectedId: number | null;
|
||||
expandedIds: Set<number>;
|
||||
onToggleExpand: (nodeId: number) => void;
|
||||
onSelect: (node: AgentNodeRow) => void;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<ul className={depth === 0 ? "space-y-0.5" : "ml-3 border-l border-border pl-2"}>
|
||||
{nodes.map((node) => (
|
||||
<li key={node.id}>
|
||||
<div
|
||||
className={cn(
|
||||
"group flex items-center gap-1 rounded-md pr-2",
|
||||
selectedId === node.id ? "bg-primary/5 ring-1 ring-primary/20" : "hover:bg-muted/60",
|
||||
)}
|
||||
>
|
||||
{node.children && node.children.length > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="toggle children"
|
||||
onClick={() => onToggleExpand(node.id)}
|
||||
className="ml-1 rounded-sm p-0.5 text-muted-foreground hover:bg-muted"
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"size-3.5 shrink-0 transition-transform",
|
||||
expandedIds.has(node.id) && "rotate-90",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<span className="ml-1 size-4 shrink-0" />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(node)}
|
||||
className={cn(
|
||||
"flex min-w-0 flex-1 items-center gap-1 rounded-md py-1.5 text-left text-sm",
|
||||
selectedId === node.id && "font-medium text-foreground",
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{node.name}</span>
|
||||
<span className="ml-auto font-mono text-[11px] text-muted-foreground">{node.code}</span>
|
||||
</button>
|
||||
</div>
|
||||
{node.children && node.children.length > 0 && expandedIds.has(node.id) ? (
|
||||
<AgentTreeNodes
|
||||
nodes={node.children}
|
||||
depth={depth + 1}
|
||||
selectedId={selectedId}
|
||||
expandedIds={expandedIds}
|
||||
onToggleExpand={onToggleExpand}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentsConsole(): React.ReactElement {
|
||||
const { t } = useTranslation(["agents", "adminUsers", "common"]);
|
||||
const tRef = useTranslationRef(["agents", "adminUsers", "common"]);
|
||||
const profile = useAdminProfile();
|
||||
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
||||
|
||||
const canManageNode = adminHasAnyPermission(profile?.permissions, [PRD_AGENT_MANAGE]);
|
||||
const canViewRoles = adminHasAnyPermission(profile?.permissions, [PRD_AGENT_ROLE_VIEW, PRD_AGENT_ROLE_MANAGE]);
|
||||
const canManageRoles = adminHasAnyPermission(profile?.permissions, [PRD_AGENT_ROLE_MANAGE]);
|
||||
const canViewUsers = adminHasAnyPermission(profile?.permissions, [PRD_AGENT_USER_VIEW, PRD_AGENT_USER_MANAGE]);
|
||||
const canManageUsers = adminHasAnyPermission(profile?.permissions, [PRD_AGENT_USER_MANAGE]);
|
||||
const isSuperAdmin = profile?.is_super_admin === true;
|
||||
|
||||
const [siteOptions, setSiteOptions] = useState<{ id: number; label: string }[]>([]);
|
||||
const [adminSiteId, setAdminSiteId] = useState<number | null>(null);
|
||||
const [tree, setTree] = useState<AgentNodeRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
const [treeKeyword, setTreeKeyword] = useState("");
|
||||
const [expandedNodeIds, setExpandedNodeIds] = useState<Set<number>>(new Set());
|
||||
|
||||
const [roles, setRoles] = useState<AdminRoleRow[]>([]);
|
||||
const [users, setUsers] = useState<AdminUserPermissionRow[]>([]);
|
||||
const [catalog, setCatalog] = useState<AdminPermissionCatalogData | null>(null);
|
||||
|
||||
const [nodeDialogOpen, setNodeDialogOpen] = useState(false);
|
||||
const [nodeDialogMode, setNodeDialogMode] = useState<"create" | "edit">("create");
|
||||
const [nodeCode, setNodeCode] = useState("");
|
||||
const [nodeName, setNodeName] = useState("");
|
||||
const [nodeStatus, setNodeStatus] = useState(1);
|
||||
const [nodeSaving, setNodeSaving] = useState(false);
|
||||
|
||||
const [roleDialogOpen, setRoleDialogOpen] = useState(false);
|
||||
const [roleSlug, setRoleSlug] = useState("");
|
||||
const [roleName, setRoleName] = useState("");
|
||||
const [rolePerms, setRolePerms] = useState<string[]>([]);
|
||||
const [roleSaving, setRoleSaving] = useState(false);
|
||||
|
||||
const [permDialogOpen, setPermDialogOpen] = useState(false);
|
||||
const [permRoleId, setPermRoleId] = useState<number | null>(null);
|
||||
const [draftPerms, setDraftPerms] = useState<string[]>([]);
|
||||
const [permSaving, setPermSaving] = useState(false);
|
||||
|
||||
const [userDialogOpen, setUserDialogOpen] = useState(false);
|
||||
const [userUsername, setUserUsername] = useState("");
|
||||
const [userNickname, setUserNickname] = useState("");
|
||||
const [userPassword, setUserPassword] = useState("");
|
||||
const [userRoleIds, setUserRoleIds] = useState<number[]>([]);
|
||||
const [userSaving, setUserSaving] = useState(false);
|
||||
|
||||
const [delegationGrants, setDelegationGrants] = useState<AgentDelegationGrantRow[]>([]);
|
||||
const [delegationSaving, setDelegationSaving] = useState(false);
|
||||
|
||||
const flatNodes = useMemo(() => flattenTree(tree), [tree]);
|
||||
const filteredTree = useMemo(() => filterTree(tree, treeKeyword), [tree, treeKeyword]);
|
||||
const selected = useMemo(
|
||||
() => flatNodes.find((n) => n.id === selectedId) ?? null,
|
||||
[flatNodes, selectedId],
|
||||
);
|
||||
const selectedChildrenCount = selected?.children?.length ?? 0;
|
||||
const selectedDescendantCount = useMemo(() => {
|
||||
if (!selected?.children?.length) {
|
||||
return 0;
|
||||
}
|
||||
return flattenTree(selected.children).length;
|
||||
}, [selected]);
|
||||
|
||||
const canManageDelegation =
|
||||
canManageNode &&
|
||||
selected !== null &&
|
||||
!selected.is_root &&
|
||||
(isSuperAdmin || profile?.agent?.id === selected.parent_id);
|
||||
|
||||
const assignablePermissionSlugs = useMemo(() => {
|
||||
const mine = new Set(profile?.permissions ?? []);
|
||||
const slugs: string[] = [];
|
||||
for (const group of catalog?.permission_menu_groups ?? []) {
|
||||
for (const p of group.permissions) {
|
||||
if (mine.has(p.slug)) {
|
||||
slugs.push(p.slug);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (slugs.length === 0) {
|
||||
for (const p of catalog?.permissions ?? []) {
|
||||
if (mine.has(p.slug)) {
|
||||
slugs.push(p.slug);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return slugs;
|
||||
}, [catalog, profile?.permissions]);
|
||||
|
||||
const loadTree = useCallback(async (siteId?: number | null) => {
|
||||
setLoading(true);
|
||||
setErr(null);
|
||||
try {
|
||||
const data = await getAgentTree(siteId ?? undefined);
|
||||
setTree(data.tree);
|
||||
setAdminSiteId(data.admin_site_id);
|
||||
setExpandedNodeIds(new Set(flattenTree(data.tree).map((node) => node.id)));
|
||||
if (selectedId === null && data.tree.length > 0) {
|
||||
const first = flattenTree(data.tree)[0];
|
||||
if (first) {
|
||||
setSelectedId(first.id);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
setErr(e instanceof LotteryApiBizError ? e.message : tRef.current("loadFailed"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [selectedId, tRef]);
|
||||
|
||||
const loadDetail = useCallback(async (nodeId: number) => {
|
||||
if (canViewRoles) {
|
||||
const roleData = await getAgentNodeRoles(nodeId);
|
||||
setRoles(roleData.items);
|
||||
} else {
|
||||
setRoles([]);
|
||||
}
|
||||
if (canViewUsers) {
|
||||
const userData = await getAgentNodeAdminUsers(nodeId);
|
||||
setUsers(userData.items);
|
||||
} else {
|
||||
setUsers([]);
|
||||
}
|
||||
const node = flattenTree(tree).find((n) => n.id === nodeId);
|
||||
const showDelegation =
|
||||
canManageNode &&
|
||||
node !== undefined &&
|
||||
!node.is_root &&
|
||||
(isSuperAdmin || profile?.agent?.id === node.parent_id);
|
||||
if (showDelegation) {
|
||||
const grantData = await getAgentDelegationGrants(nodeId);
|
||||
setDelegationGrants(grantData.grants);
|
||||
} else {
|
||||
setDelegationGrants([]);
|
||||
}
|
||||
}, [canManageNode, canViewRoles, canViewUsers, isSuperAdmin, profile?.agent?.id, tree]);
|
||||
|
||||
useAsyncEffect(() => {
|
||||
if (isSuperAdmin) {
|
||||
void getAdminIntegrationSites()
|
||||
.then((data) => {
|
||||
setSiteOptions(
|
||||
data.items.map((row) => ({ id: row.id, label: `${row.name} (${row.code})` })),
|
||||
);
|
||||
if (data.items.length > 0 && adminSiteId === null) {
|
||||
setAdminSiteId(data.items[0]?.id ?? null);
|
||||
}
|
||||
})
|
||||
.catch(() => setSiteOptions([]));
|
||||
} else if (profile?.agent?.admin_site_id) {
|
||||
setAdminSiteId(profile.agent.admin_site_id);
|
||||
}
|
||||
void getAdminUserPermissionCatalog().then(setCatalog).catch(() => setCatalog(null));
|
||||
}, [isSuperAdmin, profile?.agent?.admin_site_id]);
|
||||
|
||||
useAsyncEffect(() => {
|
||||
if (adminSiteId === null && !isSuperAdmin && profile?.agent?.admin_site_id) {
|
||||
setAdminSiteId(profile.agent.admin_site_id);
|
||||
return;
|
||||
}
|
||||
if (adminSiteId !== null || !isSuperAdmin) {
|
||||
void loadTree(adminSiteId);
|
||||
}
|
||||
}, [adminSiteId, isSuperAdmin, loadTree, profile?.agent?.admin_site_id]);
|
||||
|
||||
useAsyncEffect(() => {
|
||||
if (selectedId !== null) {
|
||||
void loadDetail(selectedId).catch(() => {
|
||||
toast.error(tRef.current("loadFailed"));
|
||||
});
|
||||
}
|
||||
}, [selectedId, loadDetail, tRef]);
|
||||
|
||||
const openCreateChild = () => {
|
||||
if (!selected) {
|
||||
return;
|
||||
}
|
||||
setNodeDialogMode("create");
|
||||
setNodeCode("");
|
||||
setNodeName("");
|
||||
setNodeStatus(1);
|
||||
setNodeDialogOpen(true);
|
||||
};
|
||||
|
||||
const openEditNode = () => {
|
||||
if (!selected) {
|
||||
return;
|
||||
}
|
||||
setNodeDialogMode("edit");
|
||||
setNodeCode(selected.code);
|
||||
setNodeName(selected.name);
|
||||
setNodeStatus(selected.status);
|
||||
setNodeDialogOpen(true);
|
||||
};
|
||||
|
||||
const saveNode = async () => {
|
||||
if (!nodeName.trim() || (nodeDialogMode === "create" && !nodeCode.trim())) {
|
||||
toast.error(t("codeRequired"));
|
||||
return;
|
||||
}
|
||||
setNodeSaving(true);
|
||||
try {
|
||||
if (nodeDialogMode === "create" && selected) {
|
||||
await postAgentNode({
|
||||
parent_id: selected.id,
|
||||
code: nodeCode.trim(),
|
||||
name: nodeName.trim(),
|
||||
status: nodeStatus,
|
||||
});
|
||||
toast.success(t("createSuccess", { name: nodeName.trim() }));
|
||||
} else if (selected) {
|
||||
await putAgentNode(selected.id, { name: nodeName.trim(), status: nodeStatus });
|
||||
toast.success(t("updateSuccess", { name: nodeName.trim() }));
|
||||
}
|
||||
setNodeDialogOpen(false);
|
||||
await loadTree(adminSiteId);
|
||||
if (selectedId !== null) {
|
||||
await loadDetail(selectedId);
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("saveFailed"));
|
||||
} finally {
|
||||
setNodeSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveNewRole = async () => {
|
||||
if (!selected || !roleSlug.trim() || !roleName.trim()) {
|
||||
return;
|
||||
}
|
||||
setRoleSaving(true);
|
||||
try {
|
||||
await postAgentRole(selected.id, {
|
||||
slug: roleSlug.trim(),
|
||||
name: roleName.trim(),
|
||||
permission_slugs: rolePerms,
|
||||
});
|
||||
toast.success(t("roles.createSuccess", { name: roleName.trim() }));
|
||||
setRoleDialogOpen(false);
|
||||
await loadDetail(selected.id);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("saveFailed"));
|
||||
} finally {
|
||||
setRoleSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveRolePermissions = async () => {
|
||||
if (permRoleId === null) {
|
||||
return;
|
||||
}
|
||||
setPermSaving(true);
|
||||
try {
|
||||
await putAgentRolePermissions(permRoleId, draftPerms);
|
||||
toast.success(t("roles.permissionSaveSuccess"));
|
||||
setPermDialogOpen(false);
|
||||
if (selectedId !== null) {
|
||||
await loadDetail(selectedId);
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("saveFailed"));
|
||||
} finally {
|
||||
setPermSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveDelegation = async () => {
|
||||
if (!selected) {
|
||||
return;
|
||||
}
|
||||
setDelegationSaving(true);
|
||||
try {
|
||||
const data = await putAgentDelegationGrants(selected.id, {
|
||||
grants: delegationGrants.map((g) => ({
|
||||
menu_action_id: g.menu_action_id,
|
||||
can_delegate: g.can_delegate,
|
||||
})),
|
||||
});
|
||||
setDelegationGrants(data.grants);
|
||||
toast.success(t("delegation.saveSuccess"));
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("saveFailed"));
|
||||
} finally {
|
||||
setDelegationSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveNewUser = async () => {
|
||||
if (!selected || !userUsername.trim() || !userPassword.trim()) {
|
||||
return;
|
||||
}
|
||||
setUserSaving(true);
|
||||
try {
|
||||
await postAgentAdminUser(selected.id, {
|
||||
username: userUsername.trim(),
|
||||
nickname: userNickname.trim() || userUsername.trim(),
|
||||
password: userPassword,
|
||||
role_ids: userRoleIds,
|
||||
});
|
||||
toast.success(t("users.createSuccess", { name: userNickname.trim() || userUsername.trim() }));
|
||||
setUserDialogOpen(false);
|
||||
await loadDetail(selected.id);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("saveFailed"));
|
||||
} finally {
|
||||
setUserSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading && tree.length === 0) {
|
||||
return <AdminLoadingState label={t("treeTitle")} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<ConfirmDialog />
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<h1 className="text-xl font-semibold">{t("title")}</h1>
|
||||
{isSuperAdmin && siteOptions.length > 0 ? (
|
||||
<Select
|
||||
value={adminSiteId !== null ? String(adminSiteId) : undefined}
|
||||
onValueChange={(v) => setAdminSiteId(Number(v))}
|
||||
>
|
||||
<SelectTrigger className="w-[240px]">
|
||||
<SelectValue placeholder={t("siteLabel")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{siteOptions.map((opt) => (
|
||||
<SelectItem key={opt.id} value={String(opt.id)}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{err ? <p className="text-sm text-destructive">{err}</p> : null}
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-[minmax(220px,280px)_1fr]">
|
||||
<AdminPageCard title={t("treeTitle")}>
|
||||
<div className="space-y-3">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={treeKeyword}
|
||||
onChange={(e) => setTreeKeyword(e.target.value)}
|
||||
className="pl-8"
|
||||
placeholder={t("treeSearch", { defaultValue: "搜索代理编码/名称" })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setExpandedNodeIds(new Set(flatNodes.map((node) => node.id)))}
|
||||
>
|
||||
{t("expandAll", { defaultValue: "展开全部" })}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
selected ? setExpandedNodeIds(new Set([selected.id])) : setExpandedNodeIds(new Set())
|
||||
}
|
||||
>
|
||||
{t("collapseAll", { defaultValue: "收起全部" })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<ScrollArea className="mt-3 h-[min(66vh,620px)] pr-2">
|
||||
<AgentTreeNodes
|
||||
nodes={filteredTree}
|
||||
depth={0}
|
||||
selectedId={selectedId}
|
||||
expandedIds={expandedNodeIds}
|
||||
onToggleExpand={(nodeId) => {
|
||||
setExpandedNodeIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(nodeId)) {
|
||||
next.delete(nodeId);
|
||||
} else {
|
||||
next.add(nodeId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
onSelect={(node) => setSelectedId(node.id)}
|
||||
/>
|
||||
</ScrollArea>
|
||||
</AdminPageCard>
|
||||
|
||||
<AdminPageCard title={selected ? selected.name : t("detailTitle")}>
|
||||
{!selected ? (
|
||||
<p className="text-sm text-muted-foreground">{t("selectNode")}</p>
|
||||
) : (
|
||||
<Tabs defaultValue="overview">
|
||||
<div className="mb-4 grid gap-3 rounded-xl border bg-muted/20 p-3 md:grid-cols-4">
|
||||
<div className="space-y-1 rounded-lg bg-background/80 p-3">
|
||||
<p className="text-xs text-muted-foreground">{t("status")}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<AdminStatusBadge tone={resolveRoleStatusTone(selected.status)}>
|
||||
{selected.status === 1
|
||||
? t("common:status.enabled", { defaultValue: "Enabled" })
|
||||
: t("common:status.disabled", { defaultValue: "Disabled" })}
|
||||
</AdminStatusBadge>
|
||||
{selected.is_root ? (
|
||||
<Badge variant="secondary">{t("isRoot", { defaultValue: "Root" })}</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1 rounded-lg bg-background/80 p-3">
|
||||
<p className="text-xs text-muted-foreground">{t("childrenCount", { defaultValue: "直属下级" })}</p>
|
||||
<p className="text-lg font-semibold">{selectedChildrenCount}</p>
|
||||
</div>
|
||||
<div className="space-y-1 rounded-lg bg-background/80 p-3">
|
||||
<p className="text-xs text-muted-foreground">{t("descendantsCount", { defaultValue: "全部下级" })}</p>
|
||||
<p className="text-lg font-semibold">{selectedDescendantCount}</p>
|
||||
</div>
|
||||
<div className="space-y-1 rounded-lg bg-background/80 p-3">
|
||||
<p className="text-xs text-muted-foreground">{t("nodeCode", { defaultValue: "节点编码" })}</p>
|
||||
<p className="truncate font-mono text-xs text-muted-foreground">{selected.code}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||
<TabsList>
|
||||
<TabsTrigger value="overview">{t("tabs.overview")}</TabsTrigger>
|
||||
{canViewRoles ? <TabsTrigger value="roles">{t("tabs.roles")}</TabsTrigger> : null}
|
||||
{canViewUsers ? <TabsTrigger value="users">{t("tabs.users")}</TabsTrigger> : null}
|
||||
{canManageDelegation ? (
|
||||
<TabsTrigger value="delegation">{t("tabs.delegation")}</TabsTrigger>
|
||||
) : null}
|
||||
</TabsList>
|
||||
{canManageNode && !selected.is_root ? (
|
||||
<Button type="button" size="sm" variant="outline" onClick={openEditNode}>
|
||||
<Pencil className="mr-1 size-3.5" />
|
||||
{t("editNode")}
|
||||
</Button>
|
||||
) : null}
|
||||
{canManageNode ? (
|
||||
<Button type="button" size="sm" onClick={openCreateChild}>
|
||||
<Plus className="mr-1 size-3.5" />
|
||||
{t("createChild")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<TabsContent value="overview" className="space-y-2 text-sm">
|
||||
<div className="grid gap-3 xl:grid-cols-[1.1fr_0.9fr]">
|
||||
<div className="space-y-2 rounded-xl border bg-background p-4">
|
||||
<p>
|
||||
<span className="text-muted-foreground">{t("code")}:</span> {selected.code}
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground">{t("depth")}:</span> {selected.depth}
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground">{t("path")}:</span>{" "}
|
||||
<code className="text-xs">{selected.path}</code>
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-3 rounded-xl border bg-background p-4">
|
||||
<p className="text-sm font-medium">{t("quickActions", { defaultValue: "常用操作" })}</p>
|
||||
{canManageNode ? (
|
||||
<Button type="button" className="w-full justify-start" onClick={openCreateChild}>
|
||||
<Plus className="mr-1 size-3.5" />
|
||||
{t("createChild")}
|
||||
</Button>
|
||||
) : null}
|
||||
{canManageNode && !selected.is_root ? (
|
||||
<Button type="button" variant="outline" className="w-full justify-start" onClick={openEditNode}>
|
||||
<Pencil className="mr-1 size-3.5" />
|
||||
{t("editNode")}
|
||||
</Button>
|
||||
) : null}
|
||||
{canManageNode && !selected.is_root ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
className="w-full justify-start"
|
||||
onClick={() => {
|
||||
requestConfirm({
|
||||
title: selected.name,
|
||||
description: t("deleteNodeConfirm", {
|
||||
defaultValue: "删除后不可恢复,请确认该节点无下级、无账号、无角色绑定。",
|
||||
}),
|
||||
onConfirm: async () => {
|
||||
await deleteAgentNode(selected.id);
|
||||
toast.success(t("deleteSuccess", { name: selected.name }));
|
||||
setSelectedId(selected.parent_id ?? null);
|
||||
await loadTree(adminSiteId);
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Trash2 className="mr-1 size-3.5" />
|
||||
{t("deleteNode", { defaultValue: "删除节点" })}
|
||||
</Button>
|
||||
) : null}
|
||||
{canViewRoles ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full justify-start"
|
||||
onClick={() => {
|
||||
setRoleSlug("");
|
||||
setRoleName("");
|
||||
setRolePerms([]);
|
||||
setRoleDialogOpen(true);
|
||||
}}
|
||||
disabled={!canManageRoles}
|
||||
>
|
||||
<Shield className="mr-1 size-3.5" />
|
||||
{t("roles.create")}
|
||||
</Button>
|
||||
) : null}
|
||||
{canViewUsers ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full justify-start"
|
||||
onClick={() => {
|
||||
setUserUsername("");
|
||||
setUserNickname("");
|
||||
setUserPassword("");
|
||||
setUserRoleIds([]);
|
||||
setUserDialogOpen(true);
|
||||
}}
|
||||
disabled={!canManageUsers}
|
||||
>
|
||||
<Users className="mr-1 size-3.5" />
|
||||
{t("users.create")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{canViewRoles ? (
|
||||
<TabsContent value="roles">
|
||||
<div className="mb-3 flex justify-end">
|
||||
{canManageRoles ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setRoleSlug("");
|
||||
setRoleName("");
|
||||
setRolePerms([]);
|
||||
setRoleDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Plus className="mr-1 size-3.5" />
|
||||
{t("roles.create")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("roles.slug")}</TableHead>
|
||||
<TableHead>{t("name")}</TableHead>
|
||||
<TableHead>{t("roles.userCount")}</TableHead>
|
||||
<TableHead className="w-[80px]" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{roles.map((role) => (
|
||||
<TableRow key={role.id}>
|
||||
<TableCell className="font-mono text-xs">{role.slug}</TableCell>
|
||||
<TableCell>{role.name}</TableCell>
|
||||
<TableCell>{role.user_count}</TableCell>
|
||||
<TableCell>
|
||||
{canManageRoles && !role.is_read_only_template ? (
|
||||
<AdminRowActionsMenu
|
||||
actions={[
|
||||
{
|
||||
key: "permissions",
|
||||
label: t("roles.permissions"),
|
||||
icon: KeyRound,
|
||||
onClick: () => {
|
||||
setPermRoleId(role.id);
|
||||
setDraftPerms([...role.permission_slugs]);
|
||||
setPermDialogOpen(true);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
label: t("common:actions.delete", { defaultValue: "Delete" }),
|
||||
icon: Trash2,
|
||||
destructive: true,
|
||||
onClick: () => {
|
||||
requestConfirm({
|
||||
title: role.name,
|
||||
description: t("common:confirm.deleteDescription", {
|
||||
defaultValue: "This cannot be undone.",
|
||||
}),
|
||||
onConfirm: async () => {
|
||||
await deleteAgentRole(role.id);
|
||||
toast.success(t("roles.deleteSuccess", { name: role.name }));
|
||||
if (selectedId !== null) {
|
||||
await loadDetail(selectedId);
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : role.is_read_only_template ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("roles.readOnlyTemplate")}
|
||||
</span>
|
||||
) : null}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TabsContent>
|
||||
) : null}
|
||||
|
||||
{canViewUsers ? (
|
||||
<TabsContent value="users">
|
||||
<div className="mb-3 flex justify-end">
|
||||
{canManageUsers ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setUserUsername("");
|
||||
setUserNickname("");
|
||||
setUserPassword("");
|
||||
setUserRoleIds([]);
|
||||
setUserDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Users className="mr-1 size-3.5" />
|
||||
{t("users.create")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("users.username")}</TableHead>
|
||||
<TableHead>{t("name")}</TableHead>
|
||||
<TableHead>{t("users.roles")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{users.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell>{user.username}</TableCell>
|
||||
<TableCell>{user.nickname}</TableCell>
|
||||
<TableCell className="text-xs">{user.roles.join(", ") || "—"}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TabsContent>
|
||||
) : null}
|
||||
|
||||
{canManageDelegation ? (
|
||||
<TabsContent value="delegation">
|
||||
<p className="mb-3 text-sm text-muted-foreground">{t("delegation.hint")}</p>
|
||||
{delegationGrants.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t("delegation.empty")}</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("delegation.permission")}</TableHead>
|
||||
<TableHead className="w-[140px]">{t("delegation.canDelegate")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{delegationGrants.map((grant) => (
|
||||
<TableRow key={grant.menu_action_id}>
|
||||
<TableCell>
|
||||
<div className="font-medium">{grant.name}</div>
|
||||
<div className="font-mono text-xs text-muted-foreground">
|
||||
{grant.permission_code}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={grant.can_delegate}
|
||||
onCheckedChange={(checked) => {
|
||||
setDelegationGrants((prev) =>
|
||||
prev.map((row) =>
|
||||
row.menu_action_id === grant.menu_action_id
|
||||
? { ...row, can_delegate: checked === true }
|
||||
: row,
|
||||
),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
<div className="mt-4 flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={delegationSaving || delegationGrants.length === 0}
|
||||
onClick={() => void saveDelegation()}
|
||||
>
|
||||
{t("delegation.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</TabsContent>
|
||||
) : null}
|
||||
</Tabs>
|
||||
)}
|
||||
</AdminPageCard>
|
||||
</div>
|
||||
|
||||
<Dialog open={nodeDialogOpen} onOpenChange={setNodeDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{nodeDialogMode === "create" ? t("createChild") : t("editNode")}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
{nodeDialogMode === "create" ? (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="agent-code">{t("code")}</Label>
|
||||
<Input
|
||||
id="agent-code"
|
||||
value={nodeCode}
|
||||
onChange={(e) => setNodeCode(e.target.value)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="agent-name">{t("name")}</Label>
|
||||
<Input
|
||||
id="agent-name"
|
||||
value={nodeName}
|
||||
onChange={(e) => setNodeName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch checked={nodeStatus === 1} onCheckedChange={(v) => setNodeStatus(v ? 1 : 0)} />
|
||||
<Label>{t("status")}</Label>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setNodeDialogOpen(false)}>
|
||||
{t("common:actions.cancel", { defaultValue: "Cancel" })}
|
||||
</Button>
|
||||
<Button type="button" disabled={nodeSaving} onClick={() => void saveNode()}>
|
||||
{t("common:actions.save", { defaultValue: "Save" })}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={roleDialogOpen} onOpenChange={setRoleDialogOpen}>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("roles.create")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
<Label>{t("roles.slug")}</Label>
|
||||
<Input value={roleSlug} onChange={(e) => setRoleSlug(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t("name")}</Label>
|
||||
<Input value={roleName} onChange={(e) => setRoleName(e.target.value)} />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t("roles.permissionSubsetHint")}</p>
|
||||
<div className="max-h-48 space-y-1 overflow-y-auto rounded-md border p-2">
|
||||
{assignablePermissionSlugs.map((slug) => (
|
||||
<label key={slug} className="flex items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={rolePerms.includes(slug)}
|
||||
onCheckedChange={(checked) => {
|
||||
setRolePerms((prev) =>
|
||||
checked ? [...prev, slug] : prev.filter((s) => s !== slug),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<span className="font-mono text-xs">{slug}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setRoleDialogOpen(false)}>
|
||||
{t("common:actions.cancel", { defaultValue: "Cancel" })}
|
||||
</Button>
|
||||
<Button type="button" disabled={roleSaving} onClick={() => void saveNewRole()}>
|
||||
{t("common:actions.save", { defaultValue: "Save" })}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={permDialogOpen} onOpenChange={setPermDialogOpen}>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("roles.permissions")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="max-h-64 space-y-1 overflow-y-auto rounded-md border p-2">
|
||||
{assignablePermissionSlugs.map((slug) => (
|
||||
<label key={slug} className="flex items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={draftPerms.includes(slug)}
|
||||
onCheckedChange={(checked) => {
|
||||
setDraftPerms((prev) =>
|
||||
checked ? [...prev, slug] : prev.filter((s) => s !== slug),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<span className="font-mono text-xs">{slug}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setPermDialogOpen(false)}>
|
||||
{t("common:actions.cancel", { defaultValue: "Cancel" })}
|
||||
</Button>
|
||||
<Button type="button" disabled={permSaving} onClick={() => void saveRolePermissions()}>
|
||||
{t("common:actions.save", { defaultValue: "Save" })}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={userDialogOpen} onOpenChange={setUserDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("users.create")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
<Label>{t("users.username")}</Label>
|
||||
<Input value={userUsername} onChange={(e) => setUserUsername(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t("name")}</Label>
|
||||
<Input value={userNickname} onChange={(e) => setUserNickname(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t("users.password")}</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={userPassword}
|
||||
onChange={(e) => setUserPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{roles.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<Label>{t("users.roles")}</Label>
|
||||
<div className="max-h-32 space-y-1 overflow-y-auto rounded-md border p-2">
|
||||
{roles.map((role) => (
|
||||
<label key={role.id} className="flex items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={userRoleIds.includes(role.id)}
|
||||
onCheckedChange={(checked) => {
|
||||
setUserRoleIds((prev) =>
|
||||
checked
|
||||
? [...prev, role.id]
|
||||
: prev.filter((id) => id !== role.id),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{role.name}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setUserDialogOpen(false)}>
|
||||
{t("common:actions.cancel", { defaultValue: "Cancel" })}
|
||||
</Button>
|
||||
<Button type="button" disabled={userSaving} onClick={() => void saveNewUser()}>
|
||||
{t("common:actions.save", { defaultValue: "Save" })}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user