fix(ui): ограничить форму маршрута без потери конечных точек
This commit is contained in:
1 parent
9124d451a5
commit
06850169ba
8 files changed
+135
-51
No files matched your search
@@ -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 && (
|
||||
|
||||
Reference in new issue
Block a user