Files
lotteryAdmin/src/modules/agents/agent-line-sidebar.tsx
kang a4454a54a4 refactor(risk, navigation): update risk management redirects and enhance loading states
Changed default redirects in risk management pages to point to the new risk pools section. Removed unused risk lock log components and streamlined the admin reports page with a loading state for better user experience. Added a new DocFigure component for improved documentation visuals and updated localization files to include new figure descriptions.
2026-06-16 13:50:58 +08:00

291 lines
8.1 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 { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
import { AdminLoadingInline } from "@/components/admin/admin-loading-state";
import { Input } from "@/components/ui/input";
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;
}
export type AgentLineSidebarProps = {
siteLabel: string | null;
/** API 返回的嵌套树(含 children */
tree: AgentNodeRow[];
parentNameMap: Map<number, string>;
selectedId: number | null;
keyword: string;
agentCount: number;
loading?: boolean;
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-md py-1 pr-2 transition-colors",
active ? "bg-primary/10 ring-1 ring-primary/25" : "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-0.5 flex size-5 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-0.5 inline-block size-5 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="truncate text-sm font-medium leading-5">{node.name}</div>
<p className="truncate text-xs leading-5 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,
loading = false,
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(() => {
return pruneTreeForSearch(tree, normalizedKeyword, parentNameMap);
}, [normalizedKeyword, parentNameMap, tree]);
useEffect(() => {
if (tree.length === 0) {
setExpandedIds(new Set());
return;
}
setExpandedIds((prev) => {
const next = new Set(prev);
for (const node of tree) {
if ((node.children?.length ?? 0) > 0) {
next.add(node.id);
}
}
return 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 min-h-0 h-full 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">
{loading ? (
<AdminLoadingInline className="py-10" />
) : !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 };