Files
lotteryAdmin/src/modules/agents/agents-directory-console.tsx
kang 6ea0a6feec feat(agents, config, dashboard, i18n): add agent line provision wizard, site deletion, and site dashboard with multi-language support
Added agent line provision wizard page with permission gating, replacing redirect placeholder. Introduced site deletion API and UI with confirmation dialog in integration sites management. Added new site-scoped dashboard panel showing bet metrics, P/L trends, active players, and quick links. Enhanced chart tooltip to support custom formatters and fix indicator color
2026-06-12 20:47:53 +08:00

315 lines
12 KiB
TypeScript

"use client";
import { Eye, 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 { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { formatAdminCreditMajorDecimal } from "@/lib/money";
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";
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 formatAdminCreditMajorDecimal(value);
}
function statusLabel(status: number, t: (key: string, options?: { defaultValue?: string }) => string): string {
return status === 1
? t("statusEnabled", { defaultValue: "启用" })
: t("statusDisabled", { defaultValue: "停用" });
}
type DirectoryStatusFilter = "all" | "enabled" | "disabled";
function directoryStatusLabel(
value: DirectoryStatusFilter,
t: (key: string, options?: { defaultValue?: string }) => string,
): string {
switch (value) {
case "enabled":
return t("directoryStatus.enabled", { defaultValue: "仅启用" });
case "disabled":
return t("directoryStatus.disabled", { defaultValue: "仅停用" });
default:
return t("directoryStatus.all", { 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<DirectoryStatusFilter>("all");
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 (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);
});
}, [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 ?? "all") as DirectoryStatusFilter)}
>
<SelectTrigger className="h-9 w-[150px]">
<SelectValue>{() => directoryStatusLabel(status, t)}</SelectValue>
</SelectTrigger>
<SelectContent>
{(["all", "enabled", "disabled"] as DirectoryStatusFilter[]).map((value) => (
<SelectItem key={value} value={value}>
{directoryStatusLabel(value, t)}
</SelectItem>
))}
</SelectContent>
</Select>
</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="sticky right-0 z-20 w-14 bg-muted text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
{t("common:table.actions", { 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="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
<AdminRowActionsMenu
actions={[
{
key: "view",
label: t("common:actions.viewDetails", { defaultValue: "查看详情" }),
icon: Eye,
href: `/admin/agents?agent_node_id=${item.id}`,
},
]}
/>
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
</AdminPageCard>
</div>
);
}