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>
);
}

View File

@@ -1,10 +1,12 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { postAdminAgentLine } from "@/api/admin-agent-lines";
import { getAdminIntegrationSites } from "@/api/admin-integration-sites";
import { AdminPageCard } from "@/components/admin/admin-page-card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -17,21 +19,22 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { useAsyncEffect } from "@/hooks/use-async-effect";
import { percentUiToRatio } from "@/lib/admin-rate-percent";
import { LotteryApiBizError } from "@/types/api/errors";
import type { AdminIntegrationSiteRow } from "@/types/api/admin-integration-site";
export function AgentLineProvisionWizard(): React.ReactElement {
const { t } = useTranslation(["agents", "common"]);
const [submitting, setSubmitting] = useState(false);
const [secrets, setSecrets] = useState<{ sso: string; wallet: string } | null>(null);
const [sitesLoading, setSitesLoading] = useState(true);
const [sites, setSites] = useState<AdminIntegrationSiteRow[]>([]);
const [form, setForm] = useState({
site_code: "",
code: "",
name: "",
username: "",
password: "",
currency_code: "NPR",
wallet_api_url: "",
notes: "",
total_share_rate: "0",
credit_limit: "0",
rebate_limit: "0",
@@ -40,33 +43,51 @@ export function AgentLineProvisionWizard(): React.ReactElement {
can_grant_extra_rebate: false,
});
useAsyncEffect(() => {
setSitesLoading(true);
void getAdminIntegrationSites()
.then((data) => setSites(data.items))
.catch(() => setSites([]))
.finally(() => setSitesLoading(false));
}, []);
const unboundSites = useMemo(
() => sites.filter((row) => !row.has_line_root),
[sites],
);
async function onSubmit(e: React.FormEvent): Promise<void> {
e.preventDefault();
if (!form.site_code.trim()) {
toast.error(t("agents:lineProvision.siteRequired", { defaultValue: "请选择接入站点" }));
return;
}
setSubmitting(true);
setSecrets(null);
try {
const result = await postAdminAgentLine({
await postAdminAgentLine({
site_code: form.site_code.trim().toLowerCase(),
code: form.code.trim().toLowerCase(),
name: form.name.trim(),
username: form.username.trim(),
password: form.password,
currency_code: form.currency_code,
wallet_api_url: form.wallet_api_url.trim() || null,
notes: form.notes.trim() || null,
total_share_rate: Number.parseFloat(form.total_share_rate) || 0,
credit_limit: Number.parseInt(form.credit_limit, 10) || 0,
rebate_limit: Number.parseFloat(form.rebate_limit) || 0,
default_player_rebate: Number.parseFloat(form.default_player_rebate) || 0,
rebate_limit: percentUiToRatio(form.rebate_limit),
default_player_rebate: percentUiToRatio(form.default_player_rebate),
settlement_cycle: form.settlement_cycle,
can_grant_extra_rebate: form.can_grant_extra_rebate,
});
if (result.secrets) {
setSecrets({
sso: result.secrets.sso_jwt_secret,
wallet: result.secrets.wallet_api_key,
});
}
toast.success(t("agents:lineProvision.success", { defaultValue: "线路已开通" }));
toast.success(t("agents:lineProvision.success", { defaultValue: "一级代理已创建" }));
setForm((f) => ({
...f,
site_code: "",
code: "",
name: "",
username: "",
password: "",
}));
const data = await getAdminIntegrationSites();
setSites(data.items);
} catch (err) {
const msg =
err instanceof LotteryApiBizError ? err.message : t("common:error.generic");
@@ -77,10 +98,61 @@ export function AgentLineProvisionWizard(): React.ReactElement {
}
return (
<AdminPageCard title={t("agents:lineProvision.title", { defaultValue: "开通代理线路" })}>
<AdminPageCard title={t("agents:lineProvision.title", { defaultValue: "创建一级代理" })}>
<p className="mb-2 max-w-xl text-sm text-muted-foreground">
{t("agents:subnav.provisionHint", {
defaultValue:
"请先在「平台管理 → 接入配置」创建接入站点;对接密钥在站点创建时一次性展示。",
})}
</p>
<p className="mb-4 max-w-xl text-sm text-muted-foreground">
{t("agents:lineProvision.description", {
defaultValue:
"将一级代理绑定到已有接入站点,并配置后台登录账号与占成、授信、回水、结算周期。代理编码创建后不可修改。",
})}{" "}
<Link
href="/admin/config/integration-sites"
className="font-medium text-primary underline-offset-4 hover:underline"
>
{t("agents:lineProvision.openIntegrationSites", {
defaultValue: "前往接入站点",
})}
</Link>
</p>
<form className="grid max-w-xl gap-4" onSubmit={onSubmit}>
<div className="grid gap-2">
<Label>{t("agents:lineProvision.code", { defaultValue: "站点 code" })}</Label>
<Label>{t("agents:lineProvision.siteCode", { defaultValue: "接入站点" })}</Label>
<Select
value={form.site_code}
onValueChange={(value) => setForm((f) => ({ ...f, site_code: value ?? "" }))}
disabled={sitesLoading || unboundSites.length === 0}
>
<SelectTrigger>
<SelectValue
placeholder={
sitesLoading
? t("common:loading", { defaultValue: "加载中…" })
: unboundSites.length === 0
? t("agents:lineProvision.noUnboundSite", {
defaultValue: "暂无未绑定一级代理的站点",
})
: t("agents:lineProvision.siteCodePlaceholder", {
defaultValue: "选择站点",
})
}
/>
</SelectTrigger>
<SelectContent>
{unboundSites.map((site) => (
<SelectItem key={site.id} value={site.code}>
{site.name} ({site.code})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label>{t("agents:lineProvision.code", { defaultValue: "代理编码" })}</Label>
<Input
value={form.code}
onChange={(e) => setForm((f) => ({ ...f, code: e.target.value }))}
@@ -89,7 +161,7 @@ export function AgentLineProvisionWizard(): React.ReactElement {
/>
</div>
<div className="grid gap-2">
<Label>{t("agents:lineProvision.name", { defaultValue: "线路名称" })}</Label>
<Label>{t("agents:lineProvision.name", { defaultValue: "一级代理名称" })}</Label>
<Input
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
@@ -97,7 +169,7 @@ export function AgentLineProvisionWizard(): React.ReactElement {
/>
</div>
<div className="grid gap-2">
<Label>{t("agents:lineProvision.username", { defaultValue: "代理账号" })}</Label>
<Label>{t("agents:lineProvision.username", { defaultValue: "后台登录账号" })}</Label>
<Input
value={form.username}
onChange={(e) => setForm((f) => ({ ...f, username: e.target.value }))}
@@ -114,13 +186,6 @@ export function AgentLineProvisionWizard(): React.ReactElement {
minLength={8}
/>
</div>
<div className="grid gap-2">
<Label>{t("agents:lineProvision.walletUrl", { defaultValue: "钱包 API URL" })}</Label>
<Input
value={form.wallet_api_url}
onChange={(e) => setForm((f) => ({ ...f, wallet_api_url: e.target.value }))}
/>
</div>
<p className="text-sm font-medium">
{t("agents:profile.section", { defaultValue: "占成与授信" })}
@@ -147,24 +212,26 @@ export function AgentLineProvisionWizard(): React.ReactElement {
/>
</div>
<div className="grid gap-2">
<Label>{t("agents:profile.rebateLimit", { defaultValue: "回水上限" })}</Label>
<Label>{t("agents:profile.rebateLimit", { defaultValue: "回水上限 (%)" })}</Label>
<Input
type="number"
min={0}
max={1}
step="0.0001"
max={100}
step="0.01"
value={form.rebate_limit}
placeholder="0.5"
onChange={(e) => setForm((f) => ({ ...f, rebate_limit: e.target.value }))}
/>
</div>
<div className="grid gap-2">
<Label>{t("agents:profile.defaultPlayerRebate", { defaultValue: "默认玩家回水" })}</Label>
<Label>{t("agents:profile.defaultPlayerRebate", { defaultValue: "默认玩家回水 (%)" })}</Label>
<Input
type="number"
min={0}
max={1}
step="0.0001"
max={100}
step="0.01"
value={form.default_player_rebate}
placeholder="0.5"
onChange={(e) => setForm((f) => ({ ...f, default_player_rebate: e.target.value }))}
/>
</div>
@@ -208,32 +275,12 @@ export function AgentLineProvisionWizard(): React.ReactElement {
</Label>
</div>
<div className="grid gap-2">
<Label>{t("common:notes", { defaultValue: "备注" })}</Label>
<Textarea
value={form.notes}
onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))}
/>
</div>
<Button type="submit" disabled={submitting}>
<Button type="submit" disabled={submitting || unboundSites.length === 0}>
{submitting
? t("common:submitting", { defaultValue: "提交中…" })
: t("agents:lineProvision.submit", { defaultValue: "开通线路" })}
: t("agents:lineProvision.submit", { defaultValue: "创建一级代理" })}
</Button>
</form>
{secrets ? (
<div className="mt-6 rounded-md border border-amber-500/40 bg-amber-500/5 p-4 text-sm">
<p className="font-medium text-amber-700">
{t("agents:lineProvision.secretsOnce", { defaultValue: "密钥仅显示一次,请妥善保存" })}
</p>
<p className="mt-2 break-all">
SSO: <code>{secrets.sso}</code>
</p>
<p className="mt-1 break-all">
Wallet API Key: <code>{secrets.wallet}</code>
</p>
</div>
) : null}
</AdminPageCard>
);
}

View File

@@ -0,0 +1,301 @@
"use client";
import { ChevronRight, Search } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
import { Input } from "@/components/ui/input";
import { resolveRoleStatusTone } from "@/lib/admin-status-tone";
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;
}
function collectExpandableIds(nodes: AgentNodeRow[], into: Set<number>): void {
for (const node of nodes) {
if ((node.children?.length ?? 0) > 0) {
into.add(node.id);
collectExpandableIds(node.children ?? [], into);
}
}
}
function unwrapSiteRoots(nodes: AgentNodeRow[]): AgentNodeRow[] {
return nodes.flatMap((node) => (node.is_root ? (node.children ?? []) : [node]));
}
export type AgentLineSidebarProps = {
siteLabel: string | null;
/** API 返回的嵌套树(含 children */
tree: AgentNodeRow[];
parentNameMap: Map<number, string>;
selectedId: number | null;
keyword: string;
agentCount: number;
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-lg py-1.5 pr-2 transition-colors",
active ? "bg-primary/12 ring-1 ring-primary/30 shadow-sm" : "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-1 flex size-6 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-1 inline-block size-6 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="flex items-center gap-2">
<span className="truncate text-sm font-medium">{node.name}</span>
<AdminStatusBadge
tone={resolveRoleStatusTone(node.status)}
className="shrink-0 px-1.5 py-0 text-[10px]"
>
{node.status === 1
? t("common:status.enabled", { defaultValue: "启用" })
: t("common:status.disabled", { defaultValue: "停用" })}
</AdminStatusBadge>
</div>
<p className="mt-0.5 truncate font-mono text-[11px] 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,
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(() => {
const pruned = pruneTreeForSearch(tree, normalizedKeyword, parentNameMap);
return unwrapSiteRoots(pruned);
}, [normalizedKeyword, parentNameMap, tree]);
useEffect(() => {
const next = new Set<number>();
collectExpandableIds(tree, next);
setExpandedIds(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 h-full min-h-[28rem] 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">
{!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 };

View File

@@ -0,0 +1,76 @@
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import {
PRD_AGENT_LINE_PROVISION,
PRD_AGENTS_ACCESS_ANY,
PRD_SETTLEMENT_AGENT_ACCESS_ANY,
} from "@/lib/admin-prd";
import type { AdminAgentContext } from "@/types/api/admin-agent";
type AdminSessionLike = {
is_super_admin?: boolean;
permissions?: string[] | null;
agent?: AdminAgentContext | null;
};
/** 线路内绑定代理可查看账单;平台账号需 settlement.agent.* */
export function canAccessAgentSettlementBills(
session: AdminSessionLike | null | undefined,
): boolean {
if (session?.agent != null) {
return true;
}
return adminHasAnyPermission(session?.permissions ?? [], [...PRD_SETTLEMENT_AGENT_ACCESS_ANY]);
}
/** 仅平台侧可开通新线路;绑定代理永不可见 */
export function canAccessAgentLineProvision(
session: AdminSessionLike | null | undefined,
): boolean {
if (session?.agent != null) {
return false;
}
return adminHasAnyPermission(session?.permissions ?? [], [PRD_AGENT_LINE_PROVISION]);
}
export function isAgentLineSubnavTabVisible(
href: string,
session: AdminSessionLike | null | undefined,
): boolean {
const perms = session?.permissions ?? [];
const isSuper = session?.is_super_admin === true;
const boundAgent = session?.agent ?? null;
if (isSuper) {
return adminHasAnyPermission(perms, tabRequiredSlugs(href));
}
if (href === "/admin/agents/provision") {
return canAccessAgentLineProvision(session);
}
if (href === "/admin/settlement-center" || href === "/admin/agents/settlement-bills") {
return canAccessAgentSettlementBills(session);
}
if (href === "/admin/agents") {
return adminHasAnyPermission(perms, [...PRD_AGENTS_ACCESS_ANY]);
}
return false;
}
function tabRequiredSlugs(href: string): readonly string[] {
switch (href) {
case "/admin/agents":
return PRD_AGENTS_ACCESS_ANY;
case "/admin/agents/provision":
return [PRD_AGENT_LINE_PROVISION];
case "/admin/settlement-center":
case "/admin/agents/settlement-bills":
return PRD_SETTLEMENT_AGENT_ACCESS_ANY;
default:
return [];
}
}

View File

@@ -0,0 +1,268 @@
"use client";
import { useTranslation } from "react-i18next";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { formatAdminCreditMajorDecimal } from "@/lib/money";
import { cn } from "@/lib/utils";
import type { AgentParentCaps } from "@/types/api/admin-agent";
export type AgentProfileFieldsProps = {
disabled?: boolean;
loading?: boolean;
parentCaps: AgentParentCaps | null;
availableCredit: number | null;
canCreateChildAgent: boolean;
isSuperAdmin: boolean;
shareRate: string;
onShareRateChange: (value: string) => void;
creditLimit: string;
onCreditLimitChange: (value: string) => void;
rebateLimit: string;
onRebateLimitChange: (value: string) => void;
defaultRebate: string;
onDefaultRebateChange: (value: string) => void;
settlementCycle: "daily" | "weekly" | "monthly";
onSettlementCycleChange: (value: "daily" | "weekly" | "monthly") => void;
extraRebate: boolean;
onExtraRebateChange: (value: boolean) => void;
canCreatePlayer: boolean;
onCanCreatePlayerChange: (value: boolean) => void;
canCreateChild: boolean;
onCanCreateChildChange: (value: boolean) => void;
riskTags: string;
onRiskTagsChange: (value: string) => void;
idPrefix?: string;
currencyCode?: string;
/** card用于代理线路详情 Tab 内的卡片表单 */
variant?: "default" | "card";
};
export function AgentProfileFields({
disabled = false,
loading = false,
parentCaps,
availableCredit,
canCreateChildAgent,
isSuperAdmin,
shareRate,
onShareRateChange,
creditLimit,
onCreditLimitChange,
rebateLimit,
onRebateLimitChange,
defaultRebate,
onDefaultRebateChange,
settlementCycle,
onSettlementCycleChange,
extraRebate,
onExtraRebateChange,
canCreatePlayer,
onCanCreatePlayerChange,
canCreateChild,
onCanCreateChildChange,
riskTags,
onRiskTagsChange,
idPrefix = "agent-profile",
currencyCode = "NPR",
variant = "default",
}: AgentProfileFieldsProps): React.ReactElement {
const { t } = useTranslation(["agents", "common"]);
const fieldDisabled = disabled || loading;
const isCard = variant === "card";
return (
<div className="space-y-5">
{(parentCaps || availableCredit !== null) && !loading ? (
<div
className={cn(
"rounded-lg text-xs text-muted-foreground",
isCard ? "border border-border/60 bg-muted/25 px-3 py-2.5 space-y-1" : "space-y-1",
)}
>
{parentCaps ? (
<p>
{t("profile.parentCaps", {
defaultValue: "上级占成 {{share}}%,可下发额度 {{credit}}",
share: parentCaps.total_share_rate,
credit: formatAdminCreditMajorDecimal(parentCaps.available_credit, currencyCode),
})}
</p>
) : null}
{availableCredit !== null ? (
<p>
{t("profile.availableCredit", {
defaultValue: "可下发额度:{{amount}}",
amount: formatAdminCreditMajorDecimal(availableCredit, currencyCode),
})}
</p>
) : null}
</div>
) : null}
{loading ? (
<p className="text-sm text-muted-foreground">
{t("profile.loading", { defaultValue: "正在加载占成与授信…" })}
</p>
) : null}
<div
className={cn(
"grid gap-4 sm:grid-cols-2",
fieldDisabled ? "pointer-events-none opacity-50" : "",
)}
>
<div className="space-y-2">
<Label htmlFor={`${idPrefix}-share-rate`}>
{t("profile.totalShareRate", { defaultValue: "占成比例 (%)" })}
</Label>
<Input
id={`${idPrefix}-share-rate`}
type="number"
min={0}
max={100}
step="0.01"
value={shareRate}
onChange={(e) => onShareRateChange(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`${idPrefix}-credit-limit`}>
{t("profile.creditLimit", { defaultValue: "授信额度" })}
</Label>
<Input
id={`${idPrefix}-credit-limit`}
type="number"
min={0}
value={creditLimit}
onChange={(e) => onCreditLimitChange(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`${idPrefix}-rebate-limit`}>
{t("profile.rebateLimit", { defaultValue: "回水上限 (%)" })}
</Label>
<Input
id={`${idPrefix}-rebate-limit`}
type="number"
min={0}
max={100}
step="0.01"
value={rebateLimit}
onChange={(e) => onRebateLimitChange(e.target.value)}
placeholder="0.5"
/>
</div>
<div className="space-y-2">
<Label htmlFor={`${idPrefix}-default-rebate`}>
{t("profile.defaultPlayerRebate", { defaultValue: "默认玩家回水 (%)" })}
</Label>
<Input
id={`${idPrefix}-default-rebate`}
type="number"
min={0}
max={100}
step="0.01"
value={defaultRebate}
onChange={(e) => onDefaultRebateChange(e.target.value)}
placeholder="0.5"
/>
</div>
<div className="space-y-2 sm:col-span-2">
<Label htmlFor={`${idPrefix}-risk-tags`}>
{t("profile.riskTags", { defaultValue: "风控标签" })}
</Label>
<Input
id={`${idPrefix}-risk-tags`}
value={riskTags}
onChange={(e) => onRiskTagsChange(e.target.value)}
placeholder={t("profile.riskTagsPlaceholder", {
defaultValue: "逗号分隔,如 overdue, high_turnover",
})}
/>
</div>
<div className="space-y-2 sm:col-span-2">
<Label htmlFor={`${idPrefix}-settlement-cycle`}>
{t("profile.settlementCycle", { defaultValue: "结算周期" })}
</Label>
<Select
value={settlementCycle}
onValueChange={(value) =>
onSettlementCycleChange((value as "daily" | "weekly" | "monthly") ?? "weekly")
}
>
<SelectTrigger id={`${idPrefix}-settlement-cycle`}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="daily">{t("profile.cycleDaily", { defaultValue: "日结" })}</SelectItem>
<SelectItem value="weekly">{t("profile.cycleWeekly", { defaultValue: "周结" })}</SelectItem>
<SelectItem value="monthly">{t("profile.cycleMonthly", { defaultValue: "月结" })}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div
className={cn(
"space-y-4 border-t border-border/60 pt-4",
fieldDisabled ? "pointer-events-none opacity-50" : "",
)}
>
{!isCard ? (
<p className="text-xs text-muted-foreground">
{t("profile.capabilityHint", {
defaultValue:
"保存后约束该代理主账号能否开玩家/下级;与平台「代理」角色叠加,以本开关为准。",
})}
</p>
) : null}
<div className="grid gap-4 sm:grid-cols-1">
<SwitchRow
checked={extraRebate}
onCheckedChange={onExtraRebateChange}
label={t("profile.canGrantExtraRebate", { defaultValue: "允许额外回水" })}
/>
<SwitchRow
checked={canCreatePlayer}
onCheckedChange={onCanCreatePlayerChange}
label={t("profile.canCreatePlayer", { defaultValue: "允许创建玩家" })}
/>
<SwitchRow
checked={canCreateChild}
onCheckedChange={onCanCreateChildChange}
disabled={!canCreateChildAgent && !isSuperAdmin}
label={t("profile.canCreateChildAgent", { defaultValue: "允许创建下级代理" })}
/>
</div>
</div>
</div>
);
}
function SwitchRow({
checked,
onCheckedChange,
label,
disabled = false,
}: {
checked: boolean;
onCheckedChange: (value: boolean) => void;
label: string;
disabled?: boolean;
}): React.ReactElement {
return (
<div className="flex items-center justify-between gap-4 rounded-lg border border-border/60 bg-muted/20 px-3 py-2.5">
<Label className="font-normal">{label}</Label>
<Switch checked={checked} onCheckedChange={onCheckedChange} disabled={disabled} />
</div>
);
}

View File

@@ -1,8 +1,6 @@
"use client";
import Link from "next/link";
import { Eye, Pencil, Plus, Search, Trash2 } from "lucide-react";
import { useCallback, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
@@ -14,13 +12,14 @@ import {
putAgentNode,
putAgentNodeProfile,
} from "@/api/admin-agents";
import { getAdminIntegrationSites } from "@/api/admin-integration-sites";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
import {
AgentLineDetailPanel,
type AgentDetailTab,
} from "@/modules/agents/agent-line-detail-panel";
import { AgentLineSidebar } from "@/modules/agents/agent-line-sidebar";
import { AgentProfileFields } from "@/modules/agents/agent-profile-fields";
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
import { AdminPageCard } from "@/components/admin/admin-page-card";
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import { Button, buttonVariants } from "@/components/ui/button";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
@@ -30,43 +29,41 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { useAsyncEffect } from "@/hooks/use-async-effect";
import { useConfirmAction } from "@/hooks/use-confirm-action";
import { useTranslationRef } from "@/hooks/use-translation-ref";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { AgentsPlayersPanel } from "@/modules/agents/agents-players-panel";
import {
PRD_AGENT_LINE_PROVISION_ACCESS_ANY,
percentUiToRatio,
percentValueToUi,
parsePercentUi,
ratioToPercentUi,
} from "@/lib/admin-rate-percent";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import {
PRD_AGENT_MANAGE,
PRD_AGENT_PROFILE_MANAGE,
PRD_AGENTS_ACCESS_ANY,
PRD_AGENT_SITES_ACCESS_ANY,
PRD_INTEGRATION_ACCESS_ANY,
PRD_USERS_MANAGE,
} from "@/lib/admin-prd";
import { resolveRoleStatusTone } from "@/lib/admin-status-tone";
import { normalizeAgentSettlementCycle } from "@/lib/agent-settlement-cycle";
import { cn } from "@/lib/utils";
import { useAdminSiteCodeOptions } from "@/hooks/use-admin-site-code-options";
import { useAdminProfile } from "@/stores/admin-session";
import type { AgentNodeRow } from "@/types/api/admin-agent";
import { useAgentManagementSiteStore } from "@/stores/agent-management-site";
import type { AgentNodeRow, AgentParentCaps, AgentProfileRow } from "@/types/api/admin-agent";
import { LotteryApiBizError } from "@/types/api/errors";
function parseRiskTagsInput(text: string): string[] {
return Array.from(
new Set(
text
.split(/[,\s]+/)
.map((tag) => tag.trim())
.filter((tag) => tag.length > 0),
),
);
}
function flattenTree(nodes: AgentNodeRow[]): AgentNodeRow[] {
const out: AgentNodeRow[] = [];
const walk = (list: AgentNodeRow[]) => {
@@ -82,10 +79,6 @@ function flattenTree(nodes: AgentNodeRow[]): AgentNodeRow[] {
return out;
}
function countBusinessAgents(nodes: AgentNodeRow[]): number {
return nodes.filter((node) => !node.is_root).length;
}
export function AgentsConsole(): React.ReactElement {
const { t } = useTranslation(["agents", "common"]);
const tRef = useTranslationRef(["agents", "common"]);
@@ -104,23 +97,18 @@ export function AgentsConsole(): React.ReactElement {
PRD_AGENT_PROFILE_MANAGE,
PRD_AGENT_MANAGE,
]);
const canProvision =
isSuperAdmin ||
adminHasAnyPermission(profile?.permissions, [...PRD_AGENT_LINE_PROVISION_ACCESS_ANY]);
const canViewSiteList = adminHasAnyPermission(profile?.permissions, [...PRD_AGENT_SITES_ACCESS_ANY]);
const canSwitchSite = isSuperAdmin || adminHasAnyPermission(profile?.permissions, [...PRD_INTEGRATION_ACCESS_ANY]);
const [siteOptions, setSiteOptions] = useState<{ id: number; label: string; code: string }[]>([]);
const [globalVisibleNodeCount, setGlobalVisibleNodeCount] = useState<number | null>(null);
const [globalBusinessAgentCount, setGlobalBusinessAgentCount] = useState<number | null>(null);
const [adminSiteId, setAdminSiteId] = useState<number | null>(null);
const { sites: siteOptions } = useAdminSiteCodeOptions();
const adminSiteId = useAgentManagementSiteStore((s) => s.adminSiteId);
const setAdminSiteId = useAgentManagementSiteStore((s) => s.setAdminSiteId);
const [tree, setTree] = useState<AgentNodeRow[]>([]);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState<string | null>(null);
const [keyword, setKeyword] = useState("");
const [operationsTab, setOperationsTab] = useState<"subordinates" | "players">("subordinates");
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(20);
const [selectedNodeId, setSelectedNodeId] = useState<number | null>(null);
const [detailTab, setDetailTab] = useState<AgentDetailTab>("overview");
const [profileSaving, setProfileSaving] = useState(false);
const [selectedProfile, setSelectedProfile] = useState<AgentProfileRow | null>(null);
const [selectedProfileLoading, setSelectedProfileLoading] = useState(false);
const [nodeDialogOpen, setNodeDialogOpen] = useState(false);
const [nodeDialogMode, setNodeDialogMode] = useState<"create" | "edit">("create");
@@ -141,19 +129,21 @@ export function AgentsConsole(): React.ReactElement {
const [profileExtraRebate, setProfileExtraRebate] = useState(false);
const [profileCanCreateChild, setProfileCanCreateChild] = useState(false);
const [profileCanCreatePlayer, setProfileCanCreatePlayer] = useState(true);
const [profileRiskTags, setProfileRiskTags] = useState("");
const [profileLoading, setProfileLoading] = useState(false);
const [profileLoaded, setProfileLoaded] = useState(true);
const [profileParentCaps, setProfileParentCaps] = useState<AgentParentCaps | null>(null);
const [profileAvailableCredit, setProfileAvailableCredit] = useState<number | null>(null);
const [editingNodeNeedsPrimaryAccount, setEditingNodeNeedsPrimaryAccount] = useState(false);
const [downlineDialogNode, setDownlineDialogNode] = useState<AgentNodeRow | null>(null);
const boundAgent = profile?.agent ?? null;
/** 登录账号是否可向子代理下放「允许创建下级」 */
const canCreateChildAgent =
isSuperAdmin || boundAgent?.can_create_child_agent !== false;
const canCreateChildForNode = (_node: AgentNodeRow) => canManageNode && canCreateChildAgent;
const canViewPlayersTab =
const hasUsersManagePermission =
isSuperAdmin ||
(boundAgent?.can_create_player !== false &&
adminHasAnyPermission(profile?.permissions, [PRD_USERS_MANAGE]));
adminHasAnyPermission(profile?.permissions, [PRD_USERS_MANAGE]);
const [rootProfile, setRootProfile] = useState<AgentProfileRow | null>(null);
const resetProfileForm = (mode: "create" | "edit" = "create") => {
setProfileShareRate("0");
@@ -164,24 +154,42 @@ export function AgentsConsole(): React.ReactElement {
setProfileExtraRebate(false);
setProfileCanCreateChild(mode === "create" ? false : false);
setProfileCanCreatePlayer(true);
setProfileRiskTags("");
setProfileParentCaps(null);
setProfileAvailableCredit(null);
};
const applyProfileRowToForm = (row: AgentProfileRow) => {
setProfileShareRate(percentValueToUi(row.total_share_rate ?? 0));
setProfileCreditLimit(String(row.credit_limit ?? 0));
setProfileRebateLimit(ratioToPercentUi(row.rebate_limit ?? 0));
setProfileDefaultRebate(ratioToPercentUi(row.default_player_rebate ?? 0));
setProfileSettlementCycle(normalizeAgentSettlementCycle(row.settlement_cycle));
setProfileExtraRebate(Boolean(row.can_grant_extra_rebate));
setProfileCanCreateChild(Boolean(row.can_create_child_agent));
setProfileCanCreatePlayer(row.can_create_player !== false);
setProfileParentCaps(row.parent_caps ?? null);
setProfileAvailableCredit(row.available_credit ?? null);
setProfileRiskTags((row.risk_tags ?? []).join(", "));
};
const profilePayload = () => ({
total_share_rate: Number.parseFloat(profileShareRate) || 0,
credit_limit: Number.parseInt(profileCreditLimit, 10) || 0,
rebate_limit: Number.parseFloat(profileRebateLimit) || 0,
default_player_rebate: Number.parseFloat(profileDefaultRebate) || 0,
rebate_limit: percentUiToRatio(profileRebateLimit),
default_player_rebate: percentUiToRatio(profileDefaultRebate),
settlement_cycle: normalizeAgentSettlementCycle(profileSettlementCycle),
can_grant_extra_rebate: profileExtraRebate,
can_create_child_agent: profileCanCreateChild,
can_create_player: profileCanCreatePlayer,
risk_tags: parseRiskTagsInput(profileRiskTags),
});
const validateProfileFields = (): string | null => {
const shareRate = Number.parseFloat(profileShareRate);
const creditLimit = Number.parseInt(profileCreditLimit, 10);
const rebateLimit = Number.parseFloat(profileRebateLimit);
const defaultRebate = Number.parseFloat(profileDefaultRebate);
const rebateLimit = parsePercentUi(profileRebateLimit);
const defaultRebate = parsePercentUi(profileDefaultRebate);
if (Number.isNaN(shareRate) || shareRate < 0 || shareRate > 100) {
return t("profile.validation.shareRange", {
@@ -195,15 +203,15 @@ export function AgentsConsole(): React.ReactElement {
});
}
if (Number.isNaN(rebateLimit) || rebateLimit < 0 || rebateLimit > 1) {
if (rebateLimit === null || rebateLimit < 0 || rebateLimit > 100) {
return t("profile.validation.rebateLimitRange", {
defaultValue: "回水上限须在 01 之间(如 0.005 表示 0.5%",
defaultValue: "回水上限须在 0100% 之间",
});
}
if (Number.isNaN(defaultRebate) || defaultRebate < 0 || defaultRebate > 1) {
if (defaultRebate === null || defaultRebate < 0 || defaultRebate > 100) {
return t("profile.validation.defaultRebateRange", {
defaultValue: "默认玩家回水须在 01 之间",
defaultValue: "默认玩家回水须在 0100% 之间",
});
}
@@ -222,10 +230,8 @@ export function AgentsConsole(): React.ReactElement {
[flatNodes],
);
const businessRows = useMemo(() => flatNodes.filter((node) => !node.is_root), [flatNodes]);
const currentSiteNodeCount = flatNodes.length;
const currentSiteBusinessAgentCount = useMemo(() => countBusinessAgents(flatNodes), [flatNodes]);
const selectedSiteLabel = useMemo(
() => siteOptions.find((site) => site.id === adminSiteId)?.label ?? null,
() => siteOptions.find((site) => site.id === adminSiteId)?.name ?? null,
[adminSiteId, siteOptions],
);
const activeSiteCode = useMemo(() => {
@@ -239,11 +245,44 @@ export function AgentsConsole(): React.ReactElement {
}
return flatNodes.find((node) => node.depth === 0)?.code?.trim() ?? "";
}, [adminSiteId, boundAgent?.site_code, flatNodes, siteOptions]);
const playersPanelAgentId = useMemo(
() => (isSuperAdmin ? null : (boundAgent?.id ?? null)),
[boundAgent?.id, isSuperAdmin],
const rootNode = useMemo(
() => flatNodes.find((node) => node.is_root || node.depth === 0) ?? null,
[flatNodes],
);
const selectedNode = useMemo(
() =>
selectedNodeId !== null
? (flatNodes.find((node) => node.id === selectedNodeId) ?? null)
: null,
[flatNodes, selectedNodeId],
);
const isOwnAgentNode =
boundAgent !== null && selectedNodeId !== null && selectedNodeId === boundAgent.id;
const canEditSelectedProfile =
canManageProfile && selectedNode !== null && (isSuperAdmin || !isOwnAgentNode);
const selectedChildAgents = useMemo(() => {
if (selectedNode === null) {
return [];
}
return flatNodes.filter((node) => node.parent_id === selectedNode.id);
}, [flatNodes, selectedNode]);
const childCountById = useMemo(() => {
const counts = new Map<number, number>();
for (const node of flatNodes) {
if (node.parent_id === null) {
continue;
}
counts.set(node.parent_id, (counts.get(node.parent_id) ?? 0) + 1);
}
return counts;
}, [flatNodes]);
const filteredRows = useMemo(() => {
const normalized = keyword.trim().toLowerCase();
@@ -262,21 +301,6 @@ export function AgentsConsole(): React.ReactElement {
});
}, [businessRows, keyword, parentNameMap]);
const total = filteredRows.length;
const lastPage = Math.max(1, Math.ceil(total / perPage));
const currentPage = Math.min(page, lastPage);
const pagedRows = useMemo(() => {
const start = (currentPage - 1) * perPage;
return filteredRows.slice(start, start + perPage);
}, [currentPage, filteredRows, perPage]);
const downlineChildAgents = useMemo(() => {
if (downlineDialogNode === null) {
return [];
}
return flatNodes.filter((node) => node.parent_id === downlineDialogNode.id);
}, [downlineDialogNode, flatNodes]);
const loadTree = useCallback(async (siteId?: number | null) => {
setLoading(true);
setErr(null);
@@ -292,59 +316,124 @@ export function AgentsConsole(): React.ReactElement {
}
}, [tRef]);
useAsyncEffect(() => {
useEffect(() => {
if (!canViewAgents) {
return;
}
if (canSwitchSite) {
void getAdminIntegrationSites()
.then((data) => {
const options = data.items.map((row) => ({
id: row.id,
code: row.code,
label: `${row.name} (${row.code})`,
}));
setSiteOptions(options);
if (options.length > 0 && adminSiteId === null) {
setAdminSiteId(options[0]?.id ?? null);
}
})
.catch(() => setSiteOptions([]));
} else if (profile?.agent?.admin_site_id) {
setAdminSiteId(profile.agent.admin_site_id);
if (adminSiteId === null) {
if (profile?.agent?.admin_site_id) {
setAdminSiteId(profile.agent.admin_site_id);
return;
}
if (siteOptions.length > 0 && isSuperAdmin) {
setAdminSiteId(siteOptions[0]?.id ?? null);
}
}
}, [adminSiteId, canSwitchSite, canViewAgents, profile?.agent?.admin_site_id]);
}, [adminSiteId, canViewAgents, isSuperAdmin, profile?.agent?.admin_site_id, setAdminSiteId, siteOptions]);
useAsyncEffect(() => {
if (!canSwitchSite || siteOptions.length === 0) {
setGlobalVisibleNodeCount(null);
setGlobalBusinessAgentCount(null);
if (selectedNode === null) {
setSelectedProfile(null);
setSelectedProfileLoading(false);
return;
}
void Promise.all(siteOptions.map(async (site) => getAgentTree(site.id)))
.then((results) => {
const allNodes = results.flatMap((result) => flattenTree(result.tree));
setGlobalVisibleNodeCount(allNodes.length);
setGlobalBusinessAgentCount(countBusinessAgents(allNodes));
setSelectedProfileLoading(true);
void getAgentNodeProfile(selectedNode.id)
.then((row) => {
setSelectedProfile(row);
if (!nodeDialogOpen) {
applyProfileRowToForm(row);
}
})
.catch(() => {
setGlobalVisibleNodeCount(null);
setGlobalBusinessAgentCount(null);
});
}, [canSwitchSite, siteOptions]);
.catch(() => setSelectedProfile(null))
.finally(() => setSelectedProfileLoading(false));
}, [selectedNode?.id, nodeDialogOpen]);
useAsyncEffect(() => {
if (adminSiteId === null && !canSwitchSite && profile?.agent?.admin_site_id) {
setAdminSiteId(profile.agent.admin_site_id);
if (rootNode === null) {
setRootProfile(null);
return;
}
if (adminSiteId !== null || !canSwitchSite) {
void getAgentNodeProfile(rootNode.id)
.then((p) => setRootProfile(p))
.catch(() => setRootProfile(null));
}, [rootNode?.id]);
/** 仅上级/平台维护下级占成授信;代理查看自己时不展示配置 Tab */
const canShowProfileTab = canEditSelectedProfile;
const canShowDownlineTab = useMemo(
() =>
selectedNode !== null &&
!selectedProfileLoading &&
selectedProfile?.can_create_child_agent === true,
[selectedNode, selectedProfile, selectedProfileLoading],
);
const canShowPlayersTab = useMemo(
() =>
selectedNode !== null &&
!selectedProfileLoading &&
selectedProfile?.can_create_player === true &&
hasUsersManagePermission,
[hasUsersManagePermission, selectedNode, selectedProfile, selectedProfileLoading],
);
const canCreateChildOnSelected = useMemo(
() => canManageNode && selectedProfile?.can_create_child_agent === true,
[canManageNode, selectedProfile?.can_create_child_agent],
);
useEffect(() => {
if (selectedProfileLoading || selectedNode === null) {
return;
}
if (detailTab === "profile" && !canShowProfileTab) {
setDetailTab("overview");
} else if (detailTab === "downline" && !canShowDownlineTab) {
setDetailTab(canShowPlayersTab ? "players" : "overview");
} else if (detailTab === "players" && !canShowPlayersTab) {
setDetailTab(canShowDownlineTab ? "downline" : "overview");
}
}, [
canShowDownlineTab,
canShowPlayersTab,
canShowProfileTab,
detailTab,
selectedNode,
selectedProfileLoading,
]);
useAsyncEffect(() => {
if (filteredRows.length === 0) {
setSelectedNodeId(null);
return;
}
if (selectedNodeId === null || !filteredRows.some((row) => row.id === selectedNodeId)) {
setSelectedNodeId(filteredRows[0]?.id ?? null);
}
}, [filteredRows, selectedNodeId]);
useEffect(() => {
setDetailTab("overview");
}, [selectedNodeId]);
useEffect(() => {
if (isOwnAgentNode && detailTab === "profile") {
setDetailTab("overview");
}
}, [detailTab, isOwnAgentNode]);
useAsyncEffect(() => {
if (adminSiteId !== null) {
void loadTree(adminSiteId);
}
}, [adminSiteId, canSwitchSite, loadTree, profile?.agent?.admin_site_id]);
}, [adminSiteId, loadTree]);
const openCreateChildForNode = (node: AgentNodeRow) => {
setNodeDialogMode("create");
@@ -359,6 +448,11 @@ export function AgentsConsole(): React.ReactElement {
setProfileLoaded(true);
setEditingNodeNeedsPrimaryAccount(false);
setNodeDialogOpen(true);
if (canManageProfile) {
void getAgentNodeProfile(node.id)
.then((p) => setProfileParentCaps(p.parent_caps ?? null))
.catch(() => setProfileParentCaps(null));
}
};
const openEditForNode = (node: AgentNodeRow) => {
@@ -383,14 +477,7 @@ export function AgentsConsole(): React.ReactElement {
setProfileLoaded(false);
void getAgentNodeProfile(node.id)
.then((p) => {
setProfileShareRate(String(p.total_share_rate ?? 0));
setProfileCreditLimit(String(p.credit_limit ?? 0));
setProfileRebateLimit(String(p.rebate_limit ?? 0));
setProfileDefaultRebate(String(p.default_player_rebate ?? 0));
setProfileSettlementCycle(normalizeAgentSettlementCycle(p.settlement_cycle));
setProfileExtraRebate(Boolean(p.can_grant_extra_rebate));
setProfileCanCreateChild(Boolean(p.can_create_child_agent));
setProfileCanCreatePlayer(p.can_create_player !== false);
applyProfileRowToForm(p);
setProfileLoaded(true);
})
.catch(() => {
@@ -404,64 +491,118 @@ export function AgentsConsole(): React.ReactElement {
});
};
const renderRowActions = (node: AgentNodeRow) => {
const rowDeleteBlockedByChildren = (node.children?.length ?? 0) > 0;
const rowDeleteBlockedBySelf = profile?.agent?.id === node.id;
const rowCanDelete =
canManageNode && !rowDeleteBlockedByChildren && !rowDeleteBlockedBySelf;
const canDeleteNode = (node: AgentNodeRow): boolean => {
const blockedByChildren = (node.children?.length ?? 0) > 0;
const blockedBySelf = profile?.agent?.id === node.id;
return (
<AdminRowActionsMenu
actions={[
{
key: "edit",
label: t("editNode", { defaultValue: "编辑代理" }),
icon: Pencil,
hidden: !canManageNode,
onClick: () => openEditForNode(node),
},
{
key: "create-child",
label: t("createChild", { defaultValue: "添加下级代理" }),
icon: Plus,
hidden: !canCreateChildForNode(node),
onClick: () => openCreateChildForNode(node),
},
{
key: "view-downline",
label: t("viewDownline", { defaultValue: "查看下级代理和玩家" }),
icon: Eye,
onClick: () => setDownlineDialogNode(node),
},
{
key: "delete",
label: t("deleteNode", { defaultValue: "删除代理" }),
icon: Trash2,
destructive: true,
hidden: !canManageNode,
disabled: !rowCanDelete,
onClick: () => {
if (!rowCanDelete) {
return;
}
requestConfirm({
title: t("deleteNode", { defaultValue: "删除代理" }),
description: t("deleteNodeConfirm", {
defaultValue: "删除后将同时移除该代理的唯一登录账号,且不可恢复。",
}),
confirmLabel: t("deleteNode", { defaultValue: "删除代理" }),
confirmVariant: "destructive",
onConfirm: async () => {
await deleteAgentNode(node.id);
toast.success(t("deleteSuccess", { name: node.name }));
await loadTree(adminSiteId);
},
});
},
},
]}
/>
);
return canManageNode && !blockedByChildren && !blockedBySelf;
};
const handleDeleteNode = (node: AgentNodeRow): void => {
if (!canDeleteNode(node)) {
return;
}
requestConfirm({
title: t("deleteNode", { defaultValue: "删除代理" }),
description: t("deleteNodeConfirm", {
defaultValue: "删除后将同时移除该代理的唯一登录账号,且不可恢复。",
}),
confirmLabel: t("deleteNode", { defaultValue: "删除代理" }),
confirmVariant: "destructive",
onConfirm: async () => {
await deleteAgentNode(node.id);
toast.success(t("deleteSuccess", { name: node.name }));
if (selectedNodeId === node.id) {
setSelectedNodeId(null);
}
await loadTree(adminSiteId);
},
});
};
const saveInlineProfile = async (): Promise<void> => {
if (selectedNode === null || !canEditSelectedProfile) {
return;
}
const validationError = validateProfileFields();
if (validationError) {
toast.error(validationError);
return;
}
setProfileSaving(true);
try {
const updated = await putAgentNodeProfile(selectedNode.id, profilePayload());
setSelectedProfile(updated);
applyProfileRowToForm(updated);
toast.success(t("profile.saveSuccess", { defaultValue: "占成与授信已保存" }));
await loadTree(adminSiteId);
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : t("saveFailed"));
} finally {
setProfileSaving(false);
}
};
const inlineProfileFields = useMemo(() => {
if (!canShowProfileTab) {
return null;
}
return {
disabled: !canEditSelectedProfile,
loading: selectedProfileLoading,
parentCaps: profileParentCaps,
availableCredit: profileAvailableCredit,
canCreateChildAgent,
isSuperAdmin,
shareRate: profileShareRate,
onShareRateChange: setProfileShareRate,
creditLimit: profileCreditLimit,
onCreditLimitChange: setProfileCreditLimit,
rebateLimit: profileRebateLimit,
onRebateLimitChange: setProfileRebateLimit,
defaultRebate: profileDefaultRebate,
onDefaultRebateChange: setProfileDefaultRebate,
settlementCycle: profileSettlementCycle,
onSettlementCycleChange: setProfileSettlementCycle,
extraRebate: profileExtraRebate,
onExtraRebateChange: setProfileExtraRebate,
canCreatePlayer: profileCanCreatePlayer,
onCanCreatePlayerChange: setProfileCanCreatePlayer,
canCreateChild: profileCanCreateChild,
onCanCreateChildChange: setProfileCanCreateChild,
riskTags: profileRiskTags,
onRiskTagsChange: setProfileRiskTags,
};
}, [
canCreateChildAgent,
canEditSelectedProfile,
canShowProfileTab,
isSuperAdmin,
profileAvailableCredit,
profileCanCreateChild,
profileCanCreatePlayer,
profileCreditLimit,
profileDefaultRebate,
profileExtraRebate,
profileParentCaps,
profileRebateLimit,
profileRiskTags,
profileSettlementCycle,
profileShareRate,
selectedProfileLoading,
]);
const showAgentSidebar = businessRows.length > 1;
const openAddAgent = (): void => {
const parent = selectedNode ?? rootNode;
if (parent !== null) {
openCreateChildForNode(parent);
}
};
const saveNode = async () => {
@@ -499,7 +640,12 @@ export function AgentsConsole(): React.ReactElement {
return;
}
if (canManageProfile) {
const includeProfileInDialog =
canManageProfile &&
(nodeDialogMode === "create" ||
(editingNodeId !== null && boundAgent?.id !== editingNodeId));
if (includeProfileInDialog) {
if (nodeDialogMode === "edit" && !profileLoaded) {
toast.error(
t("profile.loadingBlocked", {
@@ -537,7 +683,7 @@ export function AgentsConsole(): React.ReactElement {
: nodePassword.trim() || undefined,
status: nodeStatus,
});
if (canManageProfile) {
if (includeProfileInDialog) {
await putAgentNodeProfile(editingNodeId, profilePayload());
}
toast.success(t("updateSuccess", { name: nodeName.trim() }));
@@ -545,12 +691,26 @@ export function AgentsConsole(): React.ReactElement {
setNodeDialogOpen(false);
await loadTree(adminSiteId);
if (nodeDialogMode === "create" && targetParentId !== null && downlineDialogNode?.id === targetParentId) {
const refreshedParent = flattenTree(
(await getAgentTree(adminSiteId ?? undefined)).tree,
).find((node) => node.id === targetParentId);
if (refreshedParent) {
setDownlineDialogNode(refreshedParent);
if (nodeDialogMode === "create" && targetParentId !== null) {
const refreshed = flattenTree((await getAgentTree(adminSiteId ?? undefined)).tree);
const created = refreshed.find(
(node) => node.parent_id === targetParentId && node.name === nodeName.trim(),
);
if (created) {
setSelectedNodeId(created.id);
}
} else if (nodeDialogMode === "edit" && editingNodeId !== null) {
setSelectedNodeId(editingNodeId);
if (canManageProfile) {
void getAgentNodeProfile(editingNodeId)
.then((p) => {
if (selectedNodeId === editingNodeId || selectedNodeId === null) {
setSelectedProfile(p);
}
})
.catch(() => {
/* 树已刷新,占成区可能短暂不可用 */
});
}
}
} catch (e) {
@@ -560,6 +720,21 @@ export function AgentsConsole(): React.ReactElement {
}
};
const addParent = selectedNode ?? rootNode;
const parentProfileForAdd = useMemo(() => {
if (addParent === null) {
return null;
}
if (addParent.id === selectedNodeId && selectedProfile !== null) {
return selectedProfile;
}
if (rootNode !== null && addParent.id === rootNode.id) {
return rootProfile;
}
return null;
}, [addParent, rootNode, rootProfile, selectedNodeId, selectedProfile]);
if (!canViewAgents) {
return (
<p className="text-sm text-muted-foreground">
@@ -573,295 +748,71 @@ export function AgentsConsole(): React.ReactElement {
}
return (
<div className="space-y-4">
<div className="flex min-h-[32rem] flex-col gap-0">
<ConfirmDialog />
<div className="flex flex-wrap items-center gap-3">
<h1 className="text-xl font-semibold">
{t("title", { defaultValue: "代理经营" })}
</h1>
{canProvision ? (
<Link
href="/admin/agents/provision"
className={cn(buttonVariants({ variant: "outline", size: "sm" }))}
>
{t("lineProvision.link", { defaultValue: "开通线路" })}
</Link>
) : null}
{canSwitchSite && siteOptions.length > 0 ? (
<Select
value={adminSiteId !== null ? String(adminSiteId) : undefined}
onValueChange={(value) => setAdminSiteId(Number(value))}
>
<SelectTrigger className="w-[240px]">
<SelectValue placeholder={t("siteLabel", { defaultValue: "站点" })}>
{selectedSiteLabel}
</SelectValue>
</SelectTrigger>
<SelectContent>
{siteOptions.map((site) => (
<SelectItem key={site.id} value={String(site.id)}>
{site.label}
</SelectItem>
))}
</SelectContent>
</Select>
) : null}
{canViewSiteList ? (
<Link
href="/admin/agents/sites"
className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "text-muted-foreground")}
>
{t("sitesListLink", { defaultValue: "站点列表" })}
</Link>
) : null}
</div>
{err ? <p className="px-1 text-sm text-destructive">{err}</p> : null}
{err ? <p className="text-sm text-destructive">{err}</p> : null}
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<div className="rounded-xl border bg-muted/20 p-3">
<p className="text-xs text-muted-foreground">
{t("summary.currentSiteNodes", { defaultValue: "当前站点节点总数" })}
</p>
<p className="mt-1 text-2xl font-semibold">{currentSiteNodeCount}</p>
</div>
<div className="rounded-xl border bg-muted/20 p-3">
<p className="text-xs text-muted-foreground">
{t("summary.currentSiteAgents", { defaultValue: "当前站点经营代理数" })}
</p>
<p className="mt-1 text-2xl font-semibold">{currentSiteBusinessAgentCount}</p>
</div>
<div className="rounded-xl border bg-muted/20 p-3">
<p className="text-xs text-muted-foreground">
{isSuperAdmin
? t("summary.globalNodes", { defaultValue: "全部站点节点总数" })
: t("summary.visibleList", { defaultValue: "当前最上级代理数" })}
</p>
<p className="mt-1 text-2xl font-semibold">
{isSuperAdmin ? (globalVisibleNodeCount ?? "—") : filteredRows.length}
</p>
</div>
<div className="rounded-xl border bg-muted/20 p-3">
<p className="text-xs text-muted-foreground">
{isSuperAdmin
? t("summary.globalAgents", { defaultValue: "全部站点经营代理数" })
: t("summary.visibleAgents", { defaultValue: "当前可见经营代理数" })}
</p>
<p className="mt-1 text-2xl font-semibold">
{isSuperAdmin ? (globalBusinessAgentCount ?? "—") : currentSiteBusinessAgentCount}
</p>
</div>
</div>
<AdminPageCard title={t("listTitle", { defaultValue: "代理列表" })}>
{canViewPlayersTab ? (
<nav className="mb-4 flex flex-wrap gap-2 border-b border-border/60 pb-3">
<Button
type="button"
size="sm"
variant={operationsTab === "subordinates" ? "default" : "outline"}
onClick={() => setOperationsTab("subordinates")}
>
{t("tabs.subordinates", { defaultValue: "下级管理" })}
</Button>
<Button
type="button"
size="sm"
variant={operationsTab === "players" ? "default" : "outline"}
onClick={() => setOperationsTab("players")}
>
{t("tabs.players", { defaultValue: "玩家管理" })}
</Button>
</nav>
) : null}
{operationsTab === "subordinates" ? (
<div className="relative mb-3 min-w-[16rem] max-w-md">
<Search className="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={keyword}
onChange={(e) => {
setKeyword(e.target.value);
setPage(1);
}}
className="pl-8"
placeholder={t("listSearch", {
defaultValue: "搜索代理名称 / 编码 / 登录名",
})}
/>
</div>
) : null}
{operationsTab === "players" ? (
<AgentsPlayersPanel siteCode={activeSiteCode} agentNodeId={playersPanelAgentId} />
) : null}
<div className={cn("admin-table-shell mt-3", operationsTab === "players" ? "hidden" : "")}>
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("name", { defaultValue: "名称" })}</TableHead>
<TableHead>{t("code", { defaultValue: "编码" })}</TableHead>
<TableHead>{t("users.username", { defaultValue: "登录名" })}</TableHead>
<TableHead>{t("parentAgent", { defaultValue: "上级代理" })}</TableHead>
<TableHead className="w-16">{t("depth", { defaultValue: "层级" })}</TableHead>
<TableHead className="w-20">{t("childrenCount", { defaultValue: "直属下级" })}</TableHead>
<TableHead className="w-24">{t("status", { defaultValue: "状态" })}</TableHead>
<TableHead className="w-20" />
</TableRow>
</TableHeader>
<TableBody>
{pagedRows.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="text-muted-foreground">
{t("common:states.noData", { defaultValue: "暂无数据" })}
</TableCell>
</TableRow>
) : (
pagedRows.map((node) => (
<TableRow key={node.id}>
<TableCell className="font-medium">{node.name}</TableCell>
<TableCell className="font-mono text-xs">{node.code}</TableCell>
<TableCell className="font-mono text-xs">{node.username ?? "—"}</TableCell>
<TableCell className="text-muted-foreground">
{node.parent_id !== null ? (parentNameMap.get(node.parent_id) ?? "—") : "—"}
</TableCell>
<TableCell>{node.depth}</TableCell>
<TableCell>{node.children?.length ?? 0}</TableCell>
<TableCell>
<AdminStatusBadge tone={resolveRoleStatusTone(node.status)}>
{node.status === 1
? t("common:status.enabled", { defaultValue: "Enabled" })
: t("common:status.disabled", { defaultValue: "Disabled" })}
</AdminStatusBadge>
</TableCell>
<TableCell>{renderRowActions(node)}</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{operationsTab === "subordinates" ? (
<AdminListPaginationFooter
selectId="agents-operations-per-page"
total={total}
page={currentPage}
lastPage={lastPage}
perPage={perPage}
loading={loading}
onPerPageChange={(value) => {
setPerPage(value);
setPage(1);
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-2xl border border-border/70 bg-card shadow-sm lg:flex-row">
{showAgentSidebar ? (
<AgentLineSidebar
siteLabel={selectedSiteLabel}
tree={tree}
parentNameMap={parentNameMap}
selectedId={selectedNodeId}
keyword={keyword}
agentCount={businessRows.length}
onKeywordChange={(value) => {
setKeyword(value);
}}
onSelect={(node) => {
setSelectedNodeId(node.id);
}}
onPageChange={setPage}
/>
) : null}
</AdminPageCard>
<Dialog
open={downlineDialogNode !== null}
onOpenChange={(open) => {
if (!open) {
setDownlineDialogNode(null);
<AgentLineDetailPanel
node={selectedNode}
profile={selectedProfile}
profileLoading={selectedProfileLoading}
childAgents={selectedChildAgents}
childCountById={childCountById}
siteCode={activeSiteCode}
siteLabel={selectedSiteLabel}
parentName={
selectedNode?.parent_id !== null && selectedNode?.parent_id !== undefined
? (parentNameMap.get(selectedNode.parent_id) ?? null)
: null
}
}}
>
<DialogContent className="flex max-h-[min(90vh,52rem)] max-w-4xl flex-col overflow-hidden">
<DialogHeader>
<DialogTitle>
{downlineDialogNode
? t("downlineDialogTitle", {
name: downlineDialogNode.name,
defaultValue: "{{name}} — 下级代理与玩家",
})
: t("viewDownline", { defaultValue: "查看下级代理和玩家" })}
</DialogTitle>
</DialogHeader>
<div className="min-h-0 flex-1 space-y-6 overflow-y-auto pr-1">
<section className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="text-sm font-medium">
{t("downlineAgentsSection", { defaultValue: "下级代理" })}
</h3>
{downlineDialogNode && canCreateChildForNode(downlineDialogNode) ? (
<Button
type="button"
size="sm"
onClick={() => openCreateChildForNode(downlineDialogNode)}
>
<Plus className="size-4" aria-hidden />
{t("createChild", { defaultValue: "添加下级代理" })}
</Button>
) : null}
</div>
<div className="admin-table-shell">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("name", { defaultValue: "名称" })}</TableHead>
<TableHead>{t("code", { defaultValue: "编码" })}</TableHead>
<TableHead>{t("users.username", { defaultValue: "登录名" })}</TableHead>
<TableHead className="w-20">{t("childrenCount", { defaultValue: "直属下级" })}</TableHead>
<TableHead className="w-24">{t("status", { defaultValue: "状态" })}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{downlineChildAgents.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-muted-foreground">
{t("downlineNoAgents", { defaultValue: "暂无下级代理" })}
</TableCell>
</TableRow>
) : (
downlineChildAgents.map((child) => (
<TableRow key={child.id}>
<TableCell className="font-medium">{child.name}</TableCell>
<TableCell className="font-mono text-xs">{child.code}</TableCell>
<TableCell className="font-mono text-xs">{child.username ?? "—"}</TableCell>
<TableCell>{child.children?.length ?? 0}</TableCell>
<TableCell>
<AdminStatusBadge tone={resolveRoleStatusTone(child.status)}>
{child.status === 1
? t("common:status.enabled", { defaultValue: "Enabled" })
: t("common:status.disabled", { defaultValue: "Disabled" })}
</AdminStatusBadge>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</section>
{canViewPlayersTab && downlineDialogNode ? (
<section className="space-y-3">
<h3 className="text-sm font-medium">
{t("downlinePlayersSection", { defaultValue: "直属玩家" })}
</h3>
<AgentsPlayersPanel
siteCode={activeSiteCode}
agentNodeId={downlineDialogNode.id}
/>
</section>
) : null}
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setDownlineDialogNode(null)}>
{t("common:actions.close", { defaultValue: "关闭" })}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
detailTab={detailTab}
onDetailTabChange={setDetailTab}
canViewProfileTab={canShowProfileTab}
canEditProfileTab={canEditSelectedProfile}
profileReadOnly={isOwnAgentNode}
canViewDownlineTab={canShowDownlineTab}
canViewPlayersTab={canShowPlayersTab}
canManageNode={canManageNode}
canCreateChild={canCreateChildOnSelected}
canDeleteChild={canDeleteNode}
onEditChild={(node) => openEditForNode(node)}
onAddChild={() => selectedNode && openCreateChildForNode(selectedNode)}
onEditCurrent={() => selectedNode && openEditForNode(selectedNode)}
onDeleteChild={(node) => handleDeleteNode(node)}
onSelectChild={(child) => {
setSelectedNodeId(child.id);
}}
profileFields={inlineProfileFields}
profileSaving={profileSaving}
onSaveProfile={() => void saveInlineProfile()}
/>
</div>
<Dialog open={nodeDialogOpen} onOpenChange={setNodeDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogContent
showCloseButton
className="flex h-[min(90vh,760px)] !max-w-[min(520px,calc(100vw-2rem))] flex-col gap-0 overflow-hidden rounded-2xl p-0 sm:!max-w-[min(520px,calc(100vw-2rem))]"
>
<DialogHeader className="shrink-0 border-b px-4 py-4 pr-12">
<DialogTitle>
{nodeDialogMode === "create"
? t("createChild", { defaultValue: "添加下级代理" })
@@ -869,6 +820,7 @@ export function AgentsConsole(): React.ReactElement {
</DialogTitle>
</DialogHeader>
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto overscroll-contain px-4 py-4">
<div className="space-y-2">
<Label htmlFor="agent-name">{t("name", { defaultValue: "名称" })}</Label>
<Input
@@ -923,130 +875,44 @@ export function AgentsConsole(): React.ReactElement {
<Label>{t("status", { defaultValue: "状态" })}</Label>
</div>
{canManageProfile ? (
{canManageProfile &&
(nodeDialogMode === "create" ||
(editingNodeId !== null && boundAgent?.id !== editingNodeId)) ? (
<div className="space-y-3 border-t pt-3">
<p className="text-sm font-medium">
{t("profile.section", { defaultValue: "占成与授信" })}
</p>
{profileLoading ? (
<p className="text-sm text-muted-foreground">
{t("profile.loading", { defaultValue: "正在加载占成与授信…" })}
</p>
) : null}
<div className={cn("grid gap-3 sm:grid-cols-2", profileLoading ? "pointer-events-none opacity-50" : "")}>
<div className="space-y-2">
<Label htmlFor="agent-share-rate">
{t("profile.totalShareRate", { defaultValue: "占成比例 (%)" })}
</Label>
<Input
id="agent-share-rate"
type="number"
min={0}
max={100}
step="0.01"
value={profileShareRate}
onChange={(e) => setProfileShareRate(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="agent-credit-limit">
{t("profile.creditLimit", { defaultValue: "授信额度" })}
</Label>
<Input
id="agent-credit-limit"
type="number"
min={0}
value={profileCreditLimit}
onChange={(e) => setProfileCreditLimit(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="agent-rebate-limit">
{t("profile.rebateLimit", { defaultValue: "回水上限" })}
</Label>
<Input
id="agent-rebate-limit"
type="number"
min={0}
max={1}
step="0.0001"
value={profileRebateLimit}
onChange={(e) => setProfileRebateLimit(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="agent-default-rebate">
{t("profile.defaultPlayerRebate", { defaultValue: "默认玩家回水" })}
</Label>
<Input
id="agent-default-rebate"
type="number"
min={0}
max={1}
step="0.0001"
value={profileDefaultRebate}
onChange={(e) => setProfileDefaultRebate(e.target.value)}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="agent-settlement-cycle">
{t("profile.settlementCycle", { defaultValue: "结算周期" })}
</Label>
<Select
value={profileSettlementCycle}
onValueChange={(value) =>
setProfileSettlementCycle((value as "daily" | "weekly" | "monthly") ?? "weekly")
}
>
<SelectTrigger id="agent-settlement-cycle">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="daily">
{t("profile.cycleDaily", { defaultValue: "日结" })}
</SelectItem>
<SelectItem value="weekly">
{t("profile.cycleWeekly", { defaultValue: "周结" })}
</SelectItem>
<SelectItem value="monthly">
{t("profile.cycleMonthly", { defaultValue: "月结" })}
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Switch
checked={profileExtraRebate}
onCheckedChange={setProfileExtraRebate}
/>
<Label>
{t("profile.canGrantExtraRebate", { defaultValue: "允许额外回水" })}
</Label>
</div>
<div className="flex items-center gap-2">
<Switch
checked={profileCanCreatePlayer}
onCheckedChange={setProfileCanCreatePlayer}
/>
<Label>
{t("profile.canCreatePlayer", { defaultValue: "允许创建玩家" })}
</Label>
</div>
<div className="flex items-center gap-2">
<Switch
checked={profileCanCreateChild}
onCheckedChange={setProfileCanCreateChild}
disabled={!canCreateChildAgent && !isSuperAdmin}
/>
<Label>
{t("profile.canCreateChildAgent", { defaultValue: "允许创建下级代理" })}
</Label>
</div>
<AgentProfileFields
loading={profileLoading}
parentCaps={profileParentCaps}
availableCredit={profileAvailableCredit}
canCreateChildAgent={canCreateChildAgent}
isSuperAdmin={isSuperAdmin}
shareRate={profileShareRate}
onShareRateChange={setProfileShareRate}
creditLimit={profileCreditLimit}
onCreditLimitChange={setProfileCreditLimit}
rebateLimit={profileRebateLimit}
onRebateLimitChange={setProfileRebateLimit}
defaultRebate={profileDefaultRebate}
onDefaultRebateChange={setProfileDefaultRebate}
settlementCycle={profileSettlementCycle}
onSettlementCycleChange={setProfileSettlementCycle}
extraRebate={profileExtraRebate}
onExtraRebateChange={setProfileExtraRebate}
canCreatePlayer={profileCanCreatePlayer}
onCanCreatePlayerChange={setProfileCanCreatePlayer}
canCreateChild={profileCanCreateChild}
onCanCreateChildChange={setProfileCanCreateChild}
riskTags={profileRiskTags}
onRiskTagsChange={setProfileRiskTags}
idPrefix="dialog-agent-profile"
/>
</div>
) : null}
</div>
<DialogFooter>
<DialogFooter className="!m-0 shrink-0 rounded-b-xl border-t bg-background px-4 py-4">
<Button type="button" variant="outline" onClick={() => setNodeDialogOpen(false)}>
{t("common:actions.cancel", { defaultValue: "取消" })}
</Button>

View File

@@ -1,11 +1,21 @@
"use client";
import { Plus } from "lucide-react";
import { Eye, Pencil, Plus, Trash2 } from "lucide-react";
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { getAdminPlayers, postAdminPlayer } from "@/api/admin-player";
import { getAgentNodeProfile } from "@/api/admin-agents";
import {
deleteAdminPlayer,
getAdminPlayer,
getAdminPlayers,
postAdminPlayer,
putAdminPlayer,
} from "@/api/admin-player";
import { formatCredit } from "@/modules/agents/agent-line-sidebar";
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
@@ -19,6 +29,13 @@ import {
} from "@/components/ui/dialog";
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,
@@ -28,31 +45,119 @@ import {
TableRow,
} from "@/components/ui/table";
import { useAsyncEffect } from "@/hooks/use-async-effect";
import { useConfirmAction } from "@/hooks/use-confirm-action";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import { PlayerFundingModeBadge } from "@/components/admin/player-funding-badges";
import { playerBalanceCells } from "@/lib/admin-player-display";
import { formatAdminMinorUnits } from "@/lib/money";
import { parsePercentUi, percentUiToRatio, ratioToPercentUi } from "@/lib/admin-rate-percent";
import { adminPlayerDetailPath } from "@/lib/admin-player-paths";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { PRD_USERS_MANAGE } from "@/lib/admin-prd";
import { resolveRoleStatusTone } from "@/lib/admin-status-tone";
import { useAdminProfile } from "@/stores/admin-session";
import { LotteryApiBizError } from "@/types/api/errors";
import type { AdminPlayerRow } from "@/types/api/admin-player";
const PLAYER_STATUS_OPTIONS = [
{ value: 0, labelKey: "players:statusNormal" as const },
{ value: 1, labelKey: "players:statusFrozen" as const },
{ value: 2, labelKey: "players:statusBanned" as const },
];
function playerStatusLabel(
status: number,
t: (key: string, opts?: { defaultValue?: string }) => string,
): string {
const hit = PLAYER_STATUS_OPTIONS.find((opt) => opt.value === status);
if (hit) {
return t(hit.labelKey, {
defaultValue: status === 0 ? "正常" : status === 1 ? "冻结" : "封禁",
});
}
return String(status);
}
function resolvePlayerRebateRate(row: AdminPlayerRow): number | null {
if (row.rebate_rate != null) {
return row.rebate_rate;
}
const defaultProfile = row.rebate_profiles?.find((p) => p.game_type === "*");
if (defaultProfile && !defaultProfile.inherit_from_agent) {
return defaultProfile.rebate_rate;
}
return null;
}
function parseRiskTagsInput(text: string): string[] {
return Array.from(
new Set(
text
.split(/[,\s]+/)
.map((tag) => tag.trim())
.filter((tag) => tag.length > 0),
),
);
}
function fillEditFormFromPlayer(row: AdminPlayerRow): {
username: string;
nickname: string;
currency: string;
status: number;
creditLimit: string;
rebateRate: string;
riskTags: string;
} {
const rebate = resolvePlayerRebateRate(row);
return {
username: row.username ?? "",
nickname: row.nickname ?? "",
currency: row.default_currency ?? "",
status: row.status,
creditLimit: row.credit_limit != null ? String(row.credit_limit) : "",
rebateRate: rebate != null ? ratioToPercentUi(rebate) : "",
riskTags: (row.risk_tags ?? []).join(", "),
};
}
type AgentsPlayersPanelProps = {
siteCode: string;
/** 筛选直属玩家时的代理节点null 表示当前登录代理或不过滤 */
agentNodeId: number | null;
/** 当前代理 profile 是否允许创建玩家;未传时沿用登录代理能力 */
allowCreatePlayer?: boolean;
/** 嵌入代理线路详情 Tab 时使用紧凑顶栏 */
embedded?: boolean;
};
export function AgentsPlayersPanel({
siteCode,
agentNodeId,
allowCreatePlayer,
embedded = false,
}: AgentsPlayersPanelProps): React.ReactElement {
const { t } = useTranslation(["agents", "players", "common"]);
const formatDt = useAdminDateTimeFormatter();
const profile = useAdminProfile();
const boundAgent = profile?.agent ?? null;
const isSuperAdmin = profile?.is_super_admin === true;
const { request: requestConfirm, ConfirmDialog, busy: confirmBusy } = useConfirmAction();
const profileAllowsCreate =
allowCreatePlayer === undefined
? boundAgent?.can_create_player !== false
: allowCreatePlayer === true;
const canCreatePlayer =
isSuperAdmin ||
(boundAgent?.can_create_player !== false &&
(profileAllowsCreate &&
adminHasAnyPermission(profile?.permissions, [PRD_USERS_MANAGE]));
const canManagePlayerRows = canCreatePlayer;
const effectiveAgentId = useMemo(() => {
if (agentNodeId !== null) {
@@ -72,7 +177,22 @@ export function AgentsPlayersPanel({
const [saving, setSaving] = useState(false);
const [sitePlayerId, setSitePlayerId] = useState("");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [nickname, setNickname] = useState("");
const [creditLimit, setCreditLimit] = useState("");
const [rebateRate, setRebateRate] = useState("");
const [parentAvailableCredit, setParentAvailableCredit] = useState<number | null>(null);
const [editDialogOpen, setEditDialogOpen] = useState(false);
const [editSaving, setEditSaving] = useState(false);
const [editingPlayer, setEditingPlayer] = useState<Awaited<ReturnType<typeof getAdminPlayers>>["items"][number] | null>(null);
const [editUsername, setEditUsername] = useState("");
const [editNickname, setEditNickname] = useState("");
const [editDefaultCurrency, setEditDefaultCurrency] = useState("");
const [editStatus, setEditStatus] = useState(0);
const [editCreditLimit, setEditCreditLimit] = useState("");
const [editRebateRate, setEditRebateRate] = useState("");
const [editRiskTags, setEditRiskTags] = useState("");
const [editDetailLoading, setEditDetailLoading] = useState(false);
const load = useCallback(async () => {
if (siteCode.trim() === "") {
@@ -108,8 +228,12 @@ export function AgentsPlayersPanel({
}, [load]);
async function savePlayer(): Promise<void> {
if (siteCode.trim() === "" || sitePlayerId.trim() === "") {
toast.error(t("players:sitePlayerIdRequired", { defaultValue: "请填写站点玩家 ID" }));
if (siteCode.trim() === "") {
toast.error(t("players:siteCodeRequired", { defaultValue: "请填写主站编号" }));
return;
}
if (username.trim() === "" || password.trim() === "") {
toast.error(t("playersPanel.loginRequired", { defaultValue: "请填写登录账号与初始密码" }));
return;
}
@@ -117,16 +241,30 @@ export function AgentsPlayersPanel({
try {
await postAdminPlayer({
site_code: siteCode.trim(),
site_player_id: sitePlayerId.trim(),
username: username.trim() || null,
...(sitePlayerId.trim() !== "" ? { site_player_id: sitePlayerId.trim() } : {}),
username: username.trim(),
password: password,
nickname: nickname.trim() || null,
...(isSuperAdmin && effectiveAgentId ? { agent_node_id: effectiveAgentId } : {}),
credit_limit:
creditLimit.trim() === "" ? 0 : Math.max(0, Number.parseInt(creditLimit, 10) || 0),
...(rebateRate.trim() !== ""
? { rebate_rate: percentUiToRatio(rebateRate) }
: {}),
});
toast.success(t("players:createSuccess", { name: sitePlayerId.trim() }));
toast.success(
t("playersPanel.createSuccessNative", {
name: username.trim(),
defaultValue: "玩家 {{name}} 已创建,请使用彩票端登录",
}),
);
setDialogOpen(false);
setSitePlayerId("");
setUsername("");
setPassword("");
setNickname("");
setCreditLimit("");
setRebateRate("");
await load();
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : t("players:createFailed"));
@@ -135,56 +273,285 @@ export function AgentsPlayersPanel({
}
}
function openCreateDialog(): void {
setDialogOpen(true);
if (effectiveAgentId !== null) {
void getAgentNodeProfile(effectiveAgentId)
.then((p) => setParentAvailableCredit(p.available_credit ?? null))
.catch(() => setParentAvailableCredit(null));
} else {
setParentAvailableCredit(null);
}
}
const applyEditForm = (row: AdminPlayerRow): void => {
const form = fillEditFormFromPlayer(row);
setEditUsername(form.username);
setEditNickname(form.nickname);
setEditDefaultCurrency(form.currency);
setEditStatus(form.status);
setEditCreditLimit(form.creditLimit);
setEditRebateRate(form.rebateRate);
setEditRiskTags(form.riskTags);
};
const openEditPlayer = (row: AdminPlayerRow): void => {
setEditingPlayer(row);
applyEditForm(row);
setEditDialogOpen(true);
setEditDetailLoading(true);
void getAdminPlayer(row.id)
.then((full) => {
setEditingPlayer(full);
applyEditForm(full);
})
.catch(() => {
toast.error(t("players:loadFailed", { defaultValue: "加载玩家详情失败" }));
})
.finally(() => {
setEditDetailLoading(false);
});
};
function handleEditDialogOpenChange(open: boolean): void {
setEditDialogOpen(open);
if (!open) {
setEditingPlayer(null);
}
}
async function saveEditedPlayer(): Promise<void> {
if (!editingPlayer) {
return;
}
const body: Parameters<typeof putAdminPlayer>[1] = {};
if (editUsername.trim() !== "" && editUsername.trim() !== (editingPlayer.username ?? "")) {
body.username = editUsername.trim();
}
if (editNickname.trim() !== (editingPlayer.nickname ?? "")) {
body.nickname = editNickname.trim() || null;
}
const nextCurrency = editDefaultCurrency.trim().toUpperCase();
if (nextCurrency !== editingPlayer.default_currency) {
body.default_currency = nextCurrency;
}
if (editStatus !== editingPlayer.status) {
body.status = editStatus;
}
const nextCredit =
editCreditLimit.trim() === "" ? 0 : Number.parseInt(editCreditLimit, 10);
if (!Number.isNaN(nextCredit) && nextCredit !== (editingPlayer.credit_limit ?? 0)) {
body.credit_limit = Math.max(0, nextCredit);
}
const prevRebate = resolvePlayerRebateRate(editingPlayer);
const nextPercent = parsePercentUi(editRebateRate);
const nextRebate = nextPercent === null ? null : percentUiToRatio(nextPercent);
if (nextRebate !== null && nextRebate !== (prevRebate ?? 0)) {
body.rebate_rate = nextRebate;
}
const nextRiskTags = parseRiskTagsInput(editRiskTags);
const prevRiskTags = editingPlayer.risk_tags ?? [];
if (JSON.stringify(nextRiskTags) !== JSON.stringify(prevRiskTags)) {
body.risk_tags = nextRiskTags;
}
if (Object.keys(body).length === 0) {
toast.success(t("players:noChanges", { defaultValue: "没有变更" }));
handleEditDialogOpenChange(false);
return;
}
setEditSaving(true);
try {
const updated = await putAdminPlayer(editingPlayer.id, body);
setItems((prev) => prev.map((row) => (row.id === updated.id ? updated : row)));
toast.success(
t("players:updateSuccess", {
name: updated.username ?? updated.site_player_id,
defaultValue: "已更新 {{name}}",
}),
);
handleEditDialogOpenChange(false);
} catch (e) {
toast.error(
e instanceof LotteryApiBizError ? e.message : t("players:updateFailed", { defaultValue: "更新玩家失败" }),
);
} finally {
setEditSaving(false);
}
}
async function confirmDeletePlayer(row: Awaited<ReturnType<typeof getAdminPlayers>>["items"][number]): Promise<void> {
try {
await deleteAdminPlayer(row.id);
setItems((prev) => prev.filter((item) => item.id !== row.id));
setTotal((current) => Math.max(0, current - 1));
toast.success(t("deleteSuccess", { name: row.username ?? row.site_player_id }));
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : t("deleteFailed"));
}
}
return (
<div className="space-y-3">
{canCreatePlayer ? (
<div className="flex justify-end">
<Button type="button" size="sm" onClick={() => setDialogOpen(true)}>
<Plus className="mr-1 size-3.5" />
<div className="space-y-4">
<ConfirmDialog />
<div className="flex flex-wrap items-center justify-between gap-3">
{!embedded ? (
<p className="text-xs text-muted-foreground">
{t("playersPanel.creditListHint", {
defaultValue: "信用占成盘:下列为玩家授信额度与可用信用,非主站钱包余额。",
})}
</p>
) : (
<p className="text-sm text-muted-foreground">
{t("playersPanel.creditListHint", {
defaultValue: "信用占成盘:下列为玩家授信额度与可用信用,非主站钱包余额。",
})}
</p>
)}
{canCreatePlayer ? (
<Button type="button" size="sm" className="shrink-0" onClick={openCreateDialog}>
<Plus className="mr-1.5 size-3.5" />
{t("playersPanel.create", { defaultValue: "创建玩家" })}
</Button>
</div>
) : null}
) : null}
</div>
{loading ? (
<AdminLoadingState minHeight="6rem" />
) : (
<>
<div className="admin-table-shell">
<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>
<TableHead>{t("players:sitePlayerId", { defaultValue: "站点玩家 ID" })}</TableHead>
<TableHead>{t("players:username", { defaultValue: "用户名" })}</TableHead>
<TableHead>{t("players:nickname", { defaultValue: "昵称" })}</TableHead>
<TableHead className="w-24">{t("players:status", { defaultValue: "状态" })}</TableHead>
<TableRow className="bg-muted/40 hover:bg-muted/40">
<TableHead className="w-14">{t("common:table.id", { defaultValue: "ID" })}</TableHead>
<TableHead>{t("playersPanel.playerRef", { defaultValue: "玩家标识" })}</TableHead>
<TableHead className="whitespace-nowrap">
{t("playersPanel.usernameNickname", { defaultValue: "用户名 / 昵称" })}
</TableHead>
<TableHead className="whitespace-nowrap">
{t("players:fundingMode", { defaultValue: "资金模式" })}
</TableHead>
<TableHead className="whitespace-nowrap">{t("players:currency", { defaultValue: "币种" })}</TableHead>
<TableHead className="text-right whitespace-nowrap">
{t("playersPanel.creditLimitAvailable", { defaultValue: "授信 / 可用" })}
</TableHead>
<TableHead className="text-right whitespace-nowrap">
{t("players:rebateRate", { defaultValue: "回水" })}
</TableHead>
<TableHead className="whitespace-nowrap">{t("players:lastLogin", { defaultValue: "最后登录" })}</TableHead>
{!embedded ? (
<TableHead className="w-24">{t("players:status", { defaultValue: "状态" })}</TableHead>
) : null}
<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>
</TableRow>
</TableHeader>
<TableBody>
{items.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="text-muted-foreground">
{t("common:states.noData", { defaultValue: "暂无数据" })}
</TableCell>
</TableRow>
<AdminTableNoResourceRow colSpan={embedded ? 8 : 9} cellClassName="py-12 text-center" />
) : (
items.map((row) => (
items.map((row) => {
const balances = playerBalanceCells(row, formatAdminMinorUnits);
const rebate = resolvePlayerRebateRate(row);
return (
<TableRow key={row.id}>
<TableCell className="font-mono text-xs">{row.site_player_id}</TableCell>
<TableCell>{row.username ?? "—"}</TableCell>
<TableCell>{row.nickname ?? "—"}</TableCell>
<TableCell className="tabular-nums text-xs font-medium">#{row.id}</TableCell>
<TableCell className="max-w-[8rem] truncate font-mono text-xs" title={row.site_player_id}>
{row.site_player_id}
</TableCell>
<TableCell className="text-sm">
<span className="font-medium">{row.username ?? "—"}</span>
<span className="text-muted-foreground"> / </span>
<span className="text-muted-foreground">{row.nickname ?? "—"}</span>
</TableCell>
<TableCell>
<PlayerFundingModeBadge row={row} />
</TableCell>
<TableCell className="text-xs font-medium">{row.default_currency}</TableCell>
<TableCell className="text-right text-xs tabular-nums">
<span>{balances.balance}</span>
<span className="text-muted-foreground"> / </span>
<span className="text-muted-foreground">{balances.available}</span>
</TableCell>
<TableCell
className="text-right text-xs tabular-nums font-medium"
title={
row.rebate_inherited
? t("playersPanel.rebateInherited", { defaultValue: "继承代理默认回水" })
: undefined
}
>
{rebate != null ? `${ratioToPercentUi(rebate)}%` : "—"}
</TableCell>
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
{row.last_login_at ? formatDt(row.last_login_at) : "—"}
</TableCell>
{!embedded ? (
<TableCell>
<AdminStatusBadge tone={resolveRoleStatusTone(row.status)}>
{row.status === 0
? t("players:statusNormal", { defaultValue: "正常" })
: String(row.status)}
{playerStatusLabel(row.status, t)}
</AdminStatusBadge>
</TableCell>
) : null}
<TableCell
className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_var(--border)]"
onClick={(e) => e.stopPropagation()}
>
<AdminRowActionsMenu
busy={confirmBusy}
actions={[
{
key: "detail",
label: t("players:viewDetail", { defaultValue: "查看详情" }),
icon: Eye,
href: adminPlayerDetailPath(row.id),
},
...(canManagePlayerRows
? [
{
key: "edit",
label: t("players:edit", { defaultValue: "编辑" }),
icon: Pencil,
onClick: () => openEditPlayer(row),
},
{
key: "delete",
label: t("players:delete", { defaultValue: "删除" }),
icon: Trash2,
destructive: true,
onClick: () =>
requestConfirm({
title: t("players:confirmDelete", {
defaultValue: "确认删除",
}),
description: t("players:confirmDeleteDesc", {
name: row.username ?? row.site_player_id,
defaultValue:
"确定要删除玩家 {{name}} 吗?此操作不可恢复。",
}),
confirmVariant: "destructive",
onConfirm: () => void confirmDeletePlayer(row),
}),
},
]
: []),
]}
/>
</TableCell>
</TableRow>
))
);
})
)}
</TableBody>
</Table>
</div>
</div>
<AdminListPaginationFooter
selectId="agents-players-per-page"
@@ -208,28 +575,42 @@ export function AgentsPlayersPanel({
<DialogTitle>{t("playersPanel.create", { defaultValue: "创建玩家" })}</DialogTitle>
</DialogHeader>
<div className="space-y-2">
<Label>{t("players:siteCode", { defaultValue: "站点" })}</Label>
<Label>{t("agents:lineProvision.code", { defaultValue: "代理编码" })}</Label>
<Input value={siteCode} readOnly disabled />
</div>
<div className="space-y-2">
<Label htmlFor="agent-player-site-id">
{t("players:sitePlayerId", { defaultValue: "站点玩家 ID" })}
{t("playersPanel.externalIdOptional", { defaultValue: "外部 ID可选" })}
</Label>
<Input
id="agent-player-site-id"
value={sitePlayerId}
onChange={(e) => setSitePlayerId(e.target.value)}
autoComplete="off"
placeholder={t("playersPanel.externalIdHint", { defaultValue: "留空则系统自动生成" })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="agent-player-username">
{t("players:username", { defaultValue: "用户名" })}
{t("playersPanel.loginUsername", { defaultValue: "登录账号" })}
</Label>
<Input
id="agent-player-username"
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete="off"
/>
</div>
<div className="space-y-2">
<Label htmlFor="agent-player-password">
{t("playersPanel.initialPassword", { defaultValue: "初始密码" })}
</Label>
<Input
id="agent-player-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="new-password"
/>
</div>
<div className="space-y-2">
@@ -242,6 +623,41 @@ export function AgentsPlayersPanel({
onChange={(e) => setNickname(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="agent-player-credit">
{t("playersPanel.creditLimit", { defaultValue: "授信额度" })}
</Label>
<Input
id="agent-player-credit"
type="number"
min={0}
value={creditLimit}
onChange={(e) => setCreditLimit(e.target.value)}
/>
{parentAvailableCredit !== null ? (
<p className="text-xs text-muted-foreground">
{t("playersPanel.availableToGrant", {
defaultValue: "代理剩余可下发:{{amount}}",
amount: formatCredit(parentAvailableCredit),
})}
</p>
) : null}
</div>
<div className="space-y-2">
<Label htmlFor="agent-player-rebate">
{t("playersPanel.rebateRate", { defaultValue: "回水比例 (%)" })}
</Label>
<Input
id="agent-player-rebate"
type="number"
min={0}
max={100}
step="0.01"
value={rebateRate}
placeholder="0.5"
onChange={(e) => setRebateRate(e.target.value)}
/>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setDialogOpen(false)}>
{t("common:actions.cancel", { defaultValue: "取消" })}
@@ -252,6 +668,126 @@ export function AgentsPlayersPanel({
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={editDialogOpen} onOpenChange={handleEditDialogOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("players:editDialogTitle", { defaultValue: "编辑玩家" })}</DialogTitle>
</DialogHeader>
{editDetailLoading ? <AdminLoadingState minHeight="6rem" /> : null}
<div className={editDetailLoading ? "hidden" : "space-y-3"}>
<div className="space-y-2">
<Label htmlFor="agent-player-edit-username">
{t("players:username", { defaultValue: "用户名" })}
</Label>
<Input
id="agent-player-edit-username"
value={editUsername}
onChange={(e) => setEditUsername(e.target.value)}
autoComplete="off"
/>
</div>
<div className="space-y-2">
<Label htmlFor="agent-player-edit-nickname">
{t("players:nickname", { defaultValue: "昵称" })}
</Label>
<Input
id="agent-player-edit-nickname"
value={editNickname}
onChange={(e) => setEditNickname(e.target.value)}
autoComplete="off"
/>
</div>
<div className="space-y-2">
<Label htmlFor="agent-player-edit-currency">
{t("players:defaultCurrency", { defaultValue: "默认币种" })}
</Label>
<Input
id="agent-player-edit-currency"
value={editDefaultCurrency}
onChange={(e) => setEditDefaultCurrency(e.target.value)}
autoComplete="off"
/>
</div>
<div className="space-y-2">
<Label htmlFor="agent-player-edit-credit">
{t("playersPanel.creditLimit", { defaultValue: "授信额度" })}
</Label>
<Input
id="agent-player-edit-credit"
type="number"
min={0}
value={editCreditLimit}
onChange={(e) => setEditCreditLimit(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="agent-player-edit-rebate">
{t("playersPanel.rebateRate", { defaultValue: "回水比例 (%)" })}
</Label>
<Input
id="agent-player-edit-rebate"
type="number"
min={0}
max={100}
step="0.01"
value={editRebateRate}
onChange={(e) => setEditRebateRate(e.target.value)}
placeholder="0.5"
/>
<p className="text-xs text-muted-foreground">
{t("playersPanel.rebateRateHint", { defaultValue: "填写百分比,如 0.5 表示 0.5%" })}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="agent-player-edit-risk-tags">
{t("playersPanel.riskTags", { defaultValue: "风控标签" })}
</Label>
<Input
id="agent-player-edit-risk-tags"
value={editRiskTags}
onChange={(e) => setEditRiskTags(e.target.value)}
placeholder={t("playersPanel.riskTagsPlaceholder", {
defaultValue: "逗号分隔",
})}
/>
</div>
<div className="space-y-2">
<Label htmlFor="agent-player-edit-status">
{t("players:status", { defaultValue: "状态" })}
</Label>
<Select value={String(editStatus)} onValueChange={(value) => setEditStatus(Number(value))}>
<SelectTrigger id="agent-player-edit-status">
<SelectValue placeholder={t("players:status", { defaultValue: "状态" })}>
{playerStatusLabel(editStatus, t)}
</SelectValue>
</SelectTrigger>
<SelectContent>
{PLAYER_STATUS_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={String(opt.value)}>
{t(opt.labelKey, {
defaultValue: opt.value === 0 ? "正常" : opt.value === 1 ? "冻结" : "封禁",
})}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setEditDialogOpen(false)}>
{t("common:actions.cancel", { defaultValue: "取消" })}
</Button>
<Button
type="button"
disabled={editSaving || editDetailLoading}
onClick={() => void saveEditedPlayer()}
>
{t("players:saveChanges", { defaultValue: "保存修改" })}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -2,60 +2,43 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useMemo } from "react";
import { useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import {
PRD_AGENT_LINE_PROVISION_ACCESS_ANY,
PRD_AGENT_SITES_ACCESS_ANY,
PRD_AGENTS_ACCESS_ANY,
PRD_SETTLEMENT_AGENT_ACCESS_ANY,
} from "@/lib/admin-prd";
import { useAdminSiteCodeOptions } from "@/hooks/use-admin-site-code-options";
import { isAgentLineSubnavTabVisible } from "@/modules/agents/agent-line-subnav-visibility";
import { cn } from "@/lib/utils";
import { useAdminProfile } from "@/stores/admin-session";
import { useAgentManagementSiteStore } from "@/stores/agent-management-site";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { PRD_INTEGRATION_ACCESS_ANY } from "@/lib/admin-prd";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
const tabs: {
const primaryTabs: {
href: string;
labelKey: string;
matchPrefix: string;
requiredAny: readonly string[];
}[] = [
{
href: "/admin/agents",
labelKey: "subnav.operations",
matchPrefix: "/admin/agents",
requiredAny: PRD_AGENTS_ACCESS_ANY,
},
{
href: "/admin/agents/provision",
labelKey: "subnav.provision",
matchPrefix: "/admin/agents/provision",
requiredAny: PRD_AGENT_LINE_PROVISION_ACCESS_ANY,
},
{
href: "/admin/agents/sites",
labelKey: "subnav.sites",
matchPrefix: "/admin/agents/sites",
requiredAny: PRD_AGENT_SITES_ACCESS_ANY,
},
{
href: "/admin/agents/settlement-bills",
labelKey: "subnav.settlementBills",
matchPrefix: "/admin/agents/settlement",
requiredAny: PRD_SETTLEMENT_AGENT_ACCESS_ANY,
},
];
const provisionTab = {
href: "/admin/agents/provision",
labelKey: "subnav.provision",
matchPrefix: "/admin/agents/provision",
} as const;
function isTabActive(pathname: string, href: string, matchPrefix: string): boolean {
if (href === "/admin/agents") {
return (
pathname === "/admin/agents" ||
pathname === "/admin/agents/list" ||
(pathname.startsWith("/admin/agents/") &&
!pathname.startsWith("/admin/agents/provision") &&
!pathname.startsWith("/admin/agents/sites") &&
!pathname.startsWith("/admin/agents/settlement"))
!pathname.startsWith("/admin/agents/provision"))
);
}
@@ -66,45 +49,105 @@ export function AgentsSubnav(): React.ReactElement {
const { t } = useTranslation("agents");
const pathname = usePathname();
const profile = useAdminProfile();
const perms = profile?.permissions;
const { sites: siteOptions } = useAdminSiteCodeOptions();
const adminSiteId = useAgentManagementSiteStore((s) => s.adminSiteId);
const setAdminSiteId = useAgentManagementSiteStore((s) => s.setAdminSiteId);
const visibleTabs = useMemo(
() =>
tabs.filter(
(tab) =>
profile?.is_super_admin === true ||
adminHasAnyPermission(perms, [...tab.requiredAny]),
),
[perms, profile?.is_super_admin],
const canSwitchSite =
profile?.is_super_admin === true ||
adminHasAnyPermission(profile?.permissions, [...PRD_INTEGRATION_ACCESS_ANY]);
const showProvision = isAgentLineSubnavTabVisible(provisionTab.href, profile);
const visiblePrimaryTabs = useMemo(
() => primaryTabs.filter((tab) => isAgentLineSubnavTabVisible(tab.href, profile)),
[profile],
);
if (visibleTabs.length === 0) {
useEffect(() => {
if (adminSiteId !== null || siteOptions.length === 0) {
return;
}
const boundSiteId = profile?.agent?.admin_site_id;
if (boundSiteId != null) {
setAdminSiteId(boundSiteId);
return;
}
setAdminSiteId(siteOptions[0]?.id ?? null);
}, [adminSiteId, profile?.agent?.admin_site_id, setAdminSiteId, siteOptions]);
const selectSiteId = adminSiteId ?? siteOptions[0]?.id ?? null;
const selectedSiteLabel = useMemo(() => {
const site = siteOptions.find((item) => item.id === selectSiteId);
return site ? `${site.name} (${site.code})` : null;
}, [selectSiteId, siteOptions]);
if (visiblePrimaryTabs.length === 0 && !showProvision) {
return <></>;
}
return (
<nav
aria-label={t("subnav.label", { defaultValue: "代理线路导航" })}
className="flex w-full flex-wrap items-end gap-1 border-b border-border/60 px-1"
>
{visibleTabs.map((tab) => {
const active = isTabActive(pathname, tab.href, tab.matchPrefix);
<div className="flex w-full flex-wrap items-center justify-between gap-3 rounded-lg bg-muted/50 p-1">
<nav
aria-label={t("subnav.label", { defaultValue: "代理管理导航" })}
className="inline-flex max-w-full flex-wrap items-center gap-1"
>
{visiblePrimaryTabs.map((tab) => {
const active = isTabActive(pathname, tab.href, tab.matchPrefix);
return (
<Link
key={tab.href}
href={tab.href}
className={cn(
"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/80 hover:text-foreground",
)}
>
{t(tab.labelKey)}
</Link>
);
})}
</nav>
return (
<Link
key={tab.href}
href={tab.href}
className={cn(
"rounded-md px-3 py-2 text-sm font-medium transition-colors",
active
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{t(tab.labelKey)}
</Link>
);
})}
{showProvision ? (
<>
<span className="mx-1 hidden h-5 w-px bg-border/80 sm:inline-block" aria-hidden />
<Link
href={provisionTab.href}
className={cn(
"rounded-md px-3 py-2 text-sm font-medium transition-colors",
isTabActive(pathname, provisionTab.href, provisionTab.matchPrefix)
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{t(provisionTab.labelKey)}
</Link>
</>
) : null}
</nav>
{canSwitchSite && siteOptions.length > 0 && selectSiteId !== null ? (
<Select
value={String(selectSiteId)}
onValueChange={(value) => setAdminSiteId(Number(value))}
>
<SelectTrigger className="h-9 w-[200px] bg-background">
<SelectValue placeholder={t("lineFilter", { defaultValue: "一级代理" })}>
{selectedSiteLabel ?? t("lineFilter", { defaultValue: "一级代理" })}
</SelectValue>
</SelectTrigger>
<SelectContent>
{siteOptions.map((site) => (
<SelectItem key={site.id} value={String(site.id)}>
{site.name} ({site.code})
</SelectItem>
))}
</SelectContent>
</Select>
) : null}
</div>
);
}