feat(admin, i18n): enhance admin dashboard and user management with new features and translations

Added the ability to filter admin dashboard data by site code and agent node ID, improving data retrieval capabilities. Introduced new functions for fetching dashboard data based on these parameters. Updated the admin users and roles management components to reflect these changes. Enhanced multi-language support by adding new translations for agent management and permission levels in English, Nepali, and Chinese, ensuring a consistent user experience across the admin interface.
This commit is contained in:
2026-06-03 10:07:51 +08:00
parent b15e377187
commit ce27a3ec8a
66 changed files with 1361 additions and 720 deletions

View File

@@ -1,6 +1,6 @@
"use client";
import { ChevronRight, KeyRound, Pencil, Plus, Search, Shield, Trash2, Users } from "lucide-react";
import { ChevronRight, KeyRound, Pencil, Plus, Search, Trash2, Users } from "lucide-react";
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useAsyncEffect } from "@/hooks/use-async-effect";
@@ -9,7 +9,6 @@ import { useConfirmAction } from "@/hooks/use-confirm-action";
import { toast } from "sonner";
import {
deleteAgentNode,
deleteAgentRole,
getAgentNodeAdminUsers,
getAgentNodeRoles,
@@ -17,9 +16,7 @@ import {
postAgentAdminUser,
postAgentNode,
postAgentRole,
putAgentAdminUserRoles,
putAgentNode,
putAgentRole,
putAgentRolePermissions,
getAgentDelegationGrants,
putAgentDelegationGrants,
@@ -27,6 +24,7 @@ import {
import { getAdminIntegrationSites } from "@/api/admin-integration-sites";
import { getAdminUserPermissionCatalog } from "@/api/admin-users";
import { AdminPageCard } from "@/components/admin/admin-page-card";
import { AdminPermissionPackageSelector } from "@/components/admin/admin-permission-package-selector";
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";
@@ -75,6 +73,20 @@ import type { AgentDelegationGrantRow, AgentNodeRow } from "@/types/api/admin-ag
import type { AdminPermissionCatalogData, AdminRoleRow, AdminUserPermissionRow } from "@/types/api/admin-user";
import { LotteryApiBizError } from "@/types/api/errors";
function permissionGroupLabel(key: string, fallback: string, t: (key: string, options?: Record<string, unknown>) => string): string {
const translated = t(`adminUsers:permissionGroups.${key}`);
return translated === `adminUsers:permissionGroups.${key}` ? fallback : translated;
}
function permissionPackageLabel(
key: string,
fallback: string,
t: (key: string, options?: Record<string, unknown>) => string,
): string {
const translated = t(`adminUsers:permissionLevels.${key}`);
return translated === `adminUsers:permissionLevels.${key}` ? fallback : translated;
}
function flattenTree(nodes: AgentNodeRow[]): AgentNodeRow[] {
const out: AgentNodeRow[] = [];
const walk = (list: AgentNodeRow[]) => {
@@ -257,6 +269,7 @@ export function AgentsConsole(): React.ReactElement {
selected !== null &&
!selected.is_root &&
(isSuperAdmin || profile?.agent?.id === selected.parent_id);
const defaultDetailTab = canViewRoles ? "roles" : canViewUsers ? "users" : canManageDelegation ? "delegation" : "roles";
const assignablePermissionSlugs = useMemo(() => {
const mine = new Set(profile?.permissions ?? []);
@@ -279,6 +292,24 @@ export function AgentsConsole(): React.ReactElement {
return slugs;
}, [catalog, profile?.permissions]);
const selectedRoleCountText = useMemo(
() => t("roles.selectedCount", {
defaultValue: "已选 {{selected}} / {{total}} 项",
selected: rolePerms.length,
total: assignablePermissionSlugs.length,
}),
[assignablePermissionSlugs.length, rolePerms.length, t],
);
const selectedDraftCountText = useMemo(
() => t("roles.selectedCount", {
defaultValue: "已选 {{selected}} / {{total}} 项",
selected: draftPerms.length,
total: assignablePermissionSlugs.length,
}),
[assignablePermissionSlugs.length, draftPerms.length, t],
);
const loadTree = useCallback(async (siteId?: number | null) => {
setLoading(true);
setErr(null);
@@ -514,7 +545,11 @@ export function AgentsConsole(): React.ReactElement {
onValueChange={(v) => setAdminSiteId(Number(v))}
>
<SelectTrigger className="w-[240px]">
<SelectValue placeholder={t("siteLabel")} />
<SelectValue placeholder={t("siteLabel")}>
{adminSiteId !== null
? siteOptions.find((opt) => opt.id === adminSiteId)?.label ?? adminSiteId
: undefined}
</SelectValue>
</SelectTrigger>
<SelectContent>
{siteOptions.map((opt) => (
@@ -526,6 +561,12 @@ export function AgentsConsole(): React.ReactElement {
</Select>
) : null}
</div>
<div className="rounded-xl border bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
{t("modelGuide", {
defaultValue:
"代理层负责数据范围Scope与授权上限Ceiling账号权限请通过角色分配。",
})}
</div>
{err ? <p className="text-sm text-destructive">{err}</p> : null}
@@ -588,7 +629,7 @@ export function AgentsConsole(): React.ReactElement {
{!selected ? (
<p className="text-sm text-muted-foreground">{t("selectNode")}</p>
) : (
<Tabs defaultValue="overview">
<Tabs defaultValue={defaultDetailTab}>
<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>
@@ -619,7 +660,6 @@ export function AgentsConsole(): React.ReactElement {
<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 ? (
@@ -640,97 +680,6 @@ export function AgentsConsole(): React.ReactElement {
) : 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">
@@ -750,68 +699,70 @@ export function AgentsConsole(): React.ReactElement {
</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>
<div className="rounded-xl border">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("roles.slug")}</TableHead>
<TableHead>{t("name")}</TableHead>
<TableHead>{t("roles.userCount")}</TableHead>
<TableHead className="sticky right-0 z-20 bg-muted w-[80px] shadow-[-1px_0_0_rgba(203,213,225,0.7)]" />
</TableRow>
))}
</TableBody>
</Table>
</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 className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
{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>
</div>
</TabsContent>
) : null}
@@ -835,24 +786,26 @@ export function AgentsConsole(): React.ReactElement {
</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>
<div className="rounded-xl border">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("users.username")}</TableHead>
<TableHead>{t("name")}</TableHead>
<TableHead>{t("users.roles")}</TableHead>
</TableRow>
))}
</TableBody>
</Table>
</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>
</div>
</TabsContent>
) : null}
@@ -862,40 +815,42 @@ export function AgentsConsole(): React.ReactElement {
{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>
<div className="rounded-xl border">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("delegation.permission")}</TableHead>
<TableHead className="w-[140px]">{t("delegation.canDelegate")}</TableHead>
</TableRow>
))}
</TableBody>
</Table>
</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>
)}
<div className="mt-4 flex justify-end">
<Button
@@ -956,33 +911,38 @@ export function AgentsConsole(): React.ReactElement {
</Dialog>
<Dialog open={roleDialogOpen} onOpenChange={setRoleDialogOpen}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-lg">
<DialogContent className="max-h-[92vh] overflow-y-auto sm:max-w-4xl">
<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 className="grid gap-6 lg:grid-cols-[320px_minmax(0,1fr)]">
<div className="space-y-4">
<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>
<div className="rounded-xl border bg-muted/20 p-3 text-sm text-muted-foreground">
<p>{t("roles.permissionSubsetHint")}</p>
<p className="mt-2 font-medium text-foreground">{selectedRoleCountText}</p>
</div>
</div>
<div className="min-w-0">
<AdminPermissionPackageSelector
catalog={catalog}
selectedSlugs={rolePerms}
onChange={setRolePerms}
selectableSlugs={assignablePermissionSlugs}
resolveGroupLabel={(key, fallback) => permissionGroupLabel(key, fallback, t)}
resolvePackageLabel={(key, fallback) => permissionPackageLabel(key, fallback, t)}
helperText={t("roles.permissionSubsetHint")}
summaryText={selectedRoleCountText}
emptyText={t("roles.noAssignablePermissions", { defaultValue: "当前没有可分配权限" })}
/>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setRoleDialogOpen(false)}>
@@ -996,25 +956,24 @@ export function AgentsConsole(): React.ReactElement {
</Dialog>
<Dialog open={permDialogOpen} onOpenChange={setPermDialogOpen}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-lg">
<DialogContent className="max-h-[92vh] overflow-y-auto sm:max-w-4xl">
<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 className="rounded-xl border bg-muted/20 px-3 py-2 text-sm text-muted-foreground">
{selectedDraftCountText}
</div>
<AdminPermissionPackageSelector
catalog={catalog}
selectedSlugs={draftPerms}
onChange={setDraftPerms}
selectableSlugs={assignablePermissionSlugs}
resolveGroupLabel={(key, fallback) => permissionGroupLabel(key, fallback, t)}
resolvePackageLabel={(key, fallback) => permissionPackageLabel(key, fallback, t)}
helperText={t("roles.permissionSubsetHint")}
summaryText={selectedDraftCountText}
emptyText={t("roles.noAssignablePermissions", { defaultValue: "当前没有可分配权限" })}
/>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setPermDialogOpen(false)}>
{t("common:actions.cancel", { defaultValue: "Cancel" })}