Refactor Mihomo proxy groups component for improved layout and functionality
Publish telemt-api gateway Docker image / test (push) Successful in 28s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 2m31s

- Replaced the lazy loading mechanism with a compact mode for better user experience and performance.
- Enhanced the layout of proxy groups to support a more flexible display, accommodating both compact and standard views.
- Updated button styles and descriptions for clarity, ensuring a more intuitive interaction with proxy nodes.
- Adjusted the overall structure of the Mihomo page to streamline the presentation of proxy data and improve accessibility.
This commit is contained in:
Denozordec
2026-03-31 17:10:50 +07:00
parent 206fee9ff9
commit 398c776238
2 changed files with 175 additions and 89 deletions
@@ -5,17 +5,17 @@
import { Badge } from '$lib/components/ui/badge/index.js';
import DataQueryState from '$lib/components/fleet/data-query-state.svelte';
import { Skeleton } from '$lib/components/ui/skeleton/index.js';
import { cn } from '$lib/utils.js';
let {
alias,
lazy = false
/** Плотная сетка мелких ячеек — для флота и списков из многих нод. */
compact = false
}: {
alias: string;
/** Не запрашивать /proxies до явного нажатия «Загрузить» / «Обновить». */
lazy?: boolean;
compact?: boolean;
} = $props();
/** Mihomo hub/route/proxies.go: без query `url` URLTest часто даёт delay=0 → HTTP 503. */
const MIHOMO_DELAY_DEFAULT_URL = 'https://www.gstatic.com/generate_204';
let proxiesLoading = $state(false);
@@ -48,11 +48,10 @@
$effect(() => {
const a = alias;
if (!a || lazy) return;
if (!a) return;
void loadProxies();
});
/** Группы, у которых есть `all` + `now` и PUT /proxies/{name} переключает узел (как в Clash/Mihomo). */
function isSelectableGroup(p: MihomoProxyEntry) {
const t = (p.type ?? '').toLowerCase();
if (t === 'selector' || t === 'urltest' || t === 'fallback') return true;
@@ -127,91 +126,178 @@
}
</script>
<div class="mb-4 flex flex-wrap items-center justify-end gap-2">
{#if lazy && !proxiesData && !proxiesLoading}
<Button variant="default" size="sm" onclick={() => loadProxies()}>Загрузить прокси</Button>
{/if}
<Button variant="outline" size="sm" onclick={() => loadProxies()} disabled={proxiesLoading || !alias}>
<div
class={cn(
'flex flex-wrap items-center justify-end gap-1.5',
compact ? 'mb-2' : 'mb-4'
)}
>
<Button
variant="ghost"
size="sm"
class={cn(compact && 'h-7 px-2 text-xs')}
onclick={() => loadProxies()}
disabled={proxiesLoading || !alias}
>
Обновить
</Button>
</div>
<DataQueryState err={proxiesErr} showSkeleton={proxiesLoading} isEmpty={false}>
{#snippet skeleton()}
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{#each Array.from({ length: 6 }) as _, i (i)}
<Skeleton class="h-32 rounded-xl" />
{/each}
</div>
{#if compact}
<div class="flex flex-wrap gap-1">
{#each Array.from({ length: 12 }) as _, i (i)}
<Skeleton class="h-7 w-24 rounded-md" />
{/each}
</div>
{:else}
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{#each Array.from({ length: 6 }) as _, i (i)}
<Skeleton class="h-32 rounded-xl" />
{/each}
</div>
{/if}
{/snippet}
{#snippet children()}
{#if proxiesData?.proxies}
<div class="flex flex-col gap-8">
<div class={cn('flex flex-col', compact ? 'gap-3' : 'gap-8')}>
{#each Object.entries(proxiesData.proxies).filter(([_, v]) => isSelectableGroup(v)) as [gName, group] (gName)}
<div class="space-y-3">
<div class="flex flex-wrap items-center justify-between gap-2">
<div>
<h2 class="text-lg font-semibold">{gName}</h2>
<p class="text-muted-foreground text-sm">
Сейчас: <span class="text-foreground">{group.now ?? '—'}</span>
</p>
</div>
<Button
variant="secondary"
size="sm"
onclick={() => pingGroup(gName)}
disabled={testingGroup === gName}
>
{testingGroup === gName ? 'Проверка…' : 'Проверить все'}
</Button>
</div>
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{#each group.all ?? [] as nodeName (nodeName)}
{@const node = proxiesData.proxies?.[nodeName]}
{@const d = node ? lastDelay(node) : null}
{@const active = group.now === nodeName}
<div
class="bg-card text-card-foreground rounded-xl border p-4 transition-shadow {active
? 'ring-primary ring-2'
: ''}"
>
<button
type="button"
class="w-full text-left"
onclick={() => selectProxy(gName, nodeName)}
disabled={switching === gName}
{#if compact}
<div class="bg-muted/20 rounded-lg border px-2 py-2 sm:px-3">
<div class="mb-1.5 flex flex-wrap items-center justify-between gap-x-2 gap-y-1">
<div class="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-0">
<span class="text-xs font-semibold tracking-wide uppercase text-muted-foreground"
>{gName}</span
>
<div class="flex items-start justify-between gap-2">
<span class="font-medium">{nodeName}</span>
<Badge variant="outline">{node?.type ?? '—'}</Badge>
</div>
<div
class="mt-2 text-sm {d != null && d < 500 ? 'text-emerald-400' : 'text-muted-foreground'}"
>
{d != null ? `${d} ms` : '—'}
</div>
</button>
<div class="mt-2 flex justify-end">
<Button
variant="ghost"
size="sm"
class="h-7 px-2"
onclick={() => pingOne(nodeName)}
disabled={testing === nodeName}
>
Ping
</Button>
</div>
<span class="text-muted-foreground text-[11px] sm:text-xs">
<span class="text-foreground">{group.now ?? '—'}</span>
</span>
</div>
{/each}
<Button
variant="secondary"
size="sm"
class="h-6 shrink-0 px-2 text-[11px]"
onclick={() => pingGroup(gName)}
disabled={testingGroup === gName}
>
{testingGroup === gName ? '…' : 'Все delay'}
</Button>
</div>
<div
class="flex flex-wrap gap-1"
role="group"
aria-label="Узлы группы {gName}"
>
{#each group.all ?? [] as nodeName (nodeName)}
{@const node = proxiesData.proxies?.[nodeName]}
{@const d = node ? lastDelay(node) : null}
{@const active = group.now === nodeName}
<div
class={cn(
'bg-background/80 flex min-w-0 max-w-full items-stretch rounded border text-left text-xs shadow-sm',
active ? 'border-primary ring-primary/30 ring-1' : 'border-border'
)}
>
<button
type="button"
class="hover:bg-muted/60 min-w-0 flex-1 px-2 py-1 text-left transition-colors disabled:opacity-50"
onclick={() => selectProxy(gName, nodeName)}
disabled={switching === gName}
title="Выбрать {nodeName}"
>
<div class="truncate font-medium leading-tight">{nodeName}</div>
<div
class="text-muted-foreground mt-0.5 flex items-center justify-between gap-1 text-[10px] leading-none"
>
<span class="truncate">{node?.type ?? '—'}</span>
<span
class={cn(
d != null && d < 500 ? 'text-emerald-500 shrink-0' : 'shrink-0'
)}
>
{d != null ? `${d}` : '—'}<span class="text-muted-foreground/80">ms</span>
</span>
</div>
</button>
<button
type="button"
class="text-muted-foreground hover:bg-muted hover:text-foreground border-border/80 border-l px-1.5 py-1 text-[10px] font-medium leading-none tabular-nums"
onclick={(ev) => {
ev.stopPropagation();
void pingOne(nodeName);
}}
disabled={testing === nodeName}
title="Проверить задержку"
>
{testing === nodeName ? '…' : 'ping'}
</button>
</div>
{/each}
</div>
</div>
</div>
{:else}
<div class="space-y-3">
<div class="flex flex-wrap items-center justify-between gap-2">
<div>
<h2 class="text-lg font-semibold">{gName}</h2>
<p class="text-muted-foreground text-sm">
Сейчас: <span class="text-foreground">{group.now ?? '—'}</span>
</p>
</div>
<Button
variant="secondary"
size="sm"
onclick={() => pingGroup(gName)}
disabled={testingGroup === gName}
>
{testingGroup === gName ? 'Проверка…' : 'Проверить все'}
</Button>
</div>
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{#each group.all ?? [] as nodeName (nodeName)}
{@const node = proxiesData.proxies?.[nodeName]}
{@const d = node ? lastDelay(node) : null}
{@const active = group.now === nodeName}
<div
class="bg-card text-card-foreground rounded-xl border p-4 transition-shadow {active
? 'ring-primary ring-2'
: ''}"
>
<button
type="button"
class="w-full text-left"
onclick={() => selectProxy(gName, nodeName)}
disabled={switching === gName}
>
<div class="flex items-start justify-between gap-2">
<span class="font-medium">{nodeName}</span>
<Badge variant="outline">{node?.type ?? '—'}</Badge>
</div>
<div
class="mt-2 text-sm {d != null && d < 500 ? 'text-emerald-400' : 'text-muted-foreground'}"
>
{d != null ? `${d} ms` : '—'}
</div>
</button>
<div class="mt-2 flex justify-end">
<Button
variant="ghost"
size="sm"
class="h-7 px-2"
onclick={() => pingOne(nodeName)}
disabled={testing === nodeName}
>
Ping
</Button>
</div>
</div>
{/each}
</div>
</div>
{/if}
{/each}
</div>
{:else if !lazy || proxiesLoading}
<!-- lazy без данных: ждём клика; скелетон выше -->
{:else}
<p class="text-muted-foreground text-sm">Нажмите «Загрузить прокси», чтобы получить список групп.</p>
{/if}
{/snippet}
</DataQueryState>
+13 -13
View File
@@ -245,8 +245,8 @@
<Card.Header class="pb-2">
<Card.Title class="text-base">Ноды</Card.Title>
<Card.Description>
Контроллер и метрики. Ниже на этой же странице — переключение прокси по каждой ноде; «Детально»
ведёт на полный обзор с графиками.
Контроллер и метрики. Ниже — компактные группы прокси (автозагрузка). «Детально» — обзор с
графиками.
</Card.Description>
</Card.Header>
<Card.Content class="overflow-x-auto px-2 sm:px-6">
@@ -322,30 +322,30 @@
</Card.Content>
</Card.Root>
<div class="mt-10 space-y-4">
<div class="mt-8 space-y-3">
<div>
<h2 class="text-lg font-semibold tracking-tight">Прокси по нодам</h2>
<p class="text-muted-foreground mt-1 text-sm">
Выбор узла в группах Mihomo на месте. Для каждой ноды список загружается по кнопке «Загрузить
прокси», чтобы не запрашивать все конфиги сразу.
<h2 class="text-base font-semibold tracking-tight">Прокси по нодам</h2>
<p class="text-muted-foreground mt-0.5 text-xs">
Список групп подгружается автоматически. Клик по ячейке — выбор узла; «ping» — задержка.
</p>
</div>
<div class="flex flex-col gap-8">
<div class="flex flex-col gap-3">
{#each rows.filter((r) => r.metaStatus === 'ok') as r (r.alias)}
<Card.Root
id="mihomo-proxies-{encodeURIComponent(r.alias)}"
data-size="sm"
class="border-l-4 border-l-primary/30 scroll-mt-20 shadow-sm"
>
<Card.Header class="pb-2">
<Card.Title class="text-lg">{r.alias}</Card.Title>
<Card.Header class="space-y-0 pb-1 pt-3">
<Card.Title class="text-sm font-semibold">{r.alias}</Card.Title>
{#if r.meta?.controller_base}
<Card.Description class="font-mono text-xs">
<Card.Description class="font-mono text-[11px] leading-snug">
{r.meta.controller_base}
</Card.Description>
{/if}
</Card.Header>
<Card.Content>
<MihomoProxyGroups alias={r.alias} lazy={true} />
<Card.Content class="px-3 pb-3 pt-0 sm:px-4">
<MihomoProxyGroups alias={r.alias} compact={true} />
</Card.Content>
</Card.Root>
{/each}