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
315 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|