refactor: update agent API schemas, standardize UI text styling, and enhance settlement credit ledger components
This commit is contained in:
308
src/modules/agents/agents-directory-console.tsx
Normal file
308
src/modules/agents/agents-directory-console.tsx
Normal file
@@ -0,0 +1,308 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { RefreshCw, Search } from "lucide-react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { getAgentNodes } from "@/api/admin-agents";
|
||||
import { AdminPageCard } from "@/components/admin/admin-page-card";
|
||||
import { AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import { AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
|
||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
import type { AgentNodeRow } from "@/types/api/admin-agent";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function formatPercent(value: number | null | undefined): string {
|
||||
if (value == null || Number.isNaN(value)) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
return `${Number(value).toFixed(2).replace(/\.?0+$/, "")}%`;
|
||||
}
|
||||
|
||||
function formatCredit(value: number | null | undefined): string {
|
||||
if (value == null || Number.isNaN(value)) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
return new Intl.NumberFormat("zh-CN", { maximumFractionDigits: 0 }).format(value);
|
||||
}
|
||||
|
||||
function statusLabel(status: number, t: (key: string, options?: { defaultValue?: string }) => string): string {
|
||||
return status === 1
|
||||
? t("statusEnabled", { defaultValue: "启用" })
|
||||
: t("statusDisabled", { defaultValue: "停用" });
|
||||
}
|
||||
|
||||
export function AgentsDirectoryConsole(): React.ReactElement {
|
||||
const { t } = useTranslation(["agents", "common"]);
|
||||
const tRef = useTranslationRef(["agents", "common"]);
|
||||
|
||||
const [items, setItems] = useState<AgentNodeRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [status, setStatus] = useState<"all" | "enabled" | "disabled">("all");
|
||||
const [includeRoots, setIncludeRoots] = useState(false);
|
||||
const [reloadKey, setReloadKey] = useState(0);
|
||||
|
||||
const parentNameMap = useMemo(
|
||||
() => new Map(items.map((item) => [item.id, item.name])),
|
||||
[items],
|
||||
);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setErr(null);
|
||||
try {
|
||||
const data = await getAgentNodes();
|
||||
setItems(data.items);
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: tRef.current("agents:loadFailed", { defaultValue: "加载代理列表失败" });
|
||||
setErr(message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [tRef]);
|
||||
|
||||
useAsyncEffect(load, [load, reloadKey]);
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
const normalized = keyword.trim().toLowerCase();
|
||||
|
||||
return items.filter((item) => {
|
||||
if (!includeRoots && item.is_root) {
|
||||
return false;
|
||||
}
|
||||
if (status === "enabled" && item.status !== 1) {
|
||||
return false;
|
||||
}
|
||||
if (status === "disabled" && item.status === 1) {
|
||||
return false;
|
||||
}
|
||||
if (!normalized) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const parentName = item.parent_id != null ? parentNameMap.get(item.parent_id) ?? "" : "";
|
||||
return [item.name, item.code, item.username ?? "", item.email ?? "", parentName]
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
.includes(normalized);
|
||||
});
|
||||
}, [includeRoots, items, keyword, parentNameMap, status]);
|
||||
|
||||
const totalOperatingAgents = useMemo(
|
||||
() => items.filter((item) => !item.is_root).length,
|
||||
[items],
|
||||
);
|
||||
const enabledOperatingAgents = useMemo(
|
||||
() => items.filter((item) => !item.is_root && item.status === 1).length,
|
||||
[items],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<div className="rounded-lg border border-border/70 bg-card px-4 py-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("summary.visibleAgents", { defaultValue: "当前可见经营代理数" })}
|
||||
</p>
|
||||
<p className="mt-1 text-2xl font-semibold tabular-nums">{totalOperatingAgents}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border/70 bg-card px-4 py-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("summary.enabledAgents", { defaultValue: "启用中的经营代理数" })}
|
||||
</p>
|
||||
<p className="mt-1 text-2xl font-semibold tabular-nums">{enabledOperatingAgents}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border/70 bg-card px-4 py-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("summary.visibleList", { defaultValue: "当前平铺列表条数" })}
|
||||
</p>
|
||||
<p className="mt-1 text-2xl font-semibold tabular-nums">{filteredItems.length}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AdminPageCard
|
||||
title={t("listTitle", { defaultValue: "代理列表" })}
|
||||
actions={
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setReloadKey((value) => value + 1)}
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
{t("common:actions.refresh", { defaultValue: "刷新" })}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="mb-4 flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="relative min-w-0 flex-1">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={keyword}
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
placeholder={t("listSearch", { defaultValue: "搜索代理名称 / 编码 / 登录名" })}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Select value={status} onValueChange={(value) => setStatus(value as typeof status)}>
|
||||
<SelectTrigger className="h-9 w-[150px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">
|
||||
{t("directoryStatus.all", { defaultValue: "全部状态" })}
|
||||
</SelectItem>
|
||||
<SelectItem value="enabled">
|
||||
{t("directoryStatus.enabled", { defaultValue: "仅启用" })}
|
||||
</SelectItem>
|
||||
<SelectItem value="disabled">
|
||||
{t("directoryStatus.disabled", { defaultValue: "仅停用" })}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Label className="flex h-9 items-center gap-2 rounded-md border border-border/70 px-3 text-sm font-normal">
|
||||
<Checkbox
|
||||
checked={includeRoots}
|
||||
onCheckedChange={(checked) => setIncludeRoots(checked === true)}
|
||||
/>
|
||||
{t("includeRoots", { defaultValue: "包含根节点" })}
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{err ? (
|
||||
<div className="mb-4 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{err}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="min-w-[140px]">{t("name", { defaultValue: "名称" })}</TableHead>
|
||||
<TableHead className="min-w-[120px]">{t("code", { defaultValue: "编码" })}</TableHead>
|
||||
<TableHead className="w-[90px]">{t("depth", { defaultValue: "层级" })}</TableHead>
|
||||
<TableHead className="w-[90px]">{t("status", { defaultValue: "状态" })}</TableHead>
|
||||
<TableHead className="min-w-[140px]">{t("parentAgent", { defaultValue: "上级代理" })}</TableHead>
|
||||
<TableHead className="min-w-[140px]">{t("username", { defaultValue: "登录名" })}</TableHead>
|
||||
<TableHead className="w-[110px] text-right">
|
||||
{t("profile.totalShareRate", { defaultValue: "占成比例 (%)" })}
|
||||
</TableHead>
|
||||
<TableHead className="w-[110px] text-right">
|
||||
{t("profile.rebateLimit", { defaultValue: "回水上限 (%)" })}
|
||||
</TableHead>
|
||||
<TableHead className="w-[130px] text-right">
|
||||
{t("profile.creditLimit", { defaultValue: "授信额度" })}
|
||||
</TableHead>
|
||||
<TableHead className="w-[130px] text-right">
|
||||
{t("lineUi.availableCredit", { defaultValue: "可下发" })}
|
||||
</TableHead>
|
||||
<TableHead className="w-[110px] text-right">
|
||||
{t("common:actions.title", { defaultValue: "操作" })}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<AdminTableLoadingRow colSpan={11} />
|
||||
) : filteredItems.length === 0 ? (
|
||||
<AdminTableNoResourceRow colSpan={11} />
|
||||
) : (
|
||||
filteredItems.map((item) => {
|
||||
const parentName =
|
||||
item.parent_id != null ? parentNameMap.get(item.parent_id) ?? "-" : "-";
|
||||
const profile = item.profile_summary;
|
||||
|
||||
return (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell>
|
||||
<div className="min-w-0">
|
||||
<span className="block truncate text-sm font-semibold">{item.name}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
<span className="font-mono">{item.code}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{item.depth}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{item.is_root ? (
|
||||
<AdminStatusBadge tone="info">
|
||||
{t("isRoot", { defaultValue: "根节点" })}
|
||||
</AdminStatusBadge>
|
||||
) : (
|
||||
<AdminStatusBadge tone={item.status === 1 ? "success" : "neutral"}>
|
||||
{statusLabel(item.status, t)}
|
||||
</AdminStatusBadge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm">{parentName}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm">{item.username ?? "-"}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<span className="tabular-nums">{formatPercent(profile?.total_share_rate)}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<span className="tabular-nums">{formatPercent(profile?.rebate_limit)}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<span className="tabular-nums">{formatCredit(profile?.credit_limit)}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<span className="tabular-nums">{formatCredit(profile?.available_credit)}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Link
|
||||
href={`/admin/agents?agent_node_id=${item.id}`}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "sm" }))}
|
||||
>
|
||||
{t("common:actions.view", { defaultValue: "查看" })}
|
||||
</Link>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</AdminPageCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user