feat(agents, i18n): enhance agent management and settlement features with new translations and UI updates

Added new translations for agent management and settlement features in English, Nepali, and Chinese, improving multi-language support. Updated the agents console to reflect changes in funding modes and player details, enhancing user experience. Refactored the admin permission gate to include new logic for handling bound line agents, ensuring better permission management. Additionally, streamlined the UI for agent-related pages and improved navigation to the settlement center, consolidating related functionalities for better accessibility.
This commit is contained in:
2026-06-04 18:01:05 +08:00
parent c2eac2fafc
commit 65eaeecf8c
139 changed files with 8852 additions and 1435 deletions

View File

@@ -0,0 +1,699 @@
"use client";
import type { ComponentType } from "react";
import { ChevronRight, Network, Pencil, Plus, Trash2, Users } from "lucide-react";
import { useTranslation } from "react-i18next";
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { AgentsPlayersPanel } from "@/modules/agents/agents-players-panel";
import { AgentProfileFields, type AgentProfileFieldsProps } from "@/modules/agents/agent-profile-fields";
import { formatCredit } from "@/modules/agents/agent-line-sidebar";
import { Button } from "@/components/ui/button";
import { ratioToPercentUi } from "@/lib/admin-rate-percent";
import { resolveRoleStatusTone } from "@/lib/admin-status-tone";
import { cn } from "@/lib/utils";
import type { AgentNodeProfileSummary, AgentNodeRow, AgentProfileRow } from "@/types/api/admin-agent";
function settlementCycleLabel(
cycle: AgentNodeProfileSummary["settlement_cycle"] | undefined,
t: (key: string, opts?: { defaultValue?: string }) => string,
): string {
if (cycle === "daily") {
return t("profile.cycleDaily", { defaultValue: "日结" });
}
if (cycle === "monthly") {
return t("profile.cycleMonthly", { defaultValue: "月结" });
}
return t("profile.cycleWeekly", { defaultValue: "周结" });
}
export type AgentDetailTab = "overview" | "profile" | "downline" | "players";
export type AgentLineDetailPanelProps = {
node: AgentNodeRow | null;
profile: AgentProfileRow | null;
profileLoading: boolean;
childAgents: AgentNodeRow[];
childCountById: Map<number, number>;
siteCode: string;
siteLabel: string | null;
parentName: string | null;
detailTab: AgentDetailTab;
onDetailTabChange: (tab: AgentDetailTab) => void;
canViewProfileTab: boolean;
canEditProfileTab: boolean;
profileReadOnly: boolean;
canViewDownlineTab: boolean;
canViewPlayersTab: boolean;
canManageNode: boolean;
canCreateChild: boolean;
canDeleteChild: (node: AgentNodeRow) => boolean;
onEditChild: (node: AgentNodeRow) => void;
onDeleteChild: (node: AgentNodeRow) => void;
onAddChild: () => void;
onEditCurrent: () => void;
onSelectChild: (node: AgentNodeRow) => void;
profileFields: AgentProfileFieldsProps | null;
profileSaving: boolean;
onSaveProfile: () => void;
};
export function AgentLineDetailPanel({
node,
profile,
profileLoading,
childAgents,
childCountById,
siteCode,
siteLabel,
parentName,
detailTab,
onDetailTabChange,
canViewProfileTab,
canEditProfileTab,
profileReadOnly,
canViewDownlineTab,
canViewPlayersTab,
canManageNode,
canCreateChild,
canDeleteChild,
onEditChild,
onDeleteChild,
onAddChild,
onEditCurrent,
onSelectChild,
profileFields,
profileSaving,
onSaveProfile,
}: AgentLineDetailPanelProps): React.ReactElement {
const { t } = useTranslation(["agents", "common"]);
if (node === null) {
return (
<div className="flex flex-1 flex-col items-center justify-center bg-muted/20 px-6 py-20 text-center">
<div className="flex size-14 items-center justify-center rounded-2xl border border-dashed border-border/80 bg-background">
<Network className="size-6 text-muted-foreground/70" aria-hidden />
</div>
<p className="mt-4 text-sm font-medium text-foreground">
{t("lineUi.selectAgent", { defaultValue: "选择左侧代理查看占成与授信" })}
</p>
<p className="mt-2 max-w-sm text-sm text-muted-foreground">
{t("lineUi.selectAgentHint", {
defaultValue: "信用占成盘以代理树为结算边界,占成、授信与回水均在代理节点配置。",
})}
</p>
</div>
);
}
const cycleLabel =
profile?.settlement_cycle === "daily"
? t("profile.cycleDaily", { defaultValue: "日结" })
: profile?.settlement_cycle === "monthly"
? t("profile.cycleMonthly", { defaultValue: "月结" })
: t("profile.cycleWeekly", { defaultValue: "周结" });
const tabs: { key: AgentDetailTab; label: string; count?: number; visible: boolean }[] = [
{
key: "overview",
label: t("lineUi.tabOverview", { defaultValue: "概览" }),
visible: true,
},
{
key: "profile",
label: profileReadOnly
? t("lineUi.tabProfileReadOnly", { defaultValue: "占成与授信(只读)" })
: t("lineUi.tabProfile", { defaultValue: "占成与授信" }),
visible: canViewProfileTab,
},
{
key: "downline",
label: t("lineUi.tabDownline", { defaultValue: "直属下级" }),
count: childAgents.length,
visible: canViewDownlineTab,
},
{
key: "players",
label: t("lineUi.tabPlayers", { defaultValue: "直属玩家" }),
visible: canViewPlayersTab,
},
];
const siteDisplay =
siteLabel && siteCode.trim() !== ""
? `${siteLabel} (${siteCode})`
: siteLabel ?? siteCode;
return (
<div className="flex min-h-[28rem] min-w-0 flex-1 flex-col bg-background">
<header className="border-b border-border/60 bg-card px-5 py-5 sm:px-6">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2.5">
<h2 className="truncate text-xl font-semibold tracking-tight text-foreground">
{node.name}
</h2>
<AdminStatusBadge tone={resolveRoleStatusTone(node.status)} className="shrink-0">
{node.status === 1
? t("common:status.enabled", { defaultValue: "启用" })
: t("common:status.disabled", { defaultValue: "停用" })}
</AdminStatusBadge>
</div>
<p className="mt-1.5 text-sm text-muted-foreground">
<span className="font-mono text-xs text-foreground/80">{node.code}</span>
{node.username ? (
<>
<span className="mx-1.5 text-border">·</span>
{node.username}
</>
) : null}
{parentName ? (
<>
<span className="mx-1.5 text-border">·</span>
{t("parentAgent", { defaultValue: "上级代理" })} {parentName}
</>
) : null}
</p>
</div>
<div className="flex shrink-0 flex-col items-end gap-2 sm:flex-row sm:items-center">
{siteDisplay ? (
<div
className="rounded-lg border border-border/70 bg-muted/30 px-3 py-1.5 text-xs text-muted-foreground"
title={siteDisplay}
>
<span className="font-medium text-foreground/90">
{t("lineUi.currentSite", { defaultValue: "当前站点" })}
</span>
<span className="mx-1.5 text-border">|</span>
<span className="truncate">{siteDisplay}</span>
</div>
) : null}
{canManageNode ? (
<div className="flex flex-wrap justify-end gap-2">
<Button type="button" size="sm" variant="outline" onClick={onEditCurrent}>
<Pencil className="mr-1.5 size-3.5" />
{t("lineUi.editAccount", { defaultValue: "账号与状态" })}
</Button>
{canCreateChild ? (
<Button type="button" size="sm" onClick={onAddChild}>
<Plus className="mr-1.5 size-3.5" />
{t("createChild", { defaultValue: "添加下级代理" })}
</Button>
) : null}
</div>
) : null}
</div>
</div>
</header>
<div className="flex items-center gap-0 overflow-x-auto border-b border-border/60 bg-card px-5 sm:px-6">
{tabs
.filter((tab) => tab.visible)
.map((tab) => (
<TabButton
key={tab.key}
active={detailTab === tab.key}
onClick={() => onDetailTabChange(tab.key)}
label={tab.label}
count={tab.count}
/>
))}
</div>
<div className="min-h-0 flex-1 overflow-y-auto bg-muted/15 px-5 py-5 sm:px-6 sm:py-6">
{detailTab === "overview" ? (
<OverviewTab
profile={profile}
profileLoading={profileLoading}
cycleLabel={cycleLabel}
profileReadOnly={profileReadOnly}
canViewDownlineTab={canViewDownlineTab}
canViewPlayersTab={canViewPlayersTab}
childCount={childAgents.length}
onGoToDownline={() => onDetailTabChange("downline")}
onGoToPlayers={() => onDetailTabChange("players")}
/>
) : null}
{detailTab === "profile" && canViewProfileTab && profileFields ? (
<Card className="mx-auto max-w-3xl border-border/70 shadow-sm">
<CardHeader className="border-b border-border/60 pb-4">
<CardTitle className="text-base">
{profileReadOnly
? t("lineUi.tabProfileReadOnly", { defaultValue: "占成与授信(只读)" })
: t("lineUi.tabProfile", { defaultValue: "占成与授信" })}
</CardTitle>
<p className="text-sm font-normal text-muted-foreground">
{profileReadOnly
? t("lineUi.profileReadOnlyHint", {
defaultValue: "占成、授信与回水由上级配置,如需调整请联系上级代理或平台。",
})
: t("lineUi.profileTabHint", {
defaultValue:
"占成、授信、回水与风控标签在此维护;登录名与密码请用「账号与状态」。",
})}
</p>
</CardHeader>
<CardContent className="pt-5">
<AgentProfileFields {...profileFields} idPrefix="inline-agent-profile" variant="card" />
{canManageNode && canEditProfileTab ? (
<div className="mt-6 flex justify-end border-t border-border/60 pt-5">
<Button
type="button"
className="min-w-[10rem]"
disabled={profileSaving || profileFields.loading}
onClick={onSaveProfile}
>
{profileSaving
? t("common:actions.saving", { defaultValue: "保存中…" })
: t("lineUi.saveProfile", { defaultValue: "保存占成与授信" })}
</Button>
</div>
) : null}
</CardContent>
</Card>
) : null}
{detailTab === "downline" && canViewDownlineTab ? (
<DownlineTable
childAgents={childAgents}
childCountById={childCountById}
canManageNode={canManageNode}
canCreateChild={canCreateChild}
canDeleteChild={canDeleteChild}
onEditChild={onEditChild}
onDeleteChild={onDeleteChild}
onSelectChild={onSelectChild}
onAddChild={onAddChild}
/>
) : null}
{detailTab === "players" && canViewPlayersTab ? (
<AgentsPlayersPanel
siteCode={siteCode}
agentNodeId={node.id}
allowCreatePlayer={profile?.can_create_player === true}
embedded
/>
) : null}
</div>
</div>
);
}
function OverviewTab({
profile,
profileLoading,
cycleLabel,
profileReadOnly,
canViewDownlineTab,
canViewPlayersTab,
childCount,
onGoToDownline,
onGoToPlayers,
}: {
profile: AgentProfileRow | null;
profileLoading: boolean;
cycleLabel: string;
profileReadOnly: boolean;
canViewDownlineTab: boolean;
canViewPlayersTab: boolean;
childCount: number;
onGoToDownline: () => void;
onGoToPlayers: () => void;
}): React.ReactElement {
const { t } = useTranslation(["agents", "common"]);
const rebateCap =
profile && !profileLoading ? ratioToPercentUi(profile.rebate_limit ?? 0) : null;
return (
<div className="mx-auto max-w-5xl space-y-6">
{profileReadOnly ? (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<MetricCard
label={t("profile.creditLimit", { defaultValue: "授信额度" })}
value={profileLoading ? "…" : formatCredit(profile?.credit_limit ?? 0)}
/>
<MetricCard
label={t("lineUi.allocatedCredit", { defaultValue: "已下发" })}
value={profileLoading ? "…" : formatCredit(profile?.allocated_credit ?? 0)}
/>
<MetricCard
label={t("lineUi.availableCredit", { defaultValue: "可下发" })}
value={profileLoading ? "…" : formatCredit(profile?.available_credit ?? 0)}
highlight
/>
</div>
) : (
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<MetricCard
label={t("profile.totalShareRate", { defaultValue: "占成比例" })}
value={profileLoading ? "…" : `${profile?.total_share_rate ?? 0}%`}
subtitle={
rebateCap !== null
? t("lineUi.shareRebateCap", {
defaultValue: "回水上限 {{rate}}%",
rate: rebateCap,
})
: undefined
}
accent
/>
<MetricCard
label={t("profile.creditLimit", { defaultValue: "授信额度" })}
value={profileLoading ? "…" : formatCredit(profile?.credit_limit ?? 0)}
/>
<MetricCard
label={t("lineUi.allocatedCredit", { defaultValue: "已下发" })}
value={profileLoading ? "…" : formatCredit(profile?.allocated_credit ?? 0)}
/>
<MetricCard
label={t("lineUi.availableCredit", { defaultValue: "可下发" })}
value={profileLoading ? "…" : formatCredit(profile?.available_credit ?? 0)}
highlight
/>
</div>
)}
{!profileReadOnly && !profileLoading && profile ? (
<p className="text-xs text-muted-foreground">
{t("lineUi.profileFootnote", {
defaultValue: "回水上限 {{rebate}}% · 默认回水 {{defaultRebate}}% · {{cycle}}",
rebate: ratioToPercentUi(profile.rebate_limit ?? 0),
defaultRebate: ratioToPercentUi(profile.default_player_rebate ?? 0),
cycle: cycleLabel,
})}
{(profile.risk_tags?.length ?? 0) > 0
? ` · ${t("profile.riskTags", { defaultValue: "风控" })}: ${profile.risk_tags?.join(", ")}`
: ""}
</p>
) : null}
{profileReadOnly ? (
<p className="rounded-lg border border-border/60 bg-card px-4 py-3 text-sm text-muted-foreground">
{t("lineUi.selfAgentOverviewHint", {
defaultValue:
"以下为上级为您分配的授信额度,占成与回水由上级在后台维护,本账号不可查看或修改。",
})}
</p>
) : null}
{canViewDownlineTab || canViewPlayersTab ? (
<div className="grid gap-4 md:grid-cols-2">
{canViewDownlineTab ? (
<OverviewLinkCard
icon={Network}
title={t("lineUi.tabDownline", { defaultValue: "直属下级" })}
description={t("lineUi.overviewDownlineCard", {
defaultValue: "{{count}} 个,可在对应 Tab 管理下级代理。",
count: childCount,
})}
actionLabel={t("lineUi.viewAll", { defaultValue: "查看全部" })}
onAction={onGoToDownline}
/>
) : null}
{canViewPlayersTab ? (
<OverviewLinkCard
icon={Users}
title={t("lineUi.tabPlayers", { defaultValue: "直属玩家" })}
description={t("lineUi.overviewPlayersHint", {
defaultValue: "直属玩家请在「直属玩家」Tab 维护。",
})}
actionLabel={t("lineUi.viewAll", { defaultValue: "查看全部" })}
onAction={onGoToPlayers}
/>
) : null}
</div>
) : null}
</div>
);
}
function OverviewLinkCard({
icon: Icon,
title,
description,
actionLabel,
onAction,
}: {
icon: ComponentType<{ className?: string }>;
title: string;
description: string;
actionLabel: string;
onAction: () => void;
}): React.ReactElement {
return (
<Card className="border-border/70 shadow-sm">
<CardContent className="flex items-start justify-between gap-4 pt-5">
<div className="flex min-w-0 gap-3">
<div className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-primary/8 text-primary">
<Icon className="size-5" aria-hidden />
</div>
<div className="min-w-0">
<p className="font-medium text-foreground">{title}</p>
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
</div>
</div>
<Button
type="button"
variant="ghost"
size="sm"
className="shrink-0 text-primary hover:text-primary"
onClick={onAction}
>
{actionLabel}
<ChevronRight className="ml-0.5 size-3.5" aria-hidden />
</Button>
</CardContent>
</Card>
);
}
function DownlineTable({
childAgents,
childCountById,
canManageNode,
canCreateChild,
canDeleteChild,
onEditChild,
onDeleteChild,
onSelectChild,
onAddChild,
}: {
childAgents: AgentNodeRow[];
childCountById: Map<number, number>;
canManageNode: boolean;
canCreateChild: boolean;
canDeleteChild: (node: AgentNodeRow) => boolean;
onEditChild: (node: AgentNodeRow) => void;
onDeleteChild: (node: AgentNodeRow) => void;
onSelectChild: (node: AgentNodeRow) => void;
onAddChild: () => void;
}): React.ReactElement {
const { t } = useTranslation(["agents", "common"]);
if (childAgents.length === 0) {
return (
<div className="flex flex-col items-center justify-center rounded-2xl border border-dashed border-border/70 bg-card px-6 py-16 text-center shadow-sm">
<AdminNoResourceState className="py-4">
{canManageNode && canCreateChild ? (
<Button type="button" className="mt-2" onClick={onAddChild}>
<Plus className="mr-1.5 size-4" />
{t("createChild", { defaultValue: "添加下级代理" })}
</Button>
) : null}
</AdminNoResourceState>
</div>
);
}
return (
<div className="admin-table-shell overflow-hidden rounded-2xl border border-border/70 bg-card shadow-sm">
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow className="bg-muted/40 hover:bg-muted/40">
<TableHead>{t("agentCode", { defaultValue: "代理编码" })}</TableHead>
<TableHead>{t("agentName", { defaultValue: "代理名称" })}</TableHead>
<TableHead>{t("loginUsername", { defaultValue: "登录名" })}</TableHead>
<TableHead>{t("lineUi.downlineColumns.email", { defaultValue: "邮箱" })}</TableHead>
<TableHead className="text-right whitespace-nowrap">
{t("profile.totalShareRate", { defaultValue: "占成 (%)" })}
</TableHead>
<TableHead className="text-right whitespace-nowrap">
{t("profile.creditLimit", { defaultValue: "授信额度" })}
</TableHead>
<TableHead className="text-right whitespace-nowrap">
{t("lineUi.allocatedCredit", { defaultValue: "已下发" })}
</TableHead>
<TableHead className="whitespace-nowrap">
{t("profile.settlementCycle", { defaultValue: "结算周期" })}
</TableHead>
<TableHead className="text-center whitespace-nowrap">
{t("lineUi.downlineColumns.downlineCount", { defaultValue: "下级数" })}
</TableHead>
<TableHead className="w-24">{t("common:status.label", { defaultValue: "状态" })}</TableHead>
{canManageNode ? (
<TableHead className="sticky right-0 z-10 w-14 bg-muted/40 text-center shadow-[-1px_0_0_var(--border)]">
{t("common:table.actions", { defaultValue: "操作" })}
</TableHead>
) : null}
</TableRow>
</TableHeader>
<TableBody>
{childAgents.map((child) => {
const summary = child.profile_summary;
return (
<TableRow
key={child.id}
className="cursor-pointer"
onClick={() => onSelectChild(child)}
>
<TableCell className="font-mono text-xs">{child.code}</TableCell>
<TableCell className="font-medium">{child.name}</TableCell>
<TableCell className="text-xs">{child.username ?? "—"}</TableCell>
<TableCell className="max-w-[10rem] truncate text-xs text-muted-foreground">
{child.email ?? "—"}
</TableCell>
<TableCell className="text-right tabular-nums text-xs">
{summary ? `${ratioToPercentUi(summary.total_share_rate)}%` : "—"}
</TableCell>
<TableCell className="text-right tabular-nums text-xs">
{summary ? formatCredit(summary.credit_limit) : "—"}
</TableCell>
<TableCell className="text-right tabular-nums text-xs">
{summary ? formatCredit(summary.allocated_credit) : "—"}
</TableCell>
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
{summary ? settlementCycleLabel(summary.settlement_cycle, t) : "—"}
</TableCell>
<TableCell className="text-center tabular-nums text-xs">
{childCountById.get(child.id) ?? 0}
</TableCell>
<TableCell>
<AdminStatusBadge tone={resolveRoleStatusTone(child.status)} className="shrink-0">
{child.status === 1
? t("common:status.enabled", { defaultValue: "启用" })
: t("common:status.disabled", { defaultValue: "停用" })}
</AdminStatusBadge>
</TableCell>
{canManageNode ? (
<TableCell
className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_var(--border)]"
onClick={(e) => e.stopPropagation()}
>
<AdminRowActionsMenu
actions={[
{
key: "edit",
label: t("editNode", { defaultValue: "编辑代理" }),
icon: Pencil,
onClick: () => onEditChild(child),
},
{
key: "delete",
label: t("deleteNode", { defaultValue: "删除代理" }),
icon: Trash2,
destructive: true,
disabled: !canDeleteChild(child),
onClick: () => onDeleteChild(child),
},
]}
/>
</TableCell>
) : null}
</TableRow>
);
})}
</TableBody>
</Table>
</div>
</div>
);
}
function MetricCard({
label,
value,
subtitle,
accent = false,
highlight = false,
}: {
label: string;
value: string;
subtitle?: string;
accent?: boolean;
highlight?: boolean;
}): React.ReactElement {
return (
<div
className={cn(
"rounded-xl border bg-card px-4 py-4 shadow-sm transition-colors",
highlight && "border-primary/25 bg-primary/[0.04]",
accent && !highlight && "border-border/70",
!accent && !highlight && "border-border/70",
)}
>
<p className="text-xs font-medium text-muted-foreground">{label}</p>
<p
className={cn(
"mt-1.5 text-2xl font-semibold tabular-nums tracking-tight",
highlight ? "text-primary" : "text-foreground",
)}
>
{value}
</p>
{subtitle ? <p className="mt-1 text-xs text-muted-foreground">{subtitle}</p> : null}
</div>
);
}
function TabButton({
active,
onClick,
label,
count,
}: {
active: boolean;
onClick: () => void;
label: string;
count?: number;
}): React.ReactElement {
return (
<button
type="button"
onClick={onClick}
className={cn(
"relative -mb-px shrink-0 border-b-2 px-4 py-3 text-sm font-medium transition-colors",
active
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:border-border hover:text-foreground",
)}
>
{label}
{count !== undefined && count > 0 ? (
<span
className={cn(
"ml-1.5 inline-flex min-w-[1.25rem] items-center justify-center rounded-full px-1.5 py-0.5 text-[10px] font-medium tabular-nums",
active ? "bg-primary/10 text-primary" : "bg-muted text-muted-foreground",
)}
>
{count}
</span>
) : null}
</button>
);
}