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

@@ -0,0 +1,180 @@
"use client";
import { Minus, Plus } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export type AdminNumericStepperProps = {
id?: string;
value: string;
onValueChange: (value: string) => void;
min?: number;
max?: number;
step?: number;
/** 整数模式(授信额度等);默认 false 支持小数(比例 % */
integer?: boolean;
disabled?: boolean;
/** 失焦时超出 max 是否保留原输入(供保存前校验报错),默认 clamp */
preserveOverMaxOnBlur?: boolean;
className?: string;
suffix?: string;
};
function parseNumeric(value: string, integer: boolean): number {
const parsed = integer ? Number.parseInt(value, 10) : Number.parseFloat(value);
return Number.isFinite(parsed) ? parsed : 0;
}
function formatNumeric(value: number, integer: boolean, step: number): string {
if (integer) {
return String(Math.trunc(value));
}
const decimals = step < 1 ? 2 : step < 0.1 ? 3 : 1;
const fixed = value.toFixed(decimals);
return fixed.replace(/\.?0+$/, "") || "0";
}
function clamp(value: number, min?: number, max?: number): number {
let next = value;
if (min !== undefined) {
next = Math.max(min, next);
}
if (max !== undefined) {
next = Math.min(max, next);
}
return next;
}
function isPartialInput(text: string, integer: boolean): boolean {
const trimmed = text.trim();
if (trimmed === "" || trimmed === "-" || trimmed === ".") {
return true;
}
if (!integer && trimmed.endsWith(".")) {
return true;
}
return false;
}
export function AdminNumericStepper({
id,
value,
onValueChange,
min = 0,
max,
step = 1,
integer = false,
disabled = false,
preserveOverMaxOnBlur = false,
className,
suffix,
}: AdminNumericStepperProps): React.ReactElement {
const current = parseNumeric(value, integer);
const atMin = min !== undefined && current <= min;
const atMax = max !== undefined && current >= max;
const outOfRange =
!disabled &&
Number.isFinite(current) &&
((min !== undefined && current < min) || (max !== undefined && current > max));
const applyDelta = (delta: number) => {
const next = clamp(current + delta, min, max);
onValueChange(formatNumeric(next, integer, step));
};
const commitInput = (raw: string) => {
if (isPartialInput(raw, integer)) {
onValueChange(formatNumeric(min ?? 0, integer, step));
return;
}
const parsed = parseNumeric(raw, integer);
if (!Number.isFinite(parsed)) {
onValueChange(formatNumeric(min ?? 0, integer, step));
return;
}
if (
preserveOverMaxOnBlur &&
max !== undefined &&
parsed > max
) {
return;
}
onValueChange(formatNumeric(clamp(parsed, min, max), integer, step));
};
return (
<div
className={cn(
"flex h-10 items-stretch overflow-hidden rounded-md border bg-background/50 shadow-xs",
outOfRange ? "border-destructive/80 ring-1 ring-destructive/25" : "border-input",
disabled && "pointer-events-none opacity-50",
className,
)}
>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="h-auto shrink-0 rounded-none border-r border-input/80 px-3 hover:bg-muted/60"
disabled={disabled || atMin}
aria-label="减少"
onClick={() => applyDelta(-step)}
>
<Minus className="size-4" aria-hidden />
</Button>
<div className="flex min-w-0 flex-1 items-center justify-center gap-1 px-2">
<input
id={id}
type="text"
inputMode={integer ? "numeric" : "decimal"}
value={value}
disabled={disabled}
aria-valuemin={min}
aria-valuemax={max}
aria-invalid={outOfRange || undefined}
className={cn(
"h-full w-full min-w-0 border-0 bg-transparent text-center text-sm font-medium tabular-nums outline-none",
"focus:ring-0",
)}
onBlur={(e) => {
commitInput(e.target.value);
}}
onChange={(e) => {
const next = e.target.value;
if (integer && next !== "" && !/^-?\d*$/.test(next)) {
return;
}
if (!integer && next !== "" && !/^-?\d*\.?\d*$/.test(next)) {
return;
}
onValueChange(next);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.currentTarget.blur();
}
}}
/>
{suffix ? (
<span className="shrink-0 text-sm text-muted-foreground">{suffix}</span>
) : null}
</div>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="h-auto shrink-0 rounded-none border-l border-input/80 px-3 hover:bg-muted/60"
disabled={disabled || atMax}
aria-label="增加"
onClick={() => applyDelta(step)}
>
<Plus className="size-4" aria-hidden />
</Button>
</div>
);
}

View File

@@ -17,6 +17,10 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { getAdminCaptcha, postAdminLogin } from "@/api";
import { verifyStoredAdminSession } from "@/lib/admin-session-verify";
import {
validateAdminLoginAccount,
validateAdminPassword,
} from "@/lib/admin-input-validation";
import { readToken } from "@/stores/admin-token";
import { authModuleMeta } from "@/modules/auth/meta";
import { useAdminSessionStore } from "@/stores/admin-session";
@@ -101,6 +105,24 @@ export function LoginForm() {
return;
}
const accountIssue = validateAdminLoginAccount(account);
if (accountIssue === "invalid_charset") {
toast.error(
t("accountInvalidCharset", {
defaultValue: "登录账号只能使用字母、数字、点(.)、下划线和连字符",
}),
);
return;
}
const passwordIssue = validateAdminPassword(password);
if (passwordIssue === "too_short") {
toast.error(
t("passwordMinLength", { defaultValue: "密码至少需要 8 个字符" }),
);
return;
}
setSubmitting(true);
try {
const result = await postAdminLogin({

View File

@@ -0,0 +1,74 @@
"use client";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { AdminNumericStepper } from "@/components/admin/admin-numeric-stepper";
import { Label } from "@/components/ui/label";
import { isNumericStepperOutOfRange } from "@/lib/agent-profile-caps";
import { formatCredit } from "@/modules/agents/agent-line-sidebar";
export type PlayerCreditLimitFieldProps = {
id: string;
value: string;
onValueChange: (value: string) => void;
parentAvailableCredit: number | null;
/** 编辑已有玩家时传入当前授信,用于计算可上调上限 */
baselineCreditLimit?: number;
};
export function PlayerCreditLimitField({
id,
value,
onValueChange,
parentAvailableCredit,
baselineCreditLimit = 0,
}: PlayerCreditLimitFieldProps): React.ReactElement {
const { t } = useTranslation(["agents"]);
const maxCredit = useMemo(() => {
if (parentAvailableCredit === null) {
return undefined;
}
return baselineCreditLimit + parentAvailableCredit;
}, [baselineCreditLimit, parentAvailableCredit]);
return (
<div className="space-y-2">
<Label htmlFor={id} className="text-muted-foreground">
{t("playersPanel.creditLimit", { defaultValue: "授信额度" })}
</Label>
<AdminNumericStepper
id={id}
value={value}
onValueChange={onValueChange}
min={0}
max={maxCredit}
step={1000}
integer
preserveOverMaxOnBlur
/>
{parentAvailableCredit !== null ? (
<p className="text-[11px] text-muted-foreground/80">
{t("playersPanel.availableToGrant", {
defaultValue: "代理剩余可下发:{{amount}}",
amount: formatCredit(parentAvailableCredit),
})}
</p>
) : null}
{parentAvailableCredit !== null &&
isNumericStepperOutOfRange(value, {
min: 0,
max: maxCredit,
integer: true,
}) ? (
<p className="text-[11px] text-destructive">
{t("playersPanel.creditLimitExceeded", {
defaultValue: "授信额度不能超过当前代理可下发额度",
})}
</p>
) : null}
</div>
);
}