refactor: update agent API schemas, standardize UI text styling, and enhance settlement credit ledger components
This commit is contained in:
@@ -5,7 +5,7 @@ import { ChevronRight, Network, Pencil, Plus, Trash2, Users } from "lucide-react
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { AdminSubnav, AdminSubnavButton } from "@/components/admin/admin-subnav";
|
||||
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
|
||||
import { AdminNoResourceState, AdminTableNoResourceRow } 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 {
|
||||
@@ -26,17 +26,16 @@ 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: "日结" });
|
||||
function relativeShareRate(totalShareRate: number | undefined, parentShareRate: number | undefined): string | null {
|
||||
if (
|
||||
totalShareRate == null ||
|
||||
parentShareRate == null ||
|
||||
parentShareRate <= 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (cycle === "monthly") {
|
||||
return t("profile.cycleMonthly", { defaultValue: "月结" });
|
||||
}
|
||||
return t("profile.cycleWeekly", { defaultValue: "周结" });
|
||||
|
||||
return percentValueToUi((totalShareRate / parentShareRate) * 100);
|
||||
}
|
||||
|
||||
export type AgentDetailTab = "overview" | "profile" | "downline" | "players";
|
||||
@@ -57,18 +56,22 @@ export type AgentLineDetailPanelProps = {
|
||||
profileReadOnly: boolean;
|
||||
canViewDownlineTab: boolean;
|
||||
canViewPlayersTab: boolean;
|
||||
playersTabHint?: string | null;
|
||||
canManageNode: boolean;
|
||||
canCreateChild: boolean;
|
||||
canCreateChildAgent: boolean;
|
||||
canCreatePlayerAction: boolean;
|
||||
canDeleteChild: (node: AgentNodeRow) => boolean;
|
||||
onEditChild: (node: AgentNodeRow) => void;
|
||||
onDeleteChild: (node: AgentNodeRow) => void;
|
||||
onAddChild: () => void;
|
||||
onAddPlayer: () => void;
|
||||
onEditCurrent: () => void;
|
||||
onSelectChild: (node: AgentNodeRow) => void;
|
||||
profileFields: AgentProfileFieldsProps | null;
|
||||
profileSaving: boolean;
|
||||
onSaveProfile: () => void;
|
||||
playerCreateRequestKey?: number;
|
||||
};
|
||||
|
||||
export function AgentLineDetailPanel({
|
||||
@@ -87,18 +90,22 @@ export function AgentLineDetailPanel({
|
||||
profileReadOnly,
|
||||
canViewDownlineTab,
|
||||
canViewPlayersTab,
|
||||
playersTabHint,
|
||||
canManageNode,
|
||||
canCreateChild,
|
||||
canCreateChildAgent,
|
||||
canCreatePlayerAction,
|
||||
canDeleteChild,
|
||||
onEditChild,
|
||||
onDeleteChild,
|
||||
onAddChild,
|
||||
onAddPlayer,
|
||||
onEditCurrent,
|
||||
onSelectChild,
|
||||
profileFields,
|
||||
profileSaving,
|
||||
onSaveProfile,
|
||||
playerCreateRequestKey = 0,
|
||||
}: AgentLineDetailPanelProps): React.ReactElement {
|
||||
const { t } = useTranslation(["agents", "common"]);
|
||||
|
||||
@@ -120,13 +127,6 @@ export function AgentLineDetailPanel({
|
||||
);
|
||||
}
|
||||
|
||||
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",
|
||||
@@ -157,8 +157,6 @@ export function AgentLineDetailPanel({
|
||||
siteLabel && siteCode.trim() !== ""
|
||||
? `${siteLabel} (${siteCode})`
|
||||
: siteLabel ?? siteCode;
|
||||
const codeText = typeof node.code === "string" ? node.code.trim() : "";
|
||||
const usernameText = typeof node.username === "string" ? node.username.trim() : "";
|
||||
const childActionHint = canCreateChild
|
||||
? null
|
||||
: canCreateChildAgent
|
||||
@@ -168,11 +166,22 @@ export function AgentLineDetailPanel({
|
||||
: t("lineUi.addChildNoPermissionHint", {
|
||||
defaultValue: "当前账号没有为该节点创建下级代理的权限。",
|
||||
});
|
||||
const playerActionHint =
|
||||
canViewPlayersTab && !canCreatePlayerAction ? playersTabHint ?? null : null;
|
||||
const showPrimaryAction = detailTab === "downline" || detailTab === "players";
|
||||
const primaryActionEnabled =
|
||||
detailTab === "players" ? canCreatePlayerAction : canCreateChild;
|
||||
const primaryActionLabel =
|
||||
detailTab === "players"
|
||||
? t("lineUi.createDirectPlayer", { defaultValue: "创建直属玩家" })
|
||||
: t("createChild", { defaultValue: "添加下级代理" });
|
||||
const primaryActionHint =
|
||||
detailTab === "players" ? playerActionHint : childActionHint;
|
||||
|
||||
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">
|
||||
<header className="border-b border-border/60 bg-card px-5 py-4 sm:px-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<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">
|
||||
@@ -184,60 +193,38 @@ export function AgentLineDetailPanel({
|
||||
: t("common:status.disabled", { defaultValue: "停用" })}
|
||||
</AdminStatusBadge>
|
||||
</div>
|
||||
{(codeText !== "" || usernameText !== "" || parentName) ? (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||
{codeText !== "" ? (
|
||||
<span className="rounded-md bg-muted/50 px-2 py-1 font-mono text-xs text-foreground/80">
|
||||
{t("lineUi.agentCode", { defaultValue: "编码" })} {codeText}
|
||||
</span>
|
||||
) : null}
|
||||
{usernameText !== "" ? (
|
||||
<span className="rounded-md bg-muted/50 px-2 py-1 text-xs">
|
||||
{t("lineUi.agentUsername", { defaultValue: "账号" })} {usernameText}
|
||||
</span>
|
||||
) : null}
|
||||
{parentName ? (
|
||||
<span className="rounded-md bg-muted/50 px-2 py-1 text-xs">
|
||||
{t("parentAgent", { defaultValue: "上级代理" })} {parentName}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{siteDisplay ? (
|
||||
<p className="mt-1 truncate text-sm text-muted-foreground" title={siteDisplay}>
|
||||
{siteDisplay}
|
||||
</p>
|
||||
) : null}
|
||||
</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}
|
||||
<div className="flex shrink-0 flex-col items-end gap-2">
|
||||
{canManageNode ? (
|
||||
<div className="flex max-w-[28rem] flex-col items-end gap-2">
|
||||
<>
|
||||
<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.editAgent", { defaultValue: "编辑代理" })}
|
||||
</Button>
|
||||
{canCreateChild ? (
|
||||
<Button type="button" size="sm" onClick={onAddChild}>
|
||||
{showPrimaryAction && primaryActionEnabled ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={detailTab === "players" ? onAddPlayer : onAddChild}
|
||||
>
|
||||
<Plus className="mr-1.5 size-3.5" />
|
||||
{t("createChild", { defaultValue: "添加下级代理" })}
|
||||
{primaryActionLabel}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{childActionHint ? (
|
||||
<p className="text-right text-xs leading-5 text-muted-foreground">
|
||||
{childActionHint}
|
||||
{primaryActionHint ? (
|
||||
<p className="max-w-[26rem] text-right text-xs leading-5 text-muted-foreground">
|
||||
{primaryActionHint}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
@@ -266,10 +253,10 @@ export function AgentLineDetailPanel({
|
||||
<OverviewTab
|
||||
profile={profile}
|
||||
profileLoading={profileLoading}
|
||||
cycleLabel={cycleLabel}
|
||||
profileReadOnly={profileReadOnly}
|
||||
canViewDownlineTab={canViewDownlineTab}
|
||||
canViewPlayersTab={canViewPlayersTab}
|
||||
playersTabHint={playersTabHint}
|
||||
childCount={childAgents.length}
|
||||
onGoToDownline={() => onDetailTabChange("downline")}
|
||||
onGoToPlayers={() => onDetailTabChange("players")}
|
||||
@@ -319,6 +306,7 @@ export function AgentLineDetailPanel({
|
||||
<DownlineTable
|
||||
childAgents={childAgents}
|
||||
childCountById={childCountById}
|
||||
parentTotalShareRate={profile?.total_share_rate}
|
||||
canManageNode={canManageNode}
|
||||
canCreateChild={canCreateChild}
|
||||
canDeleteChild={canDeleteChild}
|
||||
@@ -335,6 +323,7 @@ export function AgentLineDetailPanel({
|
||||
agentNodeId={node.id}
|
||||
allowCreatePlayer={profile?.can_create_player === true}
|
||||
embedded
|
||||
createRequestKey={playerCreateRequestKey}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -345,20 +334,20 @@ export function AgentLineDetailPanel({
|
||||
function OverviewTab({
|
||||
profile,
|
||||
profileLoading,
|
||||
cycleLabel,
|
||||
profileReadOnly,
|
||||
canViewDownlineTab,
|
||||
canViewPlayersTab,
|
||||
playersTabHint,
|
||||
childCount,
|
||||
onGoToDownline,
|
||||
onGoToPlayers,
|
||||
}: {
|
||||
profile: AgentProfileRow | null;
|
||||
profileLoading: boolean;
|
||||
cycleLabel: string;
|
||||
profileReadOnly: boolean;
|
||||
canViewDownlineTab: boolean;
|
||||
canViewPlayersTab: boolean;
|
||||
playersTabHint?: string | null;
|
||||
childCount: number;
|
||||
onGoToDownline: () => void;
|
||||
onGoToPlayers: () => void;
|
||||
@@ -367,6 +356,10 @@ function OverviewTab({
|
||||
|
||||
const rebateCap =
|
||||
profile && !profileLoading ? percentValueToUi(profile.rebate_limit ?? 0) : null;
|
||||
const parentRelativeShare = relativeShareRate(
|
||||
profile?.total_share_rate,
|
||||
profile?.parent_caps?.total_share_rate,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl space-y-6">
|
||||
@@ -392,12 +385,17 @@ function OverviewTab({
|
||||
label={t("profile.totalShareRate", { defaultValue: "占成比例" })}
|
||||
value={profileLoading ? "…" : `${profile?.total_share_rate ?? 0}%`}
|
||||
subtitle={
|
||||
rebateCap !== null
|
||||
? t("lineUi.shareRebateCap", {
|
||||
defaultValue: "回水上限 {{rate}}%",
|
||||
rate: rebateCap,
|
||||
parentRelativeShare
|
||||
? t("profile.relativeShareRateValue", {
|
||||
defaultValue: "占上级 {{rate}}%",
|
||||
rate: parentRelativeShare,
|
||||
})
|
||||
: undefined
|
||||
: rebateCap !== null
|
||||
? t("lineUi.shareRebateCap", {
|
||||
defaultValue: "回水上限 {{rate}}%",
|
||||
rate: rebateCap,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
accent
|
||||
/>
|
||||
@@ -418,17 +416,24 @@ function OverviewTab({
|
||||
)}
|
||||
|
||||
{!profileReadOnly && !profileLoading && profile ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("lineUi.profileFootnote", {
|
||||
defaultValue: "回水上限 {{rebate}}% · 默认回水 {{defaultRebate}}% · {{cycle}}",
|
||||
rebate: percentValueToUi(profile.rebate_limit ?? 0),
|
||||
defaultRebate: percentValueToUi(profile.default_player_rebate ?? 0),
|
||||
cycle: cycleLabel,
|
||||
})}
|
||||
{(profile.risk_tags?.length ?? 0) > 0
|
||||
? ` · ${t("profile.riskTags", { defaultValue: "风控" })}: ${profile.risk_tags?.join(", ")}`
|
||||
: ""}
|
||||
</p>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<MetricCard
|
||||
label={t("profile.rebateLimit", { defaultValue: "回水上限 (%)" })}
|
||||
value={`${percentValueToUi(profile.rebate_limit ?? 0)}%`}
|
||||
/>
|
||||
<MetricCard
|
||||
label={t("profile.defaultPlayerRebate", { defaultValue: "默认玩家回水 (%)" })}
|
||||
value={`${percentValueToUi(profile.default_player_rebate ?? 0)}%`}
|
||||
/>
|
||||
<MetricCard
|
||||
label={t("profile.riskTags", { defaultValue: "风控标签" })}
|
||||
value={
|
||||
(profile.risk_tags?.length ?? 0) > 0
|
||||
? profile.risk_tags!.join(", ")
|
||||
: t("common:states.none", { defaultValue: "无" })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{profileReadOnly ? (
|
||||
@@ -440,14 +445,18 @@ function OverviewTab({
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{canViewDownlineTab || canViewPlayersTab ? (
|
||||
{canViewDownlineTab || canViewPlayersTab || playersTabHint ? (
|
||||
<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 管理下级代理。",
|
||||
summary={t("lineUi.overviewDownlineCount", {
|
||||
defaultValue: "{{count}} 个",
|
||||
count: childCount,
|
||||
})}
|
||||
description={t("lineUi.overviewDownlineHint", {
|
||||
defaultValue: "直属下级 {{count}} 个,可在对应 Tab 管理下级代理。",
|
||||
count: childCount,
|
||||
})}
|
||||
actionLabel={t("lineUi.viewDownline", { defaultValue: "查看直属下级" })}
|
||||
@@ -458,6 +467,9 @@ function OverviewTab({
|
||||
<OverviewLinkCard
|
||||
icon={Users}
|
||||
title={t("lineUi.tabPlayers", { defaultValue: "直属玩家" })}
|
||||
summary={t("lineUi.overviewPlayersSummary", {
|
||||
defaultValue: "玩家管理",
|
||||
})}
|
||||
description={t("lineUi.overviewPlayersHint", {
|
||||
defaultValue: "直属玩家请在「直属玩家」Tab 维护。",
|
||||
})}
|
||||
@@ -465,6 +477,21 @@ function OverviewTab({
|
||||
onAction={onGoToPlayers}
|
||||
/>
|
||||
) : null}
|
||||
{!canViewPlayersTab && playersTabHint ? (
|
||||
<Card className="border-border/70 shadow-sm">
|
||||
<CardContent className="flex items-start gap-3 pt-5">
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-muted text-muted-foreground">
|
||||
<Users className="size-5" aria-hidden />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-foreground">
|
||||
{t("lineUi.tabPlayers", { defaultValue: "直属玩家" })}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{playersTabHint}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -474,38 +501,55 @@ function OverviewTab({
|
||||
function OverviewLinkCard({
|
||||
icon: Icon,
|
||||
title,
|
||||
summary,
|
||||
description,
|
||||
actionLabel,
|
||||
onAction,
|
||||
}: {
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
title: string;
|
||||
summary: 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>
|
||||
<Card
|
||||
className="group relative cursor-pointer overflow-hidden border-border/70 shadow-sm transition-all hover:border-primary/40 hover:shadow-md"
|
||||
onClick={onAction}
|
||||
>
|
||||
<CardContent className="flex flex-col p-5">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary transition-colors group-hover:bg-primary group-hover:text-primary-foreground">
|
||||
<Icon className="size-5.5" aria-hidden />
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="shrink-0 text-primary -mr-2"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAction();
|
||||
}}
|
||||
>
|
||||
{actionLabel}
|
||||
<ChevronRight className="ml-0.5 size-4" aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<p className="text-sm font-medium text-muted-foreground">{title}</p>
|
||||
<p className={cn(
|
||||
"mt-1 font-semibold tracking-tight text-foreground",
|
||||
summary.length > 5 ? "text-xl" : "text-2xl tabular-nums"
|
||||
)}>
|
||||
{summary}
|
||||
</p>
|
||||
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
</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>
|
||||
);
|
||||
@@ -514,6 +558,7 @@ function OverviewLinkCard({
|
||||
function DownlineTable({
|
||||
childAgents,
|
||||
childCountById,
|
||||
parentTotalShareRate,
|
||||
canManageNode,
|
||||
canCreateChild,
|
||||
canDeleteChild,
|
||||
@@ -524,6 +569,7 @@ function DownlineTable({
|
||||
}: {
|
||||
childAgents: AgentNodeRow[];
|
||||
childCountById: Map<number, number>;
|
||||
parentTotalShareRate?: number;
|
||||
canManageNode: boolean;
|
||||
canCreateChild: boolean;
|
||||
canDeleteChild: (node: AgentNodeRow) => boolean;
|
||||
@@ -537,122 +583,120 @@ function DownlineTable({
|
||||
const editChildLabel = t("lineUi.editDownline", { defaultValue: "编辑代理" });
|
||||
const deleteChildLabel = t("lineUi.deleteDownline", { defaultValue: "删除代理" });
|
||||
|
||||
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" />
|
||||
{createChildLabel}
|
||||
</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: "操作" })}
|
||||
<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>
|
||||
) : 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 ? `${summary.total_share_rate ?? 0}%` : "—"}
|
||||
</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()}
|
||||
<TableHead className="text-right whitespace-nowrap">
|
||||
{t("profile.creditLimit", { defaultValue: "授信额度" })}
|
||||
</TableHead>
|
||||
<TableHead className="text-right whitespace-nowrap">
|
||||
{t("lineUi.allocatedCredit", { 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-20 w-14 bg-muted whitespace-nowrap text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
|
||||
{t("common:table.actions", { defaultValue: "操作" })}
|
||||
</TableHead>
|
||||
) : null}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{childAgents.length === 0 ? (
|
||||
<AdminTableNoResourceRow colSpan={canManageNode ? 10 : 9} cellClassName="py-12 text-center" />
|
||||
) : (
|
||||
childAgents.map((child) => {
|
||||
const summary = child.profile_summary;
|
||||
return (
|
||||
<TableRow
|
||||
key={child.id}
|
||||
className="cursor-pointer"
|
||||
onClick={() => onSelectChild(child)}
|
||||
>
|
||||
<AdminRowActionsMenu
|
||||
actions={[
|
||||
{
|
||||
key: "edit",
|
||||
label: editChildLabel,
|
||||
icon: Pencil,
|
||||
onClick: () => onEditChild(child),
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
label: deleteChildLabel,
|
||||
icon: Trash2,
|
||||
destructive: true,
|
||||
disabled: !canDeleteChild(child),
|
||||
onClick: () => onDeleteChild(child),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</TableCell>
|
||||
) : null}
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<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 ? (
|
||||
<div className="space-y-0.5">
|
||||
<div>{`${summary.total_share_rate ?? 0}%`}</div>
|
||||
{parentTotalShareRate && parentTotalShareRate > 0 ? (
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
{t("profile.relativeShareRateValue", {
|
||||
defaultValue: "占上级 {{rate}}%",
|
||||
rate: relativeShareRate(
|
||||
summary.total_share_rate,
|
||||
parentTotalShareRate,
|
||||
) ?? "0",
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : "—"}
|
||||
</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="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_rgba(203,213,225,0.7)]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<AdminRowActionsMenu
|
||||
actions={[
|
||||
{
|
||||
key: "edit",
|
||||
label: editChildLabel,
|
||||
icon: Pencil,
|
||||
onClick: () => onEditChild(child),
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
label: deleteChildLabel,
|
||||
icon: Trash2,
|
||||
destructive: true,
|
||||
disabled: !canDeleteChild(child),
|
||||
onClick: () => onDeleteChild(child),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</TableCell>
|
||||
) : null}
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,12 +20,20 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { percentValueToUi } from "@/lib/admin-rate-percent";
|
||||
import { adminSiteCodeLabel } from "@/lib/admin-select-display";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
import type { AdminIntegrationSiteRow } from "@/types/api/admin-integration-site";
|
||||
import type { AdminAgentLineProvisionResult } from "@/types/api/admin-agent-line";
|
||||
|
||||
export function AgentLineProvisionWizard(): React.ReactElement {
|
||||
type AgentLineProvisionWizardProps = {
|
||||
embedded?: boolean;
|
||||
onSuccess?: (result: AdminAgentLineProvisionResult) => void | Promise<void>;
|
||||
};
|
||||
|
||||
export function AgentLineProvisionWizard({
|
||||
embedded = false,
|
||||
onSuccess,
|
||||
}: AgentLineProvisionWizardProps): React.ReactElement {
|
||||
const { t } = useTranslation(["agents", "common"]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [sitesLoading, setSitesLoading] = useState(true);
|
||||
@@ -40,7 +48,6 @@ export function AgentLineProvisionWizard(): React.ReactElement {
|
||||
credit_limit: "0",
|
||||
rebate_limit: "0",
|
||||
default_player_rebate: "0",
|
||||
settlement_cycle: "weekly" as "daily" | "weekly" | "monthly",
|
||||
can_grant_extra_rebate: false,
|
||||
});
|
||||
|
||||
@@ -63,32 +70,114 @@ export function AgentLineProvisionWizard(): React.ReactElement {
|
||||
toast.error(t("agents:lineProvision.siteRequired", { defaultValue: "请选择接入站点" }));
|
||||
return;
|
||||
}
|
||||
if (!form.code.trim()) {
|
||||
toast.error(t("agents:lineProvision.codeRequired", { defaultValue: "请填写代理编码" }));
|
||||
return;
|
||||
}
|
||||
if (!/^[a-z0-9][a-z0-9_-]*$/i.test(form.code.trim())) {
|
||||
toast.error(
|
||||
t("agents:lineProvision.codePatternInvalid", {
|
||||
defaultValue: "代理编码仅支持字母、数字、下划线和中划线,且需以字母或数字开头",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!form.name.trim()) {
|
||||
toast.error(t("agents:nameRequired", { defaultValue: "请填写代理名称" }));
|
||||
return;
|
||||
}
|
||||
if (!form.username.trim()) {
|
||||
toast.error(t("agents:usernameRequired", { defaultValue: "请填写登录名" }));
|
||||
return;
|
||||
}
|
||||
if (!form.password.trim()) {
|
||||
toast.error(t("agents:passwordRequired", { defaultValue: "请填写密码" }));
|
||||
return;
|
||||
}
|
||||
if (form.password.trim().length < 8) {
|
||||
toast.error(t("agents:passwordMinLength", { defaultValue: "密码至少 8 位" }));
|
||||
return;
|
||||
}
|
||||
|
||||
const shareRate = Number.parseFloat(form.total_share_rate);
|
||||
const creditLimit = Number.parseInt(form.credit_limit, 10);
|
||||
const rebateLimit = Number.parseFloat(form.rebate_limit);
|
||||
const defaultPlayerRebate = Number.parseFloat(form.default_player_rebate);
|
||||
|
||||
if (Number.isNaN(shareRate) || shareRate < 0 || shareRate > 100) {
|
||||
toast.error(
|
||||
t("agents:profile.validation.shareRange", {
|
||||
defaultValue: "占成比例须在 0–100 之间",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (Number.isNaN(creditLimit) || creditLimit < 0) {
|
||||
toast.error(
|
||||
t("agents:profile.validation.creditInvalid", {
|
||||
defaultValue: "授信额度不能为负数",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (Number.isNaN(rebateLimit) || rebateLimit < 0 || rebateLimit > 100) {
|
||||
toast.error(
|
||||
t("agents:profile.validation.rebateLimitRange", {
|
||||
defaultValue: "回水上限须在 0–100% 之间",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
Number.isNaN(defaultPlayerRebate) ||
|
||||
defaultPlayerRebate < 0 ||
|
||||
defaultPlayerRebate > 100
|
||||
) {
|
||||
toast.error(
|
||||
t("agents:profile.validation.defaultRebateRange", {
|
||||
defaultValue: "默认玩家回水须在 0–100% 之间",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (defaultPlayerRebate > rebateLimit) {
|
||||
toast.error(
|
||||
t("agents:profile.validation.defaultExceedsLimit", {
|
||||
defaultValue: "默认玩家回水不能超过回水上限",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await postAdminAgentLine({
|
||||
const result = 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,
|
||||
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,
|
||||
settlement_cycle: form.settlement_cycle,
|
||||
total_share_rate: shareRate,
|
||||
credit_limit: creditLimit,
|
||||
rebate_limit: rebateLimit,
|
||||
default_player_rebate: defaultPlayerRebate,
|
||||
can_grant_extra_rebate: form.can_grant_extra_rebate,
|
||||
});
|
||||
toast.success(t("agents:lineProvision.success", { defaultValue: "一级代理已创建" }));
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
site_code: "",
|
||||
code: "",
|
||||
name: "",
|
||||
username: "",
|
||||
password: "",
|
||||
total_share_rate: "0",
|
||||
credit_limit: "0",
|
||||
rebate_limit: "0",
|
||||
default_player_rebate: "0",
|
||||
can_grant_extra_rebate: false,
|
||||
}));
|
||||
const data = await getAdminIntegrationSites();
|
||||
setSites(data.items);
|
||||
await onSuccess?.(result);
|
||||
} catch (err) {
|
||||
const msg =
|
||||
err instanceof LotteryApiBizError ? err.message : t("common:error.generic");
|
||||
@@ -98,18 +187,20 @@ export function AgentLineProvisionWizard(): React.ReactElement {
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminPageCard title={t("agents:lineProvision.title", { defaultValue: "创建一级代理" })}>
|
||||
<p className="mb-2 max-w-xl text-sm text-muted-foreground">
|
||||
{t("agents:subnav.provisionHint", {
|
||||
defaultValue:
|
||||
const content = (
|
||||
<>
|
||||
{!embedded ? (
|
||||
<p className="mb-2 max-w-xl text-sm text-muted-foreground">
|
||||
{t("agents:subnav.provisionHint", {
|
||||
defaultValue:
|
||||
"请先在「平台管理 → 接入配置」创建接入站点;对接密钥在站点创建时一次性展示。",
|
||||
})}
|
||||
</p>
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="mb-4 max-w-xl text-sm text-muted-foreground">
|
||||
{t("agents:lineProvision.description", {
|
||||
defaultValue:
|
||||
"将一级代理绑定到已有接入站点,并配置后台登录账号与占成、授信、回水、结算周期。代理编码创建后不可修改。",
|
||||
"将一级代理绑定到已有接入站点,并配置后台登录账号与占成、授信、回水。代理编码创建后不可修改。",
|
||||
})}{" "}
|
||||
<Link
|
||||
href="/admin/config/integration-sites"
|
||||
@@ -190,6 +281,9 @@ export function AgentLineProvisionWizard(): React.ReactElement {
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("agents:lineProvision.passwordHint", { defaultValue: "至少 8 位" })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="text-sm font-medium">
|
||||
@@ -224,7 +318,7 @@ export function AgentLineProvisionWizard(): React.ReactElement {
|
||||
max={100}
|
||||
step="0.01"
|
||||
value={form.rebate_limit}
|
||||
placeholder="0.5"
|
||||
placeholder="50"
|
||||
onChange={(e) => setForm((f) => ({ ...f, rebate_limit: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
@@ -236,46 +330,11 @@ export function AgentLineProvisionWizard(): React.ReactElement {
|
||||
max={100}
|
||||
step="0.01"
|
||||
value={form.default_player_rebate}
|
||||
placeholder="0.5"
|
||||
placeholder="50"
|
||||
onChange={(e) => setForm((f) => ({ ...f, default_player_rebate: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>{t("agents:profile.settlementCycle", { defaultValue: "结算周期" })}</Label>
|
||||
<Select
|
||||
value={form.settlement_cycle}
|
||||
onValueChange={(value) =>
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
settlement_cycle: (value as "daily" | "weekly" | "monthly") ?? "weekly",
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue>
|
||||
{(v) =>
|
||||
v === "daily"
|
||||
? t("agents:profile.cycleDaily", { defaultValue: "日结" })
|
||||
: v === "monthly"
|
||||
? t("agents:profile.cycleMonthly", { defaultValue: "月结" })
|
||||
: t("agents:profile.cycleWeekly", { defaultValue: "周结" })
|
||||
}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="daily">
|
||||
{t("agents:profile.cycleDaily", { defaultValue: "日结" })}
|
||||
</SelectItem>
|
||||
<SelectItem value="weekly">
|
||||
{t("agents:profile.cycleWeekly", { defaultValue: "周结" })}
|
||||
</SelectItem>
|
||||
<SelectItem value="monthly">
|
||||
{t("agents:profile.cycleMonthly", { defaultValue: "月结" })}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={form.can_grant_extra_rebate}
|
||||
@@ -294,6 +353,16 @@ export function AgentLineProvisionWizard(): React.ReactElement {
|
||||
: t("agents:lineProvision.submit", { defaultValue: "创建一级代理" })}
|
||||
</Button>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
|
||||
if (embedded) {
|
||||
return <div className="space-y-0">{content}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminPageCard title={t("agents:lineProvision.title", { defaultValue: "创建一级代理" })}>
|
||||
{content}
|
||||
</AdminPageCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,10 +4,8 @@ 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";
|
||||
@@ -66,10 +64,6 @@ function collectExpandableIds(nodes: AgentNodeRow[], into: Set<number>): void {
|
||||
}
|
||||
}
|
||||
|
||||
function unwrapSiteRoots(nodes: AgentNodeRow[]): AgentNodeRow[] {
|
||||
return nodes.flatMap((node) => (node.is_root ? (node.children ?? []) : [node]));
|
||||
}
|
||||
|
||||
export type AgentLineSidebarProps = {
|
||||
siteLabel: string | null;
|
||||
/** API 返回的嵌套树(含 children) */
|
||||
@@ -110,8 +104,8 @@ function TreeRow({
|
||||
<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",
|
||||
"flex w-full items-start gap-0.5 rounded-md py-1 pr-2 transition-colors",
|
||||
active ? "bg-primary/10 ring-1 ring-primary/25" : "hover:bg-background/80",
|
||||
)}
|
||||
style={{ paddingLeft: `${6 + indent}px` }}
|
||||
>
|
||||
@@ -120,7 +114,7 @@ function TreeRow({
|
||||
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"
|
||||
className="mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-muted"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleExpand(node.id);
|
||||
@@ -132,7 +126,7 @@ function TreeRow({
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<span className="mt-1 inline-block size-6 shrink-0" aria-hidden />
|
||||
<span className="mt-0.5 inline-block size-5 shrink-0" aria-hidden />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
@@ -141,18 +135,8 @@ function TreeRow({
|
||||
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">
|
||||
<div className="truncate text-sm font-medium leading-5">{node.name}</div>
|
||||
<p className="truncate text-xs leading-5 text-muted-foreground">
|
||||
{node.username ?? node.code}
|
||||
</p>
|
||||
</button>
|
||||
@@ -192,9 +176,7 @@ export function AgentLineSidebar({
|
||||
const normalizedKeyword = keyword.trim().toLowerCase();
|
||||
|
||||
const displayForest = useMemo(() => {
|
||||
const pruned = pruneTreeForSearch(tree, normalizedKeyword, parentNameMap);
|
||||
|
||||
return unwrapSiteRoots(pruned);
|
||||
return pruneTreeForSearch(tree, normalizedKeyword, parentNameMap);
|
||||
}, [normalizedKeyword, parentNameMap, tree]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -16,6 +16,8 @@ import { formatAdminCreditMajorDecimal } from "@/lib/money";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { AgentParentCaps } from "@/types/api/admin-agent";
|
||||
|
||||
import { Info } from "lucide-react";
|
||||
|
||||
export type AgentProfileFieldsProps = {
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
@@ -31,8 +33,6 @@ export type AgentProfileFieldsProps = {
|
||||
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;
|
||||
@@ -62,8 +62,6 @@ export function AgentProfileFields({
|
||||
onRebateLimitChange,
|
||||
defaultRebate,
|
||||
onDefaultRebateChange,
|
||||
settlementCycle,
|
||||
onSettlementCycleChange,
|
||||
extraRebate,
|
||||
onExtraRebateChange,
|
||||
canCreatePlayer,
|
||||
@@ -81,47 +79,48 @@ export function AgentProfileFields({
|
||||
const isCard = variant === "card";
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-6">
|
||||
{(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 className="flex items-start gap-3 rounded-xl border border-primary/20 bg-primary/5 p-4 text-primary shadow-sm">
|
||||
<Info className="mt-0.5 size-5 shrink-0 opacity-80" aria-hidden />
|
||||
<div className="flex flex-col gap-1.5 min-w-0">
|
||||
{parentCaps ? (
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="text-sm font-medium leading-snug">
|
||||
{t("profile.parentCaps", {
|
||||
defaultValue: "上级占成 {{share}}%,可下发 {{credit}}",
|
||||
share: parentCaps.total_share_rate,
|
||||
credit: formatAdminCreditMajorDecimal(parentCaps.available_credit, currencyCode),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
{availableCredit !== null ? (
|
||||
<p className={cn("text-sm", parentCaps ? "text-primary/80" : "font-medium")}>
|
||||
{t("profile.availableCredit", {
|
||||
defaultValue: "可下发额度 {{amount}}",
|
||||
amount: formatAdminCreditMajorDecimal(availableCredit, currencyCode),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{loading ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<p className="text-sm text-muted-foreground animate-pulse">
|
||||
{t("profile.loading", { defaultValue: "正在加载占成与授信…" })}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"grid gap-4 sm:grid-cols-2",
|
||||
"grid gap-x-6 gap-y-5 sm:grid-cols-2",
|
||||
fieldDisabled ? "pointer-events-none opacity-50" : "",
|
||||
)}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${idPrefix}-share-rate`}>
|
||||
<Label htmlFor={`${idPrefix}-share-rate`} className="text-muted-foreground">
|
||||
{parentCaps
|
||||
? t("profile.relativeShareRate", { defaultValue: "占成比例(占上级 %)" })
|
||||
: t("profile.totalShareRate", { defaultValue: "占成比例 (%)" })}
|
||||
@@ -132,11 +131,12 @@ export function AgentProfileFields({
|
||||
min={0}
|
||||
max={100}
|
||||
step="0.01"
|
||||
className="h-10 bg-background/50 transition-colors focus:bg-background"
|
||||
value={shareRate}
|
||||
onChange={(e) => onShareRateChange(e.target.value)}
|
||||
/>
|
||||
{parentCaps && shareRate ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-xs text-muted-foreground/80">
|
||||
{t("profile.actualShareRate", {
|
||||
defaultValue: "实际占成 {{rate}}%",
|
||||
rate: Number((Number(parentCaps.total_share_rate) * Number(shareRate) / 100).toFixed(2)),
|
||||
@@ -145,19 +145,20 @@ export function AgentProfileFields({
|
||||
) : null}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${idPrefix}-credit-limit`}>
|
||||
<Label htmlFor={`${idPrefix}-credit-limit`} className="text-muted-foreground">
|
||||
{t("profile.creditLimit", { defaultValue: "授信额度" })}
|
||||
</Label>
|
||||
<Input
|
||||
id={`${idPrefix}-credit-limit`}
|
||||
type="number"
|
||||
min={0}
|
||||
className="h-10 bg-background/50 transition-colors focus:bg-background"
|
||||
value={creditLimit}
|
||||
onChange={(e) => onCreditLimitChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${idPrefix}-rebate-limit`}>
|
||||
<Label htmlFor={`${idPrefix}-rebate-limit`} className="text-muted-foreground">
|
||||
{t("profile.rebateLimit", { defaultValue: "回水上限 (%)" })}
|
||||
</Label>
|
||||
<Input
|
||||
@@ -166,13 +167,14 @@ export function AgentProfileFields({
|
||||
min={0}
|
||||
max={100}
|
||||
step="0.01"
|
||||
className="h-10 bg-background/50 transition-colors focus:bg-background"
|
||||
value={rebateLimit}
|
||||
onChange={(e) => onRebateLimitChange(e.target.value)}
|
||||
placeholder="0.5"
|
||||
placeholder="50"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${idPrefix}-default-rebate`}>
|
||||
<Label htmlFor={`${idPrefix}-default-rebate`} className="text-muted-foreground">
|
||||
{t("profile.defaultPlayerRebate", { defaultValue: "默认玩家回水 (%)" })}
|
||||
</Label>
|
||||
<Input
|
||||
@@ -181,17 +183,19 @@ export function AgentProfileFields({
|
||||
min={0}
|
||||
max={100}
|
||||
step="0.01"
|
||||
className="h-10 bg-background/50 transition-colors focus:bg-background"
|
||||
value={defaultRebate}
|
||||
onChange={(e) => onDefaultRebateChange(e.target.value)}
|
||||
placeholder="0.5"
|
||||
placeholder="50"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 sm:col-span-2">
|
||||
<Label htmlFor={`${idPrefix}-risk-tags`}>
|
||||
<Label htmlFor={`${idPrefix}-risk-tags`} className="text-muted-foreground">
|
||||
{t("profile.riskTags", { defaultValue: "风控标签" })}
|
||||
</Label>
|
||||
<Input
|
||||
id={`${idPrefix}-risk-tags`}
|
||||
className="h-10 bg-background/50 transition-colors focus:bg-background"
|
||||
value={riskTags}
|
||||
onChange={(e) => onRiskTagsChange(e.target.value)}
|
||||
placeholder={t("profile.riskTagsPlaceholder", {
|
||||
@@ -199,49 +203,15 @@ export function AgentProfileFields({
|
||||
})}
|
||||
/>
|
||||
</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>
|
||||
{settlementCycle === "daily"
|
||||
? t("profile.cycleDaily", { defaultValue: "日结" })
|
||||
: settlementCycle === "monthly"
|
||||
? t("profile.cycleMonthly", { defaultValue: "月结" })
|
||||
: t("profile.cycleWeekly", { defaultValue: "周结" })}
|
||||
</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",
|
||||
"pt-2",
|
||||
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">
|
||||
<div className="rounded-xl border border-border/70 bg-card overflow-hidden shadow-sm">
|
||||
<SwitchRow
|
||||
checked={extraRebate}
|
||||
onCheckedChange={onExtraRebateChange}
|
||||
@@ -257,8 +227,17 @@ export function AgentProfileFields({
|
||||
onCheckedChange={onCanCreateChildChange}
|
||||
disabled={!canCreateChildAgent && !isSuperAdmin}
|
||||
label={t("profile.canCreateChildAgent", { defaultValue: "允许创建下级代理" })}
|
||||
isLast
|
||||
/>
|
||||
</div>
|
||||
{!isCard ? (
|
||||
<p className="mt-3 px-1 text-xs text-muted-foreground/80">
|
||||
{t("profile.capabilityHint", {
|
||||
defaultValue:
|
||||
"保存后约束该代理主账号能否开玩家/下级;与平台「代理」角色叠加,以本开关为准。",
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -269,15 +248,20 @@ function SwitchRow({
|
||||
onCheckedChange,
|
||||
label,
|
||||
disabled = false,
|
||||
isLast = false,
|
||||
}: {
|
||||
checked: boolean;
|
||||
onCheckedChange: (value: boolean) => void;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
isLast?: 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>
|
||||
<div className={cn(
|
||||
"flex items-center justify-between gap-4 px-4 py-3.5 bg-background/50 transition-colors hover:bg-muted/30",
|
||||
!isLast && "border-b border-border/50"
|
||||
)}>
|
||||
<Label className={cn("font-medium cursor-pointer", disabled && "opacity-50")} onClick={() => !disabled && onCheckedChange(!checked)}>{label}</Label>
|
||||
<Switch checked={checked} onCheckedChange={onCheckedChange} disabled={disabled} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -40,11 +41,11 @@ import {
|
||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||
import {
|
||||
PRD_AGENT_MANAGE,
|
||||
PRD_AGENT_LINE_PROVISION_ACCESS_ANY,
|
||||
PRD_AGENT_PROFILE_MANAGE,
|
||||
PRD_AGENTS_ACCESS_ANY,
|
||||
PRD_USERS_MANAGE,
|
||||
} from "@/lib/admin-prd";
|
||||
import { normalizeAgentSettlementCycle } from "@/lib/agent-settlement-cycle";
|
||||
import { useAdminSiteCodeOptions } from "@/hooks/use-admin-site-code-options";
|
||||
import { useAdminProfile } from "@/stores/admin-session";
|
||||
import { useAgentManagementSiteStore } from "@/stores/agent-management-site";
|
||||
@@ -80,8 +81,10 @@ function flattenTree(nodes: AgentNodeRow[]): AgentNodeRow[] {
|
||||
export function AgentsConsole(): React.ReactElement {
|
||||
const { t } = useTranslation(["agents", "common"]);
|
||||
const tRef = useTranslationRef(["agents", "common"]);
|
||||
const searchParams = useSearchParams();
|
||||
const profile = useAdminProfile();
|
||||
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
||||
const boundAgent = profile?.agent ?? null;
|
||||
|
||||
const isSuperAdmin = profile?.is_super_admin === true;
|
||||
const canManageNode =
|
||||
@@ -95,6 +98,10 @@ export function AgentsConsole(): React.ReactElement {
|
||||
PRD_AGENT_PROFILE_MANAGE,
|
||||
PRD_AGENT_MANAGE,
|
||||
]);
|
||||
const canProvisionLine =
|
||||
boundAgent === null &&
|
||||
(isSuperAdmin ||
|
||||
adminHasAnyPermission(profile?.permissions, [...PRD_AGENT_LINE_PROVISION_ACCESS_ANY]));
|
||||
const { sites: siteOptions } = useAdminSiteCodeOptions();
|
||||
const adminSiteId = useAgentManagementSiteStore((s) => s.adminSiteId);
|
||||
const setAdminSiteId = useAgentManagementSiteStore((s) => s.setAdminSiteId);
|
||||
@@ -107,7 +114,7 @@ export function AgentsConsole(): React.ReactElement {
|
||||
const [profileSaving, setProfileSaving] = useState(false);
|
||||
const [selectedProfile, setSelectedProfile] = useState<AgentProfileRow | null>(null);
|
||||
const [selectedProfileLoading, setSelectedProfileLoading] = useState(false);
|
||||
|
||||
const [playerCreateRequestKey, setPlayerCreateRequestKey] = useState(0);
|
||||
const [nodeDialogOpen, setNodeDialogOpen] = useState(false);
|
||||
const [nodeDialogMode, setNodeDialogMode] = useState<"create" | "edit">("create");
|
||||
const [targetParentId, setTargetParentId] = useState<number | null>(null);
|
||||
@@ -121,9 +128,6 @@ export function AgentsConsole(): React.ReactElement {
|
||||
const [profileCreditLimit, setProfileCreditLimit] = useState("0");
|
||||
const [profileRebateLimit, setProfileRebateLimit] = useState("0");
|
||||
const [profileDefaultRebate, setProfileDefaultRebate] = useState("0");
|
||||
const [profileSettlementCycle, setProfileSettlementCycle] = useState<
|
||||
"daily" | "weekly" | "monthly"
|
||||
>("weekly");
|
||||
const [profileExtraRebate, setProfileExtraRebate] = useState(false);
|
||||
const [profileCanCreateChild, setProfileCanCreateChild] = useState(false);
|
||||
const [profileCanCreatePlayer, setProfileCanCreatePlayer] = useState(true);
|
||||
@@ -134,7 +138,6 @@ export function AgentsConsole(): React.ReactElement {
|
||||
const [profileAvailableCredit, setProfileAvailableCredit] = useState<number | null>(null);
|
||||
const [editingNodeNeedsPrimaryAccount, setEditingNodeNeedsPrimaryAccount] = useState(false);
|
||||
|
||||
const boundAgent = profile?.agent ?? null;
|
||||
/** 登录账号是否可向子代理下放「允许创建下级」 */
|
||||
const canCreateChildAgent =
|
||||
isSuperAdmin || boundAgent?.can_create_child_agent !== false;
|
||||
@@ -142,13 +145,13 @@ export function AgentsConsole(): React.ReactElement {
|
||||
isSuperAdmin ||
|
||||
adminHasAnyPermission(profile?.permissions, [PRD_USERS_MANAGE]);
|
||||
const [rootProfile, setRootProfile] = useState<AgentProfileRow | null>(null);
|
||||
const selectedNodeIdFromUrl = Number.parseInt(searchParams.get("agent_node_id") ?? "", 10);
|
||||
|
||||
const resetProfileForm = (mode: "create" | "edit" = "create") => {
|
||||
setProfileShareRate("0");
|
||||
setProfileCreditLimit("0");
|
||||
setProfileRebateLimit("0");
|
||||
setProfileDefaultRebate("0");
|
||||
setProfileSettlementCycle("weekly");
|
||||
setProfileExtraRebate(false);
|
||||
setProfileCanCreateChild(mode === "create" ? false : false);
|
||||
setProfileCanCreatePlayer(true);
|
||||
@@ -168,7 +171,6 @@ export function AgentsConsole(): React.ReactElement {
|
||||
setProfileCreditLimit(String(row.credit_limit ?? 0));
|
||||
setProfileRebateLimit(percentValueToUi(row.rebate_limit ?? 0));
|
||||
setProfileDefaultRebate(percentValueToUi(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);
|
||||
@@ -183,7 +185,6 @@ export function AgentsConsole(): React.ReactElement {
|
||||
credit_limit: Number.parseInt(profileCreditLimit, 10) || 0,
|
||||
rebate_limit: Number.parseFloat(profileRebateLimit) || 0,
|
||||
default_player_rebate: Number.parseFloat(profileDefaultRebate) || 0,
|
||||
settlement_cycle: normalizeAgentSettlementCycle(profileSettlementCycle),
|
||||
can_grant_extra_rebate: profileExtraRebate,
|
||||
can_create_child_agent: profileCanCreateChild,
|
||||
can_create_player: profileCanCreatePlayer,
|
||||
@@ -239,7 +240,7 @@ export function AgentsConsole(): React.ReactElement {
|
||||
() => new Map<number, string>(flatNodes.map((node) => [node.id, node.name])),
|
||||
[flatNodes],
|
||||
);
|
||||
const businessRows = useMemo(() => flatNodes.filter((node) => !node.is_root), [flatNodes]);
|
||||
const visibleAgentRows = flatNodes;
|
||||
const selectedSiteLabel = useMemo(
|
||||
() => siteOptions.find((site) => site.id === adminSiteId)?.name ?? null,
|
||||
[adminSiteId, siteOptions],
|
||||
@@ -324,6 +325,16 @@ export function AgentsConsole(): React.ReactElement {
|
||||
}
|
||||
}, [adminSiteId, canViewAgents, isSuperAdmin, profile?.agent?.admin_site_id, setAdminSiteId, siteOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!Number.isInteger(selectedNodeIdFromUrl) || selectedNodeIdFromUrl <= 0) {
|
||||
return;
|
||||
}
|
||||
if (!flatNodes.some((node) => node.id === selectedNodeIdFromUrl)) {
|
||||
return;
|
||||
}
|
||||
setSelectedNodeId(selectedNodeIdFromUrl);
|
||||
}, [flatNodes, selectedNodeIdFromUrl]);
|
||||
|
||||
useAsyncEffect(() => {
|
||||
if (selectedNode === null) {
|
||||
setSelectedProfile(null);
|
||||
@@ -374,6 +385,26 @@ export function AgentsConsole(): React.ReactElement {
|
||||
[hasUsersManagePermission, selectedNode, selectedProfile, selectedProfileLoading],
|
||||
);
|
||||
|
||||
const playersTabHint = useMemo(() => {
|
||||
if (selectedNode === null || selectedProfileLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (selectedProfile?.can_create_player !== true) {
|
||||
return t("lineUi.playersUnavailableHint", {
|
||||
defaultValue: "当前代理未开启“允许创建玩家”,如需新增请先调整该代理配置。",
|
||||
});
|
||||
}
|
||||
|
||||
if (!hasUsersManagePermission) {
|
||||
return t("lineUi.playersNoPermissionHint", {
|
||||
defaultValue: "当前账号没有该节点的玩家管理权限。",
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [hasUsersManagePermission, selectedNode, selectedProfile, selectedProfileLoading, t]);
|
||||
|
||||
const canCreateChildOnSelected = useMemo(
|
||||
() => canManageNode && selectedProfile?.can_create_child_agent === true,
|
||||
[canManageNode, selectedProfile?.can_create_child_agent],
|
||||
@@ -401,15 +432,15 @@ export function AgentsConsole(): React.ReactElement {
|
||||
]);
|
||||
|
||||
useAsyncEffect(() => {
|
||||
if (businessRows.length === 0) {
|
||||
if (visibleAgentRows.length === 0) {
|
||||
setSelectedNodeId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedNodeId === null || !businessRows.some((row) => row.id === selectedNodeId)) {
|
||||
setSelectedNodeId(businessRows[0]?.id ?? null);
|
||||
if (selectedNodeId === null || !visibleAgentRows.some((row) => row.id === selectedNodeId)) {
|
||||
setSelectedNodeId(visibleAgentRows[0]?.id ?? null);
|
||||
}
|
||||
}, [businessRows, selectedNodeId]);
|
||||
}, [visibleAgentRows, selectedNodeId]);
|
||||
|
||||
useEffect(() => {
|
||||
setDetailTab("overview");
|
||||
@@ -422,10 +453,14 @@ export function AgentsConsole(): React.ReactElement {
|
||||
}, [detailTab, isOwnAgentNode]);
|
||||
|
||||
useAsyncEffect(() => {
|
||||
if (!canViewAgents) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (adminSiteId !== null) {
|
||||
void loadTree(adminSiteId);
|
||||
}
|
||||
}, [adminSiteId, loadTree]);
|
||||
}, [adminSiteId, canViewAgents, loadTree]);
|
||||
|
||||
const openCreateChildForNode = (node: AgentNodeRow) => {
|
||||
setNodeDialogMode("create");
|
||||
@@ -435,9 +470,10 @@ export function AgentsConsole(): React.ReactElement {
|
||||
setNodeStatus(1);
|
||||
setNodeUsername("");
|
||||
setNodePassword("");
|
||||
setProfileLoading(false);
|
||||
setProfileLoaded(true);
|
||||
setProfileLoading(canManageProfile);
|
||||
setProfileLoaded(!canManageProfile);
|
||||
setEditingNodeNeedsPrimaryAccount(false);
|
||||
resetProfileForm("create");
|
||||
setNodeDialogOpen(true);
|
||||
if (canManageProfile) {
|
||||
void getAgentNodeProfile(node.id)
|
||||
@@ -450,15 +486,18 @@ export function AgentsConsole(): React.ReactElement {
|
||||
available_credit: p.available_credit ?? 0,
|
||||
});
|
||||
setProfileAvailableCredit(p.available_credit ?? null);
|
||||
resetProfileForm("create");
|
||||
setProfileLoaded(true);
|
||||
})
|
||||
.catch(() => {
|
||||
setProfileParentCaps(null);
|
||||
setProfileAvailableCredit(null);
|
||||
resetProfileForm("create");
|
||||
setProfileLoaded(false);
|
||||
})
|
||||
.finally(() => {
|
||||
setProfileLoading(false);
|
||||
});
|
||||
} else {
|
||||
resetProfileForm("create");
|
||||
setProfileLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -573,8 +612,6 @@ export function AgentsConsole(): React.ReactElement {
|
||||
onRebateLimitChange: setProfileRebateLimit,
|
||||
defaultRebate: profileDefaultRebate,
|
||||
onDefaultRebateChange: setProfileDefaultRebate,
|
||||
settlementCycle: profileSettlementCycle,
|
||||
onSettlementCycleChange: setProfileSettlementCycle,
|
||||
extraRebate: profileExtraRebate,
|
||||
onExtraRebateChange: setProfileExtraRebate,
|
||||
canCreatePlayer: profileCanCreatePlayer,
|
||||
@@ -598,12 +635,11 @@ export function AgentsConsole(): React.ReactElement {
|
||||
profileParentCaps,
|
||||
profileRebateLimit,
|
||||
profileRiskTags,
|
||||
profileSettlementCycle,
|
||||
profileShareRate,
|
||||
selectedProfileLoading,
|
||||
]);
|
||||
|
||||
const showAgentSidebar = businessRows.length > 1;
|
||||
const showAgentSidebar = visibleAgentRows.length > 0;
|
||||
|
||||
const openAddAgent = (): void => {
|
||||
const parent = selectedNode ?? rootNode;
|
||||
@@ -742,7 +778,7 @@ export function AgentsConsole(): React.ReactElement {
|
||||
return null;
|
||||
}, [addParent, rootNode, rootProfile, selectedNodeId, selectedProfile]);
|
||||
|
||||
if (!canViewAgents) {
|
||||
if (!canViewAgents && !canProvisionLine) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("noAccess", { defaultValue: "您没有代理经营相关权限,请联系管理员开通。" })}
|
||||
@@ -750,7 +786,7 @@ export function AgentsConsole(): React.ReactElement {
|
||||
);
|
||||
}
|
||||
|
||||
if (loading && tree.length === 0) {
|
||||
if (canViewAgents && loading && tree.length === 0) {
|
||||
return <AdminLoadingState label={t("listTitle", { defaultValue: "代理列表" })} />;
|
||||
}
|
||||
|
||||
@@ -758,17 +794,18 @@ export function AgentsConsole(): React.ReactElement {
|
||||
<div className="flex min-h-[32rem] flex-col gap-0">
|
||||
<ConfirmDialog />
|
||||
|
||||
{err ? <p className="px-1 text-sm text-destructive">{err}</p> : null}
|
||||
{canViewAgents && err ? <p className="px-1 text-sm text-destructive">{err}</p> : null}
|
||||
|
||||
<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 ? (
|
||||
{canViewAgents ? (
|
||||
<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}
|
||||
agentCount={visibleAgentRows.length}
|
||||
onKeywordChange={(value) => {
|
||||
setKeyword(value);
|
||||
}}
|
||||
@@ -798,12 +835,19 @@ export function AgentsConsole(): React.ReactElement {
|
||||
profileReadOnly={isOwnAgentNode}
|
||||
canViewDownlineTab={canShowDownlineTab}
|
||||
canViewPlayersTab={canShowPlayersTab}
|
||||
playersTabHint={playersTabHint}
|
||||
canManageNode={canManageNode}
|
||||
canCreateChild={canCreateChildOnSelected}
|
||||
canCreateChildAgent={canCreateChildAgent}
|
||||
canCreatePlayerAction={
|
||||
isSuperAdmin ||
|
||||
(selectedProfile?.can_create_player === true &&
|
||||
adminHasAnyPermission(profile?.permissions, [PRD_USERS_MANAGE]))
|
||||
}
|
||||
canDeleteChild={canDeleteNode}
|
||||
onEditChild={(node) => openEditForNode(node)}
|
||||
onAddChild={() => selectedNode && openCreateChildForNode(selectedNode)}
|
||||
onAddPlayer={() => setPlayerCreateRequestKey((value) => value + 1)}
|
||||
onEditCurrent={() => selectedNode && openEditForNode(selectedNode)}
|
||||
onDeleteChild={(node) => handleDeleteNode(node)}
|
||||
onSelectChild={(child) => {
|
||||
@@ -812,8 +856,16 @@ export function AgentsConsole(): React.ReactElement {
|
||||
profileFields={inlineProfileFields}
|
||||
profileSaving={profileSaving}
|
||||
onSaveProfile={() => void saveInlineProfile()}
|
||||
/>
|
||||
</div>
|
||||
playerCreateRequestKey={playerCreateRequestKey}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-2xl border border-border/70 bg-card px-5 py-8 text-sm text-muted-foreground shadow-sm">
|
||||
{t("lineUi.provisionOnlyHint", {
|
||||
defaultValue: "当前账号仅可开通一级代理线路,请前往「开通一级代理」页面创建新线路。",
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog open={nodeDialogOpen} onOpenChange={setNodeDialogOpen}>
|
||||
<DialogContent
|
||||
@@ -828,96 +880,103 @@ 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
|
||||
id="agent-name"
|
||||
value={nodeName}
|
||||
placeholder={t("namePlaceholder")}
|
||||
onChange={(e) => setNodeName(e.target.value)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-5 py-5">
|
||||
<div className="grid gap-5">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="agent-name" className="text-muted-foreground">{t("name", { defaultValue: "名称" })}</Label>
|
||||
<Input
|
||||
id="agent-name"
|
||||
value={nodeName}
|
||||
placeholder={t("namePlaceholder")}
|
||||
onChange={(e) => setNodeName(e.target.value)}
|
||||
autoComplete="off"
|
||||
className="bg-background/50 transition-colors focus:bg-background"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="agent-username">{t("users.username", { defaultValue: "登录名" })}</Label>
|
||||
<Input
|
||||
id="agent-username"
|
||||
value={nodeUsername}
|
||||
placeholder={t("usernamePlaceholder")}
|
||||
onChange={(e) => setNodeUsername(e.target.value)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-x-4 gap-y-5 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="agent-username" className="text-muted-foreground">{t("users.username", { defaultValue: "登录名" })}</Label>
|
||||
<Input
|
||||
id="agent-username"
|
||||
value={nodeUsername}
|
||||
placeholder={t("usernamePlaceholder")}
|
||||
onChange={(e) => setNodeUsername(e.target.value)}
|
||||
autoComplete="off"
|
||||
className="bg-background/50 transition-colors focus:bg-background"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="agent-password">
|
||||
{nodeDialogMode === "create" || editingNodeNeedsPrimaryAccount
|
||||
? t("users.password", { defaultValue: "密码" })
|
||||
: t("resetPassword", { defaultValue: "重置密码" })}
|
||||
</Label>
|
||||
{nodeDialogMode === "edit" && editingNodeNeedsPrimaryAccount ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("bindAccountHint", {
|
||||
defaultValue: "该代理尚无登录账号,保存时将自动创建并绑定。",
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
<Input
|
||||
id="agent-password"
|
||||
type="password"
|
||||
value={nodePassword}
|
||||
onChange={(e) => setNodePassword(e.target.value)}
|
||||
placeholder={
|
||||
nodeDialogMode === "edit" && !editingNodeNeedsPrimaryAccount
|
||||
? t("passwordOptionalHint")
|
||||
: t("passwordPlaceholder")
|
||||
}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="agent-password" className="text-muted-foreground">
|
||||
{nodeDialogMode === "create" || editingNodeNeedsPrimaryAccount
|
||||
? t("users.password", { defaultValue: "密码" })
|
||||
: t("resetPassword", { defaultValue: "重置密码" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="agent-password"
|
||||
type="password"
|
||||
value={nodePassword}
|
||||
onChange={(e) => setNodePassword(e.target.value)}
|
||||
placeholder={
|
||||
nodeDialogMode === "edit" && !editingNodeNeedsPrimaryAccount
|
||||
? t("passwordOptionalHint")
|
||||
: t("passwordPlaceholder")
|
||||
}
|
||||
autoComplete="new-password"
|
||||
className="bg-background/50 transition-colors focus:bg-background"
|
||||
/>
|
||||
{nodeDialogMode === "edit" && editingNodeNeedsPrimaryAccount ? (
|
||||
<p className="text-[11px] text-muted-foreground/80">
|
||||
{t("bindAccountHint", {
|
||||
defaultValue: "该代理尚无登录账号,保存时将自动创建并绑定。",
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch checked={nodeStatus === 1} onCheckedChange={(value) => setNodeStatus(value ? 1 : 0)} />
|
||||
<Label>{t("status", { defaultValue: "状态" })}</Label>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-xl border border-border/70 bg-card px-4 py-3 shadow-sm">
|
||||
<Label className="font-medium cursor-pointer" onClick={() => setNodeStatus(nodeStatus === 1 ? 0 : 1)}>
|
||||
{t("status", { defaultValue: "状态" })}
|
||||
</Label>
|
||||
<Switch checked={nodeStatus === 1} onCheckedChange={(value) => setNodeStatus(value ? 1 : 0)} />
|
||||
</div>
|
||||
|
||||
{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>
|
||||
<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"
|
||||
/>
|
||||
{canManageProfile &&
|
||||
(nodeDialogMode === "create" ||
|
||||
(editingNodeId !== null && boundAgent?.id !== editingNodeId)) ? (
|
||||
<div className="space-y-4 border-t border-border/60 pt-5 mt-1">
|
||||
<h3 className="text-sm font-semibold tracking-tight">
|
||||
{t("profile.section", { defaultValue: "占成与授信配置" })}
|
||||
</h3>
|
||||
<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}
|
||||
extraRebate={profileExtraRebate}
|
||||
onExtraRebateChange={setProfileExtraRebate}
|
||||
canCreatePlayer={profileCanCreatePlayer}
|
||||
onCanCreatePlayerChange={setProfileCanCreatePlayer}
|
||||
canCreateChild={profileCanCreateChild}
|
||||
onCanCreateChildChange={setProfileCanCreateChild}
|
||||
riskTags={profileRiskTags}
|
||||
onRiskTagsChange={setProfileRiskTags}
|
||||
idPrefix="dialog-agent-profile"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="!m-0 shrink-0 rounded-b-xl border-t bg-background px-4 py-4">
|
||||
|
||||
308
src/modules/agents/agents-directory-console.tsx
Normal file
308
src/modules/agents/agents-directory-console.tsx
Normal file
@@ -0,0 +1,308 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { RefreshCw, Search } from "lucide-react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { getAgentNodes } from "@/api/admin-agents";
|
||||
import { AdminPageCard } from "@/components/admin/admin-page-card";
|
||||
import { AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import { AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
|
||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
import type { AgentNodeRow } from "@/types/api/admin-agent";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function formatPercent(value: number | null | undefined): string {
|
||||
if (value == null || Number.isNaN(value)) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
return `${Number(value).toFixed(2).replace(/\.?0+$/, "")}%`;
|
||||
}
|
||||
|
||||
function formatCredit(value: number | null | undefined): string {
|
||||
if (value == null || Number.isNaN(value)) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
return new Intl.NumberFormat("zh-CN", { maximumFractionDigits: 0 }).format(value);
|
||||
}
|
||||
|
||||
function statusLabel(status: number, t: (key: string, options?: { defaultValue?: string }) => string): string {
|
||||
return status === 1
|
||||
? t("statusEnabled", { defaultValue: "启用" })
|
||||
: t("statusDisabled", { defaultValue: "停用" });
|
||||
}
|
||||
|
||||
export function AgentsDirectoryConsole(): React.ReactElement {
|
||||
const { t } = useTranslation(["agents", "common"]);
|
||||
const tRef = useTranslationRef(["agents", "common"]);
|
||||
|
||||
const [items, setItems] = useState<AgentNodeRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [status, setStatus] = useState<"all" | "enabled" | "disabled">("all");
|
||||
const [includeRoots, setIncludeRoots] = useState(false);
|
||||
const [reloadKey, setReloadKey] = useState(0);
|
||||
|
||||
const parentNameMap = useMemo(
|
||||
() => new Map(items.map((item) => [item.id, item.name])),
|
||||
[items],
|
||||
);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setErr(null);
|
||||
try {
|
||||
const data = await getAgentNodes();
|
||||
setItems(data.items);
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: tRef.current("agents:loadFailed", { defaultValue: "加载代理列表失败" });
|
||||
setErr(message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [tRef]);
|
||||
|
||||
useAsyncEffect(load, [load, reloadKey]);
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
const normalized = keyword.trim().toLowerCase();
|
||||
|
||||
return items.filter((item) => {
|
||||
if (!includeRoots && item.is_root) {
|
||||
return false;
|
||||
}
|
||||
if (status === "enabled" && item.status !== 1) {
|
||||
return false;
|
||||
}
|
||||
if (status === "disabled" && item.status === 1) {
|
||||
return false;
|
||||
}
|
||||
if (!normalized) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const parentName = item.parent_id != null ? parentNameMap.get(item.parent_id) ?? "" : "";
|
||||
return [item.name, item.code, item.username ?? "", item.email ?? "", parentName]
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
.includes(normalized);
|
||||
});
|
||||
}, [includeRoots, items, keyword, parentNameMap, status]);
|
||||
|
||||
const totalOperatingAgents = useMemo(
|
||||
() => items.filter((item) => !item.is_root).length,
|
||||
[items],
|
||||
);
|
||||
const enabledOperatingAgents = useMemo(
|
||||
() => items.filter((item) => !item.is_root && item.status === 1).length,
|
||||
[items],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<div className="rounded-lg border border-border/70 bg-card px-4 py-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("summary.visibleAgents", { defaultValue: "当前可见经营代理数" })}
|
||||
</p>
|
||||
<p className="mt-1 text-2xl font-semibold tabular-nums">{totalOperatingAgents}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border/70 bg-card px-4 py-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("summary.enabledAgents", { defaultValue: "启用中的经营代理数" })}
|
||||
</p>
|
||||
<p className="mt-1 text-2xl font-semibold tabular-nums">{enabledOperatingAgents}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border/70 bg-card px-4 py-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("summary.visibleList", { defaultValue: "当前平铺列表条数" })}
|
||||
</p>
|
||||
<p className="mt-1 text-2xl font-semibold tabular-nums">{filteredItems.length}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AdminPageCard
|
||||
title={t("listTitle", { defaultValue: "代理列表" })}
|
||||
actions={
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setReloadKey((value) => value + 1)}
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
{t("common:actions.refresh", { defaultValue: "刷新" })}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="mb-4 flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="relative min-w-0 flex-1">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={keyword}
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
placeholder={t("listSearch", { defaultValue: "搜索代理名称 / 编码 / 登录名" })}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Select value={status} onValueChange={(value) => setStatus(value as typeof status)}>
|
||||
<SelectTrigger className="h-9 w-[150px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">
|
||||
{t("directoryStatus.all", { defaultValue: "全部状态" })}
|
||||
</SelectItem>
|
||||
<SelectItem value="enabled">
|
||||
{t("directoryStatus.enabled", { defaultValue: "仅启用" })}
|
||||
</SelectItem>
|
||||
<SelectItem value="disabled">
|
||||
{t("directoryStatus.disabled", { defaultValue: "仅停用" })}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Label className="flex h-9 items-center gap-2 rounded-md border border-border/70 px-3 text-sm font-normal">
|
||||
<Checkbox
|
||||
checked={includeRoots}
|
||||
onCheckedChange={(checked) => setIncludeRoots(checked === true)}
|
||||
/>
|
||||
{t("includeRoots", { defaultValue: "包含根节点" })}
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{err ? (
|
||||
<div className="mb-4 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{err}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="min-w-[140px]">{t("name", { defaultValue: "名称" })}</TableHead>
|
||||
<TableHead className="min-w-[120px]">{t("code", { defaultValue: "编码" })}</TableHead>
|
||||
<TableHead className="w-[90px]">{t("depth", { defaultValue: "层级" })}</TableHead>
|
||||
<TableHead className="w-[90px]">{t("status", { defaultValue: "状态" })}</TableHead>
|
||||
<TableHead className="min-w-[140px]">{t("parentAgent", { defaultValue: "上级代理" })}</TableHead>
|
||||
<TableHead className="min-w-[140px]">{t("username", { defaultValue: "登录名" })}</TableHead>
|
||||
<TableHead className="w-[110px] text-right">
|
||||
{t("profile.totalShareRate", { defaultValue: "占成比例 (%)" })}
|
||||
</TableHead>
|
||||
<TableHead className="w-[110px] text-right">
|
||||
{t("profile.rebateLimit", { defaultValue: "回水上限 (%)" })}
|
||||
</TableHead>
|
||||
<TableHead className="w-[130px] text-right">
|
||||
{t("profile.creditLimit", { defaultValue: "授信额度" })}
|
||||
</TableHead>
|
||||
<TableHead className="w-[130px] text-right">
|
||||
{t("lineUi.availableCredit", { defaultValue: "可下发" })}
|
||||
</TableHead>
|
||||
<TableHead className="w-[110px] text-right">
|
||||
{t("common:actions.title", { defaultValue: "操作" })}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<AdminTableLoadingRow colSpan={11} />
|
||||
) : filteredItems.length === 0 ? (
|
||||
<AdminTableNoResourceRow colSpan={11} />
|
||||
) : (
|
||||
filteredItems.map((item) => {
|
||||
const parentName =
|
||||
item.parent_id != null ? parentNameMap.get(item.parent_id) ?? "-" : "-";
|
||||
const profile = item.profile_summary;
|
||||
|
||||
return (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell>
|
||||
<div className="min-w-0">
|
||||
<span className="block truncate text-sm font-semibold">{item.name}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
<span className="font-mono">{item.code}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{item.depth}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{item.is_root ? (
|
||||
<AdminStatusBadge tone="info">
|
||||
{t("isRoot", { defaultValue: "根节点" })}
|
||||
</AdminStatusBadge>
|
||||
) : (
|
||||
<AdminStatusBadge tone={item.status === 1 ? "success" : "neutral"}>
|
||||
{statusLabel(item.status, t)}
|
||||
</AdminStatusBadge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm">{parentName}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm">{item.username ?? "-"}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<span className="tabular-nums">{formatPercent(profile?.total_share_rate)}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<span className="tabular-nums">{formatPercent(profile?.rebate_limit)}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<span className="tabular-nums">{formatCredit(profile?.credit_limit)}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<span className="tabular-nums">{formatCredit(profile?.available_credit)}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Link
|
||||
href={`/admin/agents?agent_node_id=${item.id}`}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "sm" }))}
|
||||
>
|
||||
{t("common:actions.view", { defaultValue: "查看" })}
|
||||
</Link>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</AdminPageCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Eye, Pencil, Plus, ReceiptText, Trash2 } from "lucide-react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -57,7 +57,7 @@ import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter"
|
||||
import { PlayerFundingModeBadge } from "@/components/admin/player-funding-badges";
|
||||
import { formatPlayerCreditAmount, playerBalanceCells } from "@/lib/admin-player-display";
|
||||
import { formatAdminMinorUnits } from "@/lib/money";
|
||||
import { parsePercentUi, percentUiToRatio, ratioToPercentUi } from "@/lib/admin-rate-percent";
|
||||
import { parsePercentUi, percentValueToUi } 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";
|
||||
@@ -136,7 +136,7 @@ function fillEditFormFromPlayer(row: AdminPlayerRow): {
|
||||
currency: row.default_currency ?? "",
|
||||
status: row.status,
|
||||
creditLimit: row.credit_limit ?? 0,
|
||||
rebateRate: rebate != null ? ratioToPercentUi(rebate) : "",
|
||||
rebateRate: rebate != null ? percentValueToUi(rebate) : "",
|
||||
riskTags: (row.risk_tags ?? []).join(", "),
|
||||
};
|
||||
}
|
||||
@@ -149,6 +149,8 @@ type AgentsPlayersPanelProps = {
|
||||
allowCreatePlayer?: boolean;
|
||||
/** 嵌入代理线路详情 Tab 时使用紧凑顶栏 */
|
||||
embedded?: boolean;
|
||||
/** 外部触发创建直属玩家的计数器 */
|
||||
createRequestKey?: number;
|
||||
};
|
||||
|
||||
export function AgentsPlayersPanel({
|
||||
@@ -156,6 +158,7 @@ export function AgentsPlayersPanel({
|
||||
agentNodeId,
|
||||
allowCreatePlayer,
|
||||
embedded = false,
|
||||
createRequestKey = 0,
|
||||
}: AgentsPlayersPanelProps): React.ReactElement {
|
||||
const { t } = useTranslation(["agents", "players", "common"]);
|
||||
const formatDt = useAdminDateTimeFormatter();
|
||||
@@ -226,6 +229,7 @@ export function AgentsPlayersPanel({
|
||||
const [payMethod, setPayMethod] = useState("");
|
||||
const [payProof, setPayProof] = useState("");
|
||||
const [badDebtReason, setBadDebtReason] = useState("");
|
||||
const lastCreateRequestKeyRef = useRef(createRequestKey);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (siteCode.trim() === "") {
|
||||
@@ -269,6 +273,46 @@ export function AgentsPlayersPanel({
|
||||
toast.error(t("playersPanel.loginRequired", { defaultValue: "请填写登录账号与初始密码" }));
|
||||
return;
|
||||
}
|
||||
if (password.trim().length < 8) {
|
||||
toast.error(
|
||||
t("playersPanel.passwordMinLength", { defaultValue: "初始密码至少 8 位" }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedCreditLimit =
|
||||
creditLimit.trim() === "" ? 0 : Number.parseInt(creditLimit, 10);
|
||||
if (
|
||||
Number.isNaN(parsedCreditLimit) ||
|
||||
parsedCreditLimit < 0 ||
|
||||
!Number.isInteger(parsedCreditLimit)
|
||||
) {
|
||||
toast.error(
|
||||
t("playersPanel.creditLimitInvalid", { defaultValue: "授信额度必须为不小于 0 的整数" }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
parentAvailableCredit !== null &&
|
||||
parsedCreditLimit > parentAvailableCredit
|
||||
) {
|
||||
toast.error(
|
||||
t("playersPanel.creditLimitExceeded", {
|
||||
defaultValue: "授信额度不能超过当前代理可下发额度",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedRebateRate = rebateRate.trim() === "" ? null : parsePercentUi(rebateRate);
|
||||
if (rebateRate.trim() !== "" && (parsedRebateRate === null || parsedRebateRate < 0 || parsedRebateRate > 100)) {
|
||||
toast.error(
|
||||
t("playersPanel.rebateRateInvalid", {
|
||||
defaultValue: "回水比例须在 0–100% 之间",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
@@ -278,10 +322,9 @@ export function AgentsPlayersPanel({
|
||||
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) }
|
||||
credit_limit: parsedCreditLimit,
|
||||
...(parsedRebateRate !== null
|
||||
? { rebate_rate: parsedRebateRate }
|
||||
: {}),
|
||||
});
|
||||
toast.success(
|
||||
@@ -306,6 +349,11 @@ export function AgentsPlayersPanel({
|
||||
|
||||
function openCreateDialog(): void {
|
||||
setDialogOpen(true);
|
||||
setUsername("");
|
||||
setPassword("");
|
||||
setNickname("");
|
||||
setCreditLimit("");
|
||||
setRebateRate("");
|
||||
if (effectiveAgentId !== null) {
|
||||
void getAgentNodeProfile(effectiveAgentId)
|
||||
.then((p) => setParentAvailableCredit(p.available_credit ?? null))
|
||||
@@ -315,6 +363,16 @@ export function AgentsPlayersPanel({
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (createRequestKey === 0 || createRequestKey === lastCreateRequestKeyRef.current) {
|
||||
return;
|
||||
}
|
||||
lastCreateRequestKeyRef.current = createRequestKey;
|
||||
if (canCreatePlayer) {
|
||||
openCreateDialog();
|
||||
}
|
||||
}, [canCreatePlayer, createRequestKey]);
|
||||
|
||||
const applyEditForm = (row: AdminPlayerRow): void => {
|
||||
const form = fillEditFormFromPlayer(row);
|
||||
setEditUsername(form.username);
|
||||
@@ -382,7 +440,7 @@ export function AgentsPlayersPanel({
|
||||
}
|
||||
const prevRebate = resolvePlayerRebateRate(editingPlayer);
|
||||
const nextPercent = parsePercentUi(editRebateRate);
|
||||
const nextRebate = nextPercent === null ? null : percentUiToRatio(nextPercent);
|
||||
const nextRebate = nextPercent === null ? null : nextPercent;
|
||||
if (nextRebate !== null && nextRebate !== (prevRebate ?? 0)) {
|
||||
body.rebate_rate = nextRebate;
|
||||
}
|
||||
@@ -648,13 +706,9 @@ export function AgentsPlayersPanel({
|
||||
})}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("playersPanel.creditListHint", {
|
||||
defaultValue: "信用占成盘:下列为玩家授信额度与可用信用,非主站钱包余额。",
|
||||
})}
|
||||
</p>
|
||||
<div />
|
||||
)}
|
||||
{canCreatePlayer ? (
|
||||
{canCreatePlayer && !embedded ? (
|
||||
<Button type="button" size="sm" className="shrink-0" onClick={openCreateDialog}>
|
||||
<Plus className="mr-1.5 size-3.5" />
|
||||
{createPlayerLabel}
|
||||
@@ -693,7 +747,7 @@ export function AgentsPlayersPanel({
|
||||
{!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)]">
|
||||
<TableHead className="sticky right-0 z-20 w-14 bg-muted whitespace-nowrap text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
|
||||
{t("common:table.actions", { defaultValue: "操作" })}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
@@ -750,7 +804,7 @@ export function AgentsPlayersPanel({
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{rebate != null ? `${ratioToPercentUi(rebate)}%` : "—"}
|
||||
{rebate != null ? `${percentValueToUi(rebate)}%` : "—"}
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
|
||||
{row.last_login_at ? formatDt(row.last_login_at) : "—"}
|
||||
@@ -763,7 +817,7 @@ export function AgentsPlayersPanel({
|
||||
</TableCell>
|
||||
) : null}
|
||||
<TableCell
|
||||
className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_var(--border)]"
|
||||
className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<AdminRowActionsMenu
|
||||
@@ -844,83 +898,105 @@ export function AgentsPlayersPanel({
|
||||
)}
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogContent className="sm:max-w-[460px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{createPlayerLabel}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
<Label>{t("agents:lineProvision.code", { defaultValue: "代理编码" })}</Label>
|
||||
<Input value={siteCode} readOnly disabled />
|
||||
|
||||
<div className="grid gap-5 py-2">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">{t("playersPanel.siteCode", { defaultValue: "所属线路" })}</Label>
|
||||
<Input value={siteCode} readOnly disabled className="bg-muted/40 text-muted-foreground opacity-100 font-mono" />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-x-4 gap-y-5 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="agent-player-username" className="text-muted-foreground">
|
||||
{t("playersPanel.loginUsername", { defaultValue: "登录账号" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="agent-player-username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
autoComplete="off"
|
||||
className="bg-background/50 transition-colors focus:bg-background"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="agent-player-password" className="text-muted-foreground">
|
||||
{t("playersPanel.initialPassword", { defaultValue: "初始密码" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="agent-player-password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
className="bg-background/50 transition-colors focus:bg-background"
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground/80">
|
||||
{t("playersPanel.passwordHint", { defaultValue: "至少 8 位" })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="agent-player-nickname" className="text-muted-foreground">
|
||||
{t("players:nickname", { defaultValue: "昵称" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="agent-player-nickname"
|
||||
value={nickname}
|
||||
onChange={(e) => setNickname(e.target.value)}
|
||||
className="bg-background/50 transition-colors focus:bg-background"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-x-4 gap-y-5 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="agent-player-credit" className="text-muted-foreground">
|
||||
{t("playersPanel.creditLimit", { defaultValue: "授信额度" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="agent-player-credit"
|
||||
type="number"
|
||||
min={0}
|
||||
value={creditLimit}
|
||||
onChange={(e) => setCreditLimit(e.target.value)}
|
||||
className="bg-background/50 transition-colors focus:bg-background"
|
||||
/>
|
||||
{parentAvailableCredit !== null ? (
|
||||
<p className="text-[11px] text-muted-foreground/80">
|
||||
{t("playersPanel.availableToGrant", {
|
||||
defaultValue: "代理剩余可下发:{{amount}}",
|
||||
amount: formatCredit(parentAvailableCredit),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="agent-player-rebate" className="text-muted-foreground">
|
||||
{t("playersPanel.rebateRate", { defaultValue: "回水比例 (%)" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="agent-player-rebate"
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
step="0.01"
|
||||
value={rebateRate}
|
||||
placeholder="0"
|
||||
onChange={(e) => setRebateRate(e.target.value)}
|
||||
className="bg-background/50 transition-colors focus:bg-background"
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground/80">
|
||||
{t("playersPanel.rebateRateHint", { defaultValue: "填写百分比,如 5 表示 5%" })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="agent-player-username">
|
||||
{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">
|
||||
<Label htmlFor="agent-player-nickname">
|
||||
{t("players:nickname", { defaultValue: "昵称" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="agent-player-nickname"
|
||||
value={nickname}
|
||||
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>
|
||||
|
||||
<DialogFooter className="mt-2">
|
||||
<Button type="button" variant="outline" onClick={() => setDialogOpen(false)}>
|
||||
{t("common:actions.cancel", { defaultValue: "取消" })}
|
||||
</Button>
|
||||
@@ -1174,10 +1250,10 @@ export function AgentsPlayersPanel({
|
||||
step="0.01"
|
||||
value={editRebateRate}
|
||||
onChange={(e) => setEditRebateRate(e.target.value)}
|
||||
placeholder="0.5"
|
||||
placeholder="0"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("playersPanel.rebateRateHint", { defaultValue: "填写百分比,如 0.5 表示 0.5%" })}
|
||||
{t("playersPanel.rebateRateHint", { defaultValue: "填写百分比,如 5 表示 5%" })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -1,52 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { Check, ChevronDown, Search } from "lucide-react";
|
||||
import { useDeferredValue, useEffect, useMemo, useState } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import {
|
||||
AdminSubnav,
|
||||
AdminSubnavBar,
|
||||
AdminSubnavLink,
|
||||
} from "@/components/admin/admin-subnav";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { useAdminSiteCodeOptions } from "@/hooks/use-admin-site-code-options";
|
||||
import { isAgentLineSubnavTabVisible } from "@/modules/agents/agent-line-subnav-visibility";
|
||||
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";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { useAdminProfile } from "@/stores/admin-session";
|
||||
import { useAgentManagementSiteStore } from "@/stores/agent-management-site";
|
||||
|
||||
const primaryTabs: {
|
||||
href: string;
|
||||
labelKey: string;
|
||||
matchPrefix: string;
|
||||
}[] = [
|
||||
{
|
||||
href: "/admin/agents",
|
||||
labelKey: "subnav.operations",
|
||||
matchPrefix: "/admin/agents",
|
||||
},
|
||||
];
|
||||
|
||||
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"))
|
||||
);
|
||||
}
|
||||
|
||||
return pathname === href || pathname.startsWith(`${matchPrefix}/`) || pathname === matchPrefix;
|
||||
}
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function AgentsSubnav(): React.ReactElement {
|
||||
const { t } = useTranslation("agents");
|
||||
@@ -55,18 +26,14 @@ export function AgentsSubnav(): React.ReactElement {
|
||||
const { sites: siteOptions } = useAdminSiteCodeOptions();
|
||||
const adminSiteId = useAgentManagementSiteStore((s) => s.adminSiteId);
|
||||
const setAdminSiteId = useAgentManagementSiteStore((s) => s.setAdminSiteId);
|
||||
const [sitePickerOpen, setSitePickerOpen] = useState(false);
|
||||
const [siteKeyword, setSiteKeyword] = useState("");
|
||||
const deferredKeyword = useDeferredValue(siteKeyword);
|
||||
|
||||
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],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (adminSiteId !== null || siteOptions.length === 0) {
|
||||
return;
|
||||
@@ -80,58 +47,94 @@ export function AgentsSubnav(): React.ReactElement {
|
||||
}, [adminSiteId, profile?.agent?.admin_site_id, setAdminSiteId, siteOptions]);
|
||||
|
||||
const selectSiteId = adminSiteId ?? siteOptions[0]?.id ?? null;
|
||||
const selectedSiteLabel = useMemo(() => {
|
||||
const selectedSite = useMemo(() => {
|
||||
const site = siteOptions.find((item) => item.id === selectSiteId);
|
||||
return site ? `${site.name} (${site.code})` : null;
|
||||
return site ?? null;
|
||||
}, [selectSiteId, siteOptions]);
|
||||
|
||||
if (visiblePrimaryTabs.length === 0 && !showProvision) {
|
||||
return <></>;
|
||||
}
|
||||
const filteredSites = useMemo(() => {
|
||||
const normalized = deferredKeyword.trim().toLowerCase();
|
||||
if (normalized === "") {
|
||||
return siteOptions;
|
||||
}
|
||||
|
||||
return siteOptions.filter((site) => site.name.toLowerCase().includes(normalized));
|
||||
}, [deferredKeyword, siteOptions]);
|
||||
|
||||
const siteSelector =
|
||||
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>
|
||||
pathname !== "/admin/agents/list" && canSwitchSite && siteOptions.length > 0 && selectSiteId !== null ? (
|
||||
<Popover open={sitePickerOpen} onOpenChange={setSitePickerOpen}>
|
||||
<PopoverTrigger
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "lg" }),
|
||||
"h-10 w-[240px] justify-between gap-2 bg-background px-3 text-left font-normal",
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{selectedSite?.name ?? t("lineFilter", { defaultValue: "一级代理" })}
|
||||
</span>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{selectedSite?.code ?? ""}
|
||||
</span>
|
||||
<ChevronDown className="size-4 shrink-0 text-muted-foreground" />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-[320px] p-0">
|
||||
<div className="border-b border-border/60 p-3">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={siteKeyword}
|
||||
onChange={(event) => setSiteKeyword(event.target.value)}
|
||||
placeholder={t("siteSearch", { defaultValue: "搜索站点名称" })}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ScrollArea className="max-h-72">
|
||||
<div className="p-2">
|
||||
{filteredSites.map((site) => {
|
||||
const active = site.id === selectSiteId;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={site.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex w-full items-center gap-3 rounded-md px-3 py-2 text-left transition-colors",
|
||||
active ? "bg-primary/10 text-primary" : "hover:bg-muted/70",
|
||||
)}
|
||||
onClick={() => {
|
||||
setAdminSiteId(site.id);
|
||||
setSitePickerOpen(false);
|
||||
setSiteKeyword("");
|
||||
}}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium text-foreground">{site.name}</div>
|
||||
<div className="truncate text-xs text-muted-foreground">{site.code}</div>
|
||||
</div>
|
||||
{active ? <Check className="size-4 shrink-0" /> : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{filteredSites.length === 0 ? (
|
||||
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
|
||||
{t("common:states.empty", { defaultValue: "暂无数据" })}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<AdminSubnavBar trailing={siteSelector}>
|
||||
<AdminSubnav aria-label={t("subnav.label", { defaultValue: "代理管理导航" })}>
|
||||
{visiblePrimaryTabs.map((tab) => (
|
||||
<AdminSubnavLink
|
||||
key={tab.href}
|
||||
href={tab.href}
|
||||
active={isTabActive(pathname, tab.href, tab.matchPrefix)}
|
||||
>
|
||||
{t(tab.labelKey)}
|
||||
</AdminSubnavLink>
|
||||
))}
|
||||
|
||||
{showProvision ? (
|
||||
<AdminSubnavLink
|
||||
href={provisionTab.href}
|
||||
active={isTabActive(pathname, provisionTab.href, provisionTab.matchPrefix)}
|
||||
>
|
||||
{t(provisionTab.labelKey)}
|
||||
</AdminSubnavLink>
|
||||
) : null}
|
||||
</AdminSubnav>
|
||||
<div className="pb-1">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{t("title", { defaultValue: "代理管理" })}
|
||||
</p>
|
||||
</div>
|
||||
</AdminSubnavBar>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user