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.
302 lines
8.8 KiB
TypeScript
302 lines
8.8 KiB
TypeScript
"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 };
|