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:
@@ -18,6 +18,17 @@ import type { AgentParentCaps } from "@/types/api/admin-agent";
|
||||
|
||||
import { Info } from "lucide-react";
|
||||
|
||||
import { AdminNumericStepper } from "@/components/admin/admin-numeric-stepper";
|
||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||
import {
|
||||
AGENT_PERCENT_HARD_MAX,
|
||||
actualShareRateFromRelative,
|
||||
isNumericStepperOutOfRange,
|
||||
maxCreditLimitFromParent,
|
||||
maxDefaultRebatePercent,
|
||||
maxRebatePercentFromParent,
|
||||
} from "@/lib/agent-profile-caps";
|
||||
|
||||
export type AgentProfileFieldsProps = {
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
@@ -43,6 +54,12 @@ export type AgentProfileFieldsProps = {
|
||||
onRiskTagsChange: (value: string) => void;
|
||||
idPrefix?: string;
|
||||
currencyCode?: string;
|
||||
/** 打开表单时的授信 baseline,用于计算相对上级的上调空间 */
|
||||
baselineCreditLimit?: number;
|
||||
/** 已下发额度下限(编辑时不可低于 allocated) */
|
||||
minCreditLimit?: number;
|
||||
/** 一级代理占成/授信/回水仅超管可改(false 时整表只读) */
|
||||
profileScalarsEditable?: boolean;
|
||||
/** card:用于代理线路详情 Tab 内的卡片表单 */
|
||||
variant?: "default" | "card";
|
||||
};
|
||||
@@ -72,12 +89,22 @@ export function AgentProfileFields({
|
||||
onRiskTagsChange,
|
||||
idPrefix = "agent-profile",
|
||||
currencyCode = "NPR",
|
||||
baselineCreditLimit = 0,
|
||||
minCreditLimit = 0,
|
||||
profileScalarsEditable = true,
|
||||
variant = "default",
|
||||
}: AgentProfileFieldsProps): React.ReactElement {
|
||||
const { t } = useTranslation(["agents", "common"]);
|
||||
const fieldDisabled = disabled || loading;
|
||||
const showReadOnlyDisplay = !loading && (!profileScalarsEditable || fieldDisabled);
|
||||
const isCard = variant === "card";
|
||||
|
||||
const maxSharePercent = AGENT_PERCENT_HARD_MAX;
|
||||
const maxRebatePercent = maxRebatePercentFromParent(parentCaps);
|
||||
const maxDefaultRebate = maxDefaultRebatePercent(rebateLimit, parentCaps);
|
||||
const maxCreditLimit = maxCreditLimitFromParent(parentCaps, baselineCreditLimit);
|
||||
const actualShare = actualShareRateFromRelative(Number.parseFloat(shareRate) || 0, parentCaps);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{(parentCaps || availableCredit !== null) && !loading ? (
|
||||
@@ -113,122 +140,243 @@ export function AgentProfileFields({
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"grid gap-x-6 gap-y-5 sm:grid-cols-2",
|
||||
fieldDisabled ? "pointer-events-none opacity-50" : "",
|
||||
)}
|
||||
>
|
||||
{!profileScalarsEditable && !loading ? (
|
||||
<p className="rounded-lg border border-amber-200/80 bg-amber-50 px-3 py-2.5 text-sm text-amber-950 dark:border-amber-900/50 dark:bg-amber-950/40 dark:text-amber-100">
|
||||
{t("profile.lineRootScalarsReadOnlyHint", {
|
||||
defaultValue:
|
||||
"一级代理的占成、站点授信总额与回水由平台超管配置;如需调整请联系平台管理员。",
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-x-6 gap-y-5 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${idPrefix}-share-rate`} className="text-muted-foreground">
|
||||
<Label
|
||||
htmlFor={`${idPrefix}-share-rate`}
|
||||
className={showReadOnlyDisplay ? "text-foreground/85" : "text-muted-foreground"}
|
||||
>
|
||||
{parentCaps
|
||||
? t("profile.relativeShareRate", { defaultValue: "占成比例(占上级 %)" })
|
||||
: t("profile.totalShareRate", { defaultValue: "占成比例 (%)" })}
|
||||
</Label>
|
||||
<Input
|
||||
id={`${idPrefix}-share-rate`}
|
||||
type="number"
|
||||
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 ? (
|
||||
{showReadOnlyDisplay ? (
|
||||
<ReadOnlyScalar id={`${idPrefix}-share-rate`} value={shareRate} suffix="%" />
|
||||
) : (
|
||||
<AdminNumericStepper
|
||||
id={`${idPrefix}-share-rate`}
|
||||
value={shareRate}
|
||||
onValueChange={onShareRateChange}
|
||||
min={0}
|
||||
max={maxSharePercent}
|
||||
step={0.5}
|
||||
suffix="%"
|
||||
preserveOverMaxOnBlur
|
||||
/>
|
||||
)}
|
||||
{parentCaps ? (
|
||||
<p className="text-xs text-muted-foreground/80">
|
||||
{t("profile.relativeShareCapHint", {
|
||||
defaultValue: "占上级比例最高 100%(上级总占成 {{parent}}%)",
|
||||
parent: parentCaps.total_share_rate,
|
||||
})}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground/80">
|
||||
{t("profile.totalShareCapHint", { defaultValue: "占成比例最高 100%" })}
|
||||
</p>
|
||||
)}
|
||||
{parentCaps && shareRate && actualShare !== null ? (
|
||||
<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)),
|
||||
rate: actualShare,
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${idPrefix}-credit-limit`} className="text-muted-foreground">
|
||||
<Label
|
||||
htmlFor={`${idPrefix}-credit-limit`}
|
||||
className={showReadOnlyDisplay ? "text-foreground/85" : "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)}
|
||||
/>
|
||||
{showReadOnlyDisplay ? (
|
||||
<ReadOnlyScalar id={`${idPrefix}-credit-limit`} value={creditLimit} />
|
||||
) : (
|
||||
<AdminNumericStepper
|
||||
id={`${idPrefix}-credit-limit`}
|
||||
value={creditLimit}
|
||||
onValueChange={onCreditLimitChange}
|
||||
min={minCreditLimit}
|
||||
max={maxCreditLimit}
|
||||
step={1000}
|
||||
integer
|
||||
preserveOverMaxOnBlur
|
||||
/>
|
||||
)}
|
||||
{parentCaps && maxCreditLimit !== undefined && profileScalarsEditable ? (
|
||||
<p className="text-xs text-muted-foreground/80">
|
||||
{t("profile.creditParentCapHint", {
|
||||
defaultValue: "最高 {{max}}(上级可再下发 {{available}})",
|
||||
max: formatAdminCreditMajorDecimal(maxCreditLimit, currencyCode),
|
||||
available: formatAdminCreditMajorDecimal(parentCaps.available_credit, currencyCode),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
{profileScalarsEditable &&
|
||||
isNumericStepperOutOfRange(creditLimit, {
|
||||
min: minCreditLimit,
|
||||
max: maxCreditLimit,
|
||||
integer: true,
|
||||
}) ? (
|
||||
<p className="text-xs text-destructive">
|
||||
{t("profile.validation.creditExceedsParentWithMax", {
|
||||
defaultValue: "授信额度不能超过 {{max}}",
|
||||
max:
|
||||
maxCreditLimit !== undefined
|
||||
? formatAdminCreditMajorDecimal(maxCreditLimit, currencyCode)
|
||||
: creditLimit,
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
{minCreditLimit > 0 ? (
|
||||
<p className="text-xs text-muted-foreground/80">
|
||||
{t("profile.creditAllocatedFloorHint", {
|
||||
defaultValue: "不可低于已下发 {{amount}}",
|
||||
amount: formatAdminCreditMajorDecimal(minCreditLimit, currencyCode),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${idPrefix}-rebate-limit`} className="text-muted-foreground">
|
||||
<Label
|
||||
htmlFor={`${idPrefix}-rebate-limit`}
|
||||
className={showReadOnlyDisplay ? "text-foreground/85" : "text-muted-foreground"}
|
||||
>
|
||||
{t("profile.rebateLimit", { defaultValue: "回水上限 (%)" })}
|
||||
</Label>
|
||||
<Input
|
||||
id={`${idPrefix}-rebate-limit`}
|
||||
type="number"
|
||||
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="50"
|
||||
/>
|
||||
{showReadOnlyDisplay ? (
|
||||
<ReadOnlyScalar id={`${idPrefix}-rebate-limit`} value={rebateLimit} suffix="%" />
|
||||
) : (
|
||||
<AdminNumericStepper
|
||||
id={`${idPrefix}-rebate-limit`}
|
||||
value={rebateLimit}
|
||||
onValueChange={onRebateLimitChange}
|
||||
min={0}
|
||||
max={maxRebatePercent}
|
||||
step={0.5}
|
||||
suffix="%"
|
||||
preserveOverMaxOnBlur
|
||||
/>
|
||||
)}
|
||||
{parentCaps ? (
|
||||
<p className="text-xs text-muted-foreground/80">
|
||||
{t("profile.rebateParentCapHint", {
|
||||
defaultValue: "不得超过上级回水上限 {{max}}%",
|
||||
max: maxRebatePercent,
|
||||
})}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground/80">
|
||||
{t("profile.rebateHardCapHint", { defaultValue: "回水上限最高 100%" })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${idPrefix}-default-rebate`} className="text-muted-foreground">
|
||||
<Label
|
||||
htmlFor={`${idPrefix}-default-rebate`}
|
||||
className={showReadOnlyDisplay ? "text-foreground/85" : "text-muted-foreground"}
|
||||
>
|
||||
{t("profile.defaultPlayerRebate", { defaultValue: "默认玩家回水 (%)" })}
|
||||
</Label>
|
||||
<Input
|
||||
id={`${idPrefix}-default-rebate`}
|
||||
type="number"
|
||||
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="50"
|
||||
/>
|
||||
{showReadOnlyDisplay ? (
|
||||
<ReadOnlyScalar id={`${idPrefix}-default-rebate`} value={defaultRebate} suffix="%" />
|
||||
) : (
|
||||
<AdminNumericStepper
|
||||
id={`${idPrefix}-default-rebate`}
|
||||
value={defaultRebate}
|
||||
onValueChange={onDefaultRebateChange}
|
||||
min={0}
|
||||
max={maxDefaultRebate}
|
||||
step={0.5}
|
||||
suffix="%"
|
||||
preserveOverMaxOnBlur
|
||||
/>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground/80">
|
||||
{t("profile.defaultRebateCapHint", {
|
||||
defaultValue: "不得超过本节点回水上限 {{max}}%",
|
||||
max: maxDefaultRebate,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2 sm:col-span-2">
|
||||
<Label htmlFor={`${idPrefix}-risk-tags`} className="text-muted-foreground">
|
||||
<Label
|
||||
htmlFor={`${idPrefix}-risk-tags`}
|
||||
className={showReadOnlyDisplay ? "text-foreground/85" : "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", {
|
||||
defaultValue: "逗号分隔,如 overdue, high_turnover",
|
||||
})}
|
||||
/>
|
||||
{showReadOnlyDisplay ? (
|
||||
<ReadOnlyScalar
|
||||
id={`${idPrefix}-risk-tags`}
|
||||
value={riskTags.trim() === "" ? "—" : riskTags}
|
||||
className="justify-start font-normal"
|
||||
/>
|
||||
) : (
|
||||
<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", {
|
||||
defaultValue: "逗号分隔,如 overdue, high_turnover",
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"pt-2",
|
||||
fieldDisabled ? "pointer-events-none opacity-50" : "",
|
||||
)}
|
||||
>
|
||||
<div className="rounded-xl border border-border/70 bg-card overflow-hidden shadow-sm">
|
||||
<SwitchRow
|
||||
checked={extraRebate}
|
||||
onCheckedChange={onExtraRebateChange}
|
||||
label={t("profile.canGrantExtraRebate", { defaultValue: "允许额外回水" })}
|
||||
/>
|
||||
<SwitchRow
|
||||
checked={canCreatePlayer}
|
||||
onCheckedChange={onCanCreatePlayerChange}
|
||||
label={t("profile.canCreatePlayer", { defaultValue: "允许创建玩家" })}
|
||||
/>
|
||||
<SwitchRow
|
||||
checked={canCreateChild}
|
||||
onCheckedChange={onCanCreateChildChange}
|
||||
disabled={!canCreateChildAgent && !isSuperAdmin}
|
||||
label={t("profile.canCreateChildAgent", { defaultValue: "允许创建下级代理" })}
|
||||
isLast
|
||||
/>
|
||||
<div className="pt-2">
|
||||
<div className="rounded-xl border border-border/80 bg-muted/20 overflow-hidden shadow-sm">
|
||||
{showReadOnlyDisplay ? (
|
||||
<>
|
||||
<ReadOnlySwitchRow
|
||||
label={t("profile.canGrantExtraRebate", { defaultValue: "允许额外回水" })}
|
||||
checked={extraRebate}
|
||||
/>
|
||||
<ReadOnlySwitchRow
|
||||
label={t("profile.canCreatePlayer", { defaultValue: "允许创建玩家" })}
|
||||
checked={canCreatePlayer}
|
||||
/>
|
||||
<ReadOnlySwitchRow
|
||||
label={t("profile.canCreateChildAgent", { defaultValue: "允许创建下级代理" })}
|
||||
checked={canCreateChild}
|
||||
isLast
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<SwitchRow
|
||||
checked={extraRebate}
|
||||
onCheckedChange={onExtraRebateChange}
|
||||
label={t("profile.canGrantExtraRebate", { defaultValue: "允许额外回水" })}
|
||||
/>
|
||||
<SwitchRow
|
||||
checked={canCreatePlayer}
|
||||
onCheckedChange={onCanCreatePlayerChange}
|
||||
label={t("profile.canCreatePlayer", { defaultValue: "允许创建玩家" })}
|
||||
/>
|
||||
<SwitchRow
|
||||
checked={canCreateChild}
|
||||
onCheckedChange={onCanCreateChildChange}
|
||||
disabled={!canCreateChildAgent && !isSuperAdmin}
|
||||
label={t("profile.canCreateChildAgent", { defaultValue: "允许创建下级代理" })}
|
||||
isLast
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{!isCard ? (
|
||||
<p className="mt-3 px-1 text-xs text-muted-foreground/80">
|
||||
@@ -243,6 +391,61 @@ export function AgentProfileFields({
|
||||
);
|
||||
}
|
||||
|
||||
function ReadOnlyScalar({
|
||||
id,
|
||||
value,
|
||||
suffix,
|
||||
className,
|
||||
}: {
|
||||
id?: string;
|
||||
value: string;
|
||||
suffix?: string;
|
||||
className?: string;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
className={cn(
|
||||
"flex h-10 items-center justify-center rounded-md border border-border/80 bg-muted/35 px-3 text-sm font-semibold tabular-nums text-foreground shadow-xs",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span>
|
||||
{value}
|
||||
{suffix ? <span className="ml-0.5 font-medium text-foreground/80">{suffix}</span> : null}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReadOnlySwitchRow({
|
||||
label,
|
||||
checked,
|
||||
isLast = false,
|
||||
}: {
|
||||
label: string;
|
||||
checked: boolean;
|
||||
isLast?: boolean;
|
||||
}): React.ReactElement {
|
||||
const { t } = useTranslation(["agents", "common"]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between gap-4 bg-background/60 px-4 py-3.5",
|
||||
!isLast && "border-b border-border/60",
|
||||
)}
|
||||
>
|
||||
<span className="font-medium text-foreground">{label}</span>
|
||||
<AdminStatusBadge tone={checked ? "success" : "neutral"}>
|
||||
{checked
|
||||
? t("common:status.enabled", { defaultValue: "已开启" })
|
||||
: t("common:status.disabled", { defaultValue: "已关闭" })}
|
||||
</AdminStatusBadge>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SwitchRow({
|
||||
checked,
|
||||
onCheckedChange,
|
||||
@@ -261,7 +464,12 @@ function SwitchRow({
|
||||
"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>
|
||||
<Label
|
||||
className={cn("font-medium", disabled ? "cursor-default text-muted-foreground" : "cursor-pointer")}
|
||||
onClick={() => !disabled && onCheckedChange(!checked)}
|
||||
>
|
||||
{label}
|
||||
</Label>
|
||||
<Switch checked={checked} onCheckedChange={onCheckedChange} disabled={disabled} />
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user