feat(agents, validation): enhance agent profile and player management with input validation

Added input validation for admin login and player creation forms, ensuring usernames and passwords meet specified criteria. Introduced new components for numeric input handling in agent profile fields, improving user experience. Updated agent line detail and provision wizard to reflect these changes, enhancing overall data integrity and user interaction.
This commit is contained in:
2026-06-14 21:13:21 +08:00
parent 6ea0a6feec
commit 4fe206cb10
14 changed files with 1299 additions and 504 deletions

View File

@@ -1,7 +1,6 @@
"use client";
import type { ComponentType } from "react";
import { ChevronRight, Network, Pencil, Plus, Trash2, Users } from "lucide-react";
import { Pencil, Plus, Trash2, Network } from "lucide-react";
import { useTranslation } from "react-i18next";
import { AdminSubnav, AdminSubnavButton } from "@/components/admin/admin-subnav";
@@ -22,9 +21,10 @@ import { AgentProfileFields, type AgentProfileFieldsProps } from "@/modules/agen
import { formatCredit } from "@/modules/agents/agent-line-sidebar";
import { Button } from "@/components/ui/button";
import { percentValueToUi } from "@/lib/admin-rate-percent";
import { isLineRootAgentNode } from "@/lib/agent-profile-caps";
import { resolveRoleStatusTone } from "@/lib/admin-status-tone";
import { cn } from "@/lib/utils";
import type { AgentNodeProfileSummary, AgentNodeRow, AgentProfileRow } from "@/types/api/admin-agent";
import type { AgentNodeRow, AgentProfileRow } from "@/types/api/admin-agent";
function relativeShareRate(totalShareRate: number | undefined, parentShareRate: number | undefined): string | null {
if (
@@ -254,12 +254,6 @@ export function AgentLineDetailPanel({
profile={profile}
profileLoading={profileLoading}
profileReadOnly={profileReadOnly}
canViewDownlineTab={canViewDownlineTab}
canViewPlayersTab={canViewPlayersTab}
playersTabHint={playersTabHint}
childCount={childAgents.length}
onGoToDownline={() => onDetailTabChange("downline")}
onGoToPlayers={() => onDetailTabChange("players")}
/>
) : null}
@@ -273,9 +267,14 @@ export function AgentLineDetailPanel({
</CardTitle>
<p className="text-sm font-normal text-muted-foreground">
{profileReadOnly
? t("lineUi.profileReadOnlyHint", {
defaultValue: "占成、授信与回水由上级配置,如需调整请联系上级代理或平台。",
})
? isLineRootAgentNode(node)
? t("lineUi.lineRootProfileReadOnlyHint", {
defaultValue:
"一级代理的占成、站点授信总额与回水由平台超管配置;您可向直属下级代理与玩家下放额度。",
})
: t("lineUi.profileReadOnlyHint", {
defaultValue: "占成、授信与回水由上级配置,如需调整请联系上级代理或平台。",
})
: t("lineUi.profileTabHint", {
defaultValue:
"占成、授信、回水与风控标签在此维护;登录名、密码与启停状态请用「编辑代理」。",
@@ -321,7 +320,7 @@ export function AgentLineDetailPanel({
<AgentsPlayersPanel
siteCode={siteCode}
agentNodeId={node.id}
allowCreatePlayer={profile?.can_create_player === true}
allowCreatePlayer={canCreatePlayerAction}
embedded
createRequestKey={playerCreateRequestKey}
/>
@@ -335,22 +334,10 @@ function OverviewTab({
profile,
profileLoading,
profileReadOnly,
canViewDownlineTab,
canViewPlayersTab,
playersTabHint,
childCount,
onGoToDownline,
onGoToPlayers,
}: {
profile: AgentProfileRow | null;
profileLoading: boolean;
profileReadOnly: boolean;
canViewDownlineTab: boolean;
canViewPlayersTab: boolean;
playersTabHint?: string | null;
childCount: number;
onGoToDownline: () => void;
onGoToPlayers: () => void;
}): React.ReactElement {
const { t } = useTranslation(["agents", "common"]);
@@ -361,197 +348,119 @@ function OverviewTab({
profile?.parent_caps?.total_share_rate,
);
const yesLabel = t("common:states.yes", { defaultValue: "是" });
const noLabel = t("common:states.no", { defaultValue: "否" });
return (
<div className="mx-auto max-w-5xl space-y-6">
{profileReadOnly ? (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<MetricCard
label={t("profile.creditLimit", { defaultValue: "授信额度" })}
value={profileLoading ? "…" : formatCredit(profile?.credit_limit ?? 0)}
/>
<MetricCard
label={t("lineUi.allocatedCredit", { defaultValue: "已下发" })}
value={profileLoading ? "…" : formatCredit(profile?.allocated_credit ?? 0)}
/>
<MetricCard
label={t("lineUi.availableCredit", { defaultValue: "可下发" })}
value={profileLoading ? "…" : formatCredit(profile?.available_credit ?? 0)}
highlight
/>
</div>
) : (
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<MetricCard
label={t("profile.totalShareRate", { defaultValue: "占成比例" })}
value={profileLoading ? "…" : `${profile?.total_share_rate ?? 0}%`}
subtitle={
parentRelativeShare
? t("profile.relativeShareRateValue", {
defaultValue: "占上级 {{rate}}%",
rate: parentRelativeShare,
})
: rebateCap !== null
? t("lineUi.shareRebateCap", {
defaultValue: "回水上限 {{rate}}%",
rate: rebateCap,
})
: undefined
}
accent
/>
<MetricCard
label={t("profile.creditLimit", { defaultValue: "授信额度" })}
value={profileLoading ? "…" : formatCredit(profile?.credit_limit ?? 0)}
/>
<MetricCard
label={t("lineUi.allocatedCredit", { defaultValue: "已下发" })}
value={profileLoading ? "…" : formatCredit(profile?.allocated_credit ?? 0)}
/>
<MetricCard
label={t("lineUi.availableCredit", { defaultValue: "可下发" })}
value={profileLoading ? "…" : formatCredit(profile?.available_credit ?? 0)}
highlight
/>
</div>
)}
{!profileReadOnly && !profileLoading && profile ? (
<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 ? (
<p className="rounded-lg border border-border/60 bg-card px-4 py-3 text-sm text-muted-foreground">
{t("lineUi.selfAgentOverviewHint", {
defaultValue:
"以下为上级为您分配的授信额度,占成与回水由上级在后台维护,本账号不可查看或修改。",
"以下为上级为您分配的占成与授信;如需调整请联系上级代理或平台。",
})}
</p>
) : null}
{canViewDownlineTab || canViewPlayersTab || playersTabHint ? (
<div className="grid gap-4 md:grid-cols-2">
{canViewDownlineTab ? (
<OverviewLinkCard
icon={Network}
title={t("lineUi.tabDownline", { defaultValue: "直属下级" })}
summary={t("lineUi.overviewDownlineCount", {
defaultValue: "{{count}} 个",
count: childCount,
})}
description={t("lineUi.overviewDownlineHint", {
defaultValue: "直属下级 {{count}} 个,可在对应 Tab 管理下级代理。",
count: childCount,
})}
actionLabel={t("lineUi.viewDownline", { defaultValue: "查看直属下级" })}
onAction={onGoToDownline}
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<MetricCard
label={t("profile.totalShareRate", { defaultValue: "占成比例" })}
value={profileLoading ? "…" : `${profile?.total_share_rate ?? 0}%`}
subtitle={
parentRelativeShare
? t("profile.relativeShareRateValue", {
defaultValue: "占上级 {{rate}}%",
rate: parentRelativeShare,
})
: undefined
}
accent
/>
<MetricCard
label={t("profile.creditLimit", { defaultValue: "授信额度" })}
value={profileLoading ? "…" : formatCredit(profile?.credit_limit ?? 0)}
/>
<MetricCard
label={t("lineUi.allocatedCredit", { defaultValue: "已下发" })}
value={profileLoading ? "…" : formatCredit(profile?.allocated_credit ?? 0)}
/>
<MetricCard
label={t("lineUi.availableCredit", { defaultValue: "可下发" })}
value={profileLoading ? "…" : formatCredit(profile?.available_credit ?? 0)}
highlight
/>
</div>
{!profileLoading && profile ? (
<>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<MetricCard
label={t("profile.rebateLimit", { defaultValue: "回水上限 (%)" })}
value={`${rebateCap ?? "0"}%`}
/>
) : null}
{canViewPlayersTab ? (
<OverviewLinkCard
icon={Users}
title={t("lineUi.tabPlayers", { defaultValue: "直属玩家" })}
summary={t("lineUi.overviewPlayersSummary", {
defaultValue: "玩家管理",
})}
description={t("lineUi.overviewPlayersHint", {
defaultValue: "直属玩家请在「直属玩家」Tab 维护。",
})}
actionLabel={t("lineUi.viewPlayers", { defaultValue: "查看直属玩家" })}
onAction={onGoToPlayers}
<MetricCard
label={t("profile.defaultPlayerRebate", { defaultValue: "默认玩家回水 (%)" })}
value={`${percentValueToUi(profile.default_player_rebate ?? 0)}%`}
/>
) : 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>
<MetricCard
label={t("profile.riskTags", { defaultValue: "风控标签" })}
value={
(profile.risk_tags?.length ?? 0) > 0
? profile.risk_tags!.join(", ")
: t("common:states.none", { defaultValue: "无" })
}
/>
</div>
<div className="grid gap-3 sm:grid-cols-3">
<CapabilityMetric
label={t("profile.canGrantExtraRebate", { defaultValue: "允许额外回水" })}
enabled={profile.can_grant_extra_rebate === true}
yesLabel={yesLabel}
noLabel={noLabel}
/>
<CapabilityMetric
label={t("profile.canCreatePlayer", { defaultValue: "允许创建玩家" })}
enabled={profile.can_create_player !== false}
yesLabel={yesLabel}
noLabel={noLabel}
/>
<CapabilityMetric
label={t("profile.canCreateChildAgent", { defaultValue: "允许创建下级代理" })}
enabled={profile.can_create_child_agent === true}
yesLabel={yesLabel}
noLabel={noLabel}
/>
</div>
</>
) : null}
</div>
);
}
function OverviewLinkCard({
icon: Icon,
title,
summary,
description,
actionLabel,
onAction,
function CapabilityMetric({
label,
enabled,
yesLabel,
noLabel,
}: {
icon: ComponentType<{ className?: string }>;
title: string;
summary: string;
description: string;
actionLabel: string;
onAction: () => void;
label: string;
enabled: boolean;
yesLabel: string;
noLabel: string;
}): React.ReactElement {
return (
<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>
</CardContent>
</Card>
<div className="rounded-xl border border-border/70 bg-card px-4 py-4 shadow-sm">
<p className="text-xs font-medium text-muted-foreground">{label}</p>
<p
className={cn(
"mt-1.5 text-lg font-semibold",
enabled ? "text-foreground" : "text-muted-foreground",
)}
>
{enabled ? yesLabel : noLabel}
</p>
</div>
);
}