fix(ui): ограничить форму маршрута без потери конечных точек

This commit is contained in:
Denozordec committed 2026-10-04 16:01:41 +07:00
1 parent 9124d451a5
commit 06850169ba
8 files changed
+135 -51

No files matched your search

+31 -49
View File
@@ -8,6 +8,7 @@ import {
RecursiveRoutesDataGrid,
type RecursiveRouteGroup,
} from "@/components/data-grids/recursive-routes-data-grid"
import { RecursiveGatewayChoices, type GatewayOption } from "@/components/recursive-gateway-choices"
import { FormField, SectionTitle } from "@/components/form-kit"
import { DataPageCard } from "@/components/data-page-card"
import { Frame, FramePanel } from "@/components/reui/frame"
@@ -42,13 +43,6 @@ interface BackendServer {
latency: number | null
}
interface GatewayOption {
id: string
name: string
ip: string
status: "active" | "inactive" | "unknown"
}
const COUNTRY_OPTIONS = [
{ code: "RU", label: "Россия" }, { code: "DE", label: "Германия" },
{ code: "NL", label: "Нидерланды" }, { code: "SG", label: "Сингапур" },
@@ -195,10 +189,15 @@ function RouteSheet({
returnFocus: () => HTMLElement | null
}) {
const [form, setForm] = useState<RouteForm>(initial)
const [endpointPage, setEndpointPage] = useState(0)
const lastEndpointPage = Math.max(0, Math.ceil(form.endpoints.length / 10) - 1)
if (endpointPage > lastEndpointPage) setEndpointPage(lastEndpointPage)
const currentEndpointPage = Math.min(endpointPage, lastEndpointPage)
const endpointOffset = currentEndpointPage * 10
const [error, setError] = useState<string | null>(null)
// eslint-disable-next-line react-hooks/set-state-in-effect
useEffect(() => { setForm(initial); setError(null) }, [initial, open])
useEffect(() => { setForm(initial); setError(null); setEndpointPage(0) }, [initial, open])
const set = <K extends keyof RouteForm>(k: K, v: RouteForm[K]) => setForm(f => ({ ...f, [k]: v }))
const setEp = <K extends keyof RouteEndpointForm>(id: string, k: K, v: RouteEndpointForm[K]) =>
setForm(f => ({ ...f, endpoints: f.endpoints.map(ep => ep.id === id ? { ...ep, [k]: v } : ep) }))
@@ -208,9 +207,11 @@ function RouteSheet({
if (busy || saveLock.current) return
if (!form.dstAddress.trim()) { setError("Dst Address обязателен"); return }
if (form.endpoints.length === 0) { setError("Добавь хотя бы одну конечную точку"); return }
if (form.endpoints.some(ep => !ep.gateway.trim())) { setError("У каждой конечной точки должен быть Gateway"); return }
const emptyGateway = form.endpoints.findIndex(ep => !ep.gateway.trim())
if (emptyGateway >= 0) { setEndpointPage(Math.floor(emptyGateway / 10)); setError(`У каждой конечной точки должен быть Gateway (Endpoint ${emptyGateway + 1})`); return }
if (!form.routingTable.trim()) { setError("Укажите таблицу маршрутизации"); return }
if (form.endpoints.some(ep => [ep.distance, ep.scope, ep.targetScope].some(value => value !== null && (!Number.isInteger(value) || value < 0 || value > 255)))) { setError("Distance, Scope и T.Scope должны быть целыми числами от 0 до 255"); return }
const invalidEndpoint = form.endpoints.findIndex(ep => [ep.distance, ep.scope, ep.targetScope].some(value => value !== null && (!Number.isInteger(value) || value < 0 || value > 255)))
if (invalidEndpoint >= 0) { setEndpointPage(Math.floor(invalidEndpoint / 10)); setError(`Distance, Scope и T.Scope должны быть целыми числами от 0 до 255 (Endpoint ${invalidEndpoint + 1})`); return }
setError(null)
saveLock.current = true
try { await onSave(form) } finally { saveLock.current = false }
@@ -238,18 +239,31 @@ function RouteSheet({
<div className="flex flex-col gap-4">
<SectionTitle>Конечные точки</SectionTitle>
{form.endpoints.length > 10 && <div className="flex flex-wrap items-center gap-2">
<Input key={currentEndpointPage} type="number" min={1} max={form.endpoints.length} defaultValue={endpointOffset + 1} aria-label="Перейти к endpoint" aria-describedby="recursive-endpoint-jump-hint" className="w-24 min-h-11" onKeyDown={event => {
if (event.key !== "Enter") return
event.preventDefault()
const number = Number(event.currentTarget.value)
if (Number.isInteger(number) && number >= 1 && number <= form.endpoints.length) { setEndpointPage(Math.floor((number - 1) / 10)); setError(null) }
else setError("Укажите номер endpoint от 1 до " + form.endpoints.length)
}} />
<span id="recursive-endpoint-jump-hint" className="text-xs text-muted-foreground">Номер endpoint → Enter</span>
<span role="status" className="text-xs text-muted-foreground">Endpoints {endpointOffset + 1}–{Math.min(endpointOffset + 10, form.endpoints.length)} из {form.endpoints.length}</span>
<Button type="button" variant="outline" className="min-h-11" aria-label="Предыдущие endpoints формы" disabled={currentEndpointPage === 0} onClick={() => setEndpointPage(currentEndpointPage - 1)}>Назад</Button>
<Button type="button" variant="outline" className="min-h-11" aria-label="Следующие endpoints формы" disabled={currentEndpointPage === lastEndpointPage} onClick={() => setEndpointPage(currentEndpointPage + 1)}>Далее</Button>
</div>}
<div className="flex flex-col gap-3">
{form.endpoints.map((ep, idx) => (
<div key={ep.id} className="rounded-lg border border-border bg-muted/20 p-3 flex flex-col gap-3">
{form.endpoints.slice(endpointOffset, endpointOffset + 10).map((ep, idx) => (
<div key={ep.id} data-recursive-endpoint-form={ep.id} className="rounded-lg border border-border bg-muted/20 p-3 flex flex-col gap-3">
<div className="flex items-center justify-between">
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
Endpoint {idx + 1}{ep.disabled ? " · отключён" : ""}
Endpoint {endpointOffset + idx + 1}{ep.disabled ? " · отключён" : ""}
</span>
<button
type="button"
onClick={() => setForm(f => ({ ...f, endpoints: f.endpoints.filter(x => x.id !== ep.id) }))}
className="text-muted-foreground hover:text-destructive transition-colors"
aria-label={`Удалить конечную точку ${idx + 1}`}
className="size-11 shrink-0 inline-flex items-center justify-center rounded text-muted-foreground hover:text-destructive transition-colors"
aria-label={`Удалить конечную точку ${endpointOffset + idx + 1}`}
disabled={form.endpoints.length <= 1}
>
<TrashIcon aria-hidden="true" className="size-3.5" />
@@ -280,42 +294,10 @@ function RouteSheet({
</FormField>
</div>
<div className="flex flex-col gap-1.5">
{gateways.map((g) => {
const value = `${g.ip}%${g.name}`
const selected = ep.gateway === value
return (
<button
key={`${ep.id}-${g.id}`}
type="button"
onClick={() => setEp(ep.id, "gateway", value)}
className={cn(
"text-left rounded-lg border px-3 py-2.5 transition-all",
selected
? "border-primary bg-primary/5 ring-1 ring-primary/30"
: "border-border hover:border-muted-foreground/40 hover:bg-muted/40",
)}
>
<div className="flex items-center gap-2">
<span className={cn("size-1.5 rounded-full shrink-0", g.status === "active" ? "bg-success" : "bg-muted-foreground")} />
<span className="font-mono text-xs font-semibold flex-1 truncate">{g.name}</span>
<span className="text-xs text-muted-foreground">{g.status === "active" ? "Активный маршрут" : g.status === "inactive" ? "Неактивный маршрут" : "Статус неизвестен"}</span>
{selected && <CheckIcon aria-hidden="true" className="size-3.5 text-primary shrink-0" />}
</div>
<div className="mt-1.5 grid grid-cols-[auto_auto_1fr] items-start gap-2 text-[11px] text-muted-foreground font-mono">
<span className="text-muted-foreground/50 break-all">{g.ip}</span>
<span className="text-muted-foreground/30">→</span>
<span className={cn("min-w-0 break-all whitespace-normal leading-tight", selected ? "text-primary font-medium" : "")}>
{value}
</span>
</div>
</button>
)
})}
</div>
<RecursiveGatewayChoices gateways={gateways} selectedValue={ep.gateway} endpointNumber={endpointOffset + idx + 1} onChange={value => setEp(ep.id, "gateway", value)} />
</div>
))}
<Button type="button" variant="outline" size="sm" className="w-fit gap-1.5" onClick={() => setForm(f => ({ ...f, endpoints: [...f.endpoints, newEndpoint()] }))}>
<Button type="button" variant="outline" size="sm" className="w-fit gap-1.5" onClick={() => { setEndpointPage(Math.floor(form.endpoints.length / 10)); setForm(f => ({ ...f, endpoints: [...f.endpoints, newEndpoint()] })) }}>
<PlusIcon aria-hidden="true" className="size-3.5" />Добавить endpoint
</Button>
{gateways.length === 0 && (