Files
lotteryAdmin/src/components/admin/admin-numeric-stepper.tsx
kang 4fe206cb10 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.
2026-06-14 21:13:21 +08:00

181 lines
5.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}