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:
180
src/components/admin/admin-numeric-stepper.tsx
Normal file
180
src/components/admin/admin-numeric-stepper.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user