Files
lotteryAdmin/src/modules/agents/agent-line-sidebar.tsx
kang 65eaeecf8c feat(agents, i18n): enhance agent management and settlement features with new translations and UI updates
Added new translations for agent management and settlement features in English, Nepali, and Chinese, improving multi-language support. Updated the agents console to reflect changes in funding modes and player details, enhancing user experience. Refactored the admin permission gate to include new logic for handling bound line agents, ensuring better permission management. Additionally, streamlined the UI for agent-related pages and improved navigation to the settlement center, consolidating related functionalities for better accessibility.
2026-06-04 18:01:05 +08:00

302 lines
8.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { ChevronRight, Search } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
import { Input } from "@/components/ui/input";
import { resolveRoleStatusTone } from "@/lib/admin-status-tone";
import { cn } from "@/lib/utils";
import { formatAdminCreditMajorDecimal } from "@/lib/money";
import type { AgentNodeRow } from "@/types/api/admin-agent";
function formatCredit(amount: number, currencyCode = "NPR"): string {
return formatAdminCreditMajorDecimal(amount, currencyCode);
}
function nodeMatchesKeyword(
node: AgentNodeRow,
normalized: string,
parentNameMap: Map<number, string>,
): boolean {
if (normalized === "") {
return true;
}
const parentName =
node.parent_id !== null ? (parentNameMap.get(node.parent_id) ?? "") : "";
return [node.name, node.code, node.username ?? "", parentName]
.join(" ")
.toLowerCase()
.includes(normalized);
}
function pruneTreeForSearch(
nodes: AgentNodeRow[],
normalized: string,
parentNameMap: Map<number, string>,
): AgentNodeRow[] {
if (normalized === "") {
return nodes;
}
const out: AgentNodeRow[] = [];
for (const node of nodes) {
const children = pruneTreeForSearch(node.children ?? [], normalized, parentNameMap);
const selfMatch = nodeMatchesKeyword(node, normalized, parentNameMap);
if (selfMatch || children.length > 0) {
out.push({ ...node, children });
}
}
return out;
}
function collectExpandableIds(nodes: AgentNodeRow[], into: Set<number>): void {
for (const node of nodes) {
if ((node.children?.length ?? 0) > 0) {
into.add(node.id);
collectExpandableIds(node.children ?? [], into);
}
}
}
function unwrapSiteRoots(nodes: AgentNodeRow[]): AgentNodeRow[] {
return nodes.flatMap((node) => (node.is_root ? (node.children ?? []) : [node]));
}
export type AgentLineSidebarProps = {
siteLabel: string | null;
/** API 返回的嵌套树(含 children */
tree: AgentNodeRow[];
parentNameMap: Map<number, string>;
selectedId: number | null;
keyword: string;
agentCount: number;
onKeywordChange: (value: string) => void;
onSelect: (node: AgentNodeRow) => void;
};
type TreeRowProps = {
node: AgentNodeRow;
depth: number;
selectedId: number | null;
expandedIds: Set<number>;
onToggleExpand: (id: number) => void;
onSelect: (node: AgentNodeRow) => void;
};
function TreeRow({
node,
depth,
selectedId,
expandedIds,
onToggleExpand,
onSelect,
}: TreeRowProps): React.ReactElement {
const { t } = useTranslation(["agents", "common"]);
const children = node.children ?? [];
const hasChildren = children.length > 0;
const expanded = expandedIds.has(node.id);
const active = selectedId === node.id;
const indent = depth * 14;
return (
<li>
<div
className={cn(
"flex w-full items-start gap-0.5 rounded-lg py-1.5 pr-2 transition-colors",
active ? "bg-primary/12 ring-1 ring-primary/30 shadow-sm" : "hover:bg-background/80",
)}
style={{ paddingLeft: `${6 + indent}px` }}
>
{hasChildren ? (
<button
type="button"
aria-expanded={expanded}
aria-label={expanded ? t("lineUi.collapse", { defaultValue: "收起" }) : t("lineUi.expand", { defaultValue: "展开" })}
className="mt-1 flex size-6 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-muted"
onClick={(e) => {
e.stopPropagation();
onToggleExpand(node.id);
}}
>
<ChevronRight
className={cn("size-3.5 transition-transform", expanded && "rotate-90")}
aria-hidden
/>
</button>
) : (
<span className="mt-1 inline-block size-6 shrink-0" aria-hidden />
)}
<button
type="button"
role="option"
aria-selected={active}
className="min-w-0 flex-1 px-1 py-0.5 text-left"
onClick={() => onSelect(node)}
>
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium">{node.name}</span>
<AdminStatusBadge
tone={resolveRoleStatusTone(node.status)}
className="shrink-0 px-1.5 py-0 text-[10px]"
>
{node.status === 1
? t("common:status.enabled", { defaultValue: "启用" })
: t("common:status.disabled", { defaultValue: "停用" })}
</AdminStatusBadge>
</div>
<p className="mt-0.5 truncate font-mono text-[11px] text-muted-foreground">
{node.username ?? node.code}
</p>
</button>
</div>
{hasChildren && expanded ? (
<ul className="space-y-0.5">
{children.map((child) => (
<TreeRow
key={child.id}
node={child}
depth={depth + 1}
selectedId={selectedId}
expandedIds={expandedIds}
onToggleExpand={onToggleExpand}
onSelect={onSelect}
/>
))}
</ul>
) : null}
</li>
);
}
export function AgentLineSidebar({
siteLabel,
tree,
parentNameMap,
selectedId,
keyword,
agentCount,
onKeywordChange,
onSelect,
}: AgentLineSidebarProps): React.ReactElement {
const { t } = useTranslation(["agents", "common"]);
const [expandedIds, setExpandedIds] = useState<Set<number>>(() => new Set());
const normalizedKeyword = keyword.trim().toLowerCase();
const displayForest = useMemo(() => {
const pruned = pruneTreeForSearch(tree, normalizedKeyword, parentNameMap);
return unwrapSiteRoots(pruned);
}, [normalizedKeyword, parentNameMap, tree]);
useEffect(() => {
const next = new Set<number>();
collectExpandableIds(tree, next);
setExpandedIds(next);
}, [tree]);
useEffect(() => {
if (selectedId === null) {
return;
}
setExpandedIds((prev) => {
const next = new Set(prev);
const walk = (nodes: AgentNodeRow[], ancestors: number[]): boolean => {
for (const node of nodes) {
const chain = [...ancestors, node.id];
if (node.id === selectedId) {
for (const id of ancestors) {
next.add(id);
}
return true;
}
if (walk(node.children ?? [], chain)) {
return true;
}
}
return false;
};
walk(tree, []);
return next;
});
}, [selectedId, tree]);
const toggleExpand = useCallback((id: number) => {
setExpandedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
}, []);
const hasAnyAgent = displayForest.length > 0;
return (
<aside className="flex h-full min-h-[28rem] w-full flex-col bg-muted/10 lg:w-[18rem] lg:shrink-0 lg:border-r lg:border-border/70">
<div className="space-y-3 border-b border-border/60 bg-card px-4 py-4">
{siteLabel ? (
<p className="truncate text-xs font-medium text-foreground/80" title={siteLabel}>
{siteLabel}
</p>
) : null}
<p className="text-xs text-muted-foreground">
{t("lineUi.agentCount", {
defaultValue: "本组 {{count}} 个代理",
count: agentCount,
})}
</p>
<div className="relative">
<Search className="pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={keyword}
onChange={(e) => onKeywordChange(e.target.value)}
className="h-9 pl-8 text-sm"
placeholder={t("lineUi.searchPlaceholder", {
defaultValue: "搜索名称或登录名",
})}
/>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-2 py-2">
{!hasAnyAgent ? (
<AdminNoResourceState className="px-2 py-8 text-center text-sm text-muted-foreground" />
) : (
<ul className="space-y-0.5" role="listbox" aria-label={t("listTitle", { defaultValue: "代理列表" })}>
{displayForest.map((node) => (
<TreeRow
key={node.id}
node={node}
depth={0}
selectedId={selectedId}
expandedIds={expandedIds}
onToggleExpand={toggleExpand}
onSelect={onSelect}
/>
))}
</ul>
)}
</div>
</aside>
);
}
export { formatCredit };