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.
291 lines
8.1 KiB
TypeScript
291 lines
8.1 KiB
TypeScript
"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 };
|