Files
lotteryAdmin/src/modules/agents/agent-line-sidebar.tsx
kang 4484a7a77a
Some checks failed
lotteryadmin CI / build (push) Has been cancelled
Refactor agents console and related components
- Simplified share rate calculation in AgentsConsole by removing unnecessary checks and directly setting the profile share rate.
- Updated the use of `profileParentCaps` to always return total share rate in the agent profile.
- Removed unused variables and memoized calculations for improved performance.
- Cleaned up imports in various files, removing unused components and optimizing the code structure.
- Added `tRef` dependency to several useEffect hooks to ensure proper reactivity to translation changes.
- Enhanced report preview tables with better label handling for various statuses and actions.
- Updated wallet filter options to align with player-side transaction types.
- Introduced new properties in types for better type safety and clarity.
2026-06-30 17:55:00 +08:00

333 lines
9.3 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 } from "@/components/admin/admin-no-resource-state";
import { AdminLoadingInline } from "@/components/admin/admin-loading-state";
import { Button } from "@/components/ui/button";
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;
}
function collectSearchExpandIds(
nodes: AgentNodeRow[],
normalized: string,
parentNameMap: Map<number, string>,
): Set<number> {
const ids = new Set<number>();
const walk = (list: AgentNodeRow[], ancestors: number[]): void => {
for (const node of list) {
const children = node.children ?? [];
const prunedChildren = pruneTreeForSearch(children, normalized, parentNameMap);
const selfMatch = nodeMatchesKeyword(node, normalized, parentNameMap);
if (selfMatch || prunedChildren.length > 0) {
for (const id of ancestors) {
ids.add(id);
}
if (prunedChildren.length > 0) {
ids.add(node.id);
}
walk(prunedChildren, [...ancestors, node.id]);
}
}
};
walk(nodes, []);
return ids;
}
export type AgentLineSidebarProps = {
/** API 返回的嵌套树(含 children */
tree: AgentNodeRow[];
parentNameMap: Map<number, string>;
selectedId: number | null;
keyword: string;
loading?: boolean;
onKeywordChange: (value: string) => void;
onSelect: (node: AgentNodeRow) => void;
errorMessage?: string | null;
onRetry?: () => 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-muted/15",
)}
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({
tree,
parentNameMap,
selectedId,
keyword,
loading = false,
onKeywordChange,
onSelect,
errorMessage = null,
onRetry,
}: 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]);
useEffect(() => {
if (normalizedKeyword === "") {
return;
}
const expandIds = collectSearchExpandIds(tree, normalizedKeyword, parentNameMap);
if (expandIds.size === 0) {
return;
}
setExpandedIds((prev) => new Set([...prev, ...expandIds]));
}, [normalizedKeyword, parentNameMap, 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 lg:w-[18rem] lg:shrink-0 lg:border-r lg:border-border/70">
<div className="space-y-3 border-b border-border/60 px-4 py-4">
<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 && errorMessage ? (
<div className="space-y-3 px-2 py-8 text-center">
<p className="text-sm text-destructive">{errorMessage}</p>
{onRetry ? (
<Button type="button" size="sm" variant="outline" onClick={onRetry}>
{t("common:actions.retry", { defaultValue: "重试" })}
</Button>
) : null}
</div>
) : !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 };