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:
2026-06-02 14:37:08 +08:00
parent a4e7a2d228
commit b15e377187
105 changed files with 5305 additions and 1596 deletions

View 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>
);
}