From b61be0afb83db9da755a21060eb59b33ee130f3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9=20=D0=A1=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D1=8C=D0=B5=D0=B2?= Date: Wed, 3 Jun 2026 09:12:16 +0300 Subject: [PATCH] =?UTF-8?q?pcp-tgu-ops-ui:=20=D0=BC=D0=B0=D1=81=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=B2=D0=B2=D0=BE=D0=B4=D0=B0=20=D0=B2=D1=80=D0=B5?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D0=B8=20=E2=80=94=20=D0=BD=D0=B5=D0=B2=D0=BE?= =?UTF-8?q?=D0=B7=D0=BC=D0=BE=D0=B6=D0=BD=D1=8B=D0=B5=20=D0=B7=D0=BD=D0=B0?= =?UTF-8?q?=D1=87=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BD=D0=B5=D0=B4=D0=BE=D0=BF?= =?UTF-8?q?=D1=83=D1=81=D1=82=D0=B8=D0=BC=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Time24Field принимал любой текст (99:99 и т.п.), валидным он был только при коммите/блюре. Добавлена маска maskTime: на каждый ввод оставляет цифры, расставляет двоеточия и зажимает сегменты (часы 0-23, мин/сек 0-59) с авто- дополнением первого разряда, поэтому набрать невозможное время нельзя. Покрыто юнит-тестами (timeFields.test.ts). --- .../src/components/timeFields.test.ts | 27 ++++++++++++ .../src/components/timeFields.tsx | 43 ++++++++++++++++++- 2 files changed, 69 insertions(+), 1 deletion(-) create mode 100644 services/pcp-tgu-ops-ui/src/components/timeFields.test.ts diff --git a/services/pcp-tgu-ops-ui/src/components/timeFields.test.ts b/services/pcp-tgu-ops-ui/src/components/timeFields.test.ts new file mode 100644 index 0000000..c597537 --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/components/timeFields.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { maskTime } from "./timeFields"; + +describe("maskTime", () => { + it("не пропускает невозможные значения", () => { + expect(maskTime("99:99", false)).toBe("09:09"); // 9>2 → 09; 9>5 → 09 + expect(maskTime("3070", false)).toBe("03:07"); // 3>2 → 03; 7>5 → 07 + expect(maskTime("2599", false)).toBe("23:09"); // 25→23 (зажим), 9>5 → 09 + }); + + it("расставляет двоеточия и ограничивает длину", () => { + expect(maskTime("1234", false)).toBe("12:34"); + expect(maskTime("12", false)).toBe("12"); + expect(maskTime("123456789", false)).toBe("12:34"); + }); + + it("поддерживает секунды", () => { + expect(maskTime("123456", true)).toBe("12:34:56"); + expect(maskTime("235959", true)).toBe("23:59:59"); + expect(maskTime("990000", true)).toBe("09:09:00"); + }); + + it("игнорирует мусор и лишние двоеточия", () => { + expect(maskTime("1a2:b3", false)).toBe("12:3"); + expect(maskTime("", false)).toBe(""); + }); +}); diff --git a/services/pcp-tgu-ops-ui/src/components/timeFields.tsx b/services/pcp-tgu-ops-ui/src/components/timeFields.tsx index 5238cfc..6a561bb 100644 --- a/services/pcp-tgu-ops-ui/src/components/timeFields.tsx +++ b/services/pcp-tgu-ops-ui/src/components/timeFields.tsx @@ -23,6 +23,46 @@ type Time24FieldProps = { const TIME_RE_HM = /^([01]\d|2[0-3]):[0-5]\d$/; const TIME_RE_HMS = /^([01]\d|2[0-3]):[0-5]\d:[0-5]\d$/; +/** + * Забирает из потока цифр один сегмент времени `00..max` (часы → 23, мин/сек → 59). + * Если первая цифра заведомо больше старшего разряда (напр. `9` для часов), сразу + * дополняет нулём (`09`) и переходит к следующему сегменту; двузначное значение + * сверх максимума зажимается. Возвращает [сегмент, остаток цифр]. + */ +function takeSegment(digits: string, max: number): [string, string] { + if (digits.length === 0) return ["", ""]; + const firstMax = Math.floor(max / 10); + if (Number(digits[0]) > firstMax) { + return [`0${digits[0]}`, digits.slice(1)]; + } + let segment = digits.slice(0, 2); + if (segment.length === 2 && Number(segment) > max) { + segment = String(max); + } + return [segment, digits.slice(2)]; +} + +/** + * Маскирует произвольный ввод в `ЧЧ:ММ[:СС]`: оставляет только цифры, расставляет + * двоеточия и зажимает каждый сегмент в допустимый диапазон, поэтому невозможные + * значения (`99:99`, `30:70`) набрать нельзя — они приводятся к корректным. + */ +export function maskTime(input: string, withSeconds: boolean): string { + const digits = input.replace(/\D/g, "").slice(0, withSeconds ? 6 : 4); + const segments: string[] = []; + let [segment, rest] = takeSegment(digits, 23); + segments.push(segment); + if (rest.length > 0) { + [segment, rest] = takeSegment(rest, 59); + segments.push(segment); + } + if (withSeconds && rest.length > 0) { + [segment, rest] = takeSegment(rest, 59); + segments.push(segment); + } + return segments.join(":"); +} + export function Time24Field({ value, withSeconds = false, onChange, className, style, ...rest }: Time24FieldProps) { const re = withSeconds ? TIME_RE_HMS : TIME_RE_HM; const [draft, setDraft] = useState(value); @@ -38,13 +78,14 @@ export function Time24Field({ value, withSeconds = false, onChange, className, s {...rest} type="text" inputMode="numeric" + maxLength={withSeconds ? 8 : 5} className={className} style={style} placeholder={withSeconds ? "чч:мм:сс" : "чч:мм"} value={draft} onFocus={() => setEditing(true)} onChange={(event) => { - const next = event.target.value; + const next = maskTime(event.target.value, withSeconds); setDraft(next); if (re.test(next)) onChange(next); }}